From 0db25704baa41fd5679d7149d0c9fa6ba4ed675c Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Sat, 13 Dec 2025 19:31:07 -0700 Subject: [PATCH] Add GitHub Issue Milestones functionality This commit implements comprehensive support for GitHub Issue Milestones, enabling project planning and tracking capabilities. Features added: - Milestone Data class with full GitHub API field support - ManagesMilestones trait with CRUD operations for milestones - ManagesMilestonesInterface contract defining the milestone operations API - Methods to assign/remove issues from milestones - Method to list issues within a milestone - Full test coverage for Milestone data transformations Breaking changes: - Issue::$milestone changed from ?string to ?Milestone object Resolves #9 --- src/Contracts/IssuesServiceInterface.php | 2 +- src/Contracts/ManagesMilestonesInterface.php | 34 +++++ src/Data/Issue.php | 6 +- src/Data/Milestone.php | 74 +++++++++ src/Services/IssuesService.php | 2 + src/Traits/ManagesMilestones.php | 79 ++++++++++ tests/Unit/Data/IssueTest.php | 43 +++++- tests/Unit/Data/MilestoneTest.php | 151 +++++++++++++++++++ 8 files changed, 384 insertions(+), 7 deletions(-) create mode 100644 src/Contracts/ManagesMilestonesInterface.php create mode 100644 src/Data/Milestone.php create mode 100644 src/Traits/ManagesMilestones.php create mode 100644 tests/Unit/Data/MilestoneTest.php diff --git a/src/Contracts/IssuesServiceInterface.php b/src/Contracts/IssuesServiceInterface.php index 74f6e1ff..a4d2e5ef 100644 --- a/src/Contracts/IssuesServiceInterface.php +++ b/src/Contracts/IssuesServiceInterface.php @@ -4,4 +4,4 @@ namespace ConduitUI\Issue\Contracts; -interface IssuesServiceInterface extends ManagesIssueAssigneesInterface, ManagesIssueLabelsInterface, ManagesIssuesInterface {} +interface IssuesServiceInterface extends ManagesIssueAssigneesInterface, ManagesIssueLabelsInterface, ManagesIssuesInterface, ManagesMilestonesInterface {} diff --git a/src/Contracts/ManagesMilestonesInterface.php b/src/Contracts/ManagesMilestonesInterface.php new file mode 100644 index 00000000..5bc0950e --- /dev/null +++ b/src/Contracts/ManagesMilestonesInterface.php @@ -0,0 +1,34 @@ + + */ + public function listMilestones(string $owner, string $repo, array $filters = []): Collection; + + public function getMilestone(string $owner, string $repo, int $milestoneNumber): Milestone; + + public function createMilestone(string $owner, string $repo, array $data): Milestone; + + public function updateMilestone(string $owner, string $repo, int $milestoneNumber, array $data): Milestone; + + public function deleteMilestone(string $owner, string $repo, int $milestoneNumber): void; + + public function assignIssueToMilestone(string $owner, string $repo, int $issueNumber, int $milestoneNumber): Issue; + + public function removeIssueFromMilestone(string $owner, string $repo, int $issueNumber): Issue; + + /** + * @return \Illuminate\Support\Collection + */ + public function listMilestoneIssues(string $owner, string $repo, int $milestoneNumber, array $filters = []): Collection; +} diff --git a/src/Data/Issue.php b/src/Data/Issue.php index f464df3b..da783adc 100644 --- a/src/Data/Issue.php +++ b/src/Data/Issue.php @@ -17,7 +17,7 @@ public function __construct( public bool $locked, public array $assignees, public array $labels, - public ?string $milestone, + public ?Milestone $milestone, public int $comments, public DateTime $createdAt, public DateTime $updatedAt, @@ -39,7 +39,7 @@ public static function fromArray(array $data): self locked: $data['locked'], assignees: array_map(fn ($assignee) => User::fromArray($assignee), $data['assignees'] ?? []), labels: array_map(fn ($label) => Label::fromArray($label), $data['labels'] ?? []), - milestone: $data['milestone']['title'] ?? null, + milestone: $data['milestone'] ? Milestone::fromArray($data['milestone']) : null, comments: $data['comments'], createdAt: new DateTime($data['created_at']), updatedAt: new DateTime($data['updated_at']), @@ -62,7 +62,7 @@ public function toArray(): array 'locked' => $this->locked, 'assignees' => array_map(fn (User $assignee) => $assignee->toArray(), $this->assignees), 'labels' => array_map(fn (Label $label) => $label->toArray(), $this->labels), - 'milestone' => $this->milestone, + 'milestone' => $this->milestone?->toArray(), 'comments' => $this->comments, 'created_at' => $this->createdAt->format('c'), 'updated_at' => $this->updatedAt->format('c'), diff --git a/src/Data/Milestone.php b/src/Data/Milestone.php new file mode 100644 index 00000000..8330e5f7 --- /dev/null +++ b/src/Data/Milestone.php @@ -0,0 +1,74 @@ + $this->id, + 'number' => $this->number, + 'title' => $this->title, + 'description' => $this->description, + 'state' => $this->state, + 'open_issues' => $this->openIssues, + 'closed_issues' => $this->closedIssues, + 'created_at' => $this->createdAt->format('c'), + 'updated_at' => $this->updatedAt->format('c'), + 'closed_at' => $this->closedAt?->format('c'), + 'due_on' => $this->dueOn?->format('c'), + 'html_url' => $this->htmlUrl, + 'creator' => $this->creator->toArray(), + ]; + } + + public function isOpen(): bool + { + return $this->state === 'open'; + } + + public function isClosed(): bool + { + return $this->state === 'closed'; + } +} diff --git a/src/Services/IssuesService.php b/src/Services/IssuesService.php index 2d380d59..510a7d72 100644 --- a/src/Services/IssuesService.php +++ b/src/Services/IssuesService.php @@ -9,12 +9,14 @@ use ConduitUI\Issue\Traits\ManagesIssueAssignees; use ConduitUI\Issue\Traits\ManagesIssueLabels; use ConduitUI\Issue\Traits\ManagesIssues; +use ConduitUI\Issue\Traits\ManagesMilestones; class IssuesService implements IssuesServiceInterface { use ManagesIssueAssignees; use ManagesIssueLabels; use ManagesIssues; + use ManagesMilestones; public function __construct( private readonly Connector $connector diff --git a/src/Traits/ManagesMilestones.php b/src/Traits/ManagesMilestones.php new file mode 100644 index 00000000..625518a5 --- /dev/null +++ b/src/Traits/ManagesMilestones.php @@ -0,0 +1,79 @@ + + */ + public function listMilestones(string $owner, string $repo, array $filters = []): Collection + { + $response = $this->connector->send( + $this->connector->get("/repos/{$owner}/{$repo}/milestones", $filters) + ); + + return collect($response->json()) + ->map(fn (array $data) => Milestone::fromArray($data)); + } + + public function getMilestone(string $owner, string $repo, int $milestoneNumber): Milestone + { + $response = $this->connector->send( + $this->connector->get("/repos/{$owner}/{$repo}/milestones/{$milestoneNumber}") + ); + + return Milestone::fromArray($response->json()); + } + + public function createMilestone(string $owner, string $repo, array $data): Milestone + { + $response = $this->connector->send( + $this->connector->post("/repos/{$owner}/{$repo}/milestones", $data) + ); + + return Milestone::fromArray($response->json()); + } + + public function updateMilestone(string $owner, string $repo, int $milestoneNumber, array $data): Milestone + { + $response = $this->connector->send( + $this->connector->patch("/repos/{$owner}/{$repo}/milestones/{$milestoneNumber}", $data) + ); + + return Milestone::fromArray($response->json()); + } + + public function deleteMilestone(string $owner, string $repo, int $milestoneNumber): void + { + $this->connector->send( + $this->connector->delete("/repos/{$owner}/{$repo}/milestones/{$milestoneNumber}") + ); + } + + public function assignIssueToMilestone(string $owner, string $repo, int $issueNumber, int $milestoneNumber): Issue + { + return $this->updateIssue($owner, $repo, $issueNumber, ['milestone' => $milestoneNumber]); + } + + public function removeIssueFromMilestone(string $owner, string $repo, int $issueNumber): Issue + { + return $this->updateIssue($owner, $repo, $issueNumber, ['milestone' => null]); + } + + /** + * @return \Illuminate\Support\Collection + */ + public function listMilestoneIssues(string $owner, string $repo, int $milestoneNumber, array $filters = []): Collection + { + $filters['milestone'] = (string) $milestoneNumber; + + return $this->listIssues($owner, $repo, $filters); + } +} diff --git a/tests/Unit/Data/IssueTest.php b/tests/Unit/Data/IssueTest.php index 35b36bb4..c0a9ca9a 100644 --- a/tests/Unit/Data/IssueTest.php +++ b/tests/Unit/Data/IssueTest.php @@ -4,6 +4,7 @@ use ConduitUI\Issue\Data\Issue; use ConduitUI\Issue\Data\Label; +use ConduitUI\Issue\Data\Milestone; use ConduitUI\Issue\Data\User; test('can create issue from array', function () { @@ -32,7 +33,25 @@ ], ], 'milestone' => [ + 'id' => 999, + 'number' => 1, 'title' => 'v1.0', + 'description' => 'Version 1.0', + 'state' => 'open', + 'open_issues' => 5, + 'closed_issues' => 3, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'due_on' => null, + 'html_url' => 'https://github.com/owner/repo/milestone/1', + 'creator' => [ + 'id' => 201, + 'login' => 'creator', + 'avatar_url' => 'https://github.com/creator.png', + 'html_url' => 'https://github.com/creator', + 'type' => 'User', + ], ], 'comments' => 5, 'created_at' => '2023-01-01T12:00:00Z', @@ -68,7 +87,8 @@ expect($issue->assignees[0])->toBeInstanceOf(User::class); expect($issue->labels)->toHaveCount(1); expect($issue->labels[0])->toBeInstanceOf(Label::class); - expect($issue->milestone)->toBe('v1.0'); + expect($issue->milestone)->toBeInstanceOf(Milestone::class); + expect($issue->milestone->title)->toBe('v1.0'); expect($issue->comments)->toBe(5); expect($issue->user)->toBeInstanceOf(User::class); expect($issue->assignee)->toBeInstanceOf(User::class); @@ -79,6 +99,22 @@ $user = new User(101, 'author', 'https://github.com/author.png', 'https://github.com/author', 'User'); $assignee = new User(456, 'testuser', 'https://github.com/testuser.png', 'https://github.com/testuser', 'User'); $label = new Label(789, 'bug', 'fc2929', 'Something is broken'); + $creator = new User(201, 'creator', 'https://github.com/creator.png', 'https://github.com/creator', 'User'); + $milestone = new Milestone( + id: 999, + number: 1, + title: 'v1.0', + description: 'Version 1.0', + state: 'open', + openIssues: 5, + closedIssues: 3, + createdAt: new DateTime('2023-01-01T12:00:00Z'), + updatedAt: new DateTime('2023-01-02T12:00:00Z'), + closedAt: null, + dueOn: null, + htmlUrl: 'https://github.com/owner/repo/milestone/1', + creator: $creator, + ); $issue = new Issue( id: 123, @@ -89,7 +125,7 @@ locked: false, assignees: [$assignee], labels: [$label], - milestone: 'v1.0', + milestone: $milestone, comments: 5, createdAt: new DateTime('2023-01-01T12:00:00Z'), updatedAt: new DateTime('2023-01-02T12:00:00Z'), @@ -108,7 +144,8 @@ expect($array['state'])->toBe('open'); expect($array['assignees'])->toHaveCount(1); expect($array['labels'])->toHaveCount(1); - expect($array['milestone'])->toBe('v1.0'); + expect($array['milestone'])->toBeArray(); + expect($array['milestone']['title'])->toBe('v1.0'); expect($array['closed_at'])->toBeNull(); }); diff --git a/tests/Unit/Data/MilestoneTest.php b/tests/Unit/Data/MilestoneTest.php new file mode 100644 index 00000000..78c91f35 --- /dev/null +++ b/tests/Unit/Data/MilestoneTest.php @@ -0,0 +1,151 @@ + 123, + 'number' => 1, + 'title' => 'v1.0', + 'description' => 'First major release', + 'state' => 'open', + 'open_issues' => 5, + 'closed_issues' => 3, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'closed_at' => null, + 'due_on' => '2023-12-31T23:59:59Z', + 'html_url' => 'https://github.com/owner/repo/milestone/1', + 'creator' => [ + 'id' => 101, + 'login' => 'creator', + 'avatar_url' => 'https://github.com/creator.png', + 'html_url' => 'https://github.com/creator', + 'type' => 'User', + ], + ]; + + $milestone = Milestone::fromArray($data); + + expect($milestone->id)->toBe(123); + expect($milestone->number)->toBe(1); + expect($milestone->title)->toBe('v1.0'); + expect($milestone->description)->toBe('First major release'); + expect($milestone->state)->toBe('open'); + expect($milestone->openIssues)->toBe(5); + expect($milestone->closedIssues)->toBe(3); + expect($milestone->creator)->toBeInstanceOf(User::class); + expect($milestone->creator->login)->toBe('creator'); + expect($milestone->closedAt)->toBeNull(); + expect($milestone->dueOn)->toBeInstanceOf(DateTime::class); +}); + +test('can create milestone from array with null description and due date', function () { + $data = [ + 'id' => 456, + 'number' => 2, + 'title' => 'v2.0', + 'description' => null, + 'state' => 'closed', + 'open_issues' => 0, + 'closed_issues' => 10, + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-06-01T12:00:00Z', + 'closed_at' => '2023-06-01T12:00:00Z', + 'due_on' => null, + 'html_url' => 'https://github.com/owner/repo/milestone/2', + 'creator' => [ + 'id' => 102, + 'login' => 'maintainer', + 'avatar_url' => 'https://github.com/maintainer.png', + 'html_url' => 'https://github.com/maintainer', + 'type' => 'User', + ], + ]; + + $milestone = Milestone::fromArray($data); + + expect($milestone->description)->toBeNull(); + expect($milestone->dueOn)->toBeNull(); + expect($milestone->state)->toBe('closed'); + expect($milestone->closedAt)->toBeInstanceOf(DateTime::class); +}); + +test('can convert milestone to array', function () { + $creator = new User(101, 'creator', 'https://github.com/creator.png', 'https://github.com/creator', 'User'); + + $milestone = new Milestone( + id: 123, + number: 1, + title: 'v1.0', + description: 'First major release', + state: 'open', + openIssues: 5, + closedIssues: 3, + createdAt: new DateTime('2023-01-01T12:00:00Z'), + updatedAt: new DateTime('2023-01-02T12:00:00Z'), + closedAt: null, + dueOn: new DateTime('2023-12-31T23:59:59Z'), + htmlUrl: 'https://github.com/owner/repo/milestone/1', + creator: $creator, + ); + + $array = $milestone->toArray(); + + expect($array['id'])->toBe(123); + expect($array['number'])->toBe(1); + expect($array['title'])->toBe('v1.0'); + expect($array['description'])->toBe('First major release'); + expect($array['state'])->toBe('open'); + expect($array['open_issues'])->toBe(5); + expect($array['closed_issues'])->toBe(3); + expect($array['closed_at'])->toBeNull(); + expect($array['due_on'])->toBeString(); + expect($array['creator'])->toBeArray(); + expect($array['creator']['login'])->toBe('creator'); +}); + +test('can check if milestone is open', function () { + $milestone = new Milestone( + id: 123, + number: 1, + title: 'v1.0', + description: 'First major release', + state: 'open', + openIssues: 5, + closedIssues: 3, + createdAt: new DateTime, + updatedAt: new DateTime, + closedAt: null, + dueOn: null, + htmlUrl: 'https://github.com/owner/repo/milestone/1', + creator: new User(101, 'creator', 'https://github.com/creator.png', 'https://github.com/creator', 'User'), + ); + + expect($milestone->isOpen())->toBeTrue(); + expect($milestone->isClosed())->toBeFalse(); +}); + +test('can check if milestone is closed', function () { + $milestone = new Milestone( + id: 123, + number: 1, + title: 'v1.0', + description: 'First major release', + state: 'closed', + openIssues: 0, + closedIssues: 8, + createdAt: new DateTime, + updatedAt: new DateTime, + closedAt: new DateTime, + dueOn: null, + htmlUrl: 'https://github.com/owner/repo/milestone/1', + creator: new User(101, 'creator', 'https://github.com/creator.png', 'https://github.com/creator', 'User'), + ); + + expect($milestone->isOpen())->toBeFalse(); + expect($milestone->isClosed())->toBeTrue(); +});