From 36c590ef2466f027d5cf7b447962c2ceab9e8918 Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Thu, 18 Dec 2025 22:24:03 -0700 Subject: [PATCH] feat: add comprehensive contracts and interfaces architecture This commit implements a complete contracts/interfaces architecture following the interface-first pattern, establishing explicit contracts for major components to facilitate testing and enable dependency injection. ## New Contracts ### Query Interfaces - IssueQueryInterface: Provides filtering (state, labels, people, time), sorting, pagination, and execution methods - MilestoneQueryInterface: Manages milestone queries with state, sorting, and pagination options ### Manager Interfaces - IssueManagerInterface: Handles issue data retrieval, updates, label/assignment management, state control, and relations to comments/reactions - CommentManagerInterface: Manages comment operations including creation, updates, and deletion - LabelManagerInterface: Controls label addition, removal, and clearing - ReactionManagerInterface: Manages emoji reactions with type filtering and shortcuts - MilestoneManagerInterface: Oversees milestone CRUD operations and querying - RepositoryLabelManagerInterface: Repository-level label management ### Additional Interfaces - IssuesFacadeInterface: High-level facade for unified issue operations - IssueBuilderInterface: Fluent interface for building issues - LabelBuilderInterface: Fluent interface for building labels - MilestoneBuilderInterface: Fluent interface for building milestones ## Benefits - Clear contracts for all major components - Streamlined testing via interface mocking - Flexible dependency injection - Focused interface design - Implementation substitutability - Living documentation - Enhanced IDE support ## Testing - 100% test coverage maintained - All contracts validated with comprehensive architecture tests - PHPStan analysis passes with no errors Closes #26 --- src/Contracts/CommentManagerInterface.php | 44 ++++ src/Contracts/IssueBuilderInterface.php | 67 +++++ src/Contracts/IssueManagerInterface.php | 114 ++++++++ src/Contracts/IssueQueryInterface.php | 86 ++++++ src/Contracts/IssuesFacadeInterface.php | 44 ++++ src/Contracts/LabelBuilderInterface.php | 43 +++ src/Contracts/LabelManagerInterface.php | 42 +++ src/Contracts/MilestoneBuilderInterface.php | 48 ++++ src/Contracts/MilestoneManagerInterface.php | 55 ++++ src/Contracts/MilestoneQueryInterface.php | 59 +++++ src/Contracts/ReactionManagerInterface.php | 76 ++++++ .../RepositoryLabelManagerInterface.php | 48 ++++ tests/Architecture/ContractsTest.php | 248 ++++++++++++++++++ 13 files changed, 974 insertions(+) create mode 100644 src/Contracts/CommentManagerInterface.php create mode 100644 src/Contracts/IssueBuilderInterface.php create mode 100644 src/Contracts/IssueManagerInterface.php create mode 100644 src/Contracts/IssueQueryInterface.php create mode 100644 src/Contracts/IssuesFacadeInterface.php create mode 100644 src/Contracts/LabelBuilderInterface.php create mode 100644 src/Contracts/LabelManagerInterface.php create mode 100644 src/Contracts/MilestoneBuilderInterface.php create mode 100644 src/Contracts/MilestoneManagerInterface.php create mode 100644 src/Contracts/MilestoneQueryInterface.php create mode 100644 src/Contracts/ReactionManagerInterface.php create mode 100644 src/Contracts/RepositoryLabelManagerInterface.php create mode 100644 tests/Architecture/ContractsTest.php diff --git a/src/Contracts/CommentManagerInterface.php b/src/Contracts/CommentManagerInterface.php new file mode 100644 index 00000000..983a3219 --- /dev/null +++ b/src/Contracts/CommentManagerInterface.php @@ -0,0 +1,44 @@ + + */ + public function list(int $issueNumber): Collection; + + /** + * Get a specific comment by ID. + */ + public function get(int $commentId): Comment; +} diff --git a/src/Contracts/IssueBuilderInterface.php b/src/Contracts/IssueBuilderInterface.php new file mode 100644 index 00000000..41af6ea0 --- /dev/null +++ b/src/Contracts/IssueBuilderInterface.php @@ -0,0 +1,67 @@ + $assignees + */ + public function assignees(array $assignees): self; + + /** + * Add a single assignee. + */ + public function assignee(string $assignee): self; + + /** + * Set the issue labels. + * + * @param array $labels + */ + public function labels(array $labels): self; + + /** + * Add a single label. + */ + public function label(string $label): self; + + /** + * Set the milestone number. + */ + public function milestone(int $milestoneNumber): self; + + /** + * Create the issue and return the Issue data object. + */ + public function create(): Issue; + + /** + * Get the raw data array without creating the issue. + * + * @return array + */ + public function toArray(): array; +} diff --git a/src/Contracts/IssueManagerInterface.php b/src/Contracts/IssueManagerInterface.php new file mode 100644 index 00000000..20a7f8e5 --- /dev/null +++ b/src/Contracts/IssueManagerInterface.php @@ -0,0 +1,114 @@ + $data + */ + public function create(array $data): Issue; + + /** + * Update an existing issue. + * + * @param array $data + */ + public function update(int $issueNumber, array $data): Issue; + + /** + * Close an issue with an optional comment. + */ + public function close(int $issueNumber, ?string $comment = null): Issue; + + /** + * Reopen a closed issue. + */ + public function reopen(int $issueNumber): Issue; + + /** + * Lock an issue with an optional reason. + */ + public function lock(int $issueNumber, ?string $reason = null): bool; + + /** + * Unlock an issue. + */ + public function unlock(int $issueNumber): bool; + + /** + * Add assignees to an issue. + * + * @param array $assignees + */ + public function addAssignees(int $issueNumber, array $assignees): Issue; + + /** + * Remove assignees from an issue. + * + * @param array $assignees + */ + public function removeAssignees(int $issueNumber, array $assignees): Issue; + + /** + * Add labels to an issue. + * + * @param array $labels + */ + public function addLabels(int $issueNumber, array $labels): Issue; + + /** + * Remove labels from an issue. + * + * @param array $labels + */ + public function removeLabels(int $issueNumber, array $labels): Issue; + + /** + * Replace all labels on an issue. + * + * @param array $labels + */ + public function replaceLabels(int $issueNumber, array $labels): Issue; + + /** + * Remove all labels from an issue. + */ + public function clearLabels(int $issueNumber): Issue; + + /** + * Get all comments for an issue. + * + * @return \Illuminate\Support\Collection + */ + public function comments(int $issueNumber): Collection; + + /** + * Add a comment to an issue. + */ + public function addComment(int $issueNumber, string $body): Comment; +} diff --git a/src/Contracts/IssueQueryInterface.php b/src/Contracts/IssueQueryInterface.php new file mode 100644 index 00000000..fc743376 --- /dev/null +++ b/src/Contracts/IssueQueryInterface.php @@ -0,0 +1,86 @@ +|string $labels + */ + public function labels(array|string $labels): self; + + /** + * Filter issues by assignee username. + */ + public function assignee(string $username): self; + + /** + * Filter issues by creator username. + */ + public function creator(string $username): self; + + /** + * Filter issues mentioning a specific user. + */ + public function mentioned(string $username): self; + + /** + * Filter issues updated since a given date. + */ + public function since(string|\DateTimeInterface $date): self; + + /** + * Sort issues by created, updated, or comments. + */ + public function sort(string $field): self; + + /** + * Set sort direction (asc or desc). + */ + public function direction(string $direction): self; + + /** + * Set the number of results per page. + */ + public function perPage(int $perPage): self; + + /** + * Set the page number. + */ + public function page(int $page): self; + + /** + * Execute the query and return all matching issues. + * + * @return \Illuminate\Support\Collection + */ + public function get(): Collection; + + /** + * Execute the query and return the first matching issue. + */ + public function first(): ?Issue; + + /** + * Execute the query and return the count of matching issues. + */ + public function count(): int; +} diff --git a/src/Contracts/IssuesFacadeInterface.php b/src/Contracts/IssuesFacadeInterface.php new file mode 100644 index 00000000..ad74d3aa --- /dev/null +++ b/src/Contracts/IssuesFacadeInterface.php @@ -0,0 +1,44 @@ + + */ + public function toArray(): array; +} diff --git a/src/Contracts/LabelManagerInterface.php b/src/Contracts/LabelManagerInterface.php new file mode 100644 index 00000000..80237c7b --- /dev/null +++ b/src/Contracts/LabelManagerInterface.php @@ -0,0 +1,42 @@ + $labels + */ + public function add(int $issueNumber, array $labels): Issue; + + /** + * Remove labels from an issue. + * + * @param array $labels + */ + public function remove(int $issueNumber, array $labels): Issue; + + /** + * Replace all labels on an issue. + * + * @param array $labels + */ + public function replace(int $issueNumber, array $labels): Issue; + + /** + * Remove all labels from an issue. + */ + public function clear(int $issueNumber): Issue; +} diff --git a/src/Contracts/MilestoneBuilderInterface.php b/src/Contracts/MilestoneBuilderInterface.php new file mode 100644 index 00000000..83108b67 --- /dev/null +++ b/src/Contracts/MilestoneBuilderInterface.php @@ -0,0 +1,48 @@ + + */ + public function toArray(): array; +} diff --git a/src/Contracts/MilestoneManagerInterface.php b/src/Contracts/MilestoneManagerInterface.php new file mode 100644 index 00000000..070438c9 --- /dev/null +++ b/src/Contracts/MilestoneManagerInterface.php @@ -0,0 +1,55 @@ + $data + */ + public function create(array $data): Milestone; + + /** + * Update an existing milestone. + * + * @param array $data + */ + public function update(int $milestoneNumber, array $data): Milestone; + + /** + * Delete a milestone. + */ + public function delete(int $milestoneNumber): bool; + + /** + * Close a milestone. + */ + public function close(int $milestoneNumber): Milestone; + + /** + * Reopen a closed milestone. + */ + public function reopen(int $milestoneNumber): Milestone; +} diff --git a/src/Contracts/MilestoneQueryInterface.php b/src/Contracts/MilestoneQueryInterface.php new file mode 100644 index 00000000..a9f98b34 --- /dev/null +++ b/src/Contracts/MilestoneQueryInterface.php @@ -0,0 +1,59 @@ + + */ + public function get(): Collection; + + /** + * Execute the query and return the first matching milestone. + */ + public function first(): ?Milestone; + + /** + * Execute the query and return the count of matching milestones. + */ + public function count(): int; +} diff --git a/src/Contracts/ReactionManagerInterface.php b/src/Contracts/ReactionManagerInterface.php new file mode 100644 index 00000000..13e82d44 --- /dev/null +++ b/src/Contracts/ReactionManagerInterface.php @@ -0,0 +1,76 @@ + $filters + * @return \Illuminate\Support\Collection + */ + public function list(int $commentId, array $filters = []): Collection; + + /** + * Add a thumbs up reaction. + */ + public function thumbsUp(int $commentId): Reaction; + + /** + * Add a thumbs down reaction. + */ + public function thumbsDown(int $commentId): Reaction; + + /** + * Add a laugh reaction. + */ + public function laugh(int $commentId): Reaction; + + /** + * Add a hooray reaction. + */ + public function hooray(int $commentId): Reaction; + + /** + * Add a confused reaction. + */ + public function confused(int $commentId): Reaction; + + /** + * Add a heart reaction. + */ + public function heart(int $commentId): Reaction; + + /** + * Add a rocket reaction. + */ + public function rocket(int $commentId): Reaction; + + /** + * Add an eyes reaction. + */ + public function eyes(int $commentId): Reaction; +} diff --git a/src/Contracts/RepositoryLabelManagerInterface.php b/src/Contracts/RepositoryLabelManagerInterface.php new file mode 100644 index 00000000..f6bcb045 --- /dev/null +++ b/src/Contracts/RepositoryLabelManagerInterface.php @@ -0,0 +1,48 @@ + + */ + public function list(): Collection; + + /** + * Get a specific label by name. + */ + public function get(string $name): Label; + + /** + * Create a new label in the repository. + * + * @param array $data + */ + public function create(array $data): Label; + + /** + * Update an existing label. + * + * @param array $data + */ + public function update(string $name, array $data): Label; + + /** + * Delete a label from the repository. + */ + public function delete(string $name): bool; +} diff --git a/tests/Architecture/ContractsTest.php b/tests/Architecture/ContractsTest.php new file mode 100644 index 00000000..4ce886e0 --- /dev/null +++ b/tests/Architecture/ContractsTest.php @@ -0,0 +1,248 @@ +expect('ConduitUI\Issue\Contracts') + ->toBeInterfaces(); + +arch('contracts have Interface suffix') + ->expect('ConduitUI\Issue\Contracts') + ->toHaveSuffix('Interface'); + +describe('Query Contracts', function () { + it('IssueQueryInterface has required filtering methods', function () { + $reflection = new ReflectionClass(IssueQueryInterface::class); + $methods = array_map(fn ($m) => $m->getName(), $reflection->getMethods()); + + expect($methods)->toContain('state') + ->and($methods)->toContain('labels') + ->and($methods)->toContain('assignee') + ->and($methods)->toContain('creator') + ->and($methods)->toContain('mentioned') + ->and($methods)->toContain('since') + ->and($methods)->toContain('sort') + ->and($methods)->toContain('direction') + ->and($methods)->toContain('perPage') + ->and($methods)->toContain('page') + ->and($methods)->toContain('get') + ->and($methods)->toContain('first') + ->and($methods)->toContain('count'); + }); + + it('MilestoneQueryInterface has required filtering methods', function () { + $reflection = new ReflectionClass(MilestoneQueryInterface::class); + $methods = array_map(fn ($m) => $m->getName(), $reflection->getMethods()); + + expect($methods)->toContain('state') + ->and($methods)->toContain('sort') + ->and($methods)->toContain('direction') + ->and($methods)->toContain('perPage') + ->and($methods)->toContain('page') + ->and($methods)->toContain('get') + ->and($methods)->toContain('first') + ->and($methods)->toContain('count'); + }); +}); + +describe('Manager Contracts', function () { + it('IssueManagerInterface has required methods', function () { + $reflection = new ReflectionClass(IssueManagerInterface::class); + $methods = array_map(fn ($m) => $m->getName(), $reflection->getMethods()); + + expect($methods)->toContain('find') + ->and($methods)->toContain('query') + ->and($methods)->toContain('create') + ->and($methods)->toContain('update') + ->and($methods)->toContain('close') + ->and($methods)->toContain('reopen') + ->and($methods)->toContain('lock') + ->and($methods)->toContain('unlock'); + }); + + it('CommentManagerInterface has required methods', function () { + $reflection = new ReflectionClass(CommentManagerInterface::class); + $methods = array_map(fn ($m) => $m->getName(), $reflection->getMethods()); + + expect($methods)->toContain('create') + ->and($methods)->toContain('update') + ->and($methods)->toContain('delete') + ->and($methods)->toContain('list') + ->and($methods)->toContain('get'); + }); + + it('LabelManagerInterface has required methods', function () { + $reflection = new ReflectionClass(LabelManagerInterface::class); + $methods = array_map(fn ($m) => $m->getName(), $reflection->getMethods()); + + expect($methods)->toContain('add') + ->and($methods)->toContain('remove') + ->and($methods)->toContain('replace') + ->and($methods)->toContain('clear'); + }); + + it('ReactionManagerInterface has required methods', function () { + $reflection = new ReflectionClass(ReactionManagerInterface::class); + $methods = array_map(fn ($m) => $m->getName(), $reflection->getMethods()); + + expect($methods)->toContain('create') + ->and($methods)->toContain('delete') + ->and($methods)->toContain('list') + ->and($methods)->toContain('thumbsUp') + ->and($methods)->toContain('thumbsDown') + ->and($methods)->toContain('laugh') + ->and($methods)->toContain('hooray') + ->and($methods)->toContain('confused') + ->and($methods)->toContain('heart') + ->and($methods)->toContain('rocket') + ->and($methods)->toContain('eyes'); + }); + + it('MilestoneManagerInterface has required methods', function () { + $reflection = new ReflectionClass(MilestoneManagerInterface::class); + $methods = array_map(fn ($m) => $m->getName(), $reflection->getMethods()); + + expect($methods)->toContain('find') + ->and($methods)->toContain('query') + ->and($methods)->toContain('create') + ->and($methods)->toContain('update') + ->and($methods)->toContain('delete') + ->and($methods)->toContain('close') + ->and($methods)->toContain('reopen'); + }); + + it('RepositoryLabelManagerInterface has required methods', function () { + $reflection = new ReflectionClass(RepositoryLabelManagerInterface::class); + $methods = array_map(fn ($m) => $m->getName(), $reflection->getMethods()); + + expect($methods)->toContain('list') + ->and($methods)->toContain('get') + ->and($methods)->toContain('create') + ->and($methods)->toContain('update') + ->and($methods)->toContain('delete'); + }); +}); + +describe('Facade Contract', function () { + it('IssuesFacadeInterface provides unified interface', function () { + $reflection = new ReflectionClass(IssuesFacadeInterface::class); + $methods = array_map(fn ($m) => $m->getName(), $reflection->getMethods()); + + expect($methods)->toContain('issues') + ->and($methods)->toContain('comments') + ->and($methods)->toContain('labels') + ->and($methods)->toContain('reactions') + ->and($methods)->toContain('milestones'); + }); +}); + +describe('Contract Return Types', function () { + it('query contracts return Collection from get()', function () { + $queryReflection = new ReflectionClass(IssueQueryInterface::class); + $getMethod = $queryReflection->getMethod('get'); + $returnType = $getMethod->getReturnType(); + + expect($returnType)->not->toBeNull() + ->and($returnType->getName())->toBe('Illuminate\Support\Collection'); + }); + + it('query contracts return nullable data from first()', function () { + $queryReflection = new ReflectionClass(IssueQueryInterface::class); + $firstMethod = $queryReflection->getMethod('first'); + $returnType = $firstMethod->getReturnType(); + + expect($returnType)->not->toBeNull() + ->and($returnType->allowsNull())->toBeTrue(); + }); + + it('query contracts return int from count()', function () { + $queryReflection = new ReflectionClass(IssueQueryInterface::class); + $countMethod = $queryReflection->getMethod('count'); + $returnType = $countMethod->getReturnType(); + + expect($returnType)->not->toBeNull() + ->and($returnType->getName())->toBe('int'); + }); +}); + +describe('Contract Method Signatures', function () { + it('query methods are chainable', function () { + $reflection = new ReflectionClass(IssueQueryInterface::class); + $stateMethod = $reflection->getMethod('state'); + $returnType = $stateMethod->getReturnType(); + + expect($returnType)->not->toBeNull() + ->and($returnType->getName())->toBe('self'); + }); + + it('manager create methods return data objects', function () { + $reflection = new ReflectionClass(IssueManagerInterface::class); + $createMethod = $reflection->getMethod('create'); + $returnType = $createMethod->getReturnType(); + + expect($returnType)->not->toBeNull() + ->and($returnType->getName())->toBe('ConduitUI\Issue\Data\Issue'); + }); +}); + +describe('Builder Contracts', function () { + it('IssueBuilderInterface has required methods', function () { + $reflection = new ReflectionClass(IssueBuilderInterface::class); + $methods = array_map(fn ($m) => $m->getName(), $reflection->getMethods()); + + expect($methods)->toContain('title') + ->and($methods)->toContain('body') + ->and($methods)->toContain('assignees') + ->and($methods)->toContain('assignee') + ->and($methods)->toContain('labels') + ->and($methods)->toContain('label') + ->and($methods)->toContain('milestone') + ->and($methods)->toContain('create') + ->and($methods)->toContain('toArray'); + }); + + it('LabelBuilderInterface has required methods', function () { + $reflection = new ReflectionClass(LabelBuilderInterface::class); + $methods = array_map(fn ($m) => $m->getName(), $reflection->getMethods()); + + expect($methods)->toContain('name') + ->and($methods)->toContain('color') + ->and($methods)->toContain('description') + ->and($methods)->toContain('create') + ->and($methods)->toContain('toArray'); + }); + + it('MilestoneBuilderInterface has required methods', function () { + $reflection = new ReflectionClass(MilestoneBuilderInterface::class); + $methods = array_map(fn ($m) => $m->getName(), $reflection->getMethods()); + + expect($methods)->toContain('title') + ->and($methods)->toContain('state') + ->and($methods)->toContain('description') + ->and($methods)->toContain('dueOn') + ->and($methods)->toContain('create') + ->and($methods)->toContain('toArray'); + }); + + it('builder methods are chainable', function () { + $reflection = new ReflectionClass(IssueBuilderInterface::class); + $titleMethod = $reflection->getMethod('title'); + $returnType = $titleMethod->getReturnType(); + + expect($returnType)->not->toBeNull() + ->and($returnType->getName())->toBe('self'); + }); +});