diff --git a/src/DataTransferObjects/PullRequest.php b/src/DataTransferObjects/PullRequest.php index c702d6d..fd4e88a 100644 --- a/src/DataTransferObjects/PullRequest.php +++ b/src/DataTransferObjects/PullRequest.php @@ -4,6 +4,16 @@ namespace ConduitUI\Pr\DataTransferObjects; +use ConduitUi\GitHubConnector\Connector; +use ConduitUI\Pr\Requests\AddAssignees; +use ConduitUI\Pr\Requests\AddIssueLabels; +use ConduitUI\Pr\Requests\CreateIssueComment; +use ConduitUI\Pr\Requests\CreatePullRequestReview; +use ConduitUI\Pr\Requests\MergePullRequest; +use ConduitUI\Pr\Requests\RemoveAssignees; +use ConduitUI\Pr\Requests\RemoveIssueLabel; +use ConduitUI\Pr\Requests\RequestReviewers; +use ConduitUI\Pr\Requests\UpdatePullRequest; use DateTimeImmutable; class PullRequest @@ -35,12 +45,15 @@ public function __construct( public readonly array $labels, public readonly Head $head, public readonly Base $base, + private readonly ?Connector $connector = null, + private readonly ?string $owner = null, + private readonly ?string $repo = null, ) {} /** * @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 + public static function fromArray(array $data, ?Connector $connector = null, ?string $owner = null, ?string $repo = null): self { return new self( number: $data['number'], @@ -64,6 +77,9 @@ public static function fromArray(array $data): self labels: array_map(fn ($label) => Label::fromArray($label), $data['labels'] ?? []), head: Head::fromArray($data['head']), base: Base::fromArray($data['base']), + connector: $connector, + owner: $owner, + repo: $repo, ); } @@ -87,6 +103,343 @@ public function isDraft(): bool return $this->draft; } + /** + * Merge the pull request. + */ + public function merge(string $method = 'merge', ?string $message = null, ?string $title = null): self + { + $this->ensureConnectorAvailable(); + + $payload = ['merge_method' => $method]; + + if ($title !== null) { + $payload['commit_title'] = $title; + } + + if ($message !== null) { + $payload['commit_message'] = $message; + } + + $this->connector->send(new MergePullRequest( + $this->owner, + $this->repo, + $this->number, + $payload + )); + + return $this; + } + + /** + * Squash merge the pull request. + */ + public function squashMerge(?string $message = null): self + { + return $this->merge('squash', $message); + } + + /** + * Rebase merge the pull request. + */ + public function rebaseMerge(): self + { + return $this->merge('rebase'); + } + + /** + * Close the pull request. + */ + public function close(): self + { + $this->ensureConnectorAvailable(); + + $this->connector->send(new UpdatePullRequest( + $this->owner, + $this->repo, + $this->number, + ['state' => 'closed'] + )); + + return $this; + } + + /** + * Reopen the pull request. + */ + public function reopen(): self + { + $this->ensureConnectorAvailable(); + + $this->connector->send(new UpdatePullRequest( + $this->owner, + $this->repo, + $this->number, + ['state' => 'open'] + )); + + return $this; + } + + /** + * Mark the pull request as draft. + */ + public function markDraft(): self + { + $this->ensureConnectorAvailable(); + + $this->connector->send(new UpdatePullRequest( + $this->owner, + $this->repo, + $this->number, + ['draft' => true] + )); + + return $this; + } + + /** + * Mark the pull request as ready for review. + */ + public function markReady(): self + { + $this->ensureConnectorAvailable(); + + $this->connector->send(new UpdatePullRequest( + $this->owner, + $this->repo, + $this->number, + ['draft' => false] + )); + + return $this; + } + + /** + * Approve the pull request. + */ + public function approve(?string $body = null): self + { + return $this->submitReview('APPROVE', $body); + } + + /** + * Request changes on the pull request. + */ + public function requestChanges(string $body): self + { + return $this->submitReview('REQUEST_CHANGES', $body); + } + + /** + * Submit a review on the pull request. + * + * @param array $comments + */ + public function submitReview(string $event, ?string $body = null, array $comments = []): self + { + $this->ensureConnectorAvailable(); + + $this->connector->send(new CreatePullRequestReview( + $this->owner, + $this->repo, + $this->number, + $event, + $body, + $comments + )); + + return $this; + } + + /** + * Add a comment to the pull request. + */ + public function comment(string $body): self + { + $this->ensureConnectorAvailable(); + + $this->connector->send(new CreateIssueComment( + $this->owner, + $this->repo, + $this->number, + $body + )); + + return $this; + } + + /** + * Add a single label to the pull request. + */ + public function addLabel(string $label): self + { + return $this->addLabels([$label]); + } + + /** + * Add multiple labels to the pull request. + * + * @param array $labels + */ + public function addLabels(array $labels): self + { + $this->ensureConnectorAvailable(); + + $this->connector->send(new AddIssueLabels( + $this->owner, + $this->repo, + $this->number, + $labels + )); + + return $this; + } + + /** + * Remove a label from the pull request. + */ + public function removeLabel(string $label): self + { + $this->ensureConnectorAvailable(); + + $this->connector->send(new RemoveIssueLabel( + $this->owner, + $this->repo, + $this->number, + $label + )); + + return $this; + } + + /** + * Set labels on the pull request (replaces all existing labels). + * + * @param array $labels + */ + public function setLabels(array $labels): self + { + $this->ensureConnectorAvailable(); + + $this->connector->send(new UpdatePullRequest( + $this->owner, + $this->repo, + $this->number, + ['labels' => $labels] + )); + + return $this; + } + + /** + * Request a single reviewer. + */ + public function requestReviewer(string $username): self + { + return $this->requestReviewers([$username]); + } + + /** + * Request multiple reviewers. + * + * @param array $usernames + */ + public function requestReviewers(array $usernames): self + { + $this->ensureConnectorAvailable(); + + $this->connector->send(new RequestReviewers( + $this->owner, + $this->repo, + $this->number, + $usernames, + [] + )); + + return $this; + } + + /** + * Request a team review. + */ + public function requestTeamReview(string $teamSlug): self + { + $this->ensureConnectorAvailable(); + + $this->connector->send(new RequestReviewers( + $this->owner, + $this->repo, + $this->number, + [], + [$teamSlug] + )); + + return $this; + } + + /** + * Assign a single user to the pull request. + */ + public function assignUser(string $username): self + { + return $this->assign([$username]); + } + + /** + * Assign users to the pull request. + * + * @param array $usernames + */ + public function assign(array $usernames): self + { + $this->ensureConnectorAvailable(); + + $this->connector->send(new AddAssignees( + $this->owner, + $this->repo, + $this->number, + $usernames + )); + + return $this; + } + + /** + * Unassign users from the pull request. + * + * @param array $usernames + */ + public function unassign(array $usernames): self + { + $this->ensureConnectorAvailable(); + + $this->connector->send(new RemoveAssignees( + $this->owner, + $this->repo, + $this->number, + $usernames + )); + + return $this; + } + + /** + * Ensure connector is available for actions. + * + * @phpstan-assert !null $this->connector + * @phpstan-assert !null $this->owner + * @phpstan-assert !null $this->repo + */ + private function ensureConnectorAvailable(): void + { + if ($this->connector === null || $this->owner === null || $this->repo === null) { + throw new \RuntimeException( + 'Connector, owner, and repo must be provided to perform actions on the pull request. ' + .'Use PullRequest::fromArray($data, $connector, $owner, $repo) to enable actions.' + ); + } + } + /** * @return array */ diff --git a/tests/Unit/DataTransferObjects/PullRequestActionsTest.php b/tests/Unit/DataTransferObjects/PullRequestActionsTest.php new file mode 100644 index 0000000..142ad34 --- /dev/null +++ b/tests/Unit/DataTransferObjects/PullRequestActionsTest.php @@ -0,0 +1,360 @@ +mockClient = new MockClient([ + MockResponse::make(['status' => 'success'], 200), + ]); + + $this->connector = new Connector('fake-token'); + $this->connector->withMockClient($this->mockClient); + + $this->prData = [ + 'number' => 123, + 'title' => 'Test PR', + 'body' => 'Test description', + '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, + '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, + ], + ], + ]; +}); + +it('can create PR DTO with connector', function () { + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + + expect($pr)->toBeInstanceOf(PullRequest::class) + ->and($pr->number)->toBe(123) + ->and($pr->title)->toBe('Test PR'); +}); + +it('can merge a PR', function () { + $this->mockClient->addResponse( + MockResponse::make(['merged' => true], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->merge(); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can merge PR with squash strategy', function () { + $this->mockClient->addResponse( + MockResponse::make(['merged' => true], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->merge('squash', 'Squashed commit'); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can close a PR', function () { + $this->mockClient->addResponse( + MockResponse::make(['state' => 'closed'], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->close(); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can reopen a PR', function () { + $this->mockClient->addResponse( + MockResponse::make(['state' => 'open'], 200) + ); + + $closedPrData = array_merge($this->prData, ['state' => 'closed']); + $pr = PullRequest::fromArray($closedPrData, $this->connector, 'owner', 'repo'); + $result = $pr->reopen(); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can mark PR as draft', function () { + $this->mockClient->addResponse( + MockResponse::make(['draft' => true], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->markDraft(); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can mark PR as ready', function () { + $this->mockClient->addResponse( + MockResponse::make(['draft' => false], 200) + ); + + $draftPrData = array_merge($this->prData, ['draft' => true]); + $pr = PullRequest::fromArray($draftPrData, $this->connector, 'owner', 'repo'); + $result = $pr->markReady(); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can request reviewers', function () { + $this->mockClient->addResponse( + MockResponse::make(['requested_reviewers' => [['login' => 'user1']]], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->requestReviewers(['user1', 'user2']); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can request single reviewer', function () { + $this->mockClient->addResponse( + MockResponse::make(['requested_reviewers' => [['login' => 'user1']]], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->requestReviewer('user1'); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can request team reviewers', function () { + $this->mockClient->addResponse( + MockResponse::make(['requested_teams' => [['slug' => 'team1']]], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->requestTeamReview('team1'); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can add labels', function () { + $this->mockClient->addResponse( + MockResponse::make([ + ['name' => 'bug'], + ['name' => 'ready-for-review'], + ], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->addLabels(['bug', 'ready-for-review']); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can add single label', function () { + $this->mockClient->addResponse( + MockResponse::make([['name' => 'bug']], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->addLabel('bug'); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can remove label', function () { + $this->mockClient->addResponse( + MockResponse::make([], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->removeLabel('bug'); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can set labels', function () { + $this->mockClient->addResponse( + MockResponse::make([ + ['name' => 'enhancement'], + ['name' => 'documentation'], + ], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->setLabels(['enhancement', 'documentation']); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can assign users', function () { + $this->mockClient->addResponse( + MockResponse::make(['assignees' => [['login' => 'user1']]], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->assign(['user1']); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can assign single user', function () { + $this->mockClient->addResponse( + MockResponse::make(['assignees' => [['login' => 'user1']]], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->assignUser('user1'); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can unassign users', function () { + $this->mockClient->addResponse( + MockResponse::make(['assignees' => []], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->unassign(['user1']); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can approve PR', function () { + $this->mockClient->addResponse( + MockResponse::make(['state' => 'APPROVED'], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->approve('LGTM!'); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can request changes on PR', function () { + $this->mockClient->addResponse( + MockResponse::make(['state' => 'CHANGES_REQUESTED'], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->requestChanges('Please fix the tests'); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can comment on PR', function () { + $this->mockClient->addResponse( + MockResponse::make(['body' => 'Test comment'], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->comment('Test comment'); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can chain multiple actions', function () { + $this->mockClient->addResponses([ + MockResponse::make([['name' => 'ready']], 200), + MockResponse::make(['requested_reviewers' => [['login' => 'senior-dev']]], 200), + MockResponse::make(['body' => 'Ready for review'], 200), + ]); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + + $result = $pr->addLabel('ready') + ->requestReviewer('senior-dev') + ->comment('Ready for review'); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('works without connector for read-only operations', function () { + $pr = PullRequest::fromArray($this->prData); + + expect($pr)->toBeInstanceOf(PullRequest::class) + ->and($pr->number)->toBe(123) + ->and($pr->isOpen())->toBeTrue() + ->and($pr->isDraft())->toBeFalse(); +}); + +it('throws exception when trying to perform actions without connector', function () { + $pr = PullRequest::fromArray($this->prData); + + expect(fn () => $pr->merge())->toThrow(\RuntimeException::class); +}); + +it('can use squashMerge helper', function () { + $this->mockClient->addResponse( + MockResponse::make(['merged' => true], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->squashMerge('Squashed commit message'); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can use rebaseMerge helper', function () { + $this->mockClient->addResponse( + MockResponse::make(['merged' => true], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->rebaseMerge(); + + expect($result)->toBeInstanceOf(PullRequest::class); +}); + +it('can merge PR with custom title', function () { + $this->mockClient->addResponse( + MockResponse::make(['merged' => true], 200) + ); + + $pr = PullRequest::fromArray($this->prData, $this->connector, 'owner', 'repo'); + $result = $pr->merge('merge', 'Custom message', 'Custom title'); + + expect($result)->toBeInstanceOf(PullRequest::class); +});