From 2f1dfd3c23da2fdd219cfdccd0b8770b3b08c42f Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Thu, 18 Dec 2025 22:32:16 -0700 Subject: [PATCH] feat: add Issue model with chainable action methods Implements IssueInstance service providing fluent API for managing GitHub issues: - Add Issue DTO enhancements (apiUrl, activeLockReason fields) - Add helper methods (isLocked, hasLabel, isAssignedTo) - Implement IssueInstance service with chainable methods: - update(), title(), body() - close(), reopen() - addLabel(), addLabels(), removeLabel(), setLabels() - assign(), assignTo(), unassign() - milestone() - lock(), unlock() - comment() - Add find() method to IssuesService - Create LockIssueRequest and UnlockIssueRequest - Comprehensive test coverage (99.4%) Closes #21 --- src/Data/Issue.php | 27 +- src/Facades/GithubIssues.php | 1 + src/Requests/Issues/LockIssueRequest.php | 34 + src/Requests/Issues/UnlockIssueRequest.php | 24 + src/Services/IssueInstance.php | 269 +++++++ src/Services/IssuesService.php | 8 + tests/Unit/Data/IssueTest.php | 90 +++ tests/Unit/Services/IssueInstanceTest.php | 807 +++++++++++++++++++++ 8 files changed, 1257 insertions(+), 3 deletions(-) create mode 100644 src/Requests/Issues/LockIssueRequest.php create mode 100644 src/Requests/Issues/UnlockIssueRequest.php create mode 100644 src/Services/IssueInstance.php create mode 100644 tests/Unit/Services/IssueInstanceTest.php diff --git a/src/Data/Issue.php b/src/Data/Issue.php index f464df3b..72aafb10 100644 --- a/src/Data/Issue.php +++ b/src/Data/Issue.php @@ -23,6 +23,8 @@ public function __construct( public DateTime $updatedAt, public ?DateTime $closedAt, public string $htmlUrl, + public string $apiUrl, + public ?string $activeLockReason, public User $user, public ?User $assignee = null, public ?User $closedBy = null, @@ -34,7 +36,7 @@ public static function fromArray(array $data): self id: $data['id'], number: $data['number'], title: $data['title'], - body: $data['body'], + body: $data['body'] ?? null, state: $data['state'], locked: $data['locked'], assignees: array_map(fn ($assignee) => User::fromArray($assignee), $data['assignees'] ?? []), @@ -45,9 +47,11 @@ public static function fromArray(array $data): self updatedAt: new DateTime($data['updated_at']), closedAt: $data['closed_at'] ? new DateTime($data['closed_at']) : null, htmlUrl: $data['html_url'], + apiUrl: $data['url'] ?? $data['api_url'] ?? '', + activeLockReason: $data['active_lock_reason'] ?? null, user: User::fromArray($data['user']), - assignee: $data['assignee'] ? User::fromArray($data['assignee']) : null, - closedBy: $data['closed_by'] ? User::fromArray($data['closed_by']) : null, + assignee: isset($data['assignee']) && $data['assignee'] ? User::fromArray($data['assignee']) : null, + closedBy: isset($data['closed_by']) && $data['closed_by'] ? User::fromArray($data['closed_by']) : null, ); } @@ -68,6 +72,8 @@ public function toArray(): array 'updated_at' => $this->updatedAt->format('c'), 'closed_at' => $this->closedAt?->format('c'), 'html_url' => $this->htmlUrl, + 'url' => $this->apiUrl, + 'active_lock_reason' => $this->activeLockReason, 'user' => $this->user->toArray(), 'assignee' => $this->assignee?->toArray(), 'closed_by' => $this->closedBy?->toArray(), @@ -83,4 +89,19 @@ public function isClosed(): bool { return $this->state === 'closed'; } + + public function isLocked(): bool + { + return $this->locked; + } + + public function hasLabel(string $label): bool + { + return in_array($label, array_map(fn (Label $l) => $l->name, $this->labels), true); + } + + public function isAssignedTo(string $username): bool + { + return in_array($username, array_map(fn (User $u) => $u->login, $this->assignees), true); + } } diff --git a/src/Facades/GithubIssues.php b/src/Facades/GithubIssues.php index d15c8398..97c41ee3 100644 --- a/src/Facades/GithubIssues.php +++ b/src/Facades/GithubIssues.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Facade; /** + * @method static \ConduitUI\Issue\Services\IssueInstance find(string $fullName, int $number) * @method static \Illuminate\Support\Collection listIssues(string $owner, string $repo, array $filters = []) * @method static \ConduitUI\GithubIssues\Data\Issue getIssue(string $owner, string $repo, int $issueNumber) * @method static \ConduitUI\GithubIssues\Data\Issue createIssue(string $owner, string $repo, array $data) diff --git a/src/Requests/Issues/LockIssueRequest.php b/src/Requests/Issues/LockIssueRequest.php new file mode 100644 index 00000000..270a3084 --- /dev/null +++ b/src/Requests/Issues/LockIssueRequest.php @@ -0,0 +1,34 @@ +owner}/{$this->repo}/issues/{$this->issueNumber}/lock"; + } + + protected function defaultBody(): array + { + return $this->lockReason ? ['lock_reason' => $this->lockReason] : []; + } +} diff --git a/src/Requests/Issues/UnlockIssueRequest.php b/src/Requests/Issues/UnlockIssueRequest.php new file mode 100644 index 00000000..1013a8ed --- /dev/null +++ b/src/Requests/Issues/UnlockIssueRequest.php @@ -0,0 +1,24 @@ +owner}/{$this->repo}/issues/{$this->issueNumber}/lock"; + } +} diff --git a/src/Services/IssueInstance.php b/src/Services/IssueInstance.php new file mode 100644 index 00000000..5e062c5e --- /dev/null +++ b/src/Services/IssueInstance.php @@ -0,0 +1,269 @@ +owner, $this->repo] = explode('/', $fullName, 2); + } + + /** + * Get the issue data (cached) + */ + public function get(): Issue + { + if ($this->issue === null) { + $this->issue = $this->fetch(); + } + + return $this->issue; + } + + /** + * Fetch fresh issue data + */ + public function fresh(): Issue + { + $this->issue = $this->fetch(); + + return $this->issue; + } + + /** + * Update issue attributes + */ + public function update(array $attributes): self + { + $response = $this->connector->send( + new UpdateIssueRequest($this->owner, $this->repo, $this->number, $attributes) + ); + + $this->issue = Issue::fromArray($response->json()); + + return $this; + } + + /** + * Set the title + */ + public function title(string $title): self + { + return $this->update(['title' => $title]); + } + + /** + * Set the body + */ + public function body(string $body): self + { + return $this->update(['body' => $body]); + } + + /** + * Add labels (merges with existing) + */ + public function addLabel(string $label): self + { + return $this->addLabels([$label]); + } + + /** + * Add multiple labels + */ + public function addLabels(array $labels): self + { + $this->connector->send( + new AddLabelsRequest($this->owner, $this->repo, $this->number, $labels) + ); + + $this->issue = $this->fresh(); + + return $this; + } + + /** + * Remove a label + */ + public function removeLabel(string $label): self + { + $this->connector->send( + new RemoveLabelRequest($this->owner, $this->repo, $this->number, $label) + ); + + $this->issue = $this->fresh(); + + return $this; + } + + /** + * Remove multiple labels + */ + public function removeLabels(array $labels): self + { + foreach ($labels as $label) { + $this->removeLabel($label); + } + + return $this; + } + + /** + * Replace all labels + */ + public function setLabels(array $labels): self + { + $this->connector->send( + new ReplaceAllLabelsRequest($this->owner, $this->repo, $this->number, $labels) + ); + + $this->issue = $this->fresh(); + + return $this; + } + + /** + * Assign to user(s) + */ + public function assign(string|array $assignees): self + { + $assignees = is_array($assignees) ? $assignees : [$assignees]; + + return $this->update(['assignees' => $assignees]); + } + + /** + * Convenience method - assign to single user + */ + public function assignTo(string $username): self + { + return $this->assign($username); + } + + /** + * Remove assignees + */ + public function unassign(string|array $assignees): self + { + $assignees = is_array($assignees) ? $assignees : [$assignees]; + + $current = array_map(fn ($user) => $user->login, $this->get()->assignees); + $remaining = array_diff($current, $assignees); + + return $this->update(['assignees' => array_values($remaining)]); + } + + /** + * Set milestone + */ + public function milestone(?int $milestoneNumber): self + { + return $this->update(['milestone' => $milestoneNumber]); + } + + /** + * Close the issue + */ + public function close(?string $reason = null): self + { + $params = ['state' => 'closed']; + + if ($reason !== null) { + $params['state_reason'] = $reason; + } + + return $this->update($params); + } + + /** + * Reopen the issue + */ + public function reopen(): self + { + return $this->update(['state' => 'open']); + } + + /** + * Lock the issue + */ + public function lock(?string $reason = null): self + { + $this->connector->send( + new LockIssueRequest($this->owner, $this->repo, $this->number, $reason) + ); + + $this->issue = $this->fresh(); + + return $this; + } + + /** + * Unlock the issue + */ + public function unlock(): self + { + $this->connector->send( + new UnlockIssueRequest($this->owner, $this->repo, $this->number) + ); + + $this->issue = $this->fresh(); + + return $this; + } + + /** + * Add a comment + */ + public function comment(string $body): Comment + { + $response = $this->connector->send( + new CreateCommentRequest($this->owner, $this->repo, $this->number, $body) + ); + + return Comment::fromArray($response->json()); + } + + /** + * Fetch issue from API + */ + protected function fetch(): Issue + { + $response = $this->connector->send( + new GetIssueRequest($this->owner, $this->repo, $this->number) + ); + + return Issue::fromArray($response->json()); + } + + /** + * Magic method to access issue properties + */ + public function __get(string $name): mixed + { + return $this->get()->$name; + } +} diff --git a/src/Services/IssuesService.php b/src/Services/IssuesService.php index 079c202e..0e4b63b4 100644 --- a/src/Services/IssuesService.php +++ b/src/Services/IssuesService.php @@ -27,4 +27,12 @@ class IssuesService implements IssuesServiceInterface public function __construct( private readonly Connector $connector ) {} + + /** + * Find and interact with a specific issue + */ + public function find(string $fullName, int $number): IssueInstance + { + return new IssueInstance($this->connector, $fullName, $number); + } } diff --git a/tests/Unit/Data/IssueTest.php b/tests/Unit/Data/IssueTest.php index 4f5140ce..9c8a033f 100644 --- a/tests/Unit/Data/IssueTest.php +++ b/tests/Unit/Data/IssueTest.php @@ -40,6 +40,8 @@ 'updated_at' => '2023-01-02T12:00:00Z', 'closed_at' => null, 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'active_lock_reason' => null, 'user' => [ 'id' => 101, 'login' => 'author', @@ -96,6 +98,8 @@ updatedAt: new DateTime('2023-01-02T12:00:00Z'), closedAt: null, htmlUrl: 'https://github.com/owner/repo/issues/1', + apiUrl: 'https://api.github.com/repos/owner/repo/issues/1', + activeLockReason: null, user: $user, assignee: $assignee, closedBy: null, @@ -129,6 +133,8 @@ updatedAt: new DateTime, closedAt: null, htmlUrl: 'https://github.com/owner/repo/issues/1', + apiUrl: 'https://api.github.com/repos/owner/repo/issues/1', + activeLockReason: null, user: new User(101, 'author', 'https://github.com/author.png', 'https://github.com/author', 'User'), ); @@ -152,10 +158,94 @@ updatedAt: new DateTime, closedAt: new DateTime, htmlUrl: 'https://github.com/owner/repo/issues/1', + apiUrl: 'https://api.github.com/repos/owner/repo/issues/1', + activeLockReason: null, user: new User(101, 'author', 'https://github.com/author.png', 'https://github.com/author', 'User'), ); expect($issue->isOpen())->toBeFalse(); expect($issue->isClosed())->toBeTrue(); }); + + it('can check if issue is locked', function () { + $issue = new Issue( + id: 123, + number: 1, + title: 'Test Issue', + body: 'This is a test issue', + state: 'open', + locked: true, + assignees: [], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime, + updatedAt: new DateTime, + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/1', + apiUrl: 'https://api.github.com/repos/owner/repo/issues/1', + activeLockReason: 'too heated', + user: new User(101, 'author', 'https://github.com/author.png', 'https://github.com/author', 'User'), + ); + + expect($issue->isLocked())->toBeTrue(); + }); + + it('can check if issue has label', function () { + $label1 = new Label(1, 'bug', 'fc2929', null); + $label2 = new Label(2, 'urgent', 'ff0000', null); + + $issue = new Issue( + id: 123, + number: 1, + title: 'Test Issue', + body: 'This is a test issue', + state: 'open', + locked: false, + assignees: [], + labels: [$label1, $label2], + milestone: null, + comments: 0, + createdAt: new DateTime, + updatedAt: new DateTime, + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/1', + apiUrl: 'https://api.github.com/repos/owner/repo/issues/1', + activeLockReason: null, + user: new User(101, 'author', 'https://github.com/author.png', 'https://github.com/author', 'User'), + ); + + expect($issue->hasLabel('bug'))->toBeTrue(); + expect($issue->hasLabel('urgent'))->toBeTrue(); + expect($issue->hasLabel('enhancement'))->toBeFalse(); + }); + + it('can check if issue is assigned to user', function () { + $user1 = new User(1, 'developer1', 'https://github.com/developer1.png', 'https://github.com/developer1', 'User'); + $user2 = new User(2, 'developer2', 'https://github.com/developer2.png', 'https://github.com/developer2', 'User'); + + $issue = new Issue( + id: 123, + number: 1, + title: 'Test Issue', + body: 'This is a test issue', + state: 'open', + locked: false, + assignees: [$user1, $user2], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime, + updatedAt: new DateTime, + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/1', + apiUrl: 'https://api.github.com/repos/owner/repo/issues/1', + activeLockReason: null, + user: new User(101, 'author', 'https://github.com/author.png', 'https://github.com/author', 'User'), + ); + + expect($issue->isAssignedTo('developer1'))->toBeTrue(); + expect($issue->isAssignedTo('developer2'))->toBeTrue(); + expect($issue->isAssignedTo('developer3'))->toBeFalse(); + }); }); diff --git a/tests/Unit/Services/IssueInstanceTest.php b/tests/Unit/Services/IssueInstanceTest.php new file mode 100644 index 00000000..854f6454 --- /dev/null +++ b/tests/Unit/Services/IssueInstanceTest.php @@ -0,0 +1,807 @@ +mockClient = new MockClient; + $this->connector = new Connector('fake-token'); + $this->connector->withMockClient($this->mockClient); + + $this->defaultIssueData = [ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'open', + 'locked' => false, + 'assignees' => [], + 'labels' => [], + 'milestone' => null, + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'active_lock_reason' => null, + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ]; +}); + +describe('IssueInstance', function () { + it('can get issue data', function () { + $this->mockClient->addResponses([MockResponse::make($this->defaultIssueData)]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $issue = $instance->get(); + + expect($issue)->toBeInstanceOf(Issue::class); + expect($issue->number)->toBe(1); + expect($issue->title)->toBe('Test Issue'); + }); + + it('caches issue data on subsequent get calls', function () { + $this->mockClient->addResponses([MockResponse::make($this->defaultIssueData)]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + + $issue1 = $instance->get(); + $issue2 = $instance->get(); + + expect($issue1)->toBe($issue2); + expect($this->mockClient->getRecordedResponses())->toHaveCount(1); + }); + + it('can refresh issue data', function () { + $this->mockClient->addResponses([ + MockResponse::make($this->defaultIssueData), + MockResponse::make(array_merge($this->defaultIssueData, ['title' => 'Updated Title'])), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + + $issue1 = $instance->get(); + expect($issue1->title)->toBe('Test Issue'); + + $issue2 = $instance->fresh(); + expect($issue2->title)->toBe('Updated Title'); + }); + + it('can update issue title', function () { + $this->mockClient->addResponses([ + MockResponse::make(array_merge($this->defaultIssueData, ['title' => 'New Title'])), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $result = $instance->title('New Title'); + + expect($result)->toBeInstanceOf(IssueInstance::class); + expect($instance->get()->title)->toBe('New Title'); + }); + + it('can update issue body', function () { + $this->mockClient->addResponses([ + MockResponse::make(array_merge($this->defaultIssueData, ['body' => 'New body content'])), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $result = $instance->body('New body content'); + + expect($result)->toBeInstanceOf(IssueInstance::class); + expect($instance->get()->body)->toBe('New body content'); + }); + + it('can close issue', function () { + $this->mockClient->addResponses([ + MockResponse::make(array_merge($this->defaultIssueData, [ + 'state' => 'closed', + 'closed_at' => '2023-01-03T12:00:00Z', + ])), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $result = $instance->close(); + + expect($result)->toBeInstanceOf(IssueInstance::class); + expect($instance->get()->state)->toBe('closed'); + expect($instance->get()->isClosed())->toBeTrue(); + }); + + it('can close issue with reason', function () { + $this->mockClient->addResponses([ + MockResponse::make([ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'closed', + 'state_reason' => 'completed', + 'locked' => false, + 'assignees' => [], + 'labels' => [], + 'milestone' => null, + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => '2023-01-03T12:00:00Z', + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'active_lock_reason' => null, + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ], 200), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $result = $instance->close('completed'); + + expect($result)->toBeInstanceOf(IssueInstance::class); + expect($instance->get()->state)->toBe('closed'); + }); + + it('can reopen issue', function () { + $this->mockClient->addResponses([ + MockResponse::make([ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'open', + 'locked' => false, + 'assignees' => [], + 'labels' => [], + 'milestone' => null, + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'active_lock_reason' => null, + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ], 200), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $result = $instance->reopen(); + + expect($result)->toBeInstanceOf(IssueInstance::class); + expect($instance->get()->state)->toBe('open'); + expect($instance->get()->isOpen())->toBeTrue(); + }); + + it('can add single label', function () { + $this->mockClient->addResponses([ + MockResponse::make([ + ['id' => 1, 'name' => 'bug', 'color' => 'fc2929', 'description' => null], + ], 200), + MockResponse::make([ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'open', + 'locked' => false, + 'assignees' => [], + 'labels' => [ + ['id' => 1, 'name' => 'bug', 'color' => 'fc2929', 'description' => null], + ], + 'milestone' => null, + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'active_lock_reason' => null, + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ], 200), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $result = $instance->addLabel('bug'); + + expect($result)->toBeInstanceOf(IssueInstance::class); + expect($instance->get()->labels)->toHaveCount(1); + }); + + it('can add multiple labels', function () { + $this->mockClient->addResponses([ + MockResponse::make([ + ['id' => 1, 'name' => 'bug', 'color' => 'fc2929', 'description' => null], + ['id' => 2, 'name' => 'urgent', 'color' => 'ff0000', 'description' => null], + ], 200), + MockResponse::make([ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'open', + 'locked' => false, + 'assignees' => [], + 'labels' => [ + ['id' => 1, 'name' => 'bug', 'color' => 'fc2929', 'description' => null], + ['id' => 2, 'name' => 'urgent', 'color' => 'ff0000', 'description' => null], + ], + 'milestone' => null, + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'active_lock_reason' => null, + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ], 200), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $result = $instance->addLabels(['bug', 'urgent']); + + expect($result)->toBeInstanceOf(IssueInstance::class); + expect($instance->get()->labels)->toHaveCount(2); + }); + + it('can remove label', function () { + $this->mockClient->addResponses([ + MockResponse::make([], 204), + MockResponse::make([ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'open', + 'locked' => false, + 'assignees' => [], + 'labels' => [], + 'milestone' => null, + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'active_lock_reason' => null, + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ], 200), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $result = $instance->removeLabel('bug'); + + expect($result)->toBeInstanceOf(IssueInstance::class); + }); + + it('can set labels replacing all existing', function () { + $this->mockClient->addResponses([ + MockResponse::make([ + ['id' => 3, 'name' => 'enhancement', 'color' => '84b6eb', 'description' => null], + ], 200), + MockResponse::make([ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'open', + 'locked' => false, + 'assignees' => [], + 'labels' => [ + ['id' => 3, 'name' => 'enhancement', 'color' => '84b6eb', 'description' => null], + ], + 'milestone' => null, + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'active_lock_reason' => null, + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ], 200), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $result = $instance->setLabels(['enhancement']); + + expect($result)->toBeInstanceOf(IssueInstance::class); + expect($instance->get()->labels)->toHaveCount(1); + }); + + it('can assign user', function () { + $this->mockClient->addResponses([ + MockResponse::make([ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'open', + 'locked' => false, + 'assignees' => [ + [ + 'id' => 201, + 'login' => 'developer', + 'avatar_url' => 'https://github.com/developer.png', + 'html_url' => 'https://github.com/developer', + 'type' => 'User', + ], + ], + 'labels' => [], + 'milestone' => null, + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'active_lock_reason' => null, + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ], 200), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $result = $instance->assignTo('developer'); + + expect($result)->toBeInstanceOf(IssueInstance::class); + expect($instance->get()->assignees)->toHaveCount(1); + }); + + it('can assign multiple users', function () { + $this->mockClient->addResponses([ + MockResponse::make([ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'open', + 'locked' => false, + 'assignees' => [ + [ + 'id' => 201, + 'login' => 'developer1', + 'avatar_url' => 'https://github.com/developer1.png', + 'html_url' => 'https://github.com/developer1', + 'type' => 'User', + ], + [ + 'id' => 202, + 'login' => 'developer2', + 'avatar_url' => 'https://github.com/developer2.png', + 'html_url' => 'https://github.com/developer2', + 'type' => 'User', + ], + ], + 'labels' => [], + 'milestone' => null, + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'active_lock_reason' => null, + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ], 200), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $result = $instance->assign(['developer1', 'developer2']); + + expect($result)->toBeInstanceOf(IssueInstance::class); + expect($instance->get()->assignees)->toHaveCount(2); + }); + + it('can unassign users', function () { + $this->mockClient->addResponses([ + MockResponse::make([ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'open', + 'locked' => false, + 'assignees' => [ + [ + 'id' => 201, + 'login' => 'developer1', + 'avatar_url' => 'https://github.com/developer1.png', + 'html_url' => 'https://github.com/developer1', + 'type' => 'User', + ], + [ + 'id' => 202, + 'login' => 'developer2', + 'avatar_url' => 'https://github.com/developer2.png', + 'html_url' => 'https://github.com/developer2', + 'type' => 'User', + ], + ], + 'labels' => [], + 'milestone' => null, + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'active_lock_reason' => null, + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ], 200), + MockResponse::make([ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'open', + 'locked' => false, + 'assignees' => [ + [ + 'id' => 202, + 'login' => 'developer2', + 'avatar_url' => 'https://github.com/developer2.png', + 'html_url' => 'https://github.com/developer2', + 'type' => 'User', + ], + ], + 'labels' => [], + 'milestone' => null, + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'active_lock_reason' => null, + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ], 200), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $instance->get(); // Load initial state + $result = $instance->unassign('developer1'); + + expect($result)->toBeInstanceOf(IssueInstance::class); + expect($instance->get()->assignees)->toHaveCount(1); + }); + + it('can set milestone', function () { + $this->mockClient->addResponses([ + MockResponse::make([ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'open', + 'locked' => false, + 'assignees' => [], + 'labels' => [], + 'milestone' => [ + 'title' => 'v1.0', + ], + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'active_lock_reason' => null, + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ], 200), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $result = $instance->milestone(1); + + expect($result)->toBeInstanceOf(IssueInstance::class); + expect($instance->get()->milestone)->toBe('v1.0'); + }); + + it('can lock issue', function () { + $this->mockClient->addResponses([ + MockResponse::make([], 204), + MockResponse::make([ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'open', + 'locked' => true, + 'active_lock_reason' => 'too heated', + 'assignees' => [], + 'labels' => [], + 'milestone' => null, + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ], 200), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $result = $instance->lock('too heated'); + + expect($result)->toBeInstanceOf(IssueInstance::class); + expect($instance->get()->locked)->toBeTrue(); + }); + + it('can unlock issue', function () { + $this->mockClient->addResponses([ + MockResponse::make([], 204), + MockResponse::make([ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'open', + 'locked' => false, + 'active_lock_reason' => null, + 'assignees' => [], + 'labels' => [], + 'milestone' => null, + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ], 200), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $result = $instance->unlock(); + + expect($result)->toBeInstanceOf(IssueInstance::class); + expect($instance->get()->locked)->toBeFalse(); + }); + + it('can add comment', function () { + $this->mockClient->addResponses([ + MockResponse::make([ + 'id' => 999, + 'body' => 'Test comment', + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-01T12:00:00Z', + 'html_url' => 'https://github.com/owner/repo/issues/1#issuecomment-999', + 'url' => 'https://api.github.com/repos/owner/repo/issues/comments/999', + 'author_association' => 'OWNER', + ], 201), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + $comment = $instance->comment('Test comment'); + + expect($comment)->toBeInstanceOf(Comment::class); + expect($comment->body)->toBe('Test comment'); + }); + + it('supports method chaining', function () { + $this->mockClient->addResponses([ + // addLabels() POST response + MockResponse::make([ + ['id' => 1, 'name' => 'bug', 'color' => 'fc2929', 'description' => null], + ], 200), + // addLabels() fresh() GET response + MockResponse::make([ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'open', + 'locked' => false, + 'assignees' => [], + 'labels' => [ + ['id' => 1, 'name' => 'bug', 'color' => 'fc2929', 'description' => null], + ], + 'milestone' => null, + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'active_lock_reason' => null, + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ], 200), + // assign() + MockResponse::make([ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'open', + 'locked' => false, + 'assignees' => [ + [ + 'id' => 201, + 'login' => 'developer', + 'avatar_url' => 'https://github.com/developer.png', + 'html_url' => 'https://github.com/developer', + 'type' => 'User', + ], + ], + 'labels' => [ + ['id' => 1, 'name' => 'bug', 'color' => 'fc2929', 'description' => null], + ], + 'milestone' => null, + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'active_lock_reason' => null, + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ], 200), + // close() + MockResponse::make([ + 'id' => 123, + 'number' => 1, + 'title' => 'Test Issue', + 'body' => 'Test body', + 'state' => 'closed', + 'locked' => false, + 'assignees' => [ + [ + 'id' => 201, + 'login' => 'developer', + 'avatar_url' => 'https://github.com/developer.png', + 'html_url' => 'https://github.com/developer', + 'type' => 'User', + ], + ], + 'labels' => [ + ['id' => 1, 'name' => 'bug', 'color' => 'fc2929', 'description' => null], + ], + 'milestone' => null, + 'comments' => 0, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => '2023-01-03T12:00:00Z', + 'html_url' => 'https://github.com/owner/repo/issues/1', + 'url' => 'https://api.github.com/repos/owner/repo/issues/1', + 'active_lock_reason' => null, + 'user' => [ + 'id' => 101, + 'login' => 'author', + 'avatar_url' => 'https://github.com/author.png', + 'html_url' => 'https://github.com/author', + 'type' => 'User', + ], + ], 200), + ]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + + $result = $instance + ->addLabels(['bug']) + ->assign('developer') + ->close(); + + expect($result)->toBeInstanceOf(IssueInstance::class); + expect($instance->get()->state)->toBe('closed'); + expect($instance->get()->labels)->toHaveCount(1); + expect($instance->get()->assignees)->toHaveCount(1); + }); + + it('can access issue properties via magic getter', function () { + $this->mockClient->addResponses([MockResponse::make($this->defaultIssueData)]); + + $instance = new IssueInstance($this->connector, 'owner/repo', 1); + + expect($instance->title)->toBe('Test Issue'); + expect($instance->number)->toBe(1); + expect($instance->state)->toBe('open'); + }); +});