From 2d550c1122fa754611efc082e637f56fc290490e Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Thu, 18 Dec 2025 22:20:38 -0700 Subject: [PATCH] feat: add PullRequest DTO with chainable actions Added chainable action methods to PullRequest DTO that allow direct manipulation of pull requests without needing the wrapper class. The DTO now accepts optional connector, owner, and repo parameters to enable action methods. Changes: - Modified PullRequest::fromArray() to accept connector, owner, and repo parameters - Added chainable action methods: merge(), close(), reopen(), markDraft(), markReady() - Added review methods: approve(), requestChanges(), comment() - Added label management: addLabel(), addLabels(), removeLabel(), setLabels() - Added reviewer management: requestReviewer(), requestReviewers(), requestTeamReview() - Added assignee management: assign(), assignUser(), unassign() - Added convenience methods: squashMerge(), rebaseMerge() - Comprehensive test coverage with 26 test cases - 100% code coverage - All quality gates pass (PHPStan, Pint) Closes #21 --- src/DataTransferObjects/PullRequest.php | 355 ++++++++++++++++- .../PullRequestActionsTest.php | 360 ++++++++++++++++++ 2 files changed, 714 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/DataTransferObjects/PullRequestActionsTest.php 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); +});