diff --git a/src/Contracts/AssigneeManagerInterface.php b/src/Contracts/AssigneeManagerInterface.php new file mode 100644 index 0000000..b51783f --- /dev/null +++ b/src/Contracts/AssigneeManagerInterface.php @@ -0,0 +1,58 @@ + + */ + public function get(): Collection; + + /** + * Add a single assignee. + */ + public function add(string $username): self; + + /** + * Add multiple assignees. + * + * @param array $usernames + */ + public function addMany(array $usernames): self; + + /** + * Remove a single assignee. + */ + public function remove(string $username): self; + + /** + * Remove multiple assignees. + * + * @param array $usernames + */ + public function removeMany(array $usernames): self; + + /** + * Replace all assignees with new ones. + * + * @param array $usernames + */ + public function replace(array $usernames): self; + + /** + * Clear all assignees. + */ + public function clear(): self; + + /** + * Check if a user is assigned. + */ + public function has(string $username): bool; +} diff --git a/src/DataTransferObjects/Milestone.php b/src/DataTransferObjects/Milestone.php new file mode 100644 index 0000000..51fe5b3 --- /dev/null +++ b/src/DataTransferObjects/Milestone.php @@ -0,0 +1,92 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + number: $data['number'], + title: $data['title'], + description: $data['description'] ?? null, + state: $data['state'], + openIssues: $data['open_issues'], + closedIssues: $data['closed_issues'], + dueOn: isset($data['due_on']) ? Carbon::parse($data['due_on']) : null, + createdAt: Carbon::parse($data['created_at']), + updatedAt: Carbon::parse($data['updated_at']), + closedAt: isset($data['closed_at']) ? Carbon::parse($data['closed_at']) : null, + htmlUrl: $data['html_url'], + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'number' => $this->number, + 'title' => $this->title, + 'description' => $this->description, + 'state' => $this->state, + 'open_issues' => $this->openIssues, + 'closed_issues' => $this->closedIssues, + 'due_on' => $this->dueOn?->toIso8601String(), + 'created_at' => $this->createdAt->toIso8601String(), + 'updated_at' => $this->updatedAt->toIso8601String(), + 'closed_at' => $this->closedAt?->toIso8601String(), + 'html_url' => $this->htmlUrl, + ]; + } + + public function isOpen(): bool + { + return $this->state === 'open'; + } + + public function isClosed(): bool + { + return $this->state === 'closed'; + } + + public function isOverdue(): bool + { + return $this->dueOn !== null + && $this->dueOn->isPast() + && $this->isOpen(); + } + + public function progress(): float + { + $total = $this->openIssues + $this->closedIssues; + + if ($total === 0) { + return 0.0; + } + + return round(($this->closedIssues / $total) * 100, 2); + } +} diff --git a/src/PullRequest.php b/src/PullRequest.php index b4172e9..986877b 100644 --- a/src/PullRequest.php +++ b/src/PullRequest.php @@ -39,6 +39,8 @@ use ConduitUI\Pr\Requests\RemoveReviewers; use ConduitUI\Pr\Requests\RequestReviewers; use ConduitUI\Pr\Requests\UpdatePullRequest; +use ConduitUI\Pr\Services\AssigneeManager; +use ConduitUI\Pr\Services\MilestoneManager; use ConduitUI\Pr\Services\ReviewBuilder; use ConduitUI\Pr\Services\ReviewQuery; @@ -471,6 +473,32 @@ public function timeline(): array return $allEvents; } + /** + * Get an assignee manager for this pull request. + */ + public function assignees(): AssigneeManager + { + return new AssigneeManager($this->connector, "{$this->owner}/{$this->repo}", $this->data->number); + } + + /** + * Get a milestone manager for this pull request. + */ + public function milestone(): MilestoneManager + { + return new MilestoneManager($this->connector, "{$this->owner}/{$this->repo}", $this->data->number); + } + + /** + * Set the milestone for this pull request. + */ + public function setMilestone(int $milestoneNumber): static + { + $this->milestone()->set($milestoneNumber); + + return $this; + } + public function __get(string $name): mixed { return $this->data->{$name}; // @phpstan-ignore-line Variable property access is intentional for magic getter diff --git a/src/Requests/CreateMilestone.php b/src/Requests/CreateMilestone.php new file mode 100644 index 0000000..80c41cc --- /dev/null +++ b/src/Requests/CreateMilestone.php @@ -0,0 +1,39 @@ + $data + */ + public function __construct( + protected string $owner, + protected string $repo, + protected array $data, + ) {} + + public function resolveEndpoint(): string + { + return "/repos/{$this->owner}/{$this->repo}/milestones"; + } + + /** + * @return array + */ + protected function defaultBody(): array + { + return $this->data; + } +} diff --git a/src/Requests/DeleteMilestone.php b/src/Requests/DeleteMilestone.php new file mode 100644 index 0000000..6eb6a0e --- /dev/null +++ b/src/Requests/DeleteMilestone.php @@ -0,0 +1,24 @@ +owner}/{$this->repo}/milestones/{$this->number}"; + } +} diff --git a/src/Requests/GetMilestone.php b/src/Requests/GetMilestone.php new file mode 100644 index 0000000..57a2f69 --- /dev/null +++ b/src/Requests/GetMilestone.php @@ -0,0 +1,24 @@ +owner}/{$this->repo}/milestones/{$this->number}"; + } +} diff --git a/src/Requests/ListMilestones.php b/src/Requests/ListMilestones.php new file mode 100644 index 0000000..9d29e38 --- /dev/null +++ b/src/Requests/ListMilestones.php @@ -0,0 +1,32 @@ +owner}/{$this->repo}/milestones"; + } + + /** + * @return array + */ + protected function defaultQuery(): array + { + return ['state' => $this->state]; + } +} diff --git a/src/Requests/UpdateMilestone.php b/src/Requests/UpdateMilestone.php new file mode 100644 index 0000000..e6b643b --- /dev/null +++ b/src/Requests/UpdateMilestone.php @@ -0,0 +1,40 @@ + $data + */ + public function __construct( + protected string $owner, + protected string $repo, + protected int $number, + protected array $data, + ) {} + + public function resolveEndpoint(): string + { + return "/repos/{$this->owner}/{$this->repo}/milestones/{$this->number}"; + } + + /** + * @return array + */ + protected function defaultBody(): array + { + return $this->data; + } +} diff --git a/src/Services/AssigneeManager.php b/src/Services/AssigneeManager.php new file mode 100644 index 0000000..a27c171 --- /dev/null +++ b/src/Services/AssigneeManager.php @@ -0,0 +1,112 @@ +owner, $this->repo] = explode('/', $fullName, 2); + } + + public function get(): Collection + { + $response = $this->connector->send(new GetPullRequest( + $this->owner, + $this->repo, + $this->prNumber + )); + + /** @var array $json */ + $json = $response->json(); + + /** @var array> $assignees */ + $assignees = $json['assignees'] ?? []; + + return collect($assignees) + ->map(fn (array $assignee): User => User::fromArray($assignee)); + } + + public function add(string $username): self + { + return $this->addMany([$username]); + } + + public function addMany(array $usernames): self + { + $this->connector->send(new AddAssignees( + $this->owner, + $this->repo, + $this->prNumber, + $usernames + )); + + return $this; + } + + public function remove(string $username): self + { + return $this->removeMany([$username]); + } + + public function removeMany(array $usernames): self + { + $this->connector->send(new RemoveAssignees( + $this->owner, + $this->repo, + $this->prNumber, + $usernames + )); + + return $this; + } + + public function replace(array $usernames): self + { + $current = $this->get()->pluck('login')->toArray(); + + if ($current !== []) { + $this->removeMany($current); + } + + if ($usernames !== []) { + $this->addMany($usernames); + } + + return $this; + } + + public function clear(): self + { + $current = $this->get()->pluck('login')->toArray(); + + if ($current !== []) { + $this->removeMany($current); + } + + return $this; + } + + public function has(string $username): bool + { + return $this->get() + ->contains('login', $username); + } +} diff --git a/src/Services/MilestoneManager.php b/src/Services/MilestoneManager.php new file mode 100644 index 0000000..2646229 --- /dev/null +++ b/src/Services/MilestoneManager.php @@ -0,0 +1,72 @@ +owner, $this->repo] = explode('/', $fullName, 2); + } + + public function get(): ?Milestone + { + $response = $this->connector->send(new GetPullRequest( + $this->owner, + $this->repo, + $this->prNumber + )); + + /** @var array $json */ + $json = $response->json(); + + /** @var array|null $milestone */ + $milestone = $json['milestone'] ?? null; + + return $milestone !== null ? Milestone::fromArray($milestone) : null; + } + + public function set(int $milestoneNumber): Milestone + { + $response = $this->connector->send(new UpdatePullRequest( + $this->owner, + $this->repo, + $this->prNumber, + ['milestone' => $milestoneNumber] + )); + + /** @var array $json */ + $json = $response->json(); + + /** @var array $milestone */ + $milestone = $json['milestone']; + + return Milestone::fromArray($milestone); + } + + public function remove(): bool + { + $response = $this->connector->send(new UpdatePullRequest( + $this->owner, + $this->repo, + $this->prNumber, + ['milestone' => null] + )); + + return $response->successful(); + } +} diff --git a/src/Services/RepositoryMilestoneManager.php b/src/Services/RepositoryMilestoneManager.php new file mode 100644 index 0000000..f10ddbe --- /dev/null +++ b/src/Services/RepositoryMilestoneManager.php @@ -0,0 +1,166 @@ +owner, $this->repo] = explode('/', $fullName, 2); + } + + /** + * @return Collection + */ + public function get(): Collection + { + $response = $this->connector->send(new ListMilestones( + $this->owner, + $this->repo, + 'all' + )); + + /** @var array> $milestones */ + $milestones = $response->json(); + + return collect($milestones) + ->map(fn (array $milestone): Milestone => Milestone::fromArray($milestone)); + } + + /** + * @return Collection + */ + public function whereOpen(): Collection + { + $response = $this->connector->send(new ListMilestones( + $this->owner, + $this->repo, + 'open' + )); + + /** @var array> $milestones */ + $milestones = $response->json(); + + return collect($milestones) + ->map(fn (array $milestone): Milestone => Milestone::fromArray($milestone)); + } + + /** + * @return Collection + */ + public function whereClosed(): Collection + { + $response = $this->connector->send(new ListMilestones( + $this->owner, + $this->repo, + 'closed' + )); + + /** @var array> $milestones */ + $milestones = $response->json(); + + return collect($milestones) + ->map(fn (array $milestone): Milestone => Milestone::fromArray($milestone)); + } + + public function find(int $number): Milestone + { + $response = $this->connector->send(new GetMilestone( + $this->owner, + $this->repo, + $number + )); + + /** @var array $milestone */ + $milestone = $response->json(); + + return Milestone::fromArray($milestone); + } + + public function create( + string $title, + ?string $description = null, + ?Carbon $dueOn = null, + string $state = 'open' + ): Milestone { + $data = [ + 'title' => $title, + 'state' => $state, + ]; + + if ($description !== null) { + $data['description'] = $description; + } + + if ($dueOn !== null) { + $data['due_on'] = $dueOn->toIso8601String(); + } + + $response = $this->connector->send(new CreateMilestone( + $this->owner, + $this->repo, + $data + )); + + /** @var array $milestone */ + $milestone = $response->json(); + + return Milestone::fromArray($milestone); + } + + public function update( + int $number, + ?string $title = null, + ?string $description = null, + ?Carbon $dueOn = null, + ?string $state = null + ): Milestone { + $data = array_filter([ + 'title' => $title, + 'description' => $description, + 'due_on' => $dueOn?->toIso8601String(), + 'state' => $state, + ], fn ($value): bool => $value !== null); + + $response = $this->connector->send(new UpdateMilestone( + $this->owner, + $this->repo, + $number, + $data + )); + + /** @var array $milestone */ + $milestone = $response->json(); + + return Milestone::fromArray($milestone); + } + + public function delete(int $number): bool + { + $response = $this->connector->send(new DeleteMilestone( + $this->owner, + $this->repo, + $number + )); + + return $response->successful(); + } +} diff --git a/tests/Unit/DataTransferObjects/MilestoneTest.php b/tests/Unit/DataTransferObjects/MilestoneTest.php new file mode 100644 index 0000000..af085f6 --- /dev/null +++ b/tests/Unit/DataTransferObjects/MilestoneTest.php @@ -0,0 +1,232 @@ + 1, + 'title' => 'v1.0', + 'description' => 'First release', + 'state' => 'open', + 'open_issues' => 5, + 'closed_issues' => 10, + 'due_on' => '2025-12-31T23:59:59Z', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/test/repo/milestone/1', + ]); + + expect($milestone->number)->toBe(1) + ->and($milestone->title)->toBe('v1.0') + ->and($milestone->description)->toBe('First release') + ->and($milestone->state)->toBe('open') + ->and($milestone->openIssues)->toBe(5) + ->and($milestone->closedIssues)->toBe(10) + ->and($milestone->dueOn)->toBeInstanceOf(Carbon::class) + ->and($milestone->createdAt)->toBeInstanceOf(Carbon::class) + ->and($milestone->updatedAt)->toBeInstanceOf(Carbon::class) + ->and($milestone->closedAt)->toBeNull() + ->and($milestone->htmlUrl)->toBe('https://github.com/test/repo/milestone/1'); +}); + +it('can create milestone from array with null description and due date', function () { + $milestone = Milestone::fromArray([ + 'number' => 2, + 'title' => 'v2.0', + 'description' => null, + 'state' => 'closed', + 'open_issues' => 0, + 'closed_issues' => 15, + 'due_on' => null, + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => '2025-01-15T00:00:00Z', + 'html_url' => 'https://github.com/test/repo/milestone/2', + ]); + + expect($milestone->description)->toBeNull() + ->and($milestone->dueOn)->toBeNull() + ->and($milestone->closedAt)->toBeInstanceOf(Carbon::class); +}); + +it('can convert milestone to array', function () { + $milestone = Milestone::fromArray([ + 'number' => 1, + 'title' => 'v1.0', + 'description' => 'First release', + 'state' => 'open', + 'open_issues' => 5, + 'closed_issues' => 10, + 'due_on' => '2025-12-31T23:59:59Z', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/test/repo/milestone/1', + ]); + + $array = $milestone->toArray(); + + expect($array)->toBeArray() + ->and($array['number'])->toBe(1) + ->and($array['title'])->toBe('v1.0') + ->and($array['description'])->toBe('First release') + ->and($array['state'])->toBe('open'); +}); + +it('correctly identifies open milestone', function () { + $milestone = Milestone::fromArray([ + 'number' => 1, + 'title' => 'v1.0', + 'description' => null, + 'state' => 'open', + 'open_issues' => 5, + 'closed_issues' => 10, + 'due_on' => null, + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/test/repo/milestone/1', + ]); + + expect($milestone->isOpen())->toBeTrue() + ->and($milestone->isClosed())->toBeFalse(); +}); + +it('correctly identifies closed milestone', function () { + $milestone = Milestone::fromArray([ + 'number' => 1, + 'title' => 'v1.0', + 'description' => null, + 'state' => 'closed', + 'open_issues' => 0, + 'closed_issues' => 15, + 'due_on' => null, + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => '2025-01-15T00:00:00Z', + 'html_url' => 'https://github.com/test/repo/milestone/1', + ]); + + expect($milestone->isClosed())->toBeTrue() + ->and($milestone->isOpen())->toBeFalse(); +}); + +it('correctly identifies overdue milestone', function () { + $milestone = Milestone::fromArray([ + 'number' => 1, + 'title' => 'v1.0', + 'description' => null, + 'state' => 'open', + 'open_issues' => 5, + 'closed_issues' => 10, + 'due_on' => '2025-01-01T00:00:00Z', // Past date + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/test/repo/milestone/1', + ]); + + expect($milestone->isOverdue())->toBeTrue(); +}); + +it('identifies non-overdue milestone', function () { + $milestone = Milestone::fromArray([ + 'number' => 1, + 'title' => 'v1.0', + 'description' => null, + 'state' => 'open', + 'open_issues' => 5, + 'closed_issues' => 10, + 'due_on' => '2025-12-31T23:59:59Z', // Future date + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/test/repo/milestone/1', + ]); + + expect($milestone->isOverdue())->toBeFalse(); +}); + +it('identifies closed milestone as not overdue', function () { + $milestone = Milestone::fromArray([ + 'number' => 1, + 'title' => 'v1.0', + 'description' => null, + 'state' => 'closed', + 'open_issues' => 0, + 'closed_issues' => 15, + 'due_on' => '2025-01-01T00:00:00Z', // Past date but closed + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => '2025-01-14T00:00:00Z', + 'html_url' => 'https://github.com/test/repo/milestone/1', + ]); + + expect($milestone->isOverdue())->toBeFalse(); +}); + +it('calculates progress correctly', function () { + $milestone = Milestone::fromArray([ + 'number' => 1, + 'title' => 'v1.0', + 'description' => null, + 'state' => 'open', + 'open_issues' => 5, + 'closed_issues' => 10, + 'due_on' => null, + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/test/repo/milestone/1', + ]); + + expect($milestone->progress())->toBe(66.67); +}); + +it('returns zero progress when no issues exist', function () { + $milestone = Milestone::fromArray([ + 'number' => 1, + 'title' => 'v1.0', + 'description' => null, + 'state' => 'open', + 'open_issues' => 0, + 'closed_issues' => 0, + 'due_on' => null, + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/test/repo/milestone/1', + ]); + + expect($milestone->progress())->toBe(0.0); +}); + +it('returns 100 percent progress when all issues closed', function () { + $milestone = Milestone::fromArray([ + 'number' => 1, + 'title' => 'v1.0', + 'description' => null, + 'state' => 'open', + 'open_issues' => 0, + 'closed_issues' => 15, + 'due_on' => null, + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/test/repo/milestone/1', + ]); + + expect($milestone->progress())->toBe(100.0); +}); diff --git a/tests/Unit/PullRequestAssigneesAndMilestonesTest.php b/tests/Unit/PullRequestAssigneesAndMilestonesTest.php new file mode 100644 index 0000000..23a4760 --- /dev/null +++ b/tests/Unit/PullRequestAssigneesAndMilestonesTest.php @@ -0,0 +1,134 @@ +data[$key] ?? $default; + } + + return $this->data; + } + + public function successful(): bool + { + return true; + } +} + +class PullRequestAssigneeTestConnector extends Connector +{ + public ?Request $lastRequest = null; + + public function __construct(private ?array $milestone = null) + { + parent::__construct('test-token'); + } + + public function send(Request $request, ...$args): Response + { + $this->lastRequest = $request; + + return new MockPullRequestAssigneeResponse([ + 'milestone' => $this->milestone, + ]); + } +} + +beforeEach(function () { + $this->prData = new PullRequestData( + number: 123, + title: 'Test PR', + body: 'Test body', + state: 'open', + user: new User(1, 'testuser', 'https://example.com/avatar.jpg', 'https://github.com/testuser', 'User'), + htmlUrl: 'https://github.com/test/test-repo/pull/123', + createdAt: Carbon::parse('2025-01-01')->toDateTimeImmutable(), + updatedAt: Carbon::parse('2025-01-15')->toDateTimeImmutable(), + closedAt: null, + mergedAt: null, + mergeCommitSha: null, + draft: false, + additions: 10, + deletions: 5, + changedFiles: 2, + assignee: null, + assignees: [], + requestedReviewers: [], + labels: [], + head: new Head( + 'feature', + 'abc123', + new User(1, 'testuser', 'https://example.com/avatar.jpg', 'https://github.com/testuser', 'User'), + new Repository(1, 'test-repo', 'test/test-repo', 'https://github.com/test/test-repo', false) + ), + base: new Base( + 'main', + 'def456', + new User(1, 'testuser', 'https://example.com/avatar.jpg', 'https://github.com/testuser', 'User'), + new Repository(1, 'test-repo', 'test/test-repo', 'https://github.com/test/test-repo', false) + ), + ); +}); + +it('returns assignee manager instance', function () { + $connector = new PullRequestAssigneeTestConnector; + $pr = new PullRequest($connector, 'test', 'repo', $this->prData); + + $assignees = $pr->assignees(); + + expect($assignees)->toBeInstanceOf(AssigneeManager::class); +}); + +it('returns milestone manager instance', function () { + $connector = new PullRequestAssigneeTestConnector; + $pr = new PullRequest($connector, 'test', 'repo', $this->prData); + + $milestone = $pr->milestone(); + + expect($milestone)->toBeInstanceOf(MilestoneManager::class); +}); + +it('can set milestone on pull request', function () { + $connector = new PullRequestAssigneeTestConnector([ + 'number' => 5, + 'title' => 'v2.0', + 'description' => 'Second release', + 'state' => 'open', + 'open_issues' => 3, + 'closed_issues' => 7, + 'due_on' => '2025-06-30T23:59:59Z', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/test/repo/milestone/5', + ]); + + $pr = new PullRequest($connector, 'test', 'repo', $this->prData); + $result = $pr->setMilestone(5); + + expect($result)->toBeInstanceOf(PullRequest::class) + ->and($connector->lastRequest)->toBeInstanceOf(UpdatePullRequest::class); +}); diff --git a/tests/Unit/Requests/MilestoneRequestsTest.php b/tests/Unit/Requests/MilestoneRequestsTest.php new file mode 100644 index 0000000..307f76a --- /dev/null +++ b/tests/Unit/Requests/MilestoneRequestsTest.php @@ -0,0 +1,86 @@ +resolveEndpoint())->toBe('/repos/owner/repo/milestones/5'); +}); + +it('ListMilestones has correct endpoint', function () { + $request = new ListMilestones('owner', 'repo', 'all'); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/milestones'); +}); + +it('ListMilestones has correct query parameters for open state', function () { + $request = new ListMilestones('owner', 'repo', 'open'); + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + + $query = $method->invoke($request); + + expect($query)->toBe(['state' => 'open']); +}); + +it('ListMilestones has correct query parameters for closed state', function () { + $request = new ListMilestones('owner', 'repo', 'closed'); + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + + $query = $method->invoke($request); + + expect($query)->toBe(['state' => 'closed']); +}); + +it('CreateMilestone has correct endpoint', function () { + $request = new CreateMilestone('owner', 'repo', ['title' => 'v1.0']); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/milestones'); +}); + +it('CreateMilestone has correct body', function () { + $data = [ + 'title' => 'v1.0', + 'description' => 'First release', + 'due_on' => '2025-12-31T23:59:59Z', + 'state' => 'open', + ]; + $request = new CreateMilestone('owner', 'repo', $data); + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + + $body = $method->invoke($request); + + expect($body)->toBe($data); +}); + +it('UpdateMilestone has correct endpoint', function () { + $request = new UpdateMilestone('owner', 'repo', 5, ['title' => 'v1.0']); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/milestones/5'); +}); + +it('UpdateMilestone has correct body', function () { + $data = ['title' => 'v1.0 Updated']; + $request = new UpdateMilestone('owner', 'repo', 5, $data); + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + + $body = $method->invoke($request); + + expect($body)->toBe($data); +}); + +it('DeleteMilestone has correct endpoint', function () { + $request = new DeleteMilestone('owner', 'repo', 5); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/milestones/5'); +}); diff --git a/tests/Unit/Services/AssigneeManagerTest.php b/tests/Unit/Services/AssigneeManagerTest.php new file mode 100644 index 0000000..6b56e22 --- /dev/null +++ b/tests/Unit/Services/AssigneeManagerTest.php @@ -0,0 +1,161 @@ +data[$key] ?? $default; + } + + return $this->data; + } + + public function successful(): bool + { + return true; + } +} + +class AssigneeManagerTestConnector extends Connector +{ + public ?Request $lastRequest = null; + + public function __construct(private array $assignees = []) + { + parent::__construct('test-token'); + } + + public function send(Request $request, ...$args): Response + { + $this->lastRequest = $request; + + return new MockAssigneeManagerResponse([ + 'assignees' => $this->assignees, + ]); + } +} + +it('can get assignees', function () { + $connector = new AssigneeManagerTestConnector([ + ['id' => 1, 'login' => 'user1', 'avatar_url' => 'https://example.com/1.jpg', 'html_url' => 'https://github.com/user1', 'type' => 'User'], + ['id' => 2, 'login' => 'user2', 'avatar_url' => 'https://example.com/2.jpg', 'html_url' => 'https://github.com/user2', 'type' => 'User'], + ]); + + $manager = new AssigneeManager($connector, 'test/repo', 123); + $assignees = $manager->get(); + + expect($assignees)->toHaveCount(2) + ->and($assignees->first())->toBeInstanceOf(User::class) + ->and($assignees->first()->login)->toBe('user1') + ->and($assignees->last()->login)->toBe('user2'); +}); + +it('can add single assignee', function () { + $connector = new AssigneeManagerTestConnector; + + $manager = new AssigneeManager($connector, 'test/repo', 123); + $result = $manager->add('newuser'); + + expect($result)->toBeInstanceOf(AssigneeManager::class) + ->and($connector->lastRequest)->toBeInstanceOf(AddAssignees::class); +}); + +it('can add multiple assignees', function () { + $connector = new AssigneeManagerTestConnector; + + $manager = new AssigneeManager($connector, 'test/repo', 123); + $result = $manager->addMany(['user1', 'user2', 'user3']); + + expect($result)->toBeInstanceOf(AssigneeManager::class) + ->and($connector->lastRequest)->toBeInstanceOf(AddAssignees::class); +}); + +it('can remove single assignee', function () { + $connector = new AssigneeManagerTestConnector; + + $manager = new AssigneeManager($connector, 'test/repo', 123); + $result = $manager->remove('user1'); + + expect($result)->toBeInstanceOf(AssigneeManager::class) + ->and($connector->lastRequest)->toBeInstanceOf(RemoveAssignees::class); +}); + +it('can remove multiple assignees', function () { + $connector = new AssigneeManagerTestConnector; + + $manager = new AssigneeManager($connector, 'test/repo', 123); + $result = $manager->removeMany(['user1', 'user2']); + + expect($result)->toBeInstanceOf(AssigneeManager::class) + ->and($connector->lastRequest)->toBeInstanceOf(RemoveAssignees::class); +}); + +it('can replace assignees', function () { + $connector = new AssigneeManagerTestConnector([ + ['id' => 1, 'login' => 'olduser', 'avatar_url' => 'https://example.com/1.jpg', 'html_url' => 'https://github.com/olduser', 'type' => 'User'], + ]); + + $manager = new AssigneeManager($connector, 'test/repo', 123); + $result = $manager->replace(['newuser1', 'newuser2']); + + expect($result)->toBeInstanceOf(AssigneeManager::class); +}); + +it('can replace empty assignees', function () { + $connector = new AssigneeManagerTestConnector; + + $manager = new AssigneeManager($connector, 'test/repo', 123); + $result = $manager->replace(['newuser1', 'newuser2']); + + expect($result)->toBeInstanceOf(AssigneeManager::class) + ->and($connector->lastRequest)->toBeInstanceOf(AddAssignees::class); +}); + +it('can clear assignees', function () { + $connector = new AssigneeManagerTestConnector([ + ['id' => 1, 'login' => 'user1', 'avatar_url' => 'https://example.com/1.jpg', 'html_url' => 'https://github.com/user1', 'type' => 'User'], + ]); + + $manager = new AssigneeManager($connector, 'test/repo', 123); + $result = $manager->clear(); + + expect($result)->toBeInstanceOf(AssigneeManager::class); +}); + +it('clear does nothing when no assignees exist', function () { + $connector = new AssigneeManagerTestConnector([]); + + $manager = new AssigneeManager($connector, 'test/repo', 123); + $result = $manager->clear(); + + expect($result)->toBeInstanceOf(AssigneeManager::class); +}); + +it('can check if user is assigned', function () { + $connector = new AssigneeManagerTestConnector([ + ['id' => 1, 'login' => 'user1', 'avatar_url' => 'https://example.com/1.jpg', 'html_url' => 'https://github.com/user1', 'type' => 'User'], + ['id' => 2, 'login' => 'user2', 'avatar_url' => 'https://example.com/2.jpg', 'html_url' => 'https://github.com/user2', 'type' => 'User'], + ]); + + $manager = new AssigneeManager($connector, 'test/repo', 123); + + expect($manager->has('user1'))->toBeTrue() + ->and($manager->has('user2'))->toBeTrue() + ->and($manager->has('user3'))->toBeFalse(); +}); diff --git a/tests/Unit/Services/MilestoneManagerTest.php b/tests/Unit/Services/MilestoneManagerTest.php new file mode 100644 index 0000000..70fcc97 --- /dev/null +++ b/tests/Unit/Services/MilestoneManagerTest.php @@ -0,0 +1,116 @@ +data[$key] ?? $default; + } + + return $this->data; + } + + public function successful(): bool + { + return true; + } +} + +class MilestoneManagerTestConnector extends Connector +{ + public ?Request $lastRequest = null; + + public function __construct(private ?array $milestone = null) + { + parent::__construct('test-token'); + } + + public function send(Request $request, ...$args): Response + { + $this->lastRequest = $request; + + return new MockMilestoneManagerResponse([ + 'milestone' => $this->milestone, + ]); + } +} + +it('can get milestone', function () { + $connector = new MilestoneManagerTestConnector([ + 'number' => 1, + 'title' => 'v1.0', + 'description' => 'First release', + 'state' => 'open', + 'open_issues' => 5, + 'closed_issues' => 10, + 'due_on' => '2025-12-31T23:59:59Z', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/test/repo/milestone/1', + ]); + + $manager = new MilestoneManager($connector, 'test/repo', 123); + $milestone = $manager->get(); + + expect($milestone)->toBeInstanceOf(Milestone::class) + ->and($milestone->number)->toBe(1) + ->and($milestone->title)->toBe('v1.0'); +}); + +it('returns null when no milestone exists', function () { + $connector = new MilestoneManagerTestConnector(null); + + $manager = new MilestoneManager($connector, 'test/repo', 123); + $milestone = $manager->get(); + + expect($milestone)->toBeNull(); +}); + +it('can set milestone', function () { + $connector = new MilestoneManagerTestConnector([ + 'number' => 5, + 'title' => 'v2.0', + 'description' => 'Second release', + 'state' => 'open', + 'open_issues' => 3, + 'closed_issues' => 7, + 'due_on' => '2025-06-30T23:59:59Z', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/test/repo/milestone/5', + ]); + + $manager = new MilestoneManager($connector, 'test/repo', 123); + $milestone = $manager->set(5); + + expect($milestone)->toBeInstanceOf(Milestone::class) + ->and($milestone->number)->toBe(5) + ->and($connector->lastRequest)->toBeInstanceOf(UpdatePullRequest::class); +}); + +it('can remove milestone', function () { + $connector = new MilestoneManagerTestConnector(null); + + $manager = new MilestoneManager($connector, 'test/repo', 123); + $result = $manager->remove(); + + expect($result)->toBeTrue() + ->and($connector->lastRequest)->toBeInstanceOf(UpdatePullRequest::class); +}); diff --git a/tests/Unit/Services/RepositoryMilestoneManagerTest.php b/tests/Unit/Services/RepositoryMilestoneManagerTest.php new file mode 100644 index 0000000..55c3aad --- /dev/null +++ b/tests/Unit/Services/RepositoryMilestoneManagerTest.php @@ -0,0 +1,306 @@ +data[$key] ?? $default; + } + + return $this->data; + } + + public function successful(): bool + { + return $this->data !== false; + } +} + +class RepositoryMilestoneManagerTestConnector extends Connector +{ + public ?Request $lastRequest = null; + + public function __construct( + private array $milestones = [], + private ?array $singleMilestone = null, + private bool $operationSuccess = true + ) { + parent::__construct('test-token'); + } + + public function send(Request $request, ...$args): Response + { + $this->lastRequest = $request; + + if ($request instanceof ListMilestones) { + return new MockRepositoryMilestoneManagerResponse($this->milestones); + } + + if ($request instanceof GetMilestone || $request instanceof CreateMilestone || $request instanceof UpdateMilestone) { + return new MockRepositoryMilestoneManagerResponse($this->singleMilestone ?? []); + } + + if ($request instanceof DeleteMilestone) { + return new MockRepositoryMilestoneManagerResponse($this->operationSuccess); + } + + return new MockRepositoryMilestoneManagerResponse([]); + } +} + +it('can get all milestones', function () { + $connector = new RepositoryMilestoneManagerTestConnector([ + [ + 'number' => 1, + 'title' => 'v1.0', + 'description' => 'First release', + 'state' => 'open', + 'open_issues' => 5, + 'closed_issues' => 10, + 'due_on' => '2025-12-31T23:59:59Z', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/test/repo/milestone/1', + ], + [ + 'number' => 2, + 'title' => 'v2.0', + 'description' => 'Second release', + 'state' => 'closed', + 'open_issues' => 0, + 'closed_issues' => 15, + 'due_on' => null, + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => '2025-01-14T00:00:00Z', + 'html_url' => 'https://github.com/test/repo/milestone/2', + ], + ]); + + $manager = new RepositoryMilestoneManager($connector, 'test/repo'); + $milestones = $manager->get(); + + expect($milestones)->toHaveCount(2) + ->and($milestones->first())->toBeInstanceOf(Milestone::class) + ->and($milestones->first()->title)->toBe('v1.0') + ->and($milestones->last()->title)->toBe('v2.0'); +}); + +it('can get open milestones', function () { + $connector = new RepositoryMilestoneManagerTestConnector([ + [ + 'number' => 1, + 'title' => 'v1.0', + 'description' => 'First release', + 'state' => 'open', + 'open_issues' => 5, + 'closed_issues' => 10, + 'due_on' => '2025-12-31T23:59:59Z', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/test/repo/milestone/1', + ], + ]); + + $manager = new RepositoryMilestoneManager($connector, 'test/repo'); + $milestones = $manager->whereOpen(); + + expect($milestones)->toHaveCount(1) + ->and($milestones->first()->state)->toBe('open'); +}); + +it('can get closed milestones', function () { + $connector = new RepositoryMilestoneManagerTestConnector([ + [ + 'number' => 2, + 'title' => 'v2.0', + 'description' => 'Second release', + 'state' => 'closed', + 'open_issues' => 0, + 'closed_issues' => 15, + 'due_on' => null, + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => '2025-01-14T00:00:00Z', + 'html_url' => 'https://github.com/test/repo/milestone/2', + ], + ]); + + $manager = new RepositoryMilestoneManager($connector, 'test/repo'); + $milestones = $manager->whereClosed(); + + expect($milestones)->toHaveCount(1) + ->and($milestones->first()->state)->toBe('closed'); +}); + +it('can find specific milestone', function () { + $connector = new RepositoryMilestoneManagerTestConnector( + [], + [ + 'number' => 1, + 'title' => 'v1.0', + 'description' => 'First release', + 'state' => 'open', + 'open_issues' => 5, + 'closed_issues' => 10, + 'due_on' => '2025-12-31T23:59:59Z', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/test/repo/milestone/1', + ] + ); + + $manager = new RepositoryMilestoneManager($connector, 'test/repo'); + $milestone = $manager->find(1); + + expect($milestone)->toBeInstanceOf(Milestone::class) + ->and($milestone->number)->toBe(1) + ->and($connector->lastRequest)->toBeInstanceOf(GetMilestone::class); +}); + +it('can create milestone with all parameters', function () { + $connector = new RepositoryMilestoneManagerTestConnector( + [], + [ + 'number' => 3, + 'title' => 'v3.0', + 'description' => 'Third release', + 'state' => 'open', + 'open_issues' => 0, + 'closed_issues' => 0, + 'due_on' => '2025-12-31T23:59:59Z', + 'created_at' => '2025-01-15T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/test/repo/milestone/3', + ] + ); + + $manager = new RepositoryMilestoneManager($connector, 'test/repo'); + $milestone = $manager->create( + 'v3.0', + 'Third release', + Carbon::parse('2025-12-31T23:59:59Z'), + 'open' + ); + + expect($milestone)->toBeInstanceOf(Milestone::class) + ->and($milestone->title)->toBe('v3.0') + ->and($connector->lastRequest)->toBeInstanceOf(CreateMilestone::class); +}); + +it('can create milestone with minimal parameters', function () { + $connector = new RepositoryMilestoneManagerTestConnector( + [], + [ + 'number' => 4, + 'title' => 'v4.0', + 'description' => null, + 'state' => 'open', + 'open_issues' => 0, + 'closed_issues' => 0, + 'due_on' => null, + 'created_at' => '2025-01-15T00:00:00Z', + 'updated_at' => '2025-01-15T00:00:00Z', + 'closed_at' => null, + 'html_url' => 'https://github.com/test/repo/milestone/4', + ] + ); + + $manager = new RepositoryMilestoneManager($connector, 'test/repo'); + $milestone = $manager->create('v4.0'); + + expect($milestone)->toBeInstanceOf(Milestone::class) + ->and($milestone->title)->toBe('v4.0') + ->and($milestone->description)->toBeNull(); +}); + +it('can update milestone', function () { + $connector = new RepositoryMilestoneManagerTestConnector( + [], + [ + 'number' => 1, + 'title' => 'v1.0 Updated', + 'description' => 'Updated description', + 'state' => 'closed', + 'open_issues' => 0, + 'closed_issues' => 15, + 'due_on' => '2025-12-31T23:59:59Z', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T12:00:00Z', + 'closed_at' => '2025-01-15T12:00:00Z', + 'html_url' => 'https://github.com/test/repo/milestone/1', + ] + ); + + $manager = new RepositoryMilestoneManager($connector, 'test/repo'); + $milestone = $manager->update( + 1, + 'v1.0 Updated', + 'Updated description', + Carbon::parse('2025-12-31T23:59:59Z'), + 'closed' + ); + + expect($milestone)->toBeInstanceOf(Milestone::class) + ->and($milestone->title)->toBe('v1.0 Updated') + ->and($connector->lastRequest)->toBeInstanceOf(UpdateMilestone::class); +}); + +it('can update milestone with partial parameters', function () { + $connector = new RepositoryMilestoneManagerTestConnector( + [], + [ + 'number' => 1, + 'title' => 'v1.0', + 'description' => 'First release', + 'state' => 'closed', + 'open_issues' => 0, + 'closed_issues' => 15, + 'due_on' => '2025-12-31T23:59:59Z', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-15T12:00:00Z', + 'closed_at' => '2025-01-15T12:00:00Z', + 'html_url' => 'https://github.com/test/repo/milestone/1', + ] + ); + + $manager = new RepositoryMilestoneManager($connector, 'test/repo'); + $milestone = $manager->update(1, null, null, null, 'closed'); + + expect($milestone)->toBeInstanceOf(Milestone::class) + ->and($milestone->state)->toBe('closed'); +}); + +it('can delete milestone', function () { + $connector = new RepositoryMilestoneManagerTestConnector([], null, true); + + $manager = new RepositoryMilestoneManager($connector, 'test/repo'); + $result = $manager->delete(1); + + expect($result)->toBeTrue() + ->and($connector->lastRequest)->toBeInstanceOf(DeleteMilestone::class); +});