diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 933f4d2..d360b41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,24 @@ jobs: - name: Run tests run: vendor/bin/pest + + coverage: + runs-on: ubuntu-latest + name: Code Coverage + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + extensions: dom, curl, libxml, mbstring, zip + coverage: pcov + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run tests with coverage + run: vendor/bin/pest --coverage --min=100 diff --git a/composer.json b/composer.json index abb9bb4..66200f7 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,10 @@ "require-dev": { "laravel/pint": "^1.0", "pestphp/pest": "^3.0", - "phpstan/phpstan": "^1.0" + "phpstan/extension-installer": "*", + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-deprecation-rules": "^1.2", + "phpstan/phpstan-strict-rules": "^1.6" }, "autoload": { "psr-4": { @@ -40,7 +43,8 @@ "preferred-install": "dist", "optimize-autoloader": true, "allow-plugins": { - "pestphp/pest-plugin": true + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true } }, "extra": { diff --git a/phpstan.neon b/phpstan.neon index 94e9bd2..8d15a22 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 8 + level: 9 paths: - src excludePaths: @@ -7,6 +7,4 @@ parameters: # in the non-Laravel test environment. The class is only used in Laravel apps. - src/PrServiceProvider.php tmpDir: build/phpstan - ignoreErrors: - - - identifier: missingType.iterableValue + reportUnmatchedIgnoredErrors: true diff --git a/phpunit.xml b/phpunit.xml index cd9b795..6464938 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -15,5 +15,9 @@ src + + src/PrServiceProvider.php + src/Contracts + diff --git a/src/Contracts/Assignable.php b/src/Contracts/Assignable.php new file mode 100644 index 0000000..c1a5995 --- /dev/null +++ b/src/Contracts/Assignable.php @@ -0,0 +1,27 @@ + $assignees Usernames to assign + */ + public function assign(array $assignees): static; + + /** + * Remove assignees from this entity. + * + * @param array $assignees Usernames to unassign + */ + public function unassign(array $assignees): static; +} diff --git a/src/Contracts/Auditable.php b/src/Contracts/Auditable.php new file mode 100644 index 0000000..3ec0fce --- /dev/null +++ b/src/Contracts/Auditable.php @@ -0,0 +1,20 @@ + + */ + public function timeline(): array; +} diff --git a/src/Contracts/Checkable.php b/src/Contracts/Checkable.php new file mode 100644 index 0000000..5fd47e2 --- /dev/null +++ b/src/Contracts/Checkable.php @@ -0,0 +1,22 @@ + + */ + public function checks(): array; +} diff --git a/src/Contracts/Closeable.php b/src/Contracts/Closeable.php new file mode 100644 index 0000000..957d501 --- /dev/null +++ b/src/Contracts/Closeable.php @@ -0,0 +1,23 @@ + + */ + public function comments(): array; + + /** + * Add a comment to this entity. + */ + public function comment(string $body): static; +} diff --git a/src/Contracts/Diffable.php b/src/Contracts/Diffable.php new file mode 100644 index 0000000..b6a128f --- /dev/null +++ b/src/Contracts/Diffable.php @@ -0,0 +1,27 @@ + + */ + public function files(): array; +} diff --git a/src/Contracts/HasCommits.php b/src/Contracts/HasCommits.php new file mode 100644 index 0000000..fe2ff2a --- /dev/null +++ b/src/Contracts/HasCommits.php @@ -0,0 +1,22 @@ + + */ + public function commits(): array; +} diff --git a/src/Contracts/Labelable.php b/src/Contracts/Labelable.php new file mode 100644 index 0000000..bd6faaa --- /dev/null +++ b/src/Contracts/Labelable.php @@ -0,0 +1,25 @@ + $labels + */ + public function addLabels(array $labels): static; + + /** + * Remove a label from this entity. + */ + public function removeLabel(string $label): static; +} diff --git a/src/Contracts/Mergeable.php b/src/Contracts/Mergeable.php new file mode 100644 index 0000000..9289aad --- /dev/null +++ b/src/Contracts/Mergeable.php @@ -0,0 +1,20 @@ + $attributes + */ public function create(string $repository, array $attributes): PullRequest; public function for(string $repository): QueryBuilder; public function query(): QueryBuilder; + /** + * @param array $data + */ public function update(string $repository, int $number, array $data): PullRequest; public function merge(string $repository, int $number, ?string $commitMessage = null, ?string $mergeMethod = null): bool; public function close(string $repository, int $number): PullRequest; + /** + * @param array $filters + * @return array + */ public function list(string $repository, array $filters = []): array; } diff --git a/src/Contracts/Reviewable.php b/src/Contracts/Reviewable.php new file mode 100644 index 0000000..0c6d4a5 --- /dev/null +++ b/src/Contracts/Reviewable.php @@ -0,0 +1,40 @@ + + */ + public function reviews(): array; + + /** + * Approve this entity. + */ + public function approve(?string $body = null): static; + + /** + * Request changes on this entity. + */ + public function requestChanges(string $body): static; + + /** + * Submit a review with a specific event type. + * + * @param string $event APPROVE, REQUEST_CHANGES, or COMMENT + * @param array $comments Inline comments + */ + public function submitReview(string $event, ?string $body = null, array $comments = []): static; +} diff --git a/src/DataTransferObjects/Base.php b/src/DataTransferObjects/Base.php index a71bdeb..03f3105 100644 --- a/src/DataTransferObjects/Base.php +++ b/src/DataTransferObjects/Base.php @@ -13,6 +13,9 @@ public function __construct( public readonly Repository $repo, ) {} + /** + * @param array{ref: string, sha: string, user: array{id: int, login: string, avatar_url: string, html_url: string, type: string}, repo: array{id: int, name: string, full_name: string, html_url: string, private: bool}} $data + */ public static function fromArray(array $data): self { return new self( @@ -23,6 +26,9 @@ public static function fromArray(array $data): self ); } + /** + * @return array + */ public function toArray(): array { return [ diff --git a/src/DataTransferObjects/CheckRun.php b/src/DataTransferObjects/CheckRun.php index 729f4f1..29562dc 100644 --- a/src/DataTransferObjects/CheckRun.php +++ b/src/DataTransferObjects/CheckRun.php @@ -18,6 +18,9 @@ public function __construct( public readonly ?DateTimeImmutable $completedAt, ) {} + /** + * @param array{id: int, name: string, status: string, conclusion: string|null, html_url: string, started_at: string, completed_at?: string|null} $data + */ public static function fromArray(array $data): self { return new self( @@ -43,9 +46,12 @@ public function isSuccessful(): bool public function isFailed(): bool { - return in_array($this->conclusion, ['failure', 'timed_out', 'action_required']); + return in_array($this->conclusion, ['failure', 'timed_out', 'action_required'], true); } + /** + * @return array + */ public function toArray(): array { return [ diff --git a/src/DataTransferObjects/Comment.php b/src/DataTransferObjects/Comment.php index 74b06d3..908fdbf 100644 --- a/src/DataTransferObjects/Comment.php +++ b/src/DataTransferObjects/Comment.php @@ -17,6 +17,9 @@ public function __construct( public readonly DateTimeImmutable $updatedAt, ) {} + /** + * @param array{id: int, user: array{id: int, login: string, avatar_url: string, html_url: string, type: string}, body: string, html_url: string, created_at: string, updated_at: string} $data + */ public static function fromArray(array $data): self { return new self( @@ -29,6 +32,9 @@ public static function fromArray(array $data): self ); } + /** + * @return array + */ public function toArray(): array { return [ diff --git a/src/DataTransferObjects/Commit.php b/src/DataTransferObjects/Commit.php index 161454b..924bd5c 100644 --- a/src/DataTransferObjects/Commit.php +++ b/src/DataTransferObjects/Commit.php @@ -16,6 +16,9 @@ public function __construct( public readonly ?User $githubCommitter, ) {} + /** + * @param array{sha: string, commit: array{message: string, author: array{name: string, email: string, date: string}, committer: array{name: string, email: string, date: string}}, html_url: string, author?: array{id: int, login: string, avatar_url: string, html_url: string, type: string}|null, committer?: array{id: int, login: string, avatar_url: string, html_url: string, type: string}|null} $data + */ public static function fromArray(array $data): self { return new self( @@ -29,6 +32,9 @@ public static function fromArray(array $data): self ); } + /** + * @return array + */ public function toArray(): array { return [ diff --git a/src/DataTransferObjects/CommitAuthor.php b/src/DataTransferObjects/CommitAuthor.php index 5982dbf..ae00fc9 100644 --- a/src/DataTransferObjects/CommitAuthor.php +++ b/src/DataTransferObjects/CommitAuthor.php @@ -14,6 +14,9 @@ public function __construct( public readonly DateTimeImmutable $date, ) {} + /** + * @param array{name: string, email: string, date: string} $data + */ public static function fromArray(array $data): self { return new self( @@ -23,6 +26,9 @@ public static function fromArray(array $data): self ); } + /** + * @return array + */ public function toArray(): array { return [ diff --git a/src/DataTransferObjects/File.php b/src/DataTransferObjects/File.php index 536e638..1279bd8 100644 --- a/src/DataTransferObjects/File.php +++ b/src/DataTransferObjects/File.php @@ -20,6 +20,9 @@ public function __construct( public readonly ?string $previousFilename, ) {} + /** + * @param array{sha: string, filename: string, status: string, additions: int, deletions: int, changes: int, blob_url: string, raw_url: string, contents_url: string, patch?: string|null, previous_filename?: string|null} $data + */ public static function fromArray(array $data): self { return new self( @@ -57,6 +60,9 @@ public function isRenamed(): bool return $this->status === 'renamed'; } + /** + * @return array + */ public function toArray(): array { return [ diff --git a/src/DataTransferObjects/Head.php b/src/DataTransferObjects/Head.php index 9671ae0..2d8eae3 100644 --- a/src/DataTransferObjects/Head.php +++ b/src/DataTransferObjects/Head.php @@ -13,6 +13,9 @@ public function __construct( public readonly Repository $repo, ) {} + /** + * @param array{ref: string, sha: string, user: array{id: int, login: string, avatar_url: string, html_url: string, type: string}, repo: array{id: int, name: string, full_name: string, html_url: string, private: bool}} $data + */ public static function fromArray(array $data): self { return new self( @@ -23,6 +26,9 @@ public static function fromArray(array $data): self ); } + /** + * @return array + */ public function toArray(): array { return [ diff --git a/src/DataTransferObjects/Label.php b/src/DataTransferObjects/Label.php index a7a9fac..d1a4687 100644 --- a/src/DataTransferObjects/Label.php +++ b/src/DataTransferObjects/Label.php @@ -13,6 +13,9 @@ public function __construct( public readonly ?string $description, ) {} + /** + * @param array{id: int, name: string, color: string, description?: string|null} $data + */ public static function fromArray(array $data): self { return new self( @@ -23,6 +26,9 @@ public static function fromArray(array $data): self ); } + /** + * @return array + */ public function toArray(): array { return [ diff --git a/src/DataTransferObjects/PullRequest.php b/src/DataTransferObjects/PullRequest.php index 6efe855..c702d6d 100644 --- a/src/DataTransferObjects/PullRequest.php +++ b/src/DataTransferObjects/PullRequest.php @@ -8,6 +8,11 @@ class PullRequest { + /** + * @param array $assignees + * @param array $requestedReviewers + * @param array $labels + */ public function __construct( public readonly int $number, public readonly string $title, @@ -32,6 +37,9 @@ public function __construct( public readonly Base $base, ) {} + /** + * @param array{number: int, title: string, body?: string|null, state: string, user: array{id: int, login: string, avatar_url: string, html_url: string, type: string}, html_url: string, created_at: string, updated_at: string, closed_at?: string|null, merged_at?: string|null, merge_commit_sha?: string|null, draft?: bool, additions?: int|null, deletions?: int|null, changed_files?: int|null, assignee?: array{id: int, login: string, avatar_url: string, html_url: string, type: string}|null, assignees?: array, requested_reviewers?: array, labels?: array, head: array{ref: string, sha: string, user: array{id: int, login: string, avatar_url: string, html_url: string, type: string}, repo: array{id: int, name: string, full_name: string, html_url: string, private: bool}}, base: array{ref: string, sha: string, user: array{id: int, login: string, avatar_url: string, html_url: string, type: string}, repo: array{id: int, name: string, full_name: string, html_url: string, private: bool}}} $data + */ public static function fromArray(array $data): self { return new self( @@ -79,6 +87,9 @@ public function isDraft(): bool return $this->draft; } + /** + * @return array + */ public function toArray(): array { return [ diff --git a/src/DataTransferObjects/Repository.php b/src/DataTransferObjects/Repository.php index 39e61b8..a0ae2ae 100644 --- a/src/DataTransferObjects/Repository.php +++ b/src/DataTransferObjects/Repository.php @@ -14,6 +14,9 @@ public function __construct( public readonly bool $private, ) {} + /** + * @param array{id: int, name: string, full_name: string, html_url: string, private: bool} $data + */ public static function fromArray(array $data): self { return new self( @@ -25,6 +28,9 @@ public static function fromArray(array $data): self ); } + /** + * @return array + */ public function toArray(): array { return [ diff --git a/src/DataTransferObjects/Review.php b/src/DataTransferObjects/Review.php index 4e66956..7898ade 100644 --- a/src/DataTransferObjects/Review.php +++ b/src/DataTransferObjects/Review.php @@ -17,6 +17,9 @@ public function __construct( public readonly DateTimeImmutable $submittedAt, ) {} + /** + * @param 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 + */ public static function fromArray(array $data): self { return new self( @@ -44,6 +47,9 @@ public function isCommented(): bool return $this->state === 'COMMENTED'; } + /** + * @return array + */ public function toArray(): array { return [ diff --git a/src/DataTransferObjects/User.php b/src/DataTransferObjects/User.php index 20b2202..51ddcd2 100644 --- a/src/DataTransferObjects/User.php +++ b/src/DataTransferObjects/User.php @@ -14,6 +14,9 @@ public function __construct( public readonly string $type, ) {} + /** + * @param array{id: int, login: string, avatar_url: string, html_url: string, type: string} $data + */ public static function fromArray(array $data): self { return new self( @@ -25,6 +28,9 @@ public static function fromArray(array $data): self ); } + /** + * @return array + */ public function toArray(): array { return [ diff --git a/src/PullRequest.php b/src/PullRequest.php index abbc2bc..e56eaa7 100644 --- a/src/PullRequest.php +++ b/src/PullRequest.php @@ -5,30 +5,43 @@ namespace ConduitUI\Pr; use ConduitUi\GitHubConnector\Connector; +use ConduitUI\Pr\Contracts\Assignable; +use ConduitUI\Pr\Contracts\Auditable; +use ConduitUI\Pr\Contracts\Checkable; +use ConduitUI\Pr\Contracts\Closeable; +use ConduitUI\Pr\Contracts\Commentable; +use ConduitUI\Pr\Contracts\Diffable; +use ConduitUI\Pr\Contracts\HasCommits; +use ConduitUI\Pr\Contracts\Labelable; +use ConduitUI\Pr\Contracts\Mergeable; +use ConduitUI\Pr\Contracts\Reviewable; use ConduitUI\Pr\DataTransferObjects\CheckRun; use ConduitUI\Pr\DataTransferObjects\Comment; use ConduitUI\Pr\DataTransferObjects\Commit; use ConduitUI\Pr\DataTransferObjects\File; use ConduitUI\Pr\DataTransferObjects\PullRequest as PullRequestData; use ConduitUI\Pr\DataTransferObjects\Review; +use ConduitUI\Pr\Requests\AddAssignees; use ConduitUI\Pr\Requests\AddIssueLabels; use ConduitUI\Pr\Requests\CreateIssueComment; use ConduitUI\Pr\Requests\CreatePullRequestComment; use ConduitUI\Pr\Requests\CreatePullRequestReview; use ConduitUI\Pr\Requests\GetCommitCheckRuns; use ConduitUI\Pr\Requests\GetIssueComments; +use ConduitUI\Pr\Requests\GetIssueTimeline; use ConduitUI\Pr\Requests\GetPullRequestComments; 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; -class PullRequest +class PullRequest implements Assignable, Auditable, Checkable, Closeable, Commentable, Diffable, HasCommits, Labelable, Mergeable, Reviewable { public function __construct( protected Connector $connector, @@ -37,12 +50,12 @@ public function __construct( public readonly PullRequestData $data, ) {} - public function approve(?string $body = null): self + public function approve(?string $body = null): static { return $this->submitReview('APPROVE', $body); } - public function requestChanges(string $body): self + public function requestChanges(string $body): static { return $this->submitReview('REQUEST_CHANGES', $body); } @@ -52,7 +65,7 @@ public function requestChanges(string $body): self * * @param array $comments */ - public function submitReview(string $event, ?string $body = null, array $comments = []): self + public function submitReview(string $event, ?string $body = null, array $comments = []): static { $this->connector->send(new CreatePullRequestReview( $this->owner, @@ -66,7 +79,7 @@ public function submitReview(string $event, ?string $body = null, array $comment return $this; } - public function comment(string $body, ?int $line = null, ?string $path = null): self + public function comment(string $body, ?int $line = null, ?string $path = null): static { if ($line !== null && $path !== null) { $this->connector->send(new CreatePullRequestComment( @@ -89,7 +102,7 @@ public function comment(string $body, ?int $line = null, ?string $path = null): return $this; } - public function merge(string $method = 'merge', ?string $title = null, ?string $message = null): self + public function merge(string $method = 'merge', ?string $title = null, ?string $message = null): static { $payload = ['merge_method' => $method]; @@ -111,7 +124,7 @@ public function merge(string $method = 'merge', ?string $title = null, ?string $ return $this; } - public function close(): self + public function close(): static { $this->connector->send(new UpdatePullRequest( $this->owner, @@ -123,7 +136,7 @@ public function close(): self return $this; } - public function reopen(): self + public function reopen(): static { $this->connector->send(new UpdatePullRequest( $this->owner, @@ -138,7 +151,7 @@ public function reopen(): self /** * @param array $attributes */ - public function update(array $attributes): self + public function update(array $attributes): static { $this->connector->send(new UpdatePullRequest( $this->owner, @@ -161,9 +174,13 @@ public function reviews(): array $this->data->number )); + /** @var array> $items */ + $items = $response->json(); + return array_values(array_map( - fn (array $data) => Review::fromArray($data), - $response->json() + /** @param array $data */ + fn (mixed $data): Review => Review::fromArray($data), // @phpstan-ignore-line + $items )); } @@ -178,9 +195,13 @@ public function comments(): array $this->data->number )); + /** @var array> $items */ + $items = $response->json(); + return array_values(array_map( - fn (array $data) => Comment::fromArray($data), - $response->json() + /** @param array $data */ + fn (mixed $data): Comment => Comment::fromArray($data), // @phpstan-ignore-line + $items )); } @@ -195,14 +216,17 @@ public function files(): array $this->data->number )); + /** @var array> $items */ + $items = $response->json(); + return array_values(array_map( - fn (array $data) => File::fromArray($data), - $response->json() + /** @param array $data */ + fn (mixed $data): File => File::fromArray($data), // @phpstan-ignore-line + $items )); } /** -/** * Get the raw diff text for this pull request. */ public function diff(): string @@ -227,10 +251,14 @@ public function checks(): array $this->data->head->sha )); - $checkRuns = $response->json()['check_runs'] ?? []; + /** @var array $json */ + $json = $response->json(); + /** @var array> $checkRuns */ + $checkRuns = $json['check_runs'] ?? []; return array_values(array_map( - fn (array $data) => CheckRun::fromArray($data), + /** @param array $data */ + fn (mixed $data): CheckRun => CheckRun::fromArray($data), // @phpstan-ignore-line $checkRuns )); } @@ -242,6 +270,7 @@ public function checks(): array */ public function commits(): array { + /** @var array> $allCommits */ $allCommits = []; $page = 1; $perPage = 100; @@ -255,9 +284,10 @@ public function commits(): array $page )); + /** @var array> $commits */ $commits = $response->json(); - if (empty($commits)) { + if ($commits === []) { break; } @@ -266,7 +296,8 @@ public function commits(): array } while (count($commits) === $perPage); return array_values(array_map( - fn (array $data) => Commit::fromArray($data), + /** @param array $data */ + fn (mixed $data): Commit => Commit::fromArray($data), // @phpstan-ignore-line $allCommits )); } @@ -278,6 +309,7 @@ public function commits(): array */ public function issueComments(): array { + /** @var array> $allComments */ $allComments = []; $page = 1; $perPage = 100; @@ -291,9 +323,10 @@ public function issueComments(): array $page )); + /** @var array> $comments */ $comments = $response->json(); - if (empty($comments)) { + if ($comments === []) { break; } @@ -302,7 +335,8 @@ public function issueComments(): array } while (count($comments) === $perPage); return array_values(array_map( - fn (array $data) => Comment::fromArray($data), + /** @param array $data */ + fn (mixed $data): Comment => Comment::fromArray($data), // @phpstan-ignore-line $allComments )); } @@ -310,7 +344,7 @@ public function issueComments(): array /** * @param array $labels */ - public function addLabels(array $labels): self + public function addLabels(array $labels): static { $this->connector->send(new AddIssueLabels( $this->owner, @@ -322,7 +356,7 @@ public function addLabels(array $labels): self return $this; } - public function removeLabel(string $label): self + public function removeLabel(string $label): static { $this->connector->send(new RemoveIssueLabel( $this->owner, @@ -338,7 +372,7 @@ public function removeLabel(string $label): self * @param array $reviewers * @param array $teamReviewers */ - public function addReviewers(array $reviewers, array $teamReviewers = []): self + public function addReviewers(array $reviewers, array $teamReviewers = []): static { $this->connector->send(new RequestReviewers( $this->owner, @@ -355,7 +389,7 @@ public function addReviewers(array $reviewers, array $teamReviewers = []): self * @param array $reviewers * @param array $teamReviewers */ - public function removeReviewers(array $reviewers, array $teamReviewers = []): self + public function removeReviewers(array $reviewers, array $teamReviewers = []): static { $this->connector->send(new RemoveReviewers( $this->owner, @@ -368,9 +402,72 @@ public function removeReviewers(array $reviewers, array $teamReviewers = []): se return $this; } + /** + * @param array $assignees + */ + public function assign(array $assignees): static + { + $this->connector->send(new AddAssignees( + $this->owner, + $this->repo, + $this->data->number, + $assignees + )); + + return $this; + } + + /** + * @param array $assignees + */ + public function unassign(array $assignees): static + { + $this->connector->send(new RemoveAssignees( + $this->owner, + $this->repo, + $this->data->number, + $assignees + )); + + return $this; + } + + /** + * Get the timeline of events for this pull request (paginated, fetches all pages). + * + * @return array + */ + public function timeline(): array + { + $allEvents = []; + $page = 1; + $perPage = 100; + + do { + $response = $this->connector->send(new GetIssueTimeline( + $this->owner, + $this->repo, + $this->data->number, + $perPage, + $page + )); + + $events = $response->json(); + + if ($events === []) { + break; + } + + $allEvents = array_merge($allEvents, $events); + $page++; + } while (count($events) === $perPage); + + return $allEvents; + } + public function __get(string $name): mixed { - return $this->data->{$name}; + return $this->data->{$name}; // @phpstan-ignore-line Variable property access is intentional for magic getter } /** diff --git a/src/PullRequests.php b/src/PullRequests.php index d63ced4..21a0990 100644 --- a/src/PullRequests.php +++ b/src/PullRequests.php @@ -45,20 +45,6 @@ public static function setConnector(Connector $connector): void self::$service = new GitHubPrService($connector); } - /** - * Get the default connector or throw an exception - */ - protected static function connector(): Connector - { - if (self::$defaultConnector === null) { - throw new \RuntimeException( - 'GitHub connector not configured. Call PullRequests::setConnector() or setService() first.' - ); - } - - return self::$defaultConnector; - } - /** * Get the PR service or throw an exception */ @@ -118,11 +104,14 @@ public function get(int $number): PullRequest $number )); + /** @var array $data */ + $data = $response->json(); + return new PullRequest( $this->connector, $this->owner, $this->repo, - PullRequestData::fromArray($response->json()) + PullRequestData::fromArray($data) // @phpstan-ignore-line ); } @@ -147,11 +136,14 @@ public function list(array $filters = []): array )); return array_values(array_map( - fn ($pr) => new PullRequest( + /** + * @param array $pr + */ + fn (mixed $pr) => new PullRequest( $this->connector, $this->owner, $this->repo, - PullRequestData::fromArray($pr) + PullRequestData::fromArray($pr) // @phpstan-ignore-line ), $response->json() )); @@ -190,11 +182,14 @@ public function createPullRequest(array $data): PullRequest $data )); + /** @var array $responseData */ + $responseData = $response->json(); + return new PullRequest( $this->connector, $this->owner, $this->repo, - PullRequestData::fromArray($response->json()) + PullRequestData::fromArray($responseData) // @phpstan-ignore-line ); } @@ -212,11 +207,14 @@ public function update(int $number, array $data): PullRequest $data )); + /** @var array $responseData */ + $responseData = $response->json(); + return new PullRequest( $this->connector, $this->owner, $this->repo, - PullRequestData::fromArray($response->json()) + PullRequestData::fromArray($responseData) // @phpstan-ignore-line ); } @@ -237,7 +235,7 @@ public function merge(int $number, ?string $commitMessage = null, ?string $merge $data )); - return $response->json()['merged'] ?? false; + return (bool) ($response->json()['merged'] ?? false); } /** diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index daf06d9..641727e 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -101,10 +101,13 @@ public function page(int $page): self */ public function get(): array { - if (! $this->owner || ! $this->repo) { + if ($this->owner === null || $this->repo === null) { throw new \InvalidArgumentException('Repository is required. Use repository("owner/repo") first.'); } + $owner = $this->owner; + $repo = $this->repo; + $params = array_merge($this->filters, [ 'sort' => $this->sort, 'direction' => $this->direction, @@ -117,17 +120,20 @@ public function get(): array } $response = $this->connector->send(new ListPullRequests( - $this->owner, - $this->repo, + $owner, + $repo, $params )); return array_values(array_map( - fn (array $data) => new PullRequest( + /** + * @param array $data + */ + fn (mixed $data) => new PullRequest( $this->connector, - $this->owner, - $this->repo, - PullRequestData::fromArray($data) + $owner, + $repo, + PullRequestData::fromArray($data) // @phpstan-ignore-line ), $response->json() )); diff --git a/src/Requests/AddAssignees.php b/src/Requests/AddAssignees.php new file mode 100644 index 0000000..c71cc5f --- /dev/null +++ b/src/Requests/AddAssignees.php @@ -0,0 +1,40 @@ + $assignees + */ + public function __construct( + protected string $owner, + protected string $repo, + protected int $number, + protected array $assignees, + ) {} + + public function resolveEndpoint(): string + { + return "/repos/{$this->owner}/{$this->repo}/issues/{$this->number}/assignees"; + } + + /** + * @return array + */ + protected function defaultBody(): array + { + return ['assignees' => $this->assignees]; + } +} diff --git a/src/Requests/CreateIssueComment.php b/src/Requests/CreateIssueComment.php index 15b104e..0108470 100644 --- a/src/Requests/CreateIssueComment.php +++ b/src/Requests/CreateIssueComment.php @@ -19,7 +19,7 @@ public function __construct( protected string $owner, protected string $repo, protected int $number, - protected string $body, + protected string $commentBody, ) {} public function resolveEndpoint(): string @@ -32,6 +32,6 @@ public function resolveEndpoint(): string */ protected function defaultBody(): array { - return ['body' => $this->body]; + return ['body' => $this->commentBody]; } } diff --git a/src/Requests/CreatePullRequestComment.php b/src/Requests/CreatePullRequestComment.php index 20ed3e3..7356d1d 100644 --- a/src/Requests/CreatePullRequestComment.php +++ b/src/Requests/CreatePullRequestComment.php @@ -19,7 +19,7 @@ public function __construct( protected string $owner, protected string $repo, protected int $number, - protected string $body, + protected string $commentBody, protected string $path, protected int $line, ) {} @@ -35,7 +35,7 @@ public function resolveEndpoint(): string protected function defaultBody(): array { return [ - 'body' => $this->body, + 'body' => $this->commentBody, 'path' => $this->path, 'line' => $this->line, ]; diff --git a/src/Requests/CreatePullRequestReview.php b/src/Requests/CreatePullRequestReview.php index c04dd50..dde7c85 100644 --- a/src/Requests/CreatePullRequestReview.php +++ b/src/Requests/CreatePullRequestReview.php @@ -43,7 +43,7 @@ protected function defaultBody(): array $payload['body'] = $this->reviewBody; } - if (! empty($this->comments)) { + if ($this->comments !== []) { $payload['comments'] = $this->comments; } diff --git a/src/Requests/GetIssueTimeline.php b/src/Requests/GetIssueTimeline.php new file mode 100644 index 0000000..6223504 --- /dev/null +++ b/src/Requests/GetIssueTimeline.php @@ -0,0 +1,47 @@ +owner}/{$this->repo}/issues/{$this->number}/timeline"; + } + + /** + * @return array + */ + protected function defaultQuery(): array + { + return [ + 'per_page' => $this->perPage, + 'page' => $this->page, + ]; + } + + /** + * @return array + */ + protected function defaultHeaders(): array + { + return [ + 'Accept' => 'application/vnd.github.mockingbird-preview+json', + ]; + } +} diff --git a/src/Requests/ListPullRequests.php b/src/Requests/ListPullRequests.php index bae4cbf..607b8c7 100644 --- a/src/Requests/ListPullRequests.php +++ b/src/Requests/ListPullRequests.php @@ -24,6 +24,6 @@ public function resolveEndpoint(): string { $query = http_build_query($this->filters); - return "/repos/{$this->owner}/{$this->repo}/pulls".($query ? "?{$query}" : ''); + return "/repos/{$this->owner}/{$this->repo}/pulls".($query !== '' ? "?{$query}" : ''); } } diff --git a/src/Requests/RemoveAssignees.php b/src/Requests/RemoveAssignees.php new file mode 100644 index 0000000..00fa553 --- /dev/null +++ b/src/Requests/RemoveAssignees.php @@ -0,0 +1,40 @@ + $assignees + */ + public function __construct( + protected string $owner, + protected string $repo, + protected int $number, + protected array $assignees, + ) {} + + public function resolveEndpoint(): string + { + return "/repos/{$this->owner}/{$this->repo}/issues/{$this->number}/assignees"; + } + + /** + * @return array + */ + protected function defaultBody(): array + { + return ['assignees' => $this->assignees]; + } +} diff --git a/src/Requests/RemoveReviewers.php b/src/Requests/RemoveReviewers.php index c52ec31..899d056 100644 --- a/src/Requests/RemoveReviewers.php +++ b/src/Requests/RemoveReviewers.php @@ -39,11 +39,11 @@ protected function defaultBody(): array { $payload = []; - if (! empty($this->reviewers)) { + if ($this->reviewers !== []) { $payload['reviewers'] = $this->reviewers; } - if (! empty($this->teamReviewers)) { + if ($this->teamReviewers !== []) { $payload['team_reviewers'] = $this->teamReviewers; } diff --git a/src/Requests/RequestReviewers.php b/src/Requests/RequestReviewers.php index cb028c8..16b3120 100644 --- a/src/Requests/RequestReviewers.php +++ b/src/Requests/RequestReviewers.php @@ -39,11 +39,11 @@ protected function defaultBody(): array { $payload = []; - if (! empty($this->reviewers)) { + if ($this->reviewers !== []) { $payload['reviewers'] = $this->reviewers; } - if (! empty($this->teamReviewers)) { + if ($this->teamReviewers !== []) { $payload['team_reviewers'] = $this->teamReviewers; } diff --git a/src/Services/GitHubPrService.php b/src/Services/GitHubPrService.php index 8e3e8d3..108b207 100644 --- a/src/Services/GitHubPrService.php +++ b/src/Services/GitHubPrService.php @@ -27,11 +27,14 @@ public function find(string $repository, int $number): PullRequest $response = $this->connector->send(new GetPullRequest($owner, $repo, $number)); + /** @var array $data */ + $data = $response->json(); + return new PullRequest( $this->connector, $owner, $repo, - PullRequestData::fromArray($response->json()) + PullRequestData::fromArray($data) // @phpstan-ignore-line ); } @@ -44,11 +47,14 @@ public function create(string $repository, array $attributes): PullRequest $response = $this->connector->send(new CreatePullRequest($owner, $repo, $attributes)); + /** @var array $data */ + $data = $response->json(); + return new PullRequest( $this->connector, $owner, $repo, - PullRequestData::fromArray($response->json()) + PullRequestData::fromArray($data) // @phpstan-ignore-line ); } @@ -71,11 +77,14 @@ public function update(string $repository, int $number, array $data): PullReques $response = $this->connector->send(new UpdatePullRequest($owner, $repo, $number, $data)); + /** @var array $responseData */ + $responseData = $response->json(); + return new PullRequest( $this->connector, $owner, $repo, - PullRequestData::fromArray($response->json()) + PullRequestData::fromArray($responseData) // @phpstan-ignore-line ); } @@ -90,7 +99,7 @@ public function merge(string $repository, int $number, ?string $commitMessage = $response = $this->connector->send(new MergePullRequest($owner, $repo, $number, $data)); - return $response->json()['merged'] ?? false; + return (bool) ($response->json()['merged'] ?? false); } public function close(string $repository, int $number): PullRequest @@ -115,11 +124,14 @@ public function list(string $repository, array $filters = []): array $response = $this->connector->send(new ListPullRequests($owner, $repo, $mergedFilters)); return array_values(array_map( - fn ($pr) => new PullRequest( + /** + * @param array $pr + */ + fn (mixed $pr) => new PullRequest( $this->connector, $owner, $repo, - PullRequestData::fromArray($pr) + PullRequestData::fromArray($pr) // @phpstan-ignore-line ), $response->json() )); diff --git a/tests/Unit/DataTransferObjects/CheckRunTest.php b/tests/Unit/DataTransferObjects/CheckRunTest.php new file mode 100644 index 0000000..385d424 --- /dev/null +++ b/tests/Unit/DataTransferObjects/CheckRunTest.php @@ -0,0 +1,124 @@ + 1, + 'name' => 'PHPStan', + 'status' => 'completed', + 'conclusion' => 'success', + 'html_url' => 'https://github.com/owner/repo/runs/1', + 'started_at' => '2025-01-01T10:00:00Z', + 'completed_at' => '2025-01-01T10:05:00Z', + ]); + + expect($checkRun->id)->toBe(1) + ->and($checkRun->name)->toBe('PHPStan') + ->and($checkRun->status)->toBe('completed') + ->and($checkRun->conclusion)->toBe('success'); +}); + +it('can create check run with null conclusion', function () { + $checkRun = CheckRun::fromArray([ + 'id' => 1, + 'name' => 'Tests', + 'status' => 'in_progress', + 'conclusion' => null, + 'html_url' => 'https://github.com/owner/repo/runs/1', + 'started_at' => '2025-01-01T10:00:00Z', + ]); + + expect($checkRun->conclusion)->toBeNull() + ->and($checkRun->completedAt)->toBeNull(); +}); + +it('can check if check run is completed', function () { + $checkRun = CheckRun::fromArray([ + 'id' => 1, + 'name' => 'Tests', + 'status' => 'completed', + 'conclusion' => 'success', + 'html_url' => 'https://github.com/owner/repo/runs/1', + 'started_at' => '2025-01-01T10:00:00Z', + 'completed_at' => '2025-01-01T10:05:00Z', + ]); + + expect($checkRun->isCompleted())->toBeTrue(); +}); + +it('can check if check run is successful', function () { + $checkRun = CheckRun::fromArray([ + 'id' => 1, + 'name' => 'Tests', + 'status' => 'completed', + 'conclusion' => 'success', + 'html_url' => 'https://github.com/owner/repo/runs/1', + 'started_at' => '2025-01-01T10:00:00Z', + 'completed_at' => '2025-01-01T10:05:00Z', + ]); + + expect($checkRun->isSuccessful())->toBeTrue(); +}); + +it('can check if check run failed', function () { + $checkRun = CheckRun::fromArray([ + 'id' => 1, + 'name' => 'Tests', + 'status' => 'completed', + 'conclusion' => 'failure', + 'html_url' => 'https://github.com/owner/repo/runs/1', + 'started_at' => '2025-01-01T10:00:00Z', + 'completed_at' => '2025-01-01T10:05:00Z', + ]); + + expect($checkRun->isFailed())->toBeTrue() + ->and($checkRun->isSuccessful())->toBeFalse(); +}); + +it('can check if check run timed out', function () { + $checkRun = CheckRun::fromArray([ + 'id' => 1, + 'name' => 'Tests', + 'status' => 'completed', + 'conclusion' => 'timed_out', + 'html_url' => 'https://github.com/owner/repo/runs/1', + 'started_at' => '2025-01-01T10:00:00Z', + 'completed_at' => '2025-01-01T10:05:00Z', + ]); + + expect($checkRun->isFailed())->toBeTrue(); +}); + +it('can check if check run action required', function () { + $checkRun = CheckRun::fromArray([ + 'id' => 1, + 'name' => 'Tests', + 'status' => 'completed', + 'conclusion' => 'action_required', + 'html_url' => 'https://github.com/owner/repo/runs/1', + 'started_at' => '2025-01-01T10:00:00Z', + 'completed_at' => '2025-01-01T10:05:00Z', + ]); + + expect($checkRun->isFailed())->toBeTrue(); +}); + +it('can convert check run to array', function () { + $checkRun = CheckRun::fromArray([ + 'id' => 1, + 'name' => 'PHPStan', + 'status' => 'completed', + 'conclusion' => 'success', + 'html_url' => 'https://github.com/owner/repo/runs/1', + 'started_at' => '2025-01-01T10:00:00Z', + 'completed_at' => '2025-01-01T10:05:00Z', + ]); + + $array = $checkRun->toArray(); + + expect($array)->toBeArray() + ->and($array['name'])->toBe('PHPStan'); +}); diff --git a/tests/Unit/DataTransferObjects/CommentTest.php b/tests/Unit/DataTransferObjects/CommentTest.php new file mode 100644 index 0000000..1abcbb5 --- /dev/null +++ b/tests/Unit/DataTransferObjects/CommentTest.php @@ -0,0 +1,48 @@ + 1, + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'body' => 'This is a comment', + 'html_url' => 'https://github.com/owner/repo/issues/1#issuecomment-1', + 'created_at' => '2025-01-01T10:00:00Z', + 'updated_at' => '2025-01-01T11:00:00Z', + ]); + + expect($comment->id)->toBe(1) + ->and($comment->body)->toBe('This is a comment') + ->and($comment->user->login)->toBe('testuser'); +}); + +it('can convert comment to array', function () { + $comment = Comment::fromArray([ + 'id' => 1, + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'body' => 'Test comment', + 'html_url' => 'https://github.com/owner/repo/issues/1#issuecomment-1', + 'created_at' => '2025-01-01T10:00:00Z', + 'updated_at' => '2025-01-01T11:00:00Z', + ]); + + $array = $comment->toArray(); + + expect($array)->toBeArray() + ->and($array['body'])->toBe('Test comment'); +}); diff --git a/tests/Unit/DataTransferObjects/HeadBaseTest.php b/tests/Unit/DataTransferObjects/HeadBaseTest.php new file mode 100644 index 0000000..6b9f6ca --- /dev/null +++ b/tests/Unit/DataTransferObjects/HeadBaseTest.php @@ -0,0 +1,108 @@ + 'feature-branch', + 'sha' => 'abc123def456', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'repo' => [ + 'id' => 1, + 'name' => 'repo', + 'full_name' => 'owner/repo', + 'html_url' => 'https://github.com/owner/repo', + 'private' => false, + ], + ]); + + expect($head->ref)->toBe('feature-branch') + ->and($head->sha)->toBe('abc123def456') + ->and($head->user->login)->toBe('testuser') + ->and($head->repo->fullName)->toBe('owner/repo'); +}); + +it('can create base from array', function () { + $base = Base::fromArray([ + 'ref' => 'main', + 'sha' => 'def456abc123', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'repo' => [ + 'id' => 1, + 'name' => 'repo', + 'full_name' => 'owner/repo', + 'html_url' => 'https://github.com/owner/repo', + 'private' => false, + ], + ]); + + expect($base->ref)->toBe('main') + ->and($base->sha)->toBe('def456abc123'); +}); + +it('can convert head to array', function () { + $head = Head::fromArray([ + 'ref' => 'feature', + 'sha' => 'abc123', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'repo' => [ + 'id' => 1, + 'name' => 'repo', + 'full_name' => 'owner/repo', + 'html_url' => 'https://github.com/owner/repo', + 'private' => false, + ], + ]); + + $array = $head->toArray(); + + expect($array)->toBeArray() + ->and($array['ref'])->toBe('feature'); +}); + +it('can convert base to array', function () { + $base = Base::fromArray([ + 'ref' => 'main', + 'sha' => 'def456', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'repo' => [ + 'id' => 1, + 'name' => 'repo', + 'full_name' => 'owner/repo', + 'html_url' => 'https://github.com/owner/repo', + 'private' => false, + ], + ]); + + $array = $base->toArray(); + + expect($array)->toBeArray() + ->and($array['ref'])->toBe('main'); +}); diff --git a/tests/Unit/DataTransferObjects/LabelTest.php b/tests/Unit/DataTransferObjects/LabelTest.php new file mode 100644 index 0000000..4120ecc --- /dev/null +++ b/tests/Unit/DataTransferObjects/LabelTest.php @@ -0,0 +1,43 @@ + 1, + 'name' => 'bug', + 'color' => 'ff0000', + 'description' => 'Something is broken', + ]); + + expect($label->id)->toBe(1) + ->and($label->name)->toBe('bug') + ->and($label->color)->toBe('ff0000') + ->and($label->description)->toBe('Something is broken'); +}); + +it('can create label with null description', function () { + $label = Label::fromArray([ + 'id' => 1, + 'name' => 'enhancement', + 'color' => '00ff00', + ]); + + expect($label->description)->toBeNull(); +}); + +it('can convert label to array', function () { + $label = Label::fromArray([ + 'id' => 1, + 'name' => 'bug', + 'color' => 'ff0000', + 'description' => 'Something is broken', + ]); + + $array = $label->toArray(); + + expect($array)->toBeArray() + ->and($array['name'])->toBe('bug'); +}); diff --git a/tests/Unit/DataTransferObjects/RepositoryTest.php b/tests/Unit/DataTransferObjects/RepositoryTest.php new file mode 100644 index 0000000..bf22f36 --- /dev/null +++ b/tests/Unit/DataTransferObjects/RepositoryTest.php @@ -0,0 +1,48 @@ + 1, + 'name' => 'repo', + 'full_name' => 'owner/repo', + 'html_url' => 'https://github.com/owner/repo', + 'private' => false, + ]); + + expect($repo->id)->toBe(1) + ->and($repo->name)->toBe('repo') + ->and($repo->fullName)->toBe('owner/repo') + ->and($repo->htmlUrl)->toBe('https://github.com/owner/repo') + ->and($repo->private)->toBeFalse(); +}); + +it('can create private repository', function () { + $repo = Repository::fromArray([ + 'id' => 1, + 'name' => 'private-repo', + 'full_name' => 'owner/private-repo', + 'html_url' => 'https://github.com/owner/private-repo', + 'private' => true, + ]); + + expect($repo->private)->toBeTrue(); +}); + +it('can convert repository to array', function () { + $repo = Repository::fromArray([ + 'id' => 1, + 'name' => 'repo', + 'full_name' => 'owner/repo', + 'html_url' => 'https://github.com/owner/repo', + 'private' => false, + ]); + + $array = $repo->toArray(); + + expect($array)->toBeArray() + ->and($array['full_name'])->toBe('owner/repo'); +}); diff --git a/tests/Unit/DataTransferObjects/ReviewTest.php b/tests/Unit/DataTransferObjects/ReviewTest.php new file mode 100644 index 0000000..edd79d8 --- /dev/null +++ b/tests/Unit/DataTransferObjects/ReviewTest.php @@ -0,0 +1,130 @@ + 1, + 'user' => [ + 'id' => 1, + 'login' => 'reviewer', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/reviewer', + 'type' => 'User', + ], + 'body' => 'LGTM!', + 'state' => 'APPROVED', + 'html_url' => 'https://github.com/owner/repo/pull/1#pullrequestreview-1', + 'submitted_at' => '2025-01-01T10:00:00Z', + ]); + + expect($review->id)->toBe(1) + ->and($review->body)->toBe('LGTM!') + ->and($review->state)->toBe('APPROVED') + ->and($review->user->login)->toBe('reviewer'); +}); + +it('can create review with null body', function () { + $review = Review::fromArray([ + 'id' => 1, + 'user' => [ + 'id' => 1, + 'login' => 'reviewer', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/reviewer', + 'type' => 'User', + ], + 'state' => 'APPROVED', + 'html_url' => 'https://github.com/owner/repo/pull/1#pullrequestreview-1', + 'submitted_at' => '2025-01-01T10:00:00Z', + ]); + + expect($review->body)->toBeNull(); +}); + +it('can convert review to array', function () { + $review = Review::fromArray([ + 'id' => 1, + 'user' => [ + 'id' => 1, + 'login' => 'reviewer', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/reviewer', + 'type' => 'User', + ], + 'body' => 'Needs work', + 'state' => 'CHANGES_REQUESTED', + 'html_url' => 'https://github.com/owner/repo/pull/1#pullrequestreview-1', + 'submitted_at' => '2025-01-01T10:00:00Z', + ]); + + $array = $review->toArray(); + + expect($array)->toBeArray() + ->and($array['state'])->toBe('CHANGES_REQUESTED'); +}); + +it('can check if review is approved', function () { + $review = Review::fromArray([ + 'id' => 1, + 'user' => [ + 'id' => 1, + 'login' => 'reviewer', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/reviewer', + 'type' => 'User', + ], + 'body' => 'LGTM', + 'state' => 'APPROVED', + 'html_url' => 'https://github.com/owner/repo/pull/1#pullrequestreview-1', + 'submitted_at' => '2025-01-01T10:00:00Z', + ]); + + expect($review->isApproved())->toBeTrue() + ->and($review->isChangesRequested())->toBeFalse() + ->and($review->isCommented())->toBeFalse(); +}); + +it('can check if review has changes requested', function () { + $review = Review::fromArray([ + 'id' => 1, + 'user' => [ + 'id' => 1, + 'login' => 'reviewer', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/reviewer', + 'type' => 'User', + ], + 'body' => 'Please fix', + 'state' => 'CHANGES_REQUESTED', + 'html_url' => 'https://github.com/owner/repo/pull/1#pullrequestreview-1', + 'submitted_at' => '2025-01-01T10:00:00Z', + ]); + + expect($review->isChangesRequested())->toBeTrue() + ->and($review->isApproved())->toBeFalse() + ->and($review->isCommented())->toBeFalse(); +}); + +it('can check if review is commented', function () { + $review = Review::fromArray([ + 'id' => 1, + 'user' => [ + 'id' => 1, + 'login' => 'reviewer', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/reviewer', + 'type' => 'User', + ], + 'body' => 'Just a comment', + 'state' => 'COMMENTED', + 'html_url' => 'https://github.com/owner/repo/pull/1#pullrequestreview-1', + 'submitted_at' => '2025-01-01T10:00:00Z', + ]); + + expect($review->isCommented())->toBeTrue() + ->and($review->isApproved())->toBeFalse() + ->and($review->isChangesRequested())->toBeFalse(); +}); diff --git a/tests/Unit/DataTransferObjects/UserTest.php b/tests/Unit/DataTransferObjects/UserTest.php new file mode 100644 index 0000000..a30a7db --- /dev/null +++ b/tests/Unit/DataTransferObjects/UserTest.php @@ -0,0 +1,37 @@ + 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ]); + + expect($user->id)->toBe(1) + ->and($user->login)->toBe('testuser') + ->and($user->avatarUrl)->toBe('https://example.com/avatar.jpg') + ->and($user->htmlUrl)->toBe('https://github.com/testuser') + ->and($user->type)->toBe('User'); +}); + +it('can convert user to array', function () { + $user = User::fromArray([ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ]); + + $array = $user->toArray(); + + expect($array)->toBeArray() + ->and($array['id'])->toBe(1) + ->and($array['login'])->toBe('testuser'); +}); diff --git a/tests/Unit/InterfaceTest.php b/tests/Unit/InterfaceTest.php new file mode 100644 index 0000000..e801a6d --- /dev/null +++ b/tests/Unit/InterfaceTest.php @@ -0,0 +1,90 @@ +toImplement(Commentable::class); +}); + +it('implements Labelable interface', function () { + expect(PullRequest::class)->toImplement(Labelable::class); +}); + +it('implements Assignable interface', function () { + expect(PullRequest::class)->toImplement(Assignable::class); +}); + +it('implements Closeable interface', function () { + expect(PullRequest::class)->toImplement(Closeable::class); +}); + +it('implements Auditable interface', function () { + expect(PullRequest::class)->toImplement(Auditable::class); +}); + +it('implements Reviewable interface', function () { + expect(PullRequest::class)->toImplement(Reviewable::class); +}); + +it('implements Mergeable interface', function () { + expect(PullRequest::class)->toImplement(Mergeable::class); +}); + +it('implements Checkable interface', function () { + expect(PullRequest::class)->toImplement(Checkable::class); +}); + +it('implements Diffable interface', function () { + expect(PullRequest::class)->toImplement(Diffable::class); +}); + +it('implements HasCommits interface', function () { + expect(PullRequest::class)->toImplement(HasCommits::class); +}); + +it('can be type-hinted as Commentable', function () { + $acceptsCommentable = function (Commentable $entity): string { + return $entity::class; + }; + + $pr = createTestPr(); + expect($acceptsCommentable($pr))->toBe(PullRequest::class); +}); + +it('can be type-hinted as Reviewable', function () { + $acceptsReviewable = function (Reviewable $entity): string { + return $entity::class; + }; + + $pr = createTestPr(); + expect($acceptsReviewable($pr))->toBe(PullRequest::class); +}); + +it('can be type-hinted as Checkable', function () { + $acceptsCheckable = function (Checkable $entity): string { + return $entity::class; + }; + + $pr = createTestPr(); + expect($acceptsCheckable($pr))->toBe(PullRequest::class); +}); + +function createTestPr(): PullRequest +{ + $connector = createMockConnector([]); + $prData = createTestPullRequestData(); + + return new PullRequest($connector, 'owner', 'repo', $prData); +} diff --git a/tests/Unit/PullRequestTest.php b/tests/Unit/PullRequestTest.php index 1b88dd6..cd27c2f 100644 --- a/tests/Unit/PullRequestTest.php +++ b/tests/Unit/PullRequestTest.php @@ -253,6 +253,65 @@ ->and($pr->changedFiles)->toBeNull(); }); +it('can check if pull request is draft', function () { + $data = [ + 'number' => 123, + 'title' => 'Draft PR', + 'state' => 'open', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'html_url' => 'https://github.com/owner/repo/pull/123', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-01T00:00:00Z', + 'draft' => true, + 'head' => [ + 'ref' => 'feature-branch', + 'sha' => 'abc123', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'repo' => [ + 'id' => 1, + 'name' => 'repo', + 'full_name' => 'owner/repo', + 'html_url' => 'https://github.com/owner/repo', + 'private' => false, + ], + ], + 'base' => [ + 'ref' => 'main', + 'sha' => 'def456', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'repo' => [ + 'id' => 1, + 'name' => 'repo', + 'full_name' => 'owner/repo', + 'html_url' => 'https://github.com/owner/repo', + 'private' => false, + ], + ], + ]; + + $pr = PullRequest::fromArray($data); + + expect($pr->isDraft())->toBeTrue(); +}); + it('can serialize pull request with stats to array', function () { $data = [ 'number' => 123, @@ -317,3 +376,123 @@ ->and($array['deletions'])->toBe(75) ->and($array['changed_files'])->toBe(10); }); + +it('can handle pull request with assignees and labels', function () { + $data = [ + 'number' => 123, + 'title' => 'Test PR', + 'state' => 'open', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'html_url' => 'https://github.com/owner/repo/pull/123', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-01T00:00:00Z', + 'draft' => false, + 'assignee' => [ + 'id' => 2, + 'login' => 'assignee1', + 'avatar_url' => 'https://example.com/avatar2.jpg', + 'html_url' => 'https://github.com/assignee1', + 'type' => 'User', + ], + 'assignees' => [ + [ + 'id' => 2, + 'login' => 'assignee1', + 'avatar_url' => 'https://example.com/avatar2.jpg', + 'html_url' => 'https://github.com/assignee1', + 'type' => 'User', + ], + [ + 'id' => 3, + 'login' => 'assignee2', + 'avatar_url' => 'https://example.com/avatar3.jpg', + 'html_url' => 'https://github.com/assignee2', + 'type' => 'User', + ], + ], + 'requested_reviewers' => [ + [ + 'id' => 4, + 'login' => 'reviewer1', + 'avatar_url' => 'https://example.com/avatar4.jpg', + 'html_url' => 'https://github.com/reviewer1', + 'type' => 'User', + ], + ], + 'labels' => [ + [ + 'id' => 1, + 'name' => 'bug', + 'color' => 'ff0000', + 'description' => 'Bug fix', + ], + [ + 'id' => 2, + 'name' => 'enhancement', + 'color' => '00ff00', + ], + ], + 'head' => [ + 'ref' => 'feature-branch', + 'sha' => 'abc123', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'repo' => [ + 'id' => 1, + 'name' => 'repo', + 'full_name' => 'owner/repo', + 'html_url' => 'https://github.com/owner/repo', + 'private' => false, + ], + ], + 'base' => [ + 'ref' => 'main', + 'sha' => 'def456', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'repo' => [ + 'id' => 1, + 'name' => 'repo', + 'full_name' => 'owner/repo', + 'html_url' => 'https://github.com/owner/repo', + 'private' => false, + ], + ], + ]; + + $pr = PullRequest::fromArray($data); + + expect($pr->assignee)->not->toBeNull() + ->and($pr->assignee->login)->toBe('assignee1') + ->and($pr->assignees)->toHaveCount(2) + ->and($pr->assignees[0]->login)->toBe('assignee1') + ->and($pr->assignees[1]->login)->toBe('assignee2') + ->and($pr->requestedReviewers)->toHaveCount(1) + ->and($pr->requestedReviewers[0]->login)->toBe('reviewer1') + ->and($pr->labels)->toHaveCount(2) + ->and($pr->labels[0]->name)->toBe('bug') + ->and($pr->labels[1]->name)->toBe('enhancement'); + + // Test toArray includes these fields + $array = $pr->toArray(); + expect($array['assignee'])->not->toBeNull() + ->and($array['assignees'])->toHaveCount(2) + ->and($array['requested_reviewers'])->toHaveCount(1) + ->and($array['labels'])->toHaveCount(2); +}); diff --git a/tests/Unit/PullRequestWrapperTest.php b/tests/Unit/PullRequestWrapperTest.php index eaae286..d56386f 100644 --- a/tests/Unit/PullRequestWrapperTest.php +++ b/tests/Unit/PullRequestWrapperTest.php @@ -476,3 +476,241 @@ function createTestPullRequestData(): PullRequestData expect($checks)->toBeArray() ->and($checks)->toBeEmpty(); }); + +it('can assign users to pull request', function () { + $connector = createMockConnector([[]]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $result = $pr->assign(['user1', 'user2']); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can unassign users from pull request', function () { + $connector = createMockConnector([[]]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $result = $pr->unassign(['user1']); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can get timeline from pull request', function () { + $mockTimeline = [ + [ + 'event' => 'labeled', + 'label' => ['name' => 'bug'], + 'created_at' => '2025-01-01T10:00:00Z', + ], + [ + 'event' => 'commented', + 'body' => 'This is a comment', + 'created_at' => '2025-01-01T11:00:00Z', + ], + ]; + + $connector = createMockConnector([$mockTimeline]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $timeline = $pr->timeline(); + + expect($timeline)->toBeArray() + ->and($timeline)->toHaveCount(2) + ->and($timeline[0]['event'])->toBe('labeled') + ->and($timeline[1]['event'])->toBe('commented'); +}); + +it('returns empty array when no timeline events', function () { + $connector = createMockConnector([[]]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $timeline = $pr->timeline(); + + expect($timeline)->toBeArray() + ->and($timeline)->toBeEmpty(); +}); + +it('can add labels to pull request', function () { + $connector = createMockConnector([[]]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $result = $pr->addLabels(['bug', 'enhancement']); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can remove label from pull request', function () { + $connector = createMockConnector([[]]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $result = $pr->removeLabel('bug'); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can add reviewers to pull request', function () { + $connector = createMockConnector([[]]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $result = $pr->addReviewers(['reviewer1'], ['team1']); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can remove reviewers from pull request', function () { + $connector = createMockConnector([[]]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $result = $pr->removeReviewers(['reviewer1'], ['team1']); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can close pull request', function () { + $connector = createMockConnector([[]]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $result = $pr->close(); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can reopen pull request', function () { + $connector = createMockConnector([[]]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $result = $pr->reopen(); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can update pull request', function () { + $connector = createMockConnector([[]]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $result = $pr->update(['title' => 'New title']); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can merge pull request', function () { + $connector = createMockConnector([[]]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $result = $pr->merge('squash', 'Commit title', 'Commit message'); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can add comment to pull request', function () { + $connector = createMockConnector([[]]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $result = $pr->comment('This is a comment'); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can add inline comment to pull request', function () { + $connector = createMockConnector([[]]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $result = $pr->comment('Inline comment', 10, 'src/file.php'); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can get reviews from pull request', function () { + $mockReviews = [ + [ + 'id' => 1, + 'user' => [ + 'id' => 1, + 'login' => 'reviewer1', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/reviewer1', + 'type' => 'User', + ], + 'body' => 'Looks good!', + 'state' => 'APPROVED', + 'html_url' => 'https://github.com/owner/repo/pull/123#pullrequestreview-1', + 'submitted_at' => '2025-01-01T10:00:00Z', + ], + ]; + + $connector = createMockConnector([$mockReviews]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $reviews = $pr->reviews(); + + expect($reviews)->toBeArray() + ->and($reviews)->toHaveCount(1) + ->and($reviews[0]->state)->toBe('APPROVED'); +}); + +it('can get pull request review comments', function () { + $mockComments = [ + [ + 'id' => 1, + 'user' => [ + 'id' => 1, + 'login' => 'commenter', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/commenter', + 'type' => 'User', + ], + 'body' => 'Nice code!', + 'html_url' => 'https://github.com/owner/repo/pull/123#discussion_r1', + 'created_at' => '2025-01-01T10:00:00Z', + 'updated_at' => '2025-01-01T10:00:00Z', + ], + ]; + + $connector = createMockConnector([$mockComments]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $comments = $pr->comments(); + + expect($comments)->toBeArray() + ->and($comments)->toHaveCount(1) + ->and($comments[0])->toBeInstanceOf(Comment::class) + ->and($comments[0]->body)->toBe('Nice code!'); +}); + +it('can convert pull request to array', function () { + $connector = createMockConnector([]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + $array = $pr->toArray(); + + expect($array)->toBeArray() + ->and($array['number'])->toBe(123) + ->and($array['title'])->toBe('Test PR'); +}); + +it('can access pull request data via magic getter', function () { + $connector = createMockConnector([]); + $prData = createTestPullRequestData(); + $pr = new PullRequest($connector, 'owner', 'repo', $prData); + + expect($pr->number)->toBe(123) + ->and($pr->title)->toBe('Test PR') + ->and($pr->state)->toBe('open'); +}); diff --git a/tests/Unit/PullRequestsFacadeTest.php b/tests/Unit/PullRequestsFacadeTest.php new file mode 100644 index 0000000..268e63b --- /dev/null +++ b/tests/Unit/PullRequestsFacadeTest.php @@ -0,0 +1,268 @@ + $data + */ + public function __construct(private array $data) + { + // Skip parent constructor + } + + public function json(string|int|null $key = null, mixed $default = null): mixed + { + if ($key !== null) { + return $this->data[$key] ?? $default; + } + + return $this->data; + } +} + +class PullRequestsFacadeTestConnector extends Connector +{ + private int $callIndex = 0; + + /** + * @var array> + */ + protected array $mockResponses = []; + + /** + * @param array> $responses + */ + public function __construct(array $responses = []) + { + parent::__construct('test-token'); + $this->mockResponses = $responses; + } + + public function send(Request $request, ...$args): Response + { + $response = $this->mockResponses[$this->callIndex++] ?? []; + + return new PullRequestsFacadeMockResponse($response); + } +} + +function createFacadeTestConnector(array $responses = []): Connector +{ + return new PullRequestsFacadeTestConnector($responses); +} + +function createFacadeMockPrData(): array +{ + return [ + 'number' => 1, + 'title' => 'Test PR', + 'body' => 'Test body', + 'state' => 'open', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'html_url' => 'https://github.com/owner/repo/pull/1', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-01T00:00:00Z', + 'draft' => false, + 'head' => [ + 'ref' => 'feature', + 'sha' => 'abc123', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'repo' => [ + 'id' => 1, + 'name' => 'repo', + 'full_name' => 'owner/repo', + 'html_url' => 'https://github.com/owner/repo', + 'private' => false, + ], + ], + 'base' => [ + 'ref' => 'main', + 'sha' => 'def456', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'repo' => [ + 'id' => 1, + 'name' => 'repo', + 'full_name' => 'owner/repo', + 'html_url' => 'https://github.com/owner/repo', + 'private' => false, + ], + ], + ]; +} + +beforeEach(function () { + // Reset static state before each test + PullRequests::setService(new GitHubPrService(createFacadeTestConnector([createFacadeMockPrData()]))); +}); + +it('throws exception when connector not configured', function () { + // Create a fresh instance without setting connector + $reflection = new ReflectionClass(PullRequests::class); + $serviceProperty = $reflection->getProperty('service'); + $serviceProperty->setAccessible(true); + $serviceProperty->setValue(null, null); + + $connectorProperty = $reflection->getProperty('defaultConnector'); + $connectorProperty->setAccessible(true); + $connectorProperty->setValue(null, null); + + PullRequests::find('owner/repo', 1); +})->throws(RuntimeException::class, 'PR service not configured'); + +it('can set connector via deprecated method', function () { + $connector = createFacadeTestConnector([createFacadeMockPrData()]); + PullRequests::setConnector($connector); + + $pr = PullRequests::find('owner/repo', 1); + + expect($pr)->toBeInstanceOf(PullRequest::class); +}); + +it('can set service directly', function () { + $connector = createFacadeTestConnector([createFacadeMockPrData()]); + $service = new GitHubPrService($connector); + PullRequests::setService($service); + + $pr = PullRequests::find('owner/repo', 1); + + expect($pr)->toBeInstanceOf(PullRequest::class); +}); + +it('static for returns query builder', function () { + $builder = PullRequests::for('owner/repo'); + + expect($builder)->toBeInstanceOf(QueryBuilder::class); +}); + +it('static find returns pull request', function () { + $connector = createFacadeTestConnector([createFacadeMockPrData()]); + PullRequests::setConnector($connector); + + $pr = PullRequests::find('owner/repo', 1); + + expect($pr)->toBeInstanceOf(PullRequest::class); +}); + +it('static create returns pull request', function () { + $connector = createFacadeTestConnector([createFacadeMockPrData()]); + PullRequests::setConnector($connector); + + $pr = PullRequests::create('owner/repo', [ + 'title' => 'Test', + 'head' => 'feature', + 'base' => 'main', + ]); + + expect($pr)->toBeInstanceOf(PullRequest::class); +}); + +it('static query returns query builder', function () { + $builder = PullRequests::query(); + + expect($builder)->toBeInstanceOf(QueryBuilder::class); +}); + +it('instance get returns pull request', function () { + $connector = createFacadeTestConnector([createFacadeMockPrData()]); + $prs = new PullRequests($connector, 'owner', 'repo'); + + $pr = $prs->get(1); + + expect($pr)->toBeInstanceOf(PullRequest::class); +}); + +it('instance list returns array of pull requests', function () { + $connector = createFacadeTestConnector([[createFacadeMockPrData()]]); + $prs = new PullRequests($connector, 'owner', 'repo'); + + $results = $prs->list(); + + expect($results)->toBeArray() + ->and($results[0])->toBeInstanceOf(PullRequest::class); +}); + +it('instance open returns open pull requests', function () { + $connector = createFacadeTestConnector([[createFacadeMockPrData()]]); + $prs = new PullRequests($connector, 'owner', 'repo'); + + $results = $prs->open(); + + expect($results)->toBeArray(); +}); + +it('instance closed returns closed pull requests', function () { + $connector = createFacadeTestConnector([[createFacadeMockPrData()]]); + $prs = new PullRequests($connector, 'owner', 'repo'); + + $results = $prs->closed(); + + expect($results)->toBeArray(); +}); + +it('instance createPullRequest returns pull request', function () { + $connector = createFacadeTestConnector([createFacadeMockPrData()]); + $prs = new PullRequests($connector, 'owner', 'repo'); + + $pr = $prs->createPullRequest([ + 'title' => 'Test', + 'head' => 'feature', + 'base' => 'main', + ]); + + expect($pr)->toBeInstanceOf(PullRequest::class); +}); + +it('instance update returns pull request', function () { + $connector = createFacadeTestConnector([createFacadeMockPrData()]); + $prs = new PullRequests($connector, 'owner', 'repo'); + + $pr = $prs->update(1, ['title' => 'Updated']); + + expect($pr)->toBeInstanceOf(PullRequest::class); +}); + +it('instance merge returns boolean', function () { + $connector = createFacadeTestConnector([['merged' => true]]); + $prs = new PullRequests($connector, 'owner', 'repo'); + + $result = $prs->merge(1, 'Merge commit', 'squash'); + + expect($result)->toBeTrue(); +}); + +it('instance close returns pull request', function () { + $connector = createFacadeTestConnector([createFacadeMockPrData()]); + $prs = new PullRequests($connector, 'owner', 'repo'); + + $pr = $prs->close(1); + + expect($pr)->toBeInstanceOf(PullRequest::class); +}); diff --git a/tests/Unit/QueryBuilderTest.php b/tests/Unit/QueryBuilderTest.php new file mode 100644 index 0000000..bea01c1 --- /dev/null +++ b/tests/Unit/QueryBuilderTest.php @@ -0,0 +1,262 @@ +> $data + */ + public function __construct(private array $data) + { + // Skip parent constructor + } + + public function json(string|int|null $key = null, mixed $default = null): mixed + { + return $this->data; + } +} + +class QueryBuilderTestConnector extends Connector +{ + /** + * @var array> + */ + protected array $mockResponse = []; + + /** + * @param array> $response + */ + public function __construct(array $response = []) + { + parent::__construct('test-token'); + $this->mockResponse = $response; + } + + public function send(Request $request, ...$args): Response + { + return new QueryBuilderMockResponse($this->mockResponse); + } +} + +function createQueryBuilderConnector(array $response = []): Connector +{ + return new QueryBuilderTestConnector($response); +} + +function createMockPrData(): array +{ + return [ + 'number' => 1, + 'title' => 'Test PR', + 'body' => 'Test body', + 'state' => 'open', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'html_url' => 'https://github.com/owner/repo/pull/1', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-01T00:00:00Z', + 'draft' => false, + 'head' => [ + 'ref' => 'feature', + 'sha' => 'abc123', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'repo' => [ + 'id' => 1, + 'name' => 'repo', + 'full_name' => 'owner/repo', + 'html_url' => 'https://github.com/owner/repo', + 'private' => false, + ], + ], + 'base' => [ + 'ref' => 'main', + 'sha' => 'def456', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'repo' => [ + 'id' => 1, + 'name' => 'repo', + 'full_name' => 'owner/repo', + 'html_url' => 'https://github.com/owner/repo', + 'private' => false, + ], + ], + ]; +} + +it('can set repository', function () { + $connector = createQueryBuilderConnector([createMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo'); + + expect($result)->toBeInstanceOf(QueryBuilder::class); +}); + +it('can filter by state', function () { + $connector = createQueryBuilderConnector([createMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->state('closed'); + + expect($result)->toBeInstanceOf(QueryBuilder::class); +}); + +it('can filter by open state', function () { + $connector = createQueryBuilderConnector([createMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->open(); + + expect($result)->toBeInstanceOf(QueryBuilder::class); +}); + +it('can filter by closed state', function () { + $connector = createQueryBuilderConnector([createMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->closed(); + + expect($result)->toBeInstanceOf(QueryBuilder::class); +}); + +it('can filter all states', function () { + $connector = createQueryBuilderConnector([createMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->all(); + + expect($result)->toBeInstanceOf(QueryBuilder::class); +}); + +it('can filter by author', function () { + $connector = createQueryBuilderConnector([createMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->author('testuser'); + + expect($result)->toBeInstanceOf(QueryBuilder::class); +}); + +it('can filter by label', function () { + $connector = createQueryBuilderConnector([createMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->label('bug'); + + expect($result)->toBeInstanceOf(QueryBuilder::class); +}); + +it('can set order by', function () { + $connector = createQueryBuilderConnector([createMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->orderBy('updated', 'asc'); + + expect($result)->toBeInstanceOf(QueryBuilder::class); +}); + +it('can set limit', function () { + $connector = createQueryBuilderConnector([createMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->take(10); + + expect($result)->toBeInstanceOf(QueryBuilder::class); +}); + +it('can set page', function () { + $connector = createQueryBuilderConnector([createMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->page(2); + + expect($result)->toBeInstanceOf(QueryBuilder::class); +}); + +it('can get pull requests', function () { + $connector = createQueryBuilderConnector([createMockPrData()]); + $builder = new QueryBuilder($connector); + + $results = $builder->repository('owner/repo')->get(); + + expect($results)->toBeArray() + ->and($results)->toHaveCount(1) + ->and($results[0])->toBeInstanceOf(PullRequest::class); +}); + +it('throws exception when repository not set', function () { + $connector = createQueryBuilderConnector([]); + $builder = new QueryBuilder($connector); + + $builder->get(); +})->throws(InvalidArgumentException::class, 'Repository is required'); + +it('can get first pull request', function () { + $connector = createQueryBuilderConnector([createMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->first(); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('returns null when no pull requests found', function () { + $connector = createQueryBuilderConnector([]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->first(); + + expect($result)->toBeNull(); +}); + +it('can count pull requests', function () { + $connector = createQueryBuilderConnector([createMockPrData(), createMockPrData()]); + $builder = new QueryBuilder($connector); + + $count = $builder->repository('owner/repo')->count(); + + expect($count)->toBe(2); +}); + +it('can chain multiple filters', function () { + $connector = createQueryBuilderConnector([createMockPrData()]); + $builder = new QueryBuilder($connector); + + $results = $builder + ->repository('owner/repo') + ->open() + ->author('testuser') + ->label('bug') + ->orderBy('updated', 'asc') + ->take(10) + ->page(1) + ->get(); + + expect($results)->toBeArray() + ->and($results)->toHaveCount(1); +}); diff --git a/tests/Unit/RequestsTest.php b/tests/Unit/RequestsTest.php new file mode 100644 index 0000000..595455c --- /dev/null +++ b/tests/Unit/RequestsTest.php @@ -0,0 +1,296 @@ +resolveEndpoint())->toBe('/repos/owner/repo/issues/123/assignees'); +}); + +it('RemoveAssignees has correct endpoint', function () { + $request = new RemoveAssignees('owner', 'repo', 123, ['user1']); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/123/assignees'); +}); + +it('GetIssueTimeline has correct endpoint', function () { + $request = new GetIssueTimeline('owner', 'repo', 123); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/123/timeline'); +}); + +it('AddIssueLabels has correct endpoint', function () { + $request = new AddIssueLabels('owner', 'repo', 123, ['bug', 'enhancement']); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/123/labels'); +}); + +it('CreateIssueComment has correct endpoint', function () { + $request = new CreateIssueComment('owner', 'repo', 123, 'Test comment'); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/123/comments'); +}); + +it('CreatePullRequest has correct endpoint', function () { + $request = new CreatePullRequest('owner', 'repo', ['title' => 'Test', 'head' => 'feature', 'base' => 'main']); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls'); +}); + +it('CreatePullRequestComment has correct endpoint', function () { + $request = new CreatePullRequestComment('owner', 'repo', 123, 'Test comment', 'file.php', 10); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/comments'); +}); + +it('CreatePullRequestReview has correct endpoint', function () { + $request = new CreatePullRequestReview('owner', 'repo', 123, 'APPROVE', 'LGTM'); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/reviews'); +}); + +it('GetCommitCheckRuns has correct endpoint', function () { + $request = new GetCommitCheckRuns('owner', 'repo', 'abc123'); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/commits/abc123/check-runs'); +}); + +it('GetIssueComments has correct endpoint', function () { + $request = new GetIssueComments('owner', 'repo', 123); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/123/comments?per_page=100&page=1'); +}); + +it('GetPullRequest has correct endpoint', function () { + $request = new GetPullRequest('owner', 'repo', 123); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123'); +}); + +it('GetPullRequestComments has correct endpoint', function () { + $request = new GetPullRequestComments('owner', 'repo', 123); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/comments'); +}); + +it('GetPullRequestCommits has correct endpoint', function () { + $request = new GetPullRequestCommits('owner', 'repo', 123); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/commits?per_page=100&page=1'); +}); + +it('GetPullRequestDiff has correct endpoint', function () { + $request = new GetPullRequestDiff('owner', 'repo', 123); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123'); +}); + +it('GetPullRequestFiles has correct endpoint', function () { + $request = new GetPullRequestFiles('owner', 'repo', 123); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/files'); +}); + +it('GetPullRequestReviews has correct endpoint', function () { + $request = new GetPullRequestReviews('owner', 'repo', 123); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/reviews'); +}); + +it('ListPullRequests has correct endpoint', function () { + $request = new ListPullRequests('owner', 'repo', ['state' => 'open']); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls?state=open'); +}); + +it('MergePullRequest has correct endpoint', function () { + $request = new MergePullRequest('owner', 'repo', 123, ['merge_method' => 'squash']); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/merge'); +}); + +it('RemoveIssueLabel has correct endpoint', function () { + $request = new RemoveIssueLabel('owner', 'repo', 123, 'bug'); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/123/labels/bug'); +}); + +it('RemoveReviewers has correct endpoint', function () { + $request = new RemoveReviewers('owner', 'repo', 123, ['user1'], ['team1']); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/requested_reviewers'); +}); + +it('RequestReviewers has correct endpoint', function () { + $request = new RequestReviewers('owner', 'repo', 123, ['user1'], ['team1']); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/requested_reviewers'); +}); + +it('UpdatePullRequest has correct endpoint', function () { + $request = new UpdatePullRequest('owner', 'repo', 123, ['title' => 'Updated']); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123'); +}); + +it('CreatePullRequestReview includes comments when provided', function () { + $comments = [ + ['path' => 'file.php', 'line' => 10, 'body' => 'Fix this'], + ]; + $request = new CreatePullRequestReview('owner', 'repo', 123, 'REQUEST_CHANGES', 'Please fix', $comments); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/reviews'); +}); + +it('RequestReviewers handles empty team reviewers', function () { + $request = new RequestReviewers('owner', 'repo', 123, ['user1'], []); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/requested_reviewers'); +}); + +it('RemoveReviewers handles empty team reviewers', function () { + $request = new RemoveReviewers('owner', 'repo', 123, ['user1'], []); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/requested_reviewers'); +}); + +// Body method tests +it('AddAssignees has correct body', function () { + $request = new AddAssignees('owner', 'repo', 123, ['user1', 'user2']); + + expect($request->body()->all())->toBe(['assignees' => ['user1', 'user2']]); +}); + +it('RemoveAssignees has correct body', function () { + $request = new RemoveAssignees('owner', 'repo', 123, ['user1']); + + expect($request->body()->all())->toBe(['assignees' => ['user1']]); +}); + +it('AddIssueLabels has correct body', function () { + $request = new AddIssueLabels('owner', 'repo', 123, ['bug', 'enhancement']); + + expect($request->body()->all())->toBe(['labels' => ['bug', 'enhancement']]); +}); + +it('CreateIssueComment has correct body', function () { + $request = new CreateIssueComment('owner', 'repo', 123, 'Test comment'); + + expect($request->body()->all())->toBe(['body' => 'Test comment']); +}); + +it('CreatePullRequest has correct body', function () { + $data = ['title' => 'Test', 'head' => 'feature', 'base' => 'main']; + $request = new CreatePullRequest('owner', 'repo', $data); + + expect($request->body()->all())->toBe($data); +}); + +it('CreatePullRequestComment has correct body', function () { + $request = new CreatePullRequestComment('owner', 'repo', 123, 'Test comment', 'file.php', 10); + + expect($request->body()->all())->toBe([ + 'body' => 'Test comment', + 'path' => 'file.php', + 'line' => 10, + ]); +}); + +it('MergePullRequest has correct body', function () { + $request = new MergePullRequest('owner', 'repo', 123, ['merge_method' => 'squash']); + + expect($request->body()->all())->toBe(['merge_method' => 'squash']); +}); + +it('UpdatePullRequest has correct body', function () { + $request = new UpdatePullRequest('owner', 'repo', 123, ['title' => 'Updated']); + + expect($request->body()->all())->toBe(['title' => 'Updated']); +}); + +it('RequestReviewers has correct body with reviewers', function () { + $request = new RequestReviewers('owner', 'repo', 123, ['user1', 'user2'], []); + + expect($request->body()->all())->toBe(['reviewers' => ['user1', 'user2']]); +}); + +it('RequestReviewers has correct body with team reviewers', function () { + $request = new RequestReviewers('owner', 'repo', 123, [], ['team1']); + + expect($request->body()->all())->toBe(['team_reviewers' => ['team1']]); +}); + +it('RequestReviewers has correct body with both', function () { + $request = new RequestReviewers('owner', 'repo', 123, ['user1'], ['team1']); + + expect($request->body()->all())->toBe([ + 'reviewers' => ['user1'], + 'team_reviewers' => ['team1'], + ]); +}); + +it('RemoveReviewers has correct body with reviewers', function () { + $request = new RemoveReviewers('owner', 'repo', 123, ['user1'], []); + + expect($request->body()->all())->toBe(['reviewers' => ['user1']]); +}); + +it('RemoveReviewers has correct body with team reviewers', function () { + $request = new RemoveReviewers('owner', 'repo', 123, [], ['team1']); + + expect($request->body()->all())->toBe(['team_reviewers' => ['team1']]); +}); + +it('RemoveReviewers has correct body with both', function () { + $request = new RemoveReviewers('owner', 'repo', 123, ['user1'], ['team1']); + + expect($request->body()->all())->toBe([ + 'reviewers' => ['user1'], + 'team_reviewers' => ['team1'], + ]); +}); + +// Query and Headers tests +it('GetIssueTimeline has correct query parameters', function () { + $request = new GetIssueTimeline('owner', 'repo', 123, 50, 2); + + expect($request->query()->all())->toBe(['per_page' => 50, 'page' => 2]); +}); + +it('GetIssueTimeline has correct headers', function () { + $request = new GetIssueTimeline('owner', 'repo', 123); + + expect($request->headers()->all())->toHaveKey('Accept') + ->and($request->headers()->get('Accept'))->toBe('application/vnd.github.mockingbird-preview+json'); +}); + +it('GetPullRequestDiff has correct headers', function () { + $request = new GetPullRequestDiff('owner', 'repo', 123); + + expect($request->headers()->all())->toHaveKey('Accept') + ->and($request->headers()->get('Accept'))->toBe('application/vnd.github.v3.diff'); +});