diff --git a/src/CreateIssueParams.php b/src/CreateIssueParams.php new file mode 100644 index 0000000..3bfda52 --- /dev/null +++ b/src/CreateIssueParams.php @@ -0,0 +1,65 @@ + + */ + public function toArray(): array + { + $data = ['title' => $this->title]; + + if ($this->body !== '') { + $data['body'] = $this->body; + } + if ($this->assignees !== []) { + $data['assignees'] = $this->assignees; + } + if ($this->labels !== []) { + $data['labels'] = $this->labels; + } + if ($this->milestone !== null) { + $data['milestone'] = $this->milestone; + } + if ($this->type !== '') { + $data['type'] = $this->type; + } + + return $data; + } +} diff --git a/src/CreateIssueRequestFactory.php b/src/CreateIssueRequestFactory.php new file mode 100644 index 0000000..5c58e83 --- /dev/null +++ b/src/CreateIssueRequestFactory.php @@ -0,0 +1,54 @@ +repo->owner, + $this->repo->name + ); + + $jsonBody = json_encode($this->params->toArray(), JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('POST', $url); + if ($this->config->accessToken !== '') { + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + } + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/CreateIssueTypeParams.php b/src/CreateIssueTypeParams.php new file mode 100644 index 0000000..cac8478 --- /dev/null +++ b/src/CreateIssueTypeParams.php @@ -0,0 +1,53 @@ + + */ + public function toArray(): array + { + $data = [ + 'name' => $this->name, + 'is_enabled' => $this->isEnabled, + ]; + + if ($this->description !== '') { + $data['description'] = $this->description; + } + if ($this->color !== '') { + $data['color'] = $this->color; + } + + return $data; + } +} diff --git a/src/CreateIssueTypeRequestFactory.php b/src/CreateIssueTypeRequestFactory.php new file mode 100644 index 0000000..dcd7f90 --- /dev/null +++ b/src/CreateIssueTypeRequestFactory.php @@ -0,0 +1,53 @@ +org + ); + + $jsonBody = json_encode($this->params->toArray(), JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('POST', $url); + if ($this->config->accessToken !== '') { + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + } + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/CreateMilestoneParams.php b/src/CreateMilestoneParams.php new file mode 100644 index 0000000..7c1d026 --- /dev/null +++ b/src/CreateMilestoneParams.php @@ -0,0 +1,57 @@ + + */ + public function toArray(): array + { + $data = [ + 'title' => $this->title, + 'state' => $this->state, + ]; + + if ($this->description !== '') { + $data['description'] = $this->description; + } + if ($this->dueOn !== null) { + $data['due_on'] = $this->dueOn->format(DATE_ATOM); + } + + return $data; + } +} diff --git a/src/CreateMilestoneRequestFactory.php b/src/CreateMilestoneRequestFactory.php new file mode 100644 index 0000000..22793ab --- /dev/null +++ b/src/CreateMilestoneRequestFactory.php @@ -0,0 +1,54 @@ +repo->owner, + $this->repo->name + ); + + $jsonBody = json_encode($this->params->toArray(), JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('POST', $url); + if ($this->config->accessToken !== '') { + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + } + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/DeleteIssueTypeRequestFactory.php b/src/DeleteIssueTypeRequestFactory.php new file mode 100644 index 0000000..017e526 --- /dev/null +++ b/src/DeleteIssueTypeRequestFactory.php @@ -0,0 +1,47 @@ +org, + $this->issueTypeId + ); + + $request = $this->requestFactory->createRequest('DELETE', $url); + if ($this->config->accessToken !== '') { + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + } + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + + return $request; + } +} diff --git a/src/DeleteMilestoneRequestFactory.php b/src/DeleteMilestoneRequestFactory.php new file mode 100644 index 0000000..6c94f83 --- /dev/null +++ b/src/DeleteMilestoneRequestFactory.php @@ -0,0 +1,48 @@ +repo->owner, + $this->repo->name, + $this->milestoneNumber + ); + + $request = $this->requestFactory->createRequest('DELETE', $url); + if ($this->config->accessToken !== '') { + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + } + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + + return $request; + } +} diff --git a/src/GetIssueRequestFactory.php b/src/GetIssueRequestFactory.php new file mode 100644 index 0000000..b02fe25 --- /dev/null +++ b/src/GetIssueRequestFactory.php @@ -0,0 +1,48 @@ +repo->owner, + $this->repo->name, + $this->issueNumber + ); + + $request = $this->requestFactory->createRequest('GET', $url); + if ($this->config->accessToken !== '') { + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + } + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + + return $request; + } +} diff --git a/src/GetMilestoneRequestFactory.php b/src/GetMilestoneRequestFactory.php new file mode 100644 index 0000000..09f9de5 --- /dev/null +++ b/src/GetMilestoneRequestFactory.php @@ -0,0 +1,48 @@ +repo->owner, + $this->repo->name, + $this->milestoneNumber + ); + + $request = $this->requestFactory->createRequest('GET', $url); + if ($this->config->accessToken !== '') { + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + } + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + + return $request; + } +} diff --git a/src/GithubApiClient.php b/src/GithubApiClient.php index d238739..06e6abe 100644 --- a/src/GithubApiClient.php +++ b/src/GithubApiClient.php @@ -187,6 +187,527 @@ public function updatePullRequest(GithubRepository $repo, int $number, PullReque } } + /** + * List issues in a repository + * + * Returns both regular issues and pull requests (every PR is also an issue + * at the API level). Use `GithubIssue::$isPullRequest` to distinguish them. + * + * @param GithubRepository $repo The repository + * @param string $state 'open' (default), 'closed', or 'all' + * @param string $labels Comma-separated label names to filter by; empty for no filter + * @param string $milestone Milestone number, '*' (any), or 'none'; empty for no filter + * @param string $assignee Assignee login, '*' (any), or 'none'; empty for no filter + * @return GithubIssueList + * @throws Exception + */ + public function listIssues( + GithubRepository $repo, + string $state = 'open', + string $labels = '', + string $milestone = '', + string $assignee = '' + ): GithubIssueList { + $requestFactory = new ListIssuesRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $state, + $labels, + $milestone, + $assignee + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $issueFactory = new GithubIssueFactory(); + $issues = []; + if (is_array($data)) { + foreach ($data as $issueData) { + if (is_object($issueData)) { + $issues[] = $issueFactory->createFromApiResponse($issueData); + } + } + } + return new GithubIssueList($issues); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Get a single issue + * + * Works for both regular issues and pull requests — issues and PRs share + * a single counter per repo, so this returns whatever lives at #$number. + * Inspect `GithubIssue::$isPullRequest` to detect a PR. + * + * @param GithubRepository $repo The repository + * @param int $number The issue (or PR) number + * @return GithubIssue + * @throws Exception + */ + public function getIssue(GithubRepository $repo, int $number): GithubIssue + { + $requestFactory = new GetIssueRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $number + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $issueFactory = new GithubIssueFactory(); + return $issueFactory->createFromApiResponse($data); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Create a new issue + * + * @param GithubRepository $repo The repository + * @param CreateIssueParams $params The issue parameters + * @return GithubIssue The created issue + * @throws Exception + */ + public function createIssue(GithubRepository $repo, CreateIssueParams $params): GithubIssue + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for createIssue. Please provide it in the constructor.'); + } + + $requestFactory = new CreateIssueRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $params + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 201) { + $data = json_decode((string) $response->getBody()); + $issueFactory = new GithubIssueFactory(); + return $issueFactory->createFromApiResponse($data); + } else { + $this->maybeThrowAccessDenied($response); + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Update an issue + * + * The IssueUpdate DTO distinguishes "leave alone" from "clear" — calling + * withMilestone(null) or withType(null) emits literal null in the request + * body, which is GitHub's signal to clear the current assignment. + * + * @param GithubRepository $repo The repository + * @param int $number The issue (or PR) number + * @param IssueUpdate $update The update DTO + * @return GithubIssue The updated issue + * @throws Exception + */ + public function updateIssue(GithubRepository $repo, int $number, IssueUpdate $update): GithubIssue + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for updateIssue. Please provide it in the constructor.'); + } + + $requestFactory = new UpdateIssueRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $number, + $update + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $issueFactory = new GithubIssueFactory(); + return $issueFactory->createFromApiResponse($data); + } else { + $this->maybeThrowAccessDenied($response); + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Close an issue (symmetric with closePullRequest) + * + * @param GithubRepository $repo The repository + * @param int $number The issue number + * @return GithubIssue The closed issue + * @throws Exception + */ + public function closeIssue(GithubRepository $repo, int $number): GithubIssue + { + return $this->updateIssue($repo, $number, (new IssueUpdate())->withState('closed')); + } + + /** + * Reopen a closed issue (symmetric with reopenPullRequest) + * + * @param GithubRepository $repo The repository + * @param int $number The issue number + * @return GithubIssue The reopened issue + * @throws Exception + */ + public function reopenIssue(GithubRepository $repo, int $number): GithubIssue + { + return $this->updateIssue($repo, $number, (new IssueUpdate())->withState('open')); + } + + /** + * List milestones in a repository + * + * @param GithubRepository $repo The repository + * @param string $state 'open' (default), 'closed', or 'all' + * @return GithubMilestoneList + * @throws Exception + */ + public function listMilestones(GithubRepository $repo, string $state = 'open'): GithubMilestoneList + { + $requestFactory = new ListMilestonesRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $state + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $milestones = []; + if (is_array($data)) { + foreach ($data as $entry) { + if (is_object($entry)) { + $milestones[] = GithubMilestone::fromApiResponse($entry); + } + } + } + return new GithubMilestoneList($milestones); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Get a single milestone + * + * @param GithubRepository $repo The repository + * @param int $milestoneNumber The milestone number + * @return GithubMilestone + * @throws Exception + */ + public function getMilestone(GithubRepository $repo, int $milestoneNumber): GithubMilestone + { + $requestFactory = new GetMilestoneRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $milestoneNumber + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + return GithubMilestone::fromApiResponse($data); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Create a milestone + * + * @param GithubRepository $repo The repository + * @param CreateMilestoneParams $params Milestone parameters + * @return GithubMilestone The created milestone + * @throws Exception + */ + public function createMilestone(GithubRepository $repo, CreateMilestoneParams $params): GithubMilestone + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for createMilestone. Please provide it in the constructor.'); + } + + $requestFactory = new CreateMilestoneRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $params + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 201) { + $data = json_decode((string) $response->getBody()); + return GithubMilestone::fromApiResponse($data); + } else { + $this->maybeThrowAccessDenied($response); + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Update a milestone + * + * @param GithubRepository $repo The repository + * @param int $milestoneNumber The milestone number + * @param UpdateMilestoneParams $params Update parameters + * @return GithubMilestone The updated milestone + * @throws Exception + */ + public function updateMilestone(GithubRepository $repo, int $milestoneNumber, UpdateMilestoneParams $params): GithubMilestone + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for updateMilestone. Please provide it in the constructor.'); + } + + $requestFactory = new UpdateMilestoneRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $milestoneNumber, + $params + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + return GithubMilestone::fromApiResponse($data); + } else { + $this->maybeThrowAccessDenied($response); + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Delete a milestone + * + * @param GithubRepository $repo The repository + * @param int $milestoneNumber The milestone number + * @return void + * @throws Exception + */ + public function deleteMilestone(GithubRepository $repo, int $milestoneNumber): void + { + $requestFactory = new DeleteMilestoneRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $milestoneNumber + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() !== 204) { + $this->maybeThrowAccessDenied($response); + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Assign a milestone to an issue (convenience wrapper for updateIssue) + * + * @param GithubRepository $repo The repository + * @param int $issueNumber The issue (or PR) number + * @param int $milestoneNumber The milestone number to assign + * @return GithubIssue The updated issue + * @throws Exception + */ + public function assignMilestone(GithubRepository $repo, int $issueNumber, int $milestoneNumber): GithubIssue + { + return $this->updateIssue($repo, $issueNumber, (new IssueUpdate())->withMilestone($milestoneNumber)); + } + + /** + * Clear the milestone on an issue (convenience wrapper for updateIssue) + * + * @param GithubRepository $repo The repository + * @param int $issueNumber The issue (or PR) number + * @return GithubIssue The updated issue + * @throws Exception + */ + public function unassignMilestone(GithubRepository $repo, int $issueNumber): GithubIssue + { + return $this->updateIssue($repo, $issueNumber, (new IssueUpdate())->withMilestone(null)); + } + + /** + * List organization-level issue types + * + * @param GithubOrganizationId $org The organization + * @return GithubIssueTypeList + * @throws Exception + */ + public function listIssueTypes(GithubOrganizationId $org): GithubIssueTypeList + { + $requestFactory = new ListIssueTypesRequestFactory( + $this->requestFactory, + $this->config, + $org + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $types = []; + if (is_array($data)) { + foreach ($data as $entry) { + if (is_object($entry)) { + $types[] = GithubIssueType::fromApiResponse($entry); + } + } + } + return new GithubIssueTypeList($types); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Create an organization-level issue type + * + * @param GithubOrganizationId $org The organization + * @param CreateIssueTypeParams $params The issue type parameters + * @return GithubIssueType The created issue type + * @throws Exception + */ + public function createIssueType(GithubOrganizationId $org, CreateIssueTypeParams $params): GithubIssueType + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for createIssueType. Please provide it in the constructor.'); + } + + $requestFactory = new CreateIssueTypeRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $org, + $params + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 201) { + $data = json_decode((string) $response->getBody()); + return GithubIssueType::fromApiResponse($data); + } else { + $this->maybeThrowAccessDenied($response); + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Update an organization-level issue type + * + * GitHub uses PUT for this endpoint (not PATCH). + * + * @param GithubOrganizationId $org The organization + * @param int $issueTypeId The numeric id of the issue type + * @param UpdateIssueTypeParams $params Update parameters + * @return GithubIssueType The updated issue type + * @throws Exception + */ + public function updateIssueType(GithubOrganizationId $org, int $issueTypeId, UpdateIssueTypeParams $params): GithubIssueType + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for updateIssueType. Please provide it in the constructor.'); + } + + $requestFactory = new UpdateIssueTypeRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $org, + $issueTypeId, + $params + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + return GithubIssueType::fromApiResponse($data); + } else { + $this->maybeThrowAccessDenied($response); + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Delete an organization-level issue type + * + * @param GithubOrganizationId $org The organization + * @param int $issueTypeId The numeric id of the issue type + * @return void + * @throws Exception + */ + public function deleteIssueType(GithubOrganizationId $org, int $issueTypeId): void + { + $requestFactory = new DeleteIssueTypeRequestFactory( + $this->requestFactory, + $this->config, + $org, + $issueTypeId + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() !== 204) { + $this->maybeThrowAccessDenied($response); + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Assign an issue type to an issue (convenience wrapper for updateIssue) + * + * Type assignment is keyed by type name, not id, on the issue PATCH endpoint. + * + * @param GithubRepository $repo The repository + * @param int $issueNumber The issue (or PR) number + * @param string $typeName The issue type name (e.g. "Bug") + * @return GithubIssue The updated issue + * @throws Exception + */ + public function assignIssueType(GithubRepository $repo, int $issueNumber, string $typeName): GithubIssue + { + return $this->updateIssue($repo, $issueNumber, (new IssueUpdate())->withType($typeName)); + } + + /** + * Clear the issue type on an issue (convenience wrapper for updateIssue) + * + * @param GithubRepository $repo The repository + * @param int $issueNumber The issue (or PR) number + * @return GithubIssue The updated issue + * @throws Exception + */ + public function unassignIssueType(GithubRepository $repo, int $issueNumber): GithubIssue + { + return $this->updateIssue($repo, $issueNumber, (new IssueUpdate())->withType(null)); + } + /** * List all comments on a pull request * diff --git a/src/GithubIssue.php b/src/GithubIssue.php new file mode 100644 index 0000000..7de49c8 --- /dev/null +++ b/src/GithubIssue.php @@ -0,0 +1,60 @@ + $fieldValues Custom Issue Field values (from issue_field_values); empty array when none set + */ + public function __construct( + public readonly int $id, + public readonly int $number, + public readonly string $title, + public readonly string $body, + public readonly string $state, + public readonly ?string $stateReason, + public readonly string $htmlUrl, + public readonly string $apiUrl, + public readonly GithubUser $author, + public readonly array $labels, + public readonly array $assignees, + public readonly ?GithubMilestone $milestone, + public readonly ?GithubIssueType $type, + public readonly int $comments, + public readonly string $createdAt, + public readonly string $updatedAt, + public readonly ?string $closedAt, + public readonly bool $isPullRequest, + public readonly array $fieldValues = [], + public readonly string $nodeId = '', + ) {} + + public function __toString(): string + { + return sprintf('#%d %s', $this->number, $this->title); + } +} diff --git a/src/GithubIssueFactory.php b/src/GithubIssueFactory.php new file mode 100644 index 0000000..bb07357 --- /dev/null +++ b/src/GithubIssueFactory.php @@ -0,0 +1,124 @@ +user) && is_object($data->user) + ? $userFactory->createFromApiResponse($data->user) + : new GithubUser(login: '', id: 0, avatarUrl: '', htmlUrl: ''); + + $labels = []; + if (isset($data->labels) && is_array($data->labels)) { + foreach ($data->labels as $labelData) { + if (is_object($labelData)) { + $labels[] = $labelFactory->createFromApiResponse($labelData); + } + } + } + + $assignees = []; + if (isset($data->assignees) && is_array($data->assignees)) { + foreach ($data->assignees as $userData) { + if (is_object($userData)) { + $assignees[] = $userFactory->createFromApiResponse($userData); + } + } + } + + $milestone = null; + if (isset($data->milestone) && is_object($data->milestone)) { + $milestone = GithubMilestone::fromApiResponse($data->milestone); + } + + $type = null; + if (isset($data->type) && is_object($data->type)) { + $type = GithubIssueType::fromApiResponse($data->type); + } + + $fieldValues = $this->parseFieldValues($data); + + return new GithubIssue( + id: $data->id ?? 0, + number: $data->number ?? 0, + title: $data->title ?? '', + body: $data->body ?? '', + state: $data->state ?? 'open', + stateReason: $data->state_reason ?? null, + htmlUrl: $data->html_url ?? '', + apiUrl: $data->url ?? '', + author: $author, + labels: $labels, + assignees: $assignees, + milestone: $milestone, + type: $type, + comments: $data->comments ?? 0, + createdAt: $data->created_at ?? '', + updatedAt: $data->updated_at ?? '', + closedAt: $data->closed_at ?? null, + isPullRequest: isset($data->pull_request), + fieldValues: $fieldValues, + nodeId: $data->node_id ?? '', + ); + } + + /** + * Parse the issue_field_values array into a name → value map. + * + * GitHub returns each entry as an object carrying a name and a value; + * we project the array into a flat map so callers don't have to walk + * nested objects. Write-side support for these values requires the + * GraphQL surface and is not in scope for this iteration. + * + * @param object $data Decoded JSON issue object + * @return array + */ + private function parseFieldValues(object $data): array + { + if (!isset($data->issue_field_values) || !is_array($data->issue_field_values)) { + return []; + } + + $values = []; + foreach ($data->issue_field_values as $entry) { + if (!is_object($entry)) { + continue; + } + $name = $entry->name ?? ''; + if ($name === '') { + continue; + } + $values[$name] = $entry->value ?? null; + } + + return $values; + } +} diff --git a/src/GithubIssueList.php b/src/GithubIssueList.php new file mode 100644 index 0000000..438a198 --- /dev/null +++ b/src/GithubIssueList.php @@ -0,0 +1,71 @@ + + */ +class GithubIssueList implements Iterator, Countable +{ + private int $position = 0; + + /** + * @param array $issues + */ + public function __construct( + private readonly array $issues = [] + ) {} + + public function current(): GithubIssue + { + return $this->issues[$this->position]; + } + + public function key(): int + { + return $this->position; + } + + public function next(): void + { + ++$this->position; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + return isset($this->issues[$this->position]); + } + + public function count(): int + { + return count($this->issues); + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->issues; + } +} diff --git a/src/GithubIssueType.php b/src/GithubIssueType.php new file mode 100644 index 0000000..e59855a --- /dev/null +++ b/src/GithubIssueType.php @@ -0,0 +1,58 @@ +name; + } + + /** + * Create from GitHub API response + * + * @param object $data Decoded JSON from API + * @return self + */ + public static function fromApiResponse(object $data): self + { + return new self( + id: $data->id ?? 0, + name: $data->name ?? '', + description: $data->description ?? null, + color: $data->color ?? null, + isEnabled: $data->is_enabled ?? true, + createdAt: $data->created_at ?? '', + updatedAt: $data->updated_at ?? '', + nodeId: $data->node_id ?? '', + ); + } +} diff --git a/src/GithubIssueTypeList.php b/src/GithubIssueTypeList.php new file mode 100644 index 0000000..eba7ad5 --- /dev/null +++ b/src/GithubIssueTypeList.php @@ -0,0 +1,71 @@ + + */ +class GithubIssueTypeList implements Iterator, Countable +{ + private int $position = 0; + + /** + * @param array $types + */ + public function __construct( + private readonly array $types = [] + ) {} + + public function current(): GithubIssueType + { + return $this->types[$this->position]; + } + + public function key(): int + { + return $this->position; + } + + public function next(): void + { + ++$this->position; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + return isset($this->types[$this->position]); + } + + public function count(): int + { + return count($this->types); + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->types; + } +} diff --git a/src/GithubMilestone.php b/src/GithubMilestone.php new file mode 100644 index 0000000..6e1c75d --- /dev/null +++ b/src/GithubMilestone.php @@ -0,0 +1,75 @@ +title; + } + + /** + * Create from GitHub API response + * + * @param object $data Decoded JSON from API + * @return self + */ + public static function fromApiResponse(object $data): self + { + $creator = null; + if (isset($data->creator) && is_object($data->creator)) { + $creator = GithubUser::fromApiResponse($data->creator); + } + + return new self( + id: $data->id ?? 0, + number: $data->number ?? 0, + title: $data->title ?? '', + description: $data->description ?? '', + state: $data->state ?? 'open', + creator: $creator, + openIssues: $data->open_issues ?? 0, + closedIssues: $data->closed_issues ?? 0, + htmlUrl: $data->html_url ?? '', + createdAt: $data->created_at ?? '', + updatedAt: $data->updated_at ?? '', + closedAt: $data->closed_at ?? null, + dueOn: $data->due_on ?? null, + nodeId: $data->node_id ?? '', + ); + } +} diff --git a/src/GithubMilestoneList.php b/src/GithubMilestoneList.php new file mode 100644 index 0000000..4da6b08 --- /dev/null +++ b/src/GithubMilestoneList.php @@ -0,0 +1,71 @@ + + */ +class GithubMilestoneList implements Iterator, Countable +{ + private int $position = 0; + + /** + * @param array $milestones + */ + public function __construct( + private readonly array $milestones = [] + ) {} + + public function current(): GithubMilestone + { + return $this->milestones[$this->position]; + } + + public function key(): int + { + return $this->position; + } + + public function next(): void + { + ++$this->position; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + return isset($this->milestones[$this->position]); + } + + public function count(): int + { + return count($this->milestones); + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->milestones; + } +} diff --git a/src/IssueUpdate.php b/src/IssueUpdate.php new file mode 100644 index 0000000..a776bfd --- /dev/null +++ b/src/IssueUpdate.php @@ -0,0 +1,197 @@ +title = $title; + $clone->titleSet = true; + return $clone; + } + + public function withBody(?string $body): self + { + $clone = clone $this; + $clone->body = $body; + $clone->bodySet = true; + return $clone; + } + + /** + * @param string $state 'open' or 'closed' + */ + public function withState(string $state): self + { + $clone = clone $this; + $clone->state = $state; + $clone->stateSet = true; + return $clone; + } + + /** + * @param string|null $reason 'completed', 'not_planned', 'reopened', or null to clear + */ + public function withStateReason(?string $reason): self + { + $clone = clone $this; + $clone->stateReason = $reason; + $clone->stateReasonSet = true; + return $clone; + } + + /** + * Replace the full set of labels. Pass [] to clear all labels. + * + * @param string[] $labels + */ + public function withLabels(array $labels): self + { + $clone = clone $this; + $clone->labels = $labels; + $clone->labelsSet = true; + return $clone; + } + + /** + * Replace the full set of assignees. Pass [] to clear all assignees. + * + * @param string[] $assignees Usernames + */ + public function withAssignees(array $assignees): self + { + $clone = clone $this; + $clone->assignees = $assignees; + $clone->assigneesSet = true; + return $clone; + } + + /** + * Assign or clear a milestone. + * + * @param int|null $milestoneNumber Milestone number to assign, or null to clear. + */ + public function withMilestone(?int $milestoneNumber): self + { + $clone = clone $this; + $clone->milestone = $milestoneNumber; + $clone->milestoneSet = true; + return $clone; + } + + /** + * Assign or clear an issue type. + * + * @param string|null $typeName Issue type name to assign, or null to clear. + */ + public function withType(?string $typeName): self + { + $clone = clone $this; + $clone->type = $typeName; + $clone->typeSet = true; + return $clone; + } + + /** + * Convert to array for API request body. Only emits keys that were + * explicitly touched via a with*() call; emits null for fields + * explicitly set to null (the GitHub "clear" signal). + * + * @return array + */ + public function toArray(): array + { + $data = []; + + if ($this->titleSet) { + $data['title'] = $this->title; + } + if ($this->bodySet) { + $data['body'] = $this->body; + } + if ($this->stateSet) { + $data['state'] = $this->state; + } + if ($this->stateReasonSet) { + $data['state_reason'] = $this->stateReason; + } + if ($this->labelsSet) { + $data['labels'] = $this->labels; + } + if ($this->assigneesSet) { + $data['assignees'] = $this->assignees; + } + if ($this->milestoneSet) { + $data['milestone'] = $this->milestone; + } + if ($this->typeSet) { + $data['type'] = $this->type; + } + + return $data; + } + + /** + * True when no with*() builder has been called yet. + */ + public function isEmpty(): bool + { + return !$this->titleSet + && !$this->bodySet + && !$this->stateSet + && !$this->stateReasonSet + && !$this->labelsSet + && !$this->assigneesSet + && !$this->milestoneSet + && !$this->typeSet; + } +} diff --git a/src/ListIssueTypesRequestFactory.php b/src/ListIssueTypesRequestFactory.php new file mode 100644 index 0000000..7002b64 --- /dev/null +++ b/src/ListIssueTypesRequestFactory.php @@ -0,0 +1,45 @@ +org + ); + + $request = $this->requestFactory->createRequest('GET', $url); + if ($this->config->accessToken !== '') { + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + } + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + + return $request; + } +} diff --git a/src/ListIssuesRequestFactory.php b/src/ListIssuesRequestFactory.php new file mode 100644 index 0000000..64fb49c --- /dev/null +++ b/src/ListIssuesRequestFactory.php @@ -0,0 +1,62 @@ + $this->state]; + if ($this->labels !== '') { + $query['labels'] = $this->labels; + } + if ($this->milestone !== '') { + $query['milestone'] = $this->milestone; + } + if ($this->assignee !== '') { + $query['assignee'] = $this->assignee; + } + + $url = sprintf( + 'https://api.github.com/repos/%s/%s/issues?%s', + $this->repo->owner, + $this->repo->name, + http_build_query($query) + ); + + $request = $this->requestFactory->createRequest('GET', $url); + if ($this->config->accessToken !== '') { + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + } + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + + return $request; + } +} diff --git a/src/ListMilestonesRequestFactory.php b/src/ListMilestonesRequestFactory.php new file mode 100644 index 0000000..e1b18b9 --- /dev/null +++ b/src/ListMilestonesRequestFactory.php @@ -0,0 +1,48 @@ +repo->owner, + $this->repo->name, + http_build_query(['state' => $this->state]) + ); + + $request = $this->requestFactory->createRequest('GET', $url); + if ($this->config->accessToken !== '') { + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + } + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + + return $request; + } +} diff --git a/src/UpdateIssueRequestFactory.php b/src/UpdateIssueRequestFactory.php new file mode 100644 index 0000000..c45f84a --- /dev/null +++ b/src/UpdateIssueRequestFactory.php @@ -0,0 +1,56 @@ +repo->owner, + $this->repo->name, + $this->issueNumber + ); + + $jsonBody = json_encode($this->update->toArray(), JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('PATCH', $url); + if ($this->config->accessToken !== '') { + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + } + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/UpdateIssueTypeParams.php b/src/UpdateIssueTypeParams.php new file mode 100644 index 0000000..fad83c6 --- /dev/null +++ b/src/UpdateIssueTypeParams.php @@ -0,0 +1,53 @@ + + */ + public function toArray(): array + { + $data = []; + + if ($this->name !== null) { + $data['name'] = $this->name; + } + if ($this->description !== null) { + $data['description'] = $this->description; + } + if ($this->color !== null) { + $data['color'] = $this->color; + } + if ($this->isEnabled !== null) { + $data['is_enabled'] = $this->isEnabled; + } + + return $data; + } +} diff --git a/src/UpdateIssueTypeRequestFactory.php b/src/UpdateIssueTypeRequestFactory.php new file mode 100644 index 0000000..31b068f --- /dev/null +++ b/src/UpdateIssueTypeRequestFactory.php @@ -0,0 +1,57 @@ +org, + $this->issueTypeId + ); + + $jsonBody = json_encode($this->params->toArray(), JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('PUT', $url); + if ($this->config->accessToken !== '') { + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + } + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/UpdateMilestoneParams.php b/src/UpdateMilestoneParams.php new file mode 100644 index 0000000..1412ac5 --- /dev/null +++ b/src/UpdateMilestoneParams.php @@ -0,0 +1,55 @@ + + */ + public function toArray(): array + { + $data = []; + + if ($this->title !== null) { + $data['title'] = $this->title; + } + if ($this->state !== null) { + $data['state'] = $this->state; + } + if ($this->description !== null) { + $data['description'] = $this->description; + } + if ($this->dueOn !== null) { + $data['due_on'] = $this->dueOn->format(DATE_ATOM); + } + + return $data; + } +} diff --git a/src/UpdateMilestoneRequestFactory.php b/src/UpdateMilestoneRequestFactory.php new file mode 100644 index 0000000..1723851 --- /dev/null +++ b/src/UpdateMilestoneRequestFactory.php @@ -0,0 +1,56 @@ +repo->owner, + $this->repo->name, + $this->milestoneNumber + ); + + $jsonBody = json_encode($this->params->toArray(), JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('PATCH', $url); + if ($this->config->accessToken !== '') { + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + } + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/test/unit/CreateIssueParamsTest.php b/test/unit/CreateIssueParamsTest.php new file mode 100644 index 0000000..ad626cc --- /dev/null +++ b/test/unit/CreateIssueParamsTest.php @@ -0,0 +1,61 @@ +assertSame(['title' => 'Crash on launch'], $params->toArray()); + } + + public function testAllFields(): void + { + $params = new CreateIssueParams( + title: 'Crash', + body: 'Steps to reproduce', + assignees: ['alice', 'bob'], + labels: ['bug', 'urgent'], + milestone: 7, + type: 'Bug', + ); + + $this->assertSame( + [ + 'title' => 'Crash', + 'body' => 'Steps to reproduce', + 'assignees' => ['alice', 'bob'], + 'labels' => ['bug', 'urgent'], + 'milestone' => 7, + 'type' => 'Bug', + ], + $params->toArray() + ); + } + + public function testOptionalsDropOutAtDefault(): void + { + $params = new CreateIssueParams(title: 't', body: '', assignees: [], labels: []); + + $this->assertSame(['title' => 't'], $params->toArray()); + } +} diff --git a/test/unit/CreateIssueTypeParamsTest.php b/test/unit/CreateIssueTypeParamsTest.php new file mode 100644 index 0000000..3f3c011 --- /dev/null +++ b/test/unit/CreateIssueTypeParamsTest.php @@ -0,0 +1,62 @@ +assertSame(['name' => 'Bug', 'is_enabled' => true], $params->toArray()); + } + + public function testIsEnabledFalseEmits(): void + { + $params = new CreateIssueTypeParams(name: 'Deprecated', isEnabled: false); + + $array = $params->toArray(); + $this->assertArrayHasKey('is_enabled', $array); + $this->assertFalse($array['is_enabled']); + } + + public function testOptionalFieldsEmittedWhenSet(): void + { + $params = new CreateIssueTypeParams( + name: 'Bug', + description: 'A defect', + color: 'red', + ); + + $this->assertSame( + ['name' => 'Bug', 'is_enabled' => true, 'description' => 'A defect', 'color' => 'red'], + $params->toArray() + ); + } + + public function testSnakeCaseMapping(): void + { + $params = new CreateIssueTypeParams(name: 'x'); + $array = $params->toArray(); + + $this->assertArrayHasKey('is_enabled', $array); + $this->assertArrayNotHasKey('isEnabled', $array); + } +} diff --git a/test/unit/CreateMilestoneParamsTest.php b/test/unit/CreateMilestoneParamsTest.php new file mode 100644 index 0000000..9e10f4d --- /dev/null +++ b/test/unit/CreateMilestoneParamsTest.php @@ -0,0 +1,49 @@ +assertSame(['title' => 'v2', 'state' => 'open'], $params->toArray()); + } + + public function testDueOnEmittedAsIso8601(): void + { + $due = new DateTimeImmutable('2026-07-31T23:59:59+00:00'); + $params = new CreateMilestoneParams(title: 'v2', dueOn: $due); + + $this->assertSame('2026-07-31T23:59:59+00:00', $params->toArray()['due_on']); + } + + public function testDescriptionEmittedWhenNonEmpty(): void + { + $params = new CreateMilestoneParams(title: 'v2', description: 'Major'); + + $this->assertSame( + ['title' => 'v2', 'state' => 'open', 'description' => 'Major'], + $params->toArray() + ); + } +} diff --git a/test/unit/GithubApiClientIssueTest.php b/test/unit/GithubApiClientIssueTest.php new file mode 100644 index 0000000..c50a654 --- /dev/null +++ b/test/unit/GithubApiClientIssueTest.php @@ -0,0 +1,339 @@ +createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $request = $this->createMock(RequestInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($stream); + + return [$httpClient, $requestFactory, $streamFactory, $request]; + } + + /** + * @return array{ClientInterface, RequestFactoryInterface, RequestInterface} + */ + private function makeReadMocks(): array + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $request = $this->createMock(RequestInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + return [$httpClient, $requestFactory, $request]; + } + + /** + * @return array + */ + private function issueBody(int $number = 42, bool $isPr = false, ?string $state = 'open'): array + { + $body = [ + 'id' => $number * 100, + 'number' => $number, + 'title' => 'Issue ' . $number, + 'body' => 'desc', + 'state' => $state, + 'state_reason' => null, + 'html_url' => 'https://github.com/o/r/issues/' . $number, + 'url' => 'https://api.github.com/repos/o/r/issues/' . $number, + 'user' => ['login' => 'reporter', 'id' => 1, 'avatar_url' => '', 'html_url' => ''], + 'labels' => [], + 'assignees' => [], + 'milestone' => null, + 'type' => null, + 'comments' => 0, + 'created_at' => '2026-06-24T10:00:00Z', + 'updated_at' => '2026-06-24T10:00:00Z', + 'closed_at' => null, + ]; + if ($isPr) { + $body['pull_request'] = ['url' => 'https://api.github.com/repos/o/r/pulls/' . $number]; + } + return $body; + } + + public function testListIssuesSuccess(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode([ + $this->issueBody(1), + $this->issueBody(2, isPr: true), + ])); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $list = $client->listIssues($repo); + + $this->assertInstanceOf(GithubIssueList::class, $list); + $this->assertCount(2, $list); + $array = $list->toArray(); + $this->assertSame(1, $array[0]->number); + $this->assertFalse($array[0]->isPullRequest); + $this->assertTrue($array[1]->isPullRequest); + } + + public function testListIssuesPassesFilters(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn('[]'); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $list = $client->listIssues($repo, state: 'closed', labels: 'bug,urgent', milestone: '7', assignee: 'alice'); + + $this->assertCount(0, $list); + } + + public function testListIssuesThrowsOnNon200(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(404); + $response->method('getReasonPhrase')->willReturn('Not Found'); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('404 Not Found'); + + $client->listIssues($repo); + } + + public function testGetIssueSuccess(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode($this->issueBody(42))); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $issue = $client->getIssue($repo, 42); + + $this->assertInstanceOf(GithubIssue::class, $issue); + $this->assertSame(42, $issue->number); + } + + public function testGetIssueDetectsPullRequest(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode($this->issueBody(7, isPr: true))); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $issue = $client->getIssue($repo, 7); + + $this->assertTrue($issue->isPullRequest); + } + + public function testGetIssueThrowsOn404(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(404); + $response->method('getReasonPhrase')->willReturn('Not Found'); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('404 Not Found'); + + $client->getIssue($repo, 9999); + } + + public function testCreateIssueSuccess(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode($this->issueBody(42))); + $response->method('getStatusCode')->willReturn(201); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + + $issue = $client->createIssue($repo, new CreateIssueParams(title: 'Crash on launch')); + + $this->assertSame(42, $issue->number); + } + + public function testCreateIssueRequiresStreamFactory(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'tok'); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('StreamFactory is required for createIssue'); + + $client->createIssue($repo, new CreateIssueParams(title: 't')); + } + + public function testUpdateIssueSuccess(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $body = $this->issueBody(42); + $body['title'] = 'New title'; + $responseBody->method('__toString')->willReturn((string) json_encode($body)); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + + $issue = $client->updateIssue($repo, 42, (new IssueUpdate())->withTitle('New title')); + + $this->assertSame('New title', $issue->title); + } + + public function testUpdateIssueRequiresStreamFactory(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'tok'); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('StreamFactory is required for updateIssue'); + + $client->updateIssue($repo, 42, new IssueUpdate()); + } + + public function testCloseIssueSendsClosedState(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode($this->issueBody(42, state: 'closed'))); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + + $issue = $client->closeIssue($repo, 42); + + $this->assertSame('closed', $issue->state); + } + + public function testReopenIssueSendsOpenState(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode($this->issueBody(42, state: 'open'))); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + + $issue = $client->reopenIssue($repo, 42); + + $this->assertSame('open', $issue->state); + } +} diff --git a/test/unit/GithubApiClientIssueTypeTest.php b/test/unit/GithubApiClientIssueTypeTest.php new file mode 100644 index 0000000..9c48302 --- /dev/null +++ b/test/unit/GithubApiClientIssueTypeTest.php @@ -0,0 +1,308 @@ +createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $request = $this->createMock(RequestInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($stream); + + return [$httpClient, $requestFactory, $streamFactory, $request]; + } + + /** + * @return array{ClientInterface, RequestFactoryInterface, RequestInterface} + */ + private function makeReadMocks(): array + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $request = $this->createMock(RequestInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + return [$httpClient, $requestFactory, $request]; + } + + /** + * @return array + */ + private function typeBody(int $id = 9, string $name = 'Bug'): array + { + return [ + 'id' => $id, + 'name' => $name, + 'description' => 'A defect', + 'color' => 'red', + 'is_enabled' => true, + 'created_at' => '', + 'updated_at' => '', + ]; + } + + /** + * @return array + */ + private function issueBody(int $number = 42, ?array $type = null): array + { + return [ + 'id' => 1, + 'number' => $number, + 'title' => 't', + 'body' => '', + 'state' => 'open', + 'html_url' => '', + 'url' => '', + 'user' => ['login' => 'u', 'id' => 1, 'avatar_url' => '', 'html_url' => ''], + 'labels' => [], + 'assignees' => [], + 'milestone' => null, + 'type' => $type, + 'comments' => 0, + 'created_at' => '', + 'updated_at' => '', + 'closed_at' => null, + ]; + } + + public function testListIssueTypesSuccess(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn( + (string) json_encode([$this->typeBody(1, 'Bug'), $this->typeBody(2, 'Feature')]) + ); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $org = new GithubOrganizationId('horde'); + + $list = $client->listIssueTypes($org); + + $this->assertInstanceOf(GithubIssueTypeList::class, $list); + $this->assertCount(2, $list); + $array = $list->toArray(); + $this->assertSame('Bug', $array[0]->name); + $this->assertSame('Feature', $array[1]->name); + } + + public function testListIssueTypesThrowsOn404(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(404); + $response->method('getReasonPhrase')->willReturn('Not Found'); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('404 Not Found'); + + $client->listIssueTypes(new GithubOrganizationId('missing')); + } + + public function testCreateIssueTypeSuccess(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode($this->typeBody())); + $response->method('getStatusCode')->willReturn(201); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + + $type = $client->createIssueType( + new GithubOrganizationId('horde'), + new CreateIssueTypeParams(name: 'Bug') + ); + + $this->assertInstanceOf(GithubIssueType::class, $type); + $this->assertSame('Bug', $type->name); + } + + public function testCreateIssueTypeRequiresStreamFactory(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'tok'); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('StreamFactory is required for createIssueType'); + + $client->createIssueType(new GithubOrganizationId('o'), new CreateIssueTypeParams(name: 'Bug')); + } + + public function testUpdateIssueTypeSuccess(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $body = $this->typeBody(); + $body['color'] = 'green'; + $responseBody->method('__toString')->willReturn((string) json_encode($body)); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + + $type = $client->updateIssueType( + new GithubOrganizationId('horde'), + 9, + new UpdateIssueTypeParams(color: 'green') + ); + + $this->assertSame('green', $type->color); + } + + public function testUpdateIssueTypeRequiresStreamFactory(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'tok'); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('StreamFactory is required for updateIssueType'); + + $client->updateIssueType(new GithubOrganizationId('o'), 9, new UpdateIssueTypeParams()); + } + + public function testDeleteIssueTypeSuccess(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(204); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + + $client->deleteIssueType(new GithubOrganizationId('horde'), 9); + + $this->addToAssertionCount(1); + } + + public function testDeleteIssueTypeThrowsOnNon204(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(404); + $response->method('getReasonPhrase')->willReturn('Not Found'); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('404 Not Found'); + + $client->deleteIssueType(new GithubOrganizationId('horde'), 9999); + } + + public function testAssignIssueType(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode( + $this->issueBody(42, type: $this->typeBody(9, 'Bug')) + )); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + + $issue = $client->assignIssueType($repo, 42, 'Bug'); + + $this->assertNotNull($issue->type); + $this->assertSame('Bug', $issue->type->name); + } + + public function testUnassignIssueType(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode($this->issueBody(42))); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + + $issue = $client->unassignIssueType($repo, 42); + + $this->assertNull($issue->type); + } +} diff --git a/test/unit/GithubApiClientMilestoneTest.php b/test/unit/GithubApiClientMilestoneTest.php new file mode 100644 index 0000000..fe17dd4 --- /dev/null +++ b/test/unit/GithubApiClientMilestoneTest.php @@ -0,0 +1,329 @@ +createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $request = $this->createMock(RequestInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($stream); + + return [$httpClient, $requestFactory, $streamFactory, $request]; + } + + /** + * @return array{ClientInterface, RequestFactoryInterface, RequestInterface} + */ + private function makeReadMocks(): array + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $request = $this->createMock(RequestInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + return [$httpClient, $requestFactory, $request]; + } + + /** + * @return array + */ + private function milestoneBody(int $number = 3, string $title = 'v2'): array + { + return [ + 'id' => 10, + 'number' => $number, + 'title' => $title, + 'description' => '', + 'state' => 'open', + 'creator' => null, + 'open_issues' => 0, + 'closed_issues' => 0, + 'html_url' => '', + 'created_at' => '', + 'updated_at' => '', + 'closed_at' => null, + 'due_on' => null, + ]; + } + + /** + * @return array + */ + private function issueBody(int $number = 42): array + { + return [ + 'id' => 1, + 'number' => $number, + 'title' => 't', + 'body' => '', + 'state' => 'open', + 'html_url' => '', + 'url' => '', + 'user' => ['login' => 'u', 'id' => 1, 'avatar_url' => '', 'html_url' => ''], + 'labels' => [], + 'assignees' => [], + 'milestone' => null, + 'type' => null, + 'comments' => 0, + 'created_at' => '', + 'updated_at' => '', + 'closed_at' => null, + ]; + } + + public function testListMilestonesSuccess(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn( + (string) json_encode([$this->milestoneBody(1, 'v1'), $this->milestoneBody(2, 'v2')]) + ); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $list = $client->listMilestones($repo); + + $this->assertInstanceOf(GithubMilestoneList::class, $list); + $this->assertCount(2, $list); + } + + public function testGetMilestoneSuccess(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode($this->milestoneBody(3, 'v2'))); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $milestone = $client->getMilestone($repo, 3); + + $this->assertSame('v2', $milestone->title); + } + + public function testGetMilestoneThrowsOn404(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(404); + $response->method('getReasonPhrase')->willReturn('Not Found'); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('404 Not Found'); + + $client->getMilestone($repo, 9999); + } + + public function testCreateMilestoneSuccess(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode($this->milestoneBody())); + $response->method('getStatusCode')->willReturn(201); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + + $milestone = $client->createMilestone($repo, new CreateMilestoneParams(title: 'v2')); + + $this->assertInstanceOf(GithubMilestone::class, $milestone); + } + + public function testCreateMilestoneRequiresStreamFactory(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'tok'); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('StreamFactory is required for createMilestone'); + + $client->createMilestone($repo, new CreateMilestoneParams(title: 'v2')); + } + + public function testUpdateMilestoneSuccess(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $body = $this->milestoneBody(); + $body['state'] = 'closed'; + $responseBody->method('__toString')->willReturn((string) json_encode($body)); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + + $milestone = $client->updateMilestone($repo, 3, new UpdateMilestoneParams(state: 'closed')); + + $this->assertSame('closed', $milestone->state); + } + + public function testUpdateMilestoneRequiresStreamFactory(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'tok'); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('StreamFactory is required for updateMilestone'); + + $client->updateMilestone($repo, 3, new UpdateMilestoneParams()); + } + + public function testDeleteMilestoneSuccess(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(204); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $client->deleteMilestone($repo, 3); + + $this->addToAssertionCount(1); + } + + public function testDeleteMilestoneThrowsOnNon204(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(404); + $response->method('getReasonPhrase')->willReturn('Not Found'); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('404 Not Found'); + + $client->deleteMilestone($repo, 9999); + } + + public function testAssignMilestone(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $body = $this->issueBody(42); + $body['milestone'] = $this->milestoneBody(3, 'v2'); + $responseBody->method('__toString')->willReturn((string) json_encode($body)); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + + $issue = $client->assignMilestone($repo, 42, 3); + + $this->assertNotNull($issue->milestone); + $this->assertSame('v2', $issue->milestone->title); + } + + public function testUnassignMilestone(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode($this->issueBody(42))); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + + $issue = $client->unassignMilestone($repo, 42); + + $this->assertNull($issue->milestone); + } +} diff --git a/test/unit/GithubIssueTest.php b/test/unit/GithubIssueTest.php new file mode 100644 index 0000000..86c26d0 --- /dev/null +++ b/test/unit/GithubIssueTest.php @@ -0,0 +1,211 @@ +login = $login; + $u->id = 1; + $u->avatar_url = 'https://example/u.png'; + $u->html_url = 'https://github.com/' . $login; + return $u; + } + + public function testFromApiResponseRegularIssue(): void + { + $data = new stdClass(); + $data->id = 999; + $data->number = 42; + $data->title = 'Crash on launch'; + $data->body = 'Steps to reproduce…'; + $data->state = 'open'; + $data->state_reason = null; + $data->html_url = 'https://github.com/o/r/issues/42'; + $data->url = 'https://api.github.com/repos/o/r/issues/42'; + $data->user = $this->user('reporter'); + $data->labels = []; + $data->assignees = []; + $data->milestone = null; + $data->type = null; + $data->comments = 3; + $data->created_at = '2026-06-24T10:00:00Z'; + $data->updated_at = '2026-06-24T10:01:00Z'; + $data->closed_at = null; + + $issue = (new GithubIssueFactory())->createFromApiResponse($data); + + $this->assertSame(42, $issue->number); + $this->assertSame('Crash on launch', $issue->title); + $this->assertSame('reporter', $issue->author->login); + $this->assertFalse($issue->isPullRequest); + $this->assertNull($issue->milestone); + $this->assertNull($issue->type); + $this->assertSame([], $issue->labels); + $this->assertSame([], $issue->assignees); + $this->assertSame([], $issue->fieldValues); + } + + public function testFromApiResponseDetectsPullRequest(): void + { + $data = new stdClass(); + $data->number = 7; + $data->title = 'PR title'; + $data->body = ''; + $data->state = 'open'; + $data->html_url = ''; + $data->url = ''; + $data->user = $this->user(); + $data->pull_request = new stdClass(); + $data->pull_request->url = 'https://api.github.com/repos/o/r/pulls/7'; + + $issue = (new GithubIssueFactory())->createFromApiResponse($data); + + $this->assertTrue($issue->isPullRequest); + } + + public function testFromApiResponseDecodesNestedLabelsAndAssignees(): void + { + $label = new stdClass(); + $label->name = 'bug'; + $label->color = 'd73a4a'; + $label->description = 'Something broken'; + + $data = new stdClass(); + $data->number = 1; + $data->title = 't'; + $data->body = ''; + $data->state = 'open'; + $data->html_url = ''; + $data->url = ''; + $data->user = $this->user(); + $data->labels = [$label]; + $data->assignees = [$this->user('alice'), $this->user('bob')]; + + $issue = (new GithubIssueFactory())->createFromApiResponse($data); + + $this->assertCount(1, $issue->labels); + $this->assertInstanceOf(GithubLabel::class, $issue->labels[0]); + $this->assertSame('bug', $issue->labels[0]->name); + $this->assertCount(2, $issue->assignees); + $this->assertSame('alice', $issue->assignees[0]->login); + $this->assertSame('bob', $issue->assignees[1]->login); + } + + public function testFromApiResponseDecodesMilestoneAndType(): void + { + $milestone = new stdClass(); + $milestone->id = 1; + $milestone->number = 3; + $milestone->title = 'v2'; + $milestone->state = 'open'; + $milestone->description = ''; + $milestone->open_issues = 5; + $milestone->closed_issues = 2; + $milestone->html_url = ''; + $milestone->created_at = ''; + $milestone->updated_at = ''; + + $type = new stdClass(); + $type->id = 9; + $type->name = 'Bug'; + $type->description = 'A bug'; + $type->color = 'red'; + $type->is_enabled = true; + $type->created_at = ''; + $type->updated_at = ''; + + $data = new stdClass(); + $data->number = 1; + $data->title = 't'; + $data->body = ''; + $data->state = 'open'; + $data->html_url = ''; + $data->url = ''; + $data->user = $this->user(); + $data->milestone = $milestone; + $data->type = $type; + + $issue = (new GithubIssueFactory())->createFromApiResponse($data); + + $this->assertNotNull($issue->milestone); + $this->assertSame('v2', $issue->milestone->title); + $this->assertNotNull($issue->type); + $this->assertSame('Bug', $issue->type->name); + } + + public function testFromApiResponseParsesFieldValues(): void + { + $field1 = new stdClass(); + $field1->name = 'Priority'; + $field1->value = 'P1'; + $field2 = new stdClass(); + $field2->name = 'Severity'; + $field2->value = 3; + + $data = new stdClass(); + $data->number = 1; + $data->title = 't'; + $data->body = ''; + $data->state = 'open'; + $data->html_url = ''; + $data->url = ''; + $data->user = $this->user(); + $data->issue_field_values = [$field1, $field2]; + + $issue = (new GithubIssueFactory())->createFromApiResponse($data); + + $this->assertSame(['Priority' => 'P1', 'Severity' => 3], $issue->fieldValues); + } + + public function testFromApiResponseHandlesMissingFieldsCleanly(): void + { + // Bare-minimum payload — every nullable falls back without throwing. + $data = new stdClass(); + $data->number = 1; + $data->title = ''; + $data->user = $this->user(); + + $issue = (new GithubIssueFactory())->createFromApiResponse($data); + + $this->assertSame(1, $issue->number); + $this->assertSame('open', $issue->state); + $this->assertNull($issue->milestone); + $this->assertNull($issue->type); + $this->assertSame([], $issue->labels); + $this->assertFalse($issue->isPullRequest); + } + + public function testStringableFormat(): void + { + $data = new stdClass(); + $data->number = 42; + $data->title = 'Crash on launch'; + $data->user = $this->user(); + + $issue = (new GithubIssueFactory())->createFromApiResponse($data); + + $this->assertSame('#42 Crash on launch', (string) $issue); + } +} diff --git a/test/unit/GithubIssueTypeTest.php b/test/unit/GithubIssueTypeTest.php new file mode 100644 index 0000000..e874933 --- /dev/null +++ b/test/unit/GithubIssueTypeTest.php @@ -0,0 +1,73 @@ +id = 9; + $data->node_id = 'IT_kwAB'; + $data->name = 'Bug'; + $data->description = 'A defect'; + $data->color = 'red'; + $data->is_enabled = true; + $data->created_at = '2026-06-01T10:00:00Z'; + $data->updated_at = '2026-06-20T10:00:00Z'; + + $type = GithubIssueType::fromApiResponse($data); + + $this->assertSame(9, $type->id); + $this->assertSame('Bug', $type->name); + $this->assertSame('A defect', $type->description); + $this->assertSame('red', $type->color); + $this->assertTrue($type->isEnabled); + $this->assertSame('IT_kwAB', $type->nodeId); + } + + public function testFromApiResponseNullableFields(): void + { + $data = new stdClass(); + $data->id = 1; + $data->name = 'Task'; + $data->description = null; + $data->color = null; + $data->is_enabled = false; + $data->created_at = ''; + $data->updated_at = ''; + + $type = GithubIssueType::fromApiResponse($data); + + $this->assertNull($type->description); + $this->assertNull($type->color); + $this->assertFalse($type->isEnabled); + } + + public function testStringableReturnsName(): void + { + $data = new stdClass(); + $data->id = 1; + $data->name = 'Feature'; + + $this->assertSame('Feature', (string) GithubIssueType::fromApiResponse($data)); + } +} diff --git a/test/unit/GithubMilestoneTest.php b/test/unit/GithubMilestoneTest.php new file mode 100644 index 0000000..f9ff75f --- /dev/null +++ b/test/unit/GithubMilestoneTest.php @@ -0,0 +1,94 @@ +login = 'pm'; + $creator->id = 5; + $creator->avatar_url = ''; + $creator->html_url = ''; + + $data = new stdClass(); + $data->id = 10; + $data->number = 3; + $data->title = 'v2.0'; + $data->description = 'Major release'; + $data->state = 'open'; + $data->creator = $creator; + $data->open_issues = 5; + $data->closed_issues = 2; + $data->html_url = 'https://github.com/o/r/milestone/3'; + $data->created_at = '2026-06-01T10:00:00Z'; + $data->updated_at = '2026-06-20T10:00:00Z'; + $data->closed_at = null; + $data->due_on = '2026-07-31T23:59:59Z'; + + $milestone = GithubMilestone::fromApiResponse($data); + + $this->assertSame(3, $milestone->number); + $this->assertSame('v2.0', $milestone->title); + $this->assertInstanceOf(GithubUser::class, $milestone->creator); + $this->assertSame('pm', $milestone->creator->login); + $this->assertSame(5, $milestone->openIssues); + $this->assertSame(2, $milestone->closedIssues); + $this->assertNull($milestone->closedAt); + $this->assertSame('2026-07-31T23:59:59Z', $milestone->dueOn); + } + + public function testFromApiResponseHandlesNullCreatorAndDueOn(): void + { + $data = new stdClass(); + $data->id = 1; + $data->number = 1; + $data->title = 'x'; + $data->description = ''; + $data->state = 'open'; + $data->creator = null; + $data->open_issues = 0; + $data->closed_issues = 0; + $data->html_url = ''; + $data->created_at = ''; + $data->updated_at = ''; + $data->closed_at = null; + $data->due_on = null; + + $milestone = GithubMilestone::fromApiResponse($data); + + $this->assertNull($milestone->creator); + $this->assertNull($milestone->dueOn); + } + + public function testStringableReturnsTitle(): void + { + $data = new stdClass(); + $data->number = 1; + $data->title = 'Sprint 23'; + + $milestone = GithubMilestone::fromApiResponse($data); + + $this->assertSame('Sprint 23', (string) $milestone); + } +} diff --git a/test/unit/IssueUpdateTest.php b/test/unit/IssueUpdateTest.php new file mode 100644 index 0000000..8b82ce7 --- /dev/null +++ b/test/unit/IssueUpdateTest.php @@ -0,0 +1,120 @@ +assertTrue($update->isEmpty()); + $this->assertSame([], $update->toArray()); + } + + public function testUntouchedFieldsOmitted(): void + { + $update = (new IssueUpdate())->withTitle('hello'); + + $this->assertSame(['title' => 'hello'], $update->toArray()); + } + + public function testWithMilestoneNullEmitsLiteralNull(): void + { + // Critical for the "clear assignment" GitHub semantic. + $update = (new IssueUpdate())->withMilestone(null); + + $array = $update->toArray(); + $this->assertArrayHasKey('milestone', $array); + $this->assertNull($array['milestone']); + } + + public function testWithTypeNullEmitsLiteralNull(): void + { + $update = (new IssueUpdate())->withType(null); + + $array = $update->toArray(); + $this->assertArrayHasKey('type', $array); + $this->assertNull($array['type']); + } + + public function testWithMilestoneValueEmitsInteger(): void + { + $update = (new IssueUpdate())->withMilestone(7); + + $this->assertSame(['milestone' => 7], $update->toArray()); + } + + public function testWithTypeValueEmitsString(): void + { + $update = (new IssueUpdate())->withType('Bug'); + + $this->assertSame(['type' => 'Bug'], $update->toArray()); + } + + public function testLastSetWins(): void + { + // Builder returns clones, so re-applying overrides cleanly. + $update = (new IssueUpdate()) + ->withMilestone(7) + ->withMilestone(null); + + $array = $update->toArray(); + $this->assertArrayHasKey('milestone', $array); + $this->assertNull($array['milestone']); + } + + public function testWithLabelsEmpty(): void + { + // [] is a valid value: clear all labels. + $update = (new IssueUpdate())->withLabels([]); + + $this->assertSame(['labels' => []], $update->toArray()); + } + + public function testWithAssigneesEmpty(): void + { + $update = (new IssueUpdate())->withAssignees([]); + + $this->assertSame(['assignees' => []], $update->toArray()); + } + + public function testWithStateAndReason(): void + { + $update = (new IssueUpdate()) + ->withState('closed') + ->withStateReason('not_planned'); + + $this->assertSame( + ['state' => 'closed', 'state_reason' => 'not_planned'], + $update->toArray() + ); + } + + public function testBuilderReturnsClonesNotMutates(): void + { + $original = new IssueUpdate(); + $modified = $original->withTitle('changed'); + + $this->assertTrue($original->isEmpty()); + $this->assertFalse($modified->isEmpty()); + $this->assertNotSame($original, $modified); + } +} diff --git a/test/unit/UpdateIssueTypeParamsTest.php b/test/unit/UpdateIssueTypeParamsTest.php new file mode 100644 index 0000000..555c699 --- /dev/null +++ b/test/unit/UpdateIssueTypeParamsTest.php @@ -0,0 +1,45 @@ +assertSame([], (new UpdateIssueTypeParams())->toArray()); + } + + public function testPartialUpdate(): void + { + $params = new UpdateIssueTypeParams(color: 'green'); + + $this->assertSame(['color' => 'green'], $params->toArray()); + } + + public function testIsEnabledFalseEmits(): void + { + // Setting isEnabled to false is meaningful — distinct from null/untouched. + $params = new UpdateIssueTypeParams(isEnabled: false); + + $array = $params->toArray(); + $this->assertArrayHasKey('is_enabled', $array); + $this->assertFalse($array['is_enabled']); + } +} diff --git a/test/unit/UpdateMilestoneParamsTest.php b/test/unit/UpdateMilestoneParamsTest.php new file mode 100644 index 0000000..8b59347 --- /dev/null +++ b/test/unit/UpdateMilestoneParamsTest.php @@ -0,0 +1,44 @@ +assertSame([], (new UpdateMilestoneParams())->toArray()); + } + + public function testPartialUpdate(): void + { + $params = new UpdateMilestoneParams(state: 'closed'); + + $this->assertSame(['state' => 'closed'], $params->toArray()); + } + + public function testDueOnEmittedAsIso8601(): void + { + $due = new DateTimeImmutable('2026-07-31T23:59:59+00:00'); + $params = new UpdateMilestoneParams(dueOn: $due); + + $this->assertSame(['due_on' => '2026-07-31T23:59:59+00:00'], $params->toArray()); + } +}