Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/Contracts/ReviewBuilderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace ConduitUI\Pr\Contracts;

use ConduitUI\Pr\DataTransferObjects\Review;

interface ReviewBuilderInterface
{
public function approve(?string $comment = null): self;

public function requestChanges(?string $comment = null): self;

public function comment(string $body): self;

public function addInlineComment(string $path, int $line, string $comment): self;

public function addSuggestion(string $path, int $startLine, int $endLine, string $suggestion): self;

public function submit(): Review;
}
24 changes: 14 additions & 10 deletions src/Contracts/Reviewable.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

namespace ConduitUI\Pr\Contracts;

use ConduitUI\Pr\DataTransferObjects\Review;
use ConduitUI\Pr\Services\ReviewBuilder;
use ConduitUI\Pr\Services\ReviewQuery;

/**
* Interface for entities that can receive code reviews.
Expand All @@ -14,24 +15,27 @@
interface Reviewable
{
/**
* Get all reviews for this entity.
*
* @return array<int, Review>
* Get a query builder for reviews.
*/
public function reviews(): ReviewQuery;

/**
* Create a review builder for approving.
*/
public function reviews(): array;
public function approve(?string $body = null): ReviewBuilder;

/**
* Approve this entity.
* Create a review builder for requesting changes.
*/
public function approve(?string $body = null): static;
public function requestChanges(?string $body = null): ReviewBuilder;

/**
* Request changes on this entity.
* Create a new review builder.
*/
public function requestChanges(string $body): static;
public function review(): ReviewBuilder;

/**
* Submit a review with a specific event type.
* Submit a review with a specific event type (legacy method).
*
* @param string $event APPROVE, REQUEST_CHANGES, or COMMENT
* @param array<int, array{path: string, line: int, body: string}> $comments Inline comments
Expand Down
48 changes: 27 additions & 21 deletions src/PullRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@
use ConduitUI\Pr\Requests\GetPullRequestCommits;
use ConduitUI\Pr\Requests\GetPullRequestDiff;
use ConduitUI\Pr\Requests\GetPullRequestFiles;
use ConduitUI\Pr\Requests\GetPullRequestReviews;
use ConduitUI\Pr\Requests\MergePullRequest;
use ConduitUI\Pr\Requests\RemoveAssignees;
use ConduitUI\Pr\Requests\RemoveIssueLabel;
use ConduitUI\Pr\Requests\RemoveReviewers;
use ConduitUI\Pr\Requests\RequestReviewers;
use ConduitUI\Pr\Requests\UpdatePullRequest;
use ConduitUI\Pr\Services\ReviewBuilder;
use ConduitUI\Pr\Services\ReviewQuery;

class PullRequest implements Assignable, Auditable, Checkable, Closeable, Commentable, Diffable, HasCommits, Labelable, Mergeable, Reviewable
{
Expand All @@ -50,14 +51,32 @@ public function __construct(
public readonly PullRequestData $data,
) {}

public function approve(?string $body = null): static
/**
* Create a new review builder for approving a pull request.
*/
public function approve(?string $body = null): ReviewBuilder
{
$builder = new ReviewBuilder($this->connector, "{$this->owner}/{$this->repo}", $this->data->number);

return $builder->approve($body);
}

/**
* Create a new review builder for requesting changes.
*/
public function requestChanges(?string $body = null): ReviewBuilder
{
return $this->submitReview('APPROVE', $body);
$builder = new ReviewBuilder($this->connector, "{$this->owner}/{$this->repo}", $this->data->number);

return $builder->requestChanges($body);
}

public function requestChanges(string $body): static
/**
* Create a new review builder for commenting.
*/
public function review(): ReviewBuilder
{
return $this->submitReview('REQUEST_CHANGES', $body);
return new ReviewBuilder($this->connector, "{$this->owner}/{$this->repo}", $this->data->number);
}

/**
Expand Down Expand Up @@ -164,24 +183,11 @@ public function update(array $attributes): static
}

/**
* @return array<int, Review>
* Get a review query builder for this pull request.
*/
public function reviews(): array
public function reviews(): ReviewQuery
{
$response = $this->connector->send(new GetPullRequestReviews(
$this->owner,
$this->repo,
$this->data->number
));

/** @var array<int, array<string, mixed>> $items */
$items = $response->json();

return array_values(array_map(
/** @param array<string, mixed> $data */
fn (mixed $data): Review => Review::fromArray($data), // @phpstan-ignore-line
$items
));
return new ReviewQuery($this->connector, "{$this->owner}/{$this->repo}", $this->data->number);
}

/**
Expand Down
103 changes: 103 additions & 0 deletions src/Services/ReviewBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

declare(strict_types=1);

namespace ConduitUI\Pr\Services;

use ConduitUi\GitHubConnector\Connector;
use ConduitUI\Pr\Contracts\ReviewBuilderInterface;
use ConduitUI\Pr\DataTransferObjects\Review;
use ConduitUI\Pr\Requests\CreatePullRequestReview;
use InvalidArgumentException;

final class ReviewBuilder implements ReviewBuilderInterface
{
protected ?string $event = null;

protected ?string $body = null;

/**
* @var array<int, array{path: string, line: int, body: string}|array{path: string, start_line: int, line: int, body: string}>
*/
protected array $comments = [];

protected string $owner;

protected string $repo;

public function __construct(
protected Connector $connector,
string $fullName,
protected int $prNumber,
) {
[$this->owner, $this->repo] = explode('/', $fullName, 2);
}

public function approve(?string $comment = null): self
{
$this->event = 'APPROVE';
$this->body = $comment;

return $this;
}

public function requestChanges(?string $comment = null): self
{
$this->event = 'REQUEST_CHANGES';
$this->body = $comment ?? 'Changes requested';

return $this;
}

public function comment(string $body): self
{
$this->event = 'COMMENT';
$this->body = $body;

return $this;
}

public function addInlineComment(string $path, int $line, string $comment): self
{
$this->comments[] = [
'path' => $path,
'line' => $line,
'body' => $comment,
];

return $this;
}

public function addSuggestion(string $path, int $startLine, int $endLine, string $suggestion): self
{
$this->comments[] = [
'path' => $path,
'start_line' => $startLine,
'line' => $endLine,
'body' => "```suggestion\n{$suggestion}\n```",
];

return $this;
}

public function submit(): Review
{
if ($this->event === null) {
throw new InvalidArgumentException('Review event is required. Call approve(), requestChanges(), or comment() first.');
}

$response = $this->connector->send(new CreatePullRequestReview(
$this->owner,
$this->repo,
$this->prNumber,
$this->event,
$this->body,
$this->comments
));

/** @var array{id: int, user: array{id: int, login: string, avatar_url: string, html_url: string, type: string}, body?: string|null, state: string, html_url: string, submitted_at: string} $data */
$data = $response->json();

return Review::fromArray($data);
}
}
133 changes: 133 additions & 0 deletions src/Services/ReviewQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

declare(strict_types=1);

namespace ConduitUI\Pr\Services;

use ConduitUi\GitHubConnector\Connector;
use ConduitUI\Pr\DataTransferObjects\Review;
use ConduitUI\Pr\Requests\GetPullRequestReviews;

final class ReviewQuery
{
protected string $owner;

protected string $repo;

public function __construct(
protected Connector $connector,
string $fullName,
protected int $prNumber,
) {
[$this->owner, $this->repo] = explode('/', $fullName, 2);
}

/**
* Get all reviews for the pull request.
*
* @return array<int, Review>
*/
public function get(): array
{
$response = $this->connector->send(new GetPullRequestReviews(
$this->owner,
$this->repo,
$this->prNumber
));

/** @var array<int, array<string, mixed>> $items */
$items = $response->json();

return array_values(array_map(
/** @param array<string, mixed> $data */
fn (mixed $data): Review => Review::fromArray($data), // @phpstan-ignore-line
$items
));
}

/**
* Get only approved reviews.
*
* @return array<int, Review>
*/
public function whereApproved(): array
{
return array_values(array_filter(
$this->get(),
fn (Review $review): bool => $review->isApproved()
));
}

/**
* Get only reviews with changes requested.
*
* @return array<int, Review>
*/
public function whereChangesRequested(): array
{
return array_values(array_filter(
$this->get(),
fn (Review $review): bool => $review->isChangesRequested()
));
}

/**
* Get only comment reviews.
*
* @return array<int, Review>
*/
public function whereCommented(): array
{
return array_values(array_filter(
$this->get(),
fn (Review $review): bool => $review->isCommented()
));
}

/**
* Get reviews by a specific user.
*
* @return array<int, Review>
*/
public function byUser(string $username): array
{
return array_values(array_filter(
$this->get(),
fn (Review $review): bool => $review->user->login === $username
));
}

/**
* Get the latest review.
*/
public function latest(): ?Review
{
$reviews = $this->get();

if ($reviews === []) {
return null;
}

usort($reviews, fn (Review $a, Review $b): int => $b->submittedAt <=> $a->submittedAt);

return $reviews[0];
}

/**
* Get the first review.
*/
public function first(): ?Review
{
$reviews = $this->get();

return $reviews[0] ?? null;
}

/**
* Count the number of reviews.
*/
public function count(): int
{
return count($this->get());
}
}
2 changes: 1 addition & 1 deletion tests/Unit/PullRequestWrapperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -656,7 +656,7 @@ function createTestPullRequestData(): PullRequestData
$prData = createTestPullRequestData();
$pr = new PullRequest($connector, 'owner', 'repo', $prData);

$reviews = $pr->reviews();
$reviews = $pr->reviews()->get();

expect($reviews)->toBeArray()
->and($reviews)->toHaveCount(1)
Expand Down
Loading
Loading