From 20b492fbc1f1283fee84f32eaae0e6e5d2f85aaf Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Thu, 18 Dec 2025 22:19:53 -0700 Subject: [PATCH] feat: add core contracts for unimplemented features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds seven new interface contracts and two supporting DTOs that scope out the API surface for features still in flight (Checks, Merge strategies, Files API, PR creation, Comments). Implementation PRs for those features can target these contracts directly. New interfaces: - CheckRunQueryInterface (#23) - CommentManagerInterface (#29) - FileQueryInterface (#25) - MergeManagerInterface (#24) - PullRequestBuilderInterface (#26) - PullRequestManagerInterface - ReviewQueryInterface New DTOs: - CheckSummary — aggregated check run statistics - FileStats — aggregated file change statistics Excluded vs. the original branch: - PullRequestQueryInterface — already shipped in master via #38, kept as-is. - ReviewBuilderInterface — already shipped via #36, kept as-is. - PullRequestActionsInterface — the package adopted a role-trait pattern instead (Assignable, Closeable, Mergeable, Labelable, Commentable, Reviewable, Auditable), so the bundled actions interface is omitted. Closes #27. --- src/Contracts/CheckRunQueryInterface.php | 51 +++++ src/Contracts/CommentManagerInterface.php | 25 +++ src/Contracts/FileQueryInterface.php | 53 ++++++ src/Contracts/MergeManagerInterface.php | 23 +++ src/Contracts/PullRequestBuilderInterface.php | 27 +++ src/Contracts/PullRequestManagerInterface.php | 21 +++ src/Contracts/ReviewQueryInterface.php | 41 ++++ src/DataTransferObjects/CheckSummary.php | 62 ++++++ src/DataTransferObjects/FileStats.php | 53 ++++++ tests/Unit/ContractsTest.php | 176 ++++++++++++++++++ .../DataTransferObjects/CheckSummaryTest.php | 95 ++++++++++ .../DataTransferObjects/FileStatsTest.php | 66 +++++++ 12 files changed, 693 insertions(+) create mode 100644 src/Contracts/CheckRunQueryInterface.php create mode 100644 src/Contracts/CommentManagerInterface.php create mode 100644 src/Contracts/FileQueryInterface.php create mode 100644 src/Contracts/MergeManagerInterface.php create mode 100644 src/Contracts/PullRequestBuilderInterface.php create mode 100644 src/Contracts/PullRequestManagerInterface.php create mode 100644 src/Contracts/ReviewQueryInterface.php create mode 100644 src/DataTransferObjects/CheckSummary.php create mode 100644 src/DataTransferObjects/FileStats.php create mode 100644 tests/Unit/ContractsTest.php create mode 100644 tests/Unit/DataTransferObjects/CheckSummaryTest.php create mode 100644 tests/Unit/DataTransferObjects/FileStatsTest.php diff --git a/src/Contracts/CheckRunQueryInterface.php b/src/Contracts/CheckRunQueryInterface.php new file mode 100644 index 0000000..320225e --- /dev/null +++ b/src/Contracts/CheckRunQueryInterface.php @@ -0,0 +1,51 @@ + + */ + public function get(): Collection; + + /** + * @return Collection + */ + public function wherePassing(): Collection; + + /** + * @return Collection + */ + public function whereFailing(): Collection; + + /** + * @return Collection + */ + public function wherePending(): Collection; + + /** + * @return Collection + */ + public function whereNeutral(): Collection; + + /** + * @return Collection + */ + public function whereSkipped(): Collection; + + public function latest(): ?CheckRun; + + public function byName(string $name): ?CheckRun; + + public function summary(): CheckSummary; +} diff --git a/src/Contracts/CommentManagerInterface.php b/src/Contracts/CommentManagerInterface.php new file mode 100644 index 0000000..51b0a57 --- /dev/null +++ b/src/Contracts/CommentManagerInterface.php @@ -0,0 +1,25 @@ + + */ + public function get(): Collection; + + public function create(string $body): Comment; + + public function update(int $commentId, string $body): Comment; + + public function delete(int $commentId): bool; +} diff --git a/src/Contracts/FileQueryInterface.php b/src/Contracts/FileQueryInterface.php new file mode 100644 index 0000000..c5dc302 --- /dev/null +++ b/src/Contracts/FileQueryInterface.php @@ -0,0 +1,53 @@ + + */ + public function get(): Collection; + + /** + * @return Collection + */ + public function whereAdded(): Collection; + + /** + * @return Collection + */ + public function whereModified(): Collection; + + /** + * @return Collection + */ + public function whereRemoved(): Collection; + + /** + * @return Collection + */ + public function whereRenamed(): Collection; + + /** + * @param string $pattern Glob pattern for file paths + * @return Collection + */ + public function wherePath(string $pattern): Collection; + + /** + * @return Collection + */ + public function whereExtension(string $extension): Collection; + + public function stats(): FileStats; +} diff --git a/src/Contracts/MergeManagerInterface.php b/src/Contracts/MergeManagerInterface.php new file mode 100644 index 0000000..44accfe --- /dev/null +++ b/src/Contracts/MergeManagerInterface.php @@ -0,0 +1,23 @@ + + */ + public function get(): Collection; + + /** + * @return Collection + */ + public function whereApproved(): Collection; + + /** + * @return Collection + */ + public function whereChangesRequested(): Collection; + + /** + * @return Collection + */ + public function wherePending(): Collection; + + /** + * @return Collection + */ + public function byUser(string $username): Collection; + + public function latest(): ?Review; +} diff --git a/src/DataTransferObjects/CheckSummary.php b/src/DataTransferObjects/CheckSummary.php new file mode 100644 index 0000000..dac1cd4 --- /dev/null +++ b/src/DataTransferObjects/CheckSummary.php @@ -0,0 +1,62 @@ +total > 0 && $this->failing === 0 && $this->pending === 0; + } + + public function hasFailures(): bool + { + return $this->failing > 0; + } + + public function hasPending(): bool + { + return $this->pending > 0; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'total' => $this->total, + 'passing' => $this->passing, + 'failing' => $this->failing, + 'pending' => $this->pending, + 'neutral' => $this->neutral, + 'skipped' => $this->skipped, + ]; + } +} diff --git a/src/DataTransferObjects/FileStats.php b/src/DataTransferObjects/FileStats.php new file mode 100644 index 0000000..b512779 --- /dev/null +++ b/src/DataTransferObjects/FileStats.php @@ -0,0 +1,53 @@ + + */ + public function toArray(): array + { + return [ + 'total' => $this->total, + 'added' => $this->added, + 'modified' => $this->modified, + 'removed' => $this->removed, + 'renamed' => $this->renamed, + 'total_additions' => $this->totalAdditions, + 'total_deletions' => $this->totalDeletions, + 'total_changes' => $this->totalChanges, + ]; + } +} diff --git a/tests/Unit/ContractsTest.php b/tests/Unit/ContractsTest.php new file mode 100644 index 0000000..93beca0 --- /dev/null +++ b/tests/Unit/ContractsTest.php @@ -0,0 +1,176 @@ +hasMethod('find'))->toBeTrue() + ->and($reflection->hasMethod('get'))->toBeTrue() + ->and($reflection->hasMethod('query'))->toBeTrue() + ->and($reflection->hasMethod('create'))->toBeTrue(); + }); + + it('returns PullRequestQueryInterface from query method', function () { + $reflection = new ReflectionClass(PullRequestManagerInterface::class); + $method = $reflection->getMethod('query'); + + expect($method->getReturnType()?->getName())->toBe(PullRequestQueryInterface::class); + }); + + it('returns PullRequestBuilderInterface from create method', function () { + $reflection = new ReflectionClass(PullRequestManagerInterface::class); + $method = $reflection->getMethod('create'); + + expect($method->getReturnType()?->getName())->toBe(PullRequestBuilderInterface::class); + }); +}); + +describe('PullRequestBuilderInterface', function () { + it('defines all required builder methods', function () { + $reflection = new ReflectionClass(PullRequestBuilderInterface::class); + + expect($reflection->hasMethod('title'))->toBeTrue() + ->and($reflection->hasMethod('body'))->toBeTrue() + ->and($reflection->hasMethod('head'))->toBeTrue() + ->and($reflection->hasMethod('base'))->toBeTrue() + ->and($reflection->hasMethod('draft'))->toBeTrue() + ->and($reflection->hasMethod('maintainerCanModify'))->toBeTrue() + ->and($reflection->hasMethod('create'))->toBeTrue(); + }); + + it('has chainable methods returning self', function () { + $reflection = new ReflectionClass(PullRequestBuilderInterface::class); + + $title = $reflection->getMethod('title'); + expect($title->getReturnType()?->getName())->toBe('self'); + + $body = $reflection->getMethod('body'); + expect($body->getReturnType()?->getName())->toBe('self'); + + $head = $reflection->getMethod('head'); + expect($head->getReturnType()?->getName())->toBe('self'); + }); +}); + +describe('ReviewBuilderInterface', function () { + it('defines all required review builder methods', function () { + $reflection = new ReflectionClass(ReviewBuilderInterface::class); + + expect($reflection->hasMethod('approve'))->toBeTrue() + ->and($reflection->hasMethod('requestChanges'))->toBeTrue() + ->and($reflection->hasMethod('comment'))->toBeTrue() + ->and($reflection->hasMethod('addInlineComment'))->toBeTrue() + ->and($reflection->hasMethod('addSuggestion'))->toBeTrue() + ->and($reflection->hasMethod('submit'))->toBeTrue(); + }); + + it('has chainable methods returning self', function () { + $reflection = new ReflectionClass(ReviewBuilderInterface::class); + + $approve = $reflection->getMethod('approve'); + expect($approve->getReturnType()?->getName())->toBe('self'); + + $comment = $reflection->getMethod('comment'); + expect($comment->getReturnType()?->getName())->toBe('self'); + }); +}); + +describe('ReviewQueryInterface', function () { + it('defines all required review query methods', function () { + $reflection = new ReflectionClass(ReviewQueryInterface::class); + + expect($reflection->hasMethod('get'))->toBeTrue() + ->and($reflection->hasMethod('whereApproved'))->toBeTrue() + ->and($reflection->hasMethod('whereChangesRequested'))->toBeTrue() + ->and($reflection->hasMethod('wherePending'))->toBeTrue() + ->and($reflection->hasMethod('byUser'))->toBeTrue() + ->and($reflection->hasMethod('latest'))->toBeTrue(); + }); +}); + +describe('CheckRunQueryInterface', function () { + it('defines all required check run query methods', function () { + $reflection = new ReflectionClass(CheckRunQueryInterface::class); + + expect($reflection->hasMethod('get'))->toBeTrue() + ->and($reflection->hasMethod('wherePassing'))->toBeTrue() + ->and($reflection->hasMethod('whereFailing'))->toBeTrue() + ->and($reflection->hasMethod('wherePending'))->toBeTrue() + ->and($reflection->hasMethod('whereNeutral'))->toBeTrue() + ->and($reflection->hasMethod('whereSkipped'))->toBeTrue() + ->and($reflection->hasMethod('latest'))->toBeTrue() + ->and($reflection->hasMethod('byName'))->toBeTrue() + ->and($reflection->hasMethod('summary'))->toBeTrue(); + }); +}); + +describe('MergeManagerInterface', function () { + it('defines all required merge manager methods', function () { + $reflection = new ReflectionClass(MergeManagerInterface::class); + + expect($reflection->hasMethod('merge'))->toBeTrue() + ->and($reflection->hasMethod('squash'))->toBeTrue() + ->and($reflection->hasMethod('rebase'))->toBeTrue() + ->and($reflection->hasMethod('canMerge'))->toBeTrue() + ->and($reflection->hasMethod('deleteBranch'))->toBeTrue(); + }); +}); + +describe('FileQueryInterface', function () { + it('defines all required file query methods', function () { + $reflection = new ReflectionClass(FileQueryInterface::class); + + expect($reflection->hasMethod('get'))->toBeTrue() + ->and($reflection->hasMethod('whereAdded'))->toBeTrue() + ->and($reflection->hasMethod('whereModified'))->toBeTrue() + ->and($reflection->hasMethod('whereRemoved'))->toBeTrue() + ->and($reflection->hasMethod('whereRenamed'))->toBeTrue() + ->and($reflection->hasMethod('wherePath'))->toBeTrue() + ->and($reflection->hasMethod('whereExtension'))->toBeTrue() + ->and($reflection->hasMethod('stats'))->toBeTrue(); + }); +}); + +describe('CommentManagerInterface', function () { + it('defines all required comment manager methods', function () { + $reflection = new ReflectionClass(CommentManagerInterface::class); + + expect($reflection->hasMethod('get'))->toBeTrue() + ->and($reflection->hasMethod('create'))->toBeTrue() + ->and($reflection->hasMethod('update'))->toBeTrue() + ->and($reflection->hasMethod('delete'))->toBeTrue(); + }); +}); + +describe('Contract Type Safety', function () { + it('ensures ReviewQueryInterface methods return Collection', function () { + $reflection = new ReflectionClass(ReviewQueryInterface::class); + + $get = $reflection->getMethod('get'); + expect($get->getReturnType()?->getName())->toBe('Illuminate\Support\Collection'); + + $whereApproved = $reflection->getMethod('whereApproved'); + expect($whereApproved->getReturnType()?->getName())->toBe('Illuminate\Support\Collection'); + }); + + it('ensures nullable return types are properly defined', function () { + $reflection = new ReflectionClass(PullRequestQueryInterface::class); + $method = $reflection->getMethod('first'); + $returnType = $method->getReturnType(); + + expect($returnType)->not->toBeNull() + ->and($returnType->allowsNull())->toBeTrue(); + }); +}); diff --git a/tests/Unit/DataTransferObjects/CheckSummaryTest.php b/tests/Unit/DataTransferObjects/CheckSummaryTest.php new file mode 100644 index 0000000..ad9dd99 --- /dev/null +++ b/tests/Unit/DataTransferObjects/CheckSummaryTest.php @@ -0,0 +1,95 @@ + 10, + 'passing' => 8, + 'failing' => 1, + 'pending' => 1, + 'neutral' => 0, + 'skipped' => 0, + ]; + + $summary = CheckSummary::fromArray($data); + + expect($summary->total)->toBe(10) + ->and($summary->passing)->toBe(8) + ->and($summary->failing)->toBe(1) + ->and($summary->pending)->toBe(1) + ->and($summary->neutral)->toBe(0) + ->and($summary->skipped)->toBe(0); +}); + +it('can check if all checks are passing', function () { + $passingData = [ + 'total' => 5, + 'passing' => 5, + 'failing' => 0, + 'pending' => 0, + 'neutral' => 0, + 'skipped' => 0, + ]; + + $summary = CheckSummary::fromArray($passingData); + expect($summary->allPassing())->toBeTrue(); + + $failingData = [ + 'total' => 5, + 'passing' => 4, + 'failing' => 1, + 'pending' => 0, + 'neutral' => 0, + 'skipped' => 0, + ]; + + $summary = CheckSummary::fromArray($failingData); + expect($summary->allPassing())->toBeFalse(); +}); + +it('can check if has failures', function () { + $data = [ + 'total' => 5, + 'passing' => 4, + 'failing' => 1, + 'pending' => 0, + 'neutral' => 0, + 'skipped' => 0, + ]; + + $summary = CheckSummary::fromArray($data); + expect($summary->hasFailures())->toBeTrue(); +}); + +it('can check if has pending', function () { + $data = [ + 'total' => 5, + 'passing' => 4, + 'failing' => 0, + 'pending' => 1, + 'neutral' => 0, + 'skipped' => 0, + ]; + + $summary = CheckSummary::fromArray($data); + expect($summary->hasPending())->toBeTrue(); +}); + +it('can serialize to array', function () { + $data = [ + 'total' => 10, + 'passing' => 8, + 'failing' => 1, + 'pending' => 1, + 'neutral' => 0, + 'skipped' => 0, + ]; + + $summary = CheckSummary::fromArray($data); + $array = $summary->toArray(); + + expect($array)->toBe($data); +}); diff --git a/tests/Unit/DataTransferObjects/FileStatsTest.php b/tests/Unit/DataTransferObjects/FileStatsTest.php new file mode 100644 index 0000000..0be858a --- /dev/null +++ b/tests/Unit/DataTransferObjects/FileStatsTest.php @@ -0,0 +1,66 @@ + 10, + 'added' => 3, + 'modified' => 5, + 'removed' => 2, + 'renamed' => 0, + 'total_additions' => 150, + 'total_deletions' => 75, + 'total_changes' => 225, + ]; + + $stats = FileStats::fromArray($data); + + expect($stats->total)->toBe(10) + ->and($stats->added)->toBe(3) + ->and($stats->modified)->toBe(5) + ->and($stats->removed)->toBe(2) + ->and($stats->renamed)->toBe(0) + ->and($stats->totalAdditions)->toBe(150) + ->and($stats->totalDeletions)->toBe(75) + ->and($stats->totalChanges)->toBe(225); +}); + +it('can serialize to array', function () { + $data = [ + 'total' => 10, + 'added' => 3, + 'modified' => 5, + 'removed' => 2, + 'renamed' => 0, + 'total_additions' => 150, + 'total_deletions' => 75, + 'total_changes' => 225, + ]; + + $stats = FileStats::fromArray($data); + $array = $stats->toArray(); + + expect($array)->toBe($data); +}); + +it('handles zero values correctly', function () { + $data = [ + 'total' => 0, + 'added' => 0, + 'modified' => 0, + 'removed' => 0, + 'renamed' => 0, + 'total_additions' => 0, + 'total_deletions' => 0, + 'total_changes' => 0, + ]; + + $stats = FileStats::fromArray($data); + + expect($stats->total)->toBe(0) + ->and($stats->added)->toBe(0) + ->and($stats->totalChanges)->toBe(0); +});