From 72b9fd7162c97999a9f2ef172c64522f49202a78 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sat, 20 Jun 2026 16:46:46 +0100 Subject: [PATCH 1/7] feat(checkrun): add createCheckRun, updateCheckRun, getCheckRun Closes the CRUD set started by listCheckRuns. Adds CreateCheckRunParams and UpdateCheckRunParams plus three request factories; decoding reuses the existing GithubCheckRun::fromApiResponse. Endpoints: - POST /repos/{owner}/{repo}/check-runs - PATCH /repos/{owner}/{repo}/check-runs/{id} - GET /repos/{owner}/{repo}/check-runs/{id} --- src/CreateCheckRunParams.php | 88 ++++++++ src/CreateCheckRunRequestFactory.php | 59 ++++++ src/GetCheckRunRequestFactory.php | 53 +++++ src/GithubApiClient.php | 93 +++++++++ src/UpdateCheckRunParams.php | 98 +++++++++ src/UpdateCheckRunRequestFactory.php | 61 ++++++ test/unit/CreateCheckRunParamsTest.php | 103 ++++++++++ test/unit/GithubApiClientCheckRunTest.php | 240 ++++++++++++++++++++++ test/unit/UpdateCheckRunParamsTest.php | 86 ++++++++ 9 files changed, 881 insertions(+) create mode 100644 src/CreateCheckRunParams.php create mode 100644 src/CreateCheckRunRequestFactory.php create mode 100644 src/GetCheckRunRequestFactory.php create mode 100644 src/UpdateCheckRunParams.php create mode 100644 src/UpdateCheckRunRequestFactory.php create mode 100644 test/unit/CreateCheckRunParamsTest.php create mode 100644 test/unit/GithubApiClientCheckRunTest.php create mode 100644 test/unit/UpdateCheckRunParamsTest.php diff --git a/src/CreateCheckRunParams.php b/src/CreateCheckRunParams.php new file mode 100644 index 0000000..46e8fd8 --- /dev/null +++ b/src/CreateCheckRunParams.php @@ -0,0 +1,88 @@ +|null $output Output payload: {title, summary, text?, annotations?, images?}; passed verbatim when set + */ + public function __construct( + public readonly string $name, + public readonly string $headSha, + public readonly string $status = 'completed', + public readonly string $conclusion = '', + public readonly ?DateTimeImmutable $startedAt = null, + public readonly ?DateTimeImmutable $completedAt = null, + public readonly string $detailsUrl = '', + public readonly string $externalId = '', + public readonly ?array $output = null, + ) {} + + /** + * Convert to array for API request body. + * + * Required fields always emit. Optional fields drop out when at their + * default (empty string / null). + * + * @return array + */ + public function toArray(): array + { + $data = [ + 'name' => $this->name, + 'head_sha' => $this->headSha, + 'status' => $this->status, + ]; + + if ($this->conclusion !== '') { + $data['conclusion'] = $this->conclusion; + } + + if ($this->startedAt !== null) { + $data['started_at'] = $this->startedAt->format(DATE_ATOM); + } + + if ($this->completedAt !== null) { + $data['completed_at'] = $this->completedAt->format(DATE_ATOM); + } + + if ($this->detailsUrl !== '') { + $data['details_url'] = $this->detailsUrl; + } + + if ($this->externalId !== '') { + $data['external_id'] = $this->externalId; + } + + if ($this->output !== null) { + $data['output'] = $this->output; + } + + return $data; + } +} diff --git a/src/CreateCheckRunRequestFactory.php b/src/CreateCheckRunRequestFactory.php new file mode 100644 index 0000000..3b8364b --- /dev/null +++ b/src/CreateCheckRunRequestFactory.php @@ -0,0 +1,59 @@ +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/GetCheckRunRequestFactory.php b/src/GetCheckRunRequestFactory.php new file mode 100644 index 0000000..23476fd --- /dev/null +++ b/src/GetCheckRunRequestFactory.php @@ -0,0 +1,53 @@ +repo->owner, + $this->repo->name, + $this->checkRunId + ); + + $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 7d5abcc..e700d22 100644 --- a/src/GithubApiClient.php +++ b/src/GithubApiClient.php @@ -475,6 +475,99 @@ public function listCheckRuns(GithubRepository $repo, string $ref): GithubCheckR } } + /** + * Create a Check Run on a commit + * + * @param GithubRepository $repo The repository + * @param CreateCheckRunParams $params The check-run parameters + * @return GithubCheckRun The created check run + * @throws Exception + */ + public function createCheckRun(GithubRepository $repo, CreateCheckRunParams $params): GithubCheckRun + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for createCheckRun. Please provide it in the constructor.'); + } + + $requestFactory = new CreateCheckRunRequestFactory( + $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 GithubCheckRun::fromApiResponse($data); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Update an existing Check Run + * + * @param GithubRepository $repo The repository + * @param int $checkRunId The check-run ID + * @param UpdateCheckRunParams $params The update parameters + * @return GithubCheckRun The updated check run + * @throws Exception + */ + public function updateCheckRun(GithubRepository $repo, int $checkRunId, UpdateCheckRunParams $params): GithubCheckRun + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for updateCheckRun. Please provide it in the constructor.'); + } + + $requestFactory = new UpdateCheckRunRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $checkRunId, + $params + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + return GithubCheckRun::fromApiResponse($data); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Get a single Check Run by ID + * + * @param GithubRepository $repo The repository + * @param int $checkRunId The check-run ID + * @return GithubCheckRun + * @throws Exception + */ + public function getCheckRun(GithubRepository $repo, int $checkRunId): GithubCheckRun + { + $requestFactory = new GetCheckRunRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $checkRunId + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + return GithubCheckRun::fromApiResponse($data); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + /** * List labels on an issue or pull request * diff --git a/src/UpdateCheckRunParams.php b/src/UpdateCheckRunParams.php new file mode 100644 index 0000000..b8e7895 --- /dev/null +++ b/src/UpdateCheckRunParams.php @@ -0,0 +1,98 @@ +|null $output Passed verbatim when set + */ + public function __construct( + public readonly string $name = '', + public readonly string $headSha = '', + public readonly string $status = '', + public readonly string $conclusion = '', + public readonly ?DateTimeImmutable $startedAt = null, + public readonly ?DateTimeImmutable $completedAt = null, + public readonly string $detailsUrl = '', + public readonly string $externalId = '', + public readonly ?array $output = null, + ) {} + + /** + * Convert to array for API request body. All-empty round-trips to []. + * + * @return array + */ + public function toArray(): array + { + $data = []; + + if ($this->name !== '') { + $data['name'] = $this->name; + } + + if ($this->headSha !== '') { + $data['head_sha'] = $this->headSha; + } + + if ($this->status !== '') { + $data['status'] = $this->status; + } + + if ($this->conclusion !== '') { + $data['conclusion'] = $this->conclusion; + } + + if ($this->startedAt !== null) { + $data['started_at'] = $this->startedAt->format(DATE_ATOM); + } + + if ($this->completedAt !== null) { + $data['completed_at'] = $this->completedAt->format(DATE_ATOM); + } + + if ($this->detailsUrl !== '') { + $data['details_url'] = $this->detailsUrl; + } + + if ($this->externalId !== '') { + $data['external_id'] = $this->externalId; + } + + if ($this->output !== null) { + $data['output'] = $this->output; + } + + return $data; + } +} diff --git a/src/UpdateCheckRunRequestFactory.php b/src/UpdateCheckRunRequestFactory.php new file mode 100644 index 0000000..1a085a1 --- /dev/null +++ b/src/UpdateCheckRunRequestFactory.php @@ -0,0 +1,61 @@ +repo->owner, + $this->repo->name, + $this->checkRunId + ); + + $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/CreateCheckRunParamsTest.php b/test/unit/CreateCheckRunParamsTest.php new file mode 100644 index 0000000..dd2ef43 --- /dev/null +++ b/test/unit/CreateCheckRunParamsTest.php @@ -0,0 +1,103 @@ +assertSame( + [ + 'name' => 'PHPUnit (php8.4-dev)', + 'head_sha' => 'abc123', + 'status' => 'completed', + ], + $params->toArray() + ); + } + + public function testToArrayWithAllFields(): void + { + $started = new DateTimeImmutable('2026-06-24T10:00:00+00:00'); + $completed = new DateTimeImmutable('2026-06-24T10:05:00+00:00'); + $output = ['title' => 'Lane summary', 'summary' => 'All green']; + + $params = new CreateCheckRunParams( + name: 'PHPUnit', + headSha: 'deadbeef', + status: 'completed', + conclusion: 'success', + startedAt: $started, + completedAt: $completed, + detailsUrl: 'https://example.org/runs/1', + externalId: 'lane-php8.4-dev', + output: $output, + ); + + $array = $params->toArray(); + + $this->assertSame('PHPUnit', $array['name']); + $this->assertSame('deadbeef', $array['head_sha']); + $this->assertSame('completed', $array['status']); + $this->assertSame('success', $array['conclusion']); + $this->assertSame($started->format(DATE_ATOM), $array['started_at']); + $this->assertSame($completed->format(DATE_ATOM), $array['completed_at']); + $this->assertSame('https://example.org/runs/1', $array['details_url']); + $this->assertSame('lane-php8.4-dev', $array['external_id']); + $this->assertSame($output, $array['output']); + } + + public function testToArrayOmitsOptionalsWhenDefault(): void + { + $params = new CreateCheckRunParams(name: 'Check', headSha: 'sha'); + $array = $params->toArray(); + + $this->assertArrayNotHasKey('conclusion', $array); + $this->assertArrayNotHasKey('started_at', $array); + $this->assertArrayNotHasKey('completed_at', $array); + $this->assertArrayNotHasKey('details_url', $array); + $this->assertArrayNotHasKey('external_id', $array); + $this->assertArrayNotHasKey('output', $array); + } + + public function testDefaultStatusIsCompleted(): void + { + $params = new CreateCheckRunParams(name: 'Check', headSha: 'sha'); + $this->assertSame('completed', $params->status); + } + + public function testStartedAtUsesIso8601(): void + { + $started = new DateTimeImmutable('2026-06-24T10:00:00+00:00'); + $params = new CreateCheckRunParams( + name: 'Check', + headSha: 'sha', + startedAt: $started, + ); + + $this->assertSame('2026-06-24T10:00:00+00:00', $params->toArray()['started_at']); + } +} diff --git a/test/unit/GithubApiClientCheckRunTest.php b/test/unit/GithubApiClientCheckRunTest.php new file mode 100644 index 0000000..7a8db5b --- /dev/null +++ b/test/unit/GithubApiClientCheckRunTest.php @@ -0,0 +1,240 @@ +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, $stream]; + } + + /** + * @return array + */ + private function checkRunResponseBody(): array + { + return [ + 'id' => 42, + 'name' => 'PHPUnit', + 'status' => 'completed', + 'conclusion' => 'success', + 'head_sha' => 'abc123', + 'html_url' => 'https://github.com/owner/repo/runs/42', + 'details_url' => 'https://example.org/runs/1', + 'started_at' => '2026-06-24T10:00:00Z', + 'completed_at' => '2026-06-24T10:05:00Z', + ]; + } + + public function testCreateCheckRunSuccess(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode($this->checkRunResponseBody())); + $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'); + $params = new CreateCheckRunParams(name: 'PHPUnit', headSha: 'abc123', conclusion: 'success'); + + $checkRun = $client->createCheckRun($repo, $params); + + $this->assertInstanceOf(GithubCheckRun::class, $checkRun); + $this->assertSame(42, $checkRun->id); + $this->assertSame('PHPUnit', $checkRun->name); + $this->assertSame('https://github.com/owner/repo/runs/42', $checkRun->htmlUrl); + } + + public function testCreateCheckRunRequiresStreamFactory(): 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'); + $params = new CreateCheckRunParams(name: 'PHPUnit', headSha: 'abc'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('StreamFactory is required for createCheckRun'); + + $client->createCheckRun($repo, $params); + } + + public function testCreateCheckRunThrowsOn422(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(422); + $response->method('getReasonPhrase')->willReturn('Unprocessable Entity'); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + $params = new CreateCheckRunParams(name: 'PHPUnit', headSha: 'abc'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('422 Unprocessable Entity'); + + $client->createCheckRun($repo, $params); + } + + public function testUpdateCheckRunSuccess(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $body = $this->checkRunResponseBody(); + $body['conclusion'] = 'failure'; + $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'); + $params = new UpdateCheckRunParams(conclusion: 'failure'); + + $checkRun = $client->updateCheckRun($repo, 42, $params); + + $this->assertSame('failure', $checkRun->conclusion); + } + + public function testUpdateCheckRunRequiresStreamFactory(): 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 updateCheckRun'); + + $client->updateCheckRun($repo, 42, new UpdateCheckRunParams()); + } + + public function testUpdateCheckRunThrowsOn404(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $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, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('404 Not Found'); + + $client->updateCheckRun($repo, 999, new UpdateCheckRunParams(status: 'in_progress')); + } + + public function testGetCheckRunSuccess(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $responseBody->method('__toString')->willReturn((string) json_encode($this->checkRunResponseBody())); + $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'); + + $checkRun = $client->getCheckRun($repo, 42); + + $this->assertSame(42, $checkRun->id); + $this->assertSame('abc123', $checkRun->headSha); + } + + public function testGetCheckRunThrowsOn404(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $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->getCheckRun($repo, 999); + } +} diff --git a/test/unit/UpdateCheckRunParamsTest.php b/test/unit/UpdateCheckRunParamsTest.php new file mode 100644 index 0000000..ff2feea --- /dev/null +++ b/test/unit/UpdateCheckRunParamsTest.php @@ -0,0 +1,86 @@ +assertSame([], $params->toArray()); + } + + public function testPartialUpdateOnlyEmitsSetFields(): void + { + $params = new UpdateCheckRunParams( + status: 'in_progress', + ); + + $this->assertSame(['status' => 'in_progress'], $params->toArray()); + } + + public function testCompletingPartialUpdate(): void + { + $completed = new DateTimeImmutable('2026-06-24T11:00:00+00:00'); + $params = new UpdateCheckRunParams( + status: 'completed', + conclusion: 'failure', + completedAt: $completed, + output: ['title' => 'Failed', 'summary' => '3 tests failed'], + ); + + $array = $params->toArray(); + + $this->assertSame('completed', $array['status']); + $this->assertSame('failure', $array['conclusion']); + $this->assertSame($completed->format(DATE_ATOM), $array['completed_at']); + $this->assertSame(['title' => 'Failed', 'summary' => '3 tests failed'], $array['output']); + $this->assertArrayNotHasKey('name', $array); + $this->assertArrayNotHasKey('head_sha', $array); + $this->assertArrayNotHasKey('started_at', $array); + } + + public function testSnakeCaseMappingForAllFields(): void + { + $started = new DateTimeImmutable('2026-06-24T10:00:00+00:00'); + $completed = new DateTimeImmutable('2026-06-24T10:05:00+00:00'); + $params = new UpdateCheckRunParams( + name: 'Renamed', + headSha: 'newsha', + status: 'completed', + conclusion: 'success', + startedAt: $started, + completedAt: $completed, + detailsUrl: 'https://example.org/x', + externalId: 'eid', + ); + + $array = $params->toArray(); + + $this->assertArrayHasKey('head_sha', $array); + $this->assertArrayHasKey('started_at', $array); + $this->assertArrayHasKey('completed_at', $array); + $this->assertArrayHasKey('details_url', $array); + $this->assertArrayHasKey('external_id', $array); + $this->assertArrayNotHasKey('headSha', $array); + $this->assertArrayNotHasKey('detailsUrl', $array); + } +} From 5b5fdd8f3471398ea6ca50338ef9ade3308f1f36 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sat, 20 Jun 2026 17:33:14 +0100 Subject: [PATCH 2/7] feat(error): typed GithubApiAccessDeniedException on 403 missing-permission --- src/GithubApiAccessDeniedException.php | 40 +++++ src/GithubApiClient.php | 61 +++++++ test/unit/GithubApiClientAccessDeniedTest.php | 169 ++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 src/GithubApiAccessDeniedException.php create mode 100644 test/unit/GithubApiClientAccessDeniedTest.php diff --git a/src/GithubApiAccessDeniedException.php b/src/GithubApiAccessDeniedException.php new file mode 100644 index 0000000..ca83979 --- /dev/null +++ b/src/GithubApiAccessDeniedException.php @@ -0,0 +1,40 @@ +createFromApiResponse($data); } else { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -249,6 +250,7 @@ public function createPullRequestComment(GithubRepository $repo, int $number, st $commentFactory = new GithubCommentFactory(); return $commentFactory->createFromApiResponse($data); } else { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -284,6 +286,7 @@ public function updateComment(GithubRepository $repo, int $commentId, string $bo $commentFactory = new GithubCommentFactory(); return $commentFactory->createFromApiResponse($data); } else { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -308,6 +311,7 @@ public function deleteComment(GithubRepository $repo, int $commentId): void $response = $this->httpClient->sendRequest($request); if ($response->getStatusCode() !== 204) { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -377,6 +381,7 @@ public function requestReviewers(GithubRepository $repo, int $number, array $rev $prFactory = new GithubPullRequestFactory(); return $prFactory->createFromApiResponse($data); } else { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -411,6 +416,7 @@ public function createReview(GithubRepository $repo, int $number, CreateReviewPa $data = json_decode((string) $response->getBody()); return GithubReview::fromApiResponse($data); } else { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -503,6 +509,7 @@ public function createCheckRun(GithubRepository $repo, CreateCheckRunParams $par $data = json_decode((string) $response->getBody()); return GithubCheckRun::fromApiResponse($data); } else { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -537,6 +544,7 @@ public function updateCheckRun(GithubRepository $repo, int $checkRunId, UpdateCh $data = json_decode((string) $response->getBody()); return GithubCheckRun::fromApiResponse($data); } else { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -635,6 +643,7 @@ public function addLabels(GithubRepository $repo, int $number, array $labels): G } return new GithubLabelList($labelsList); } else { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -674,6 +683,7 @@ public function setLabels(GithubRepository $repo, int $number, array $labels): G } return new GithubLabelList($labelsList); } else { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -700,6 +710,7 @@ public function removeLabel(GithubRepository $repo, int $number, string $labelNa $response = $this->httpClient->sendRequest($request); if ($response->getStatusCode() !== 200) { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -734,6 +745,7 @@ public function mergePullRequest(GithubRepository $repo, int $number, MergePullR $data = json_decode((string) $response->getBody()); return MergeResult::fromApiResponse($data); } else { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -795,6 +807,7 @@ public function createPullRequest(GithubRepository $repo, CreatePullRequestParam $prFactory = new GithubPullRequestFactory(); return $prFactory->createFromApiResponse($data); } else { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -827,6 +840,7 @@ public function createRelease(GithubRepository $repo, CreateReleaseParams $param $data = json_decode((string) $response->getBody()); return GithubRelease::fromApiResponse($data); } else { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -888,6 +902,7 @@ public function updateRelease(GithubRepository $repo, int $releaseId, UpdateRele $data = json_decode((string) $response->getBody()); return GithubRelease::fromApiResponse($data); } else { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -925,6 +940,7 @@ public function uploadReleaseAsset(string $uploadUrl, string $filename, string $ $data = json_decode((string) $response->getBody()); return GithubReleaseAsset::fromApiResponse($data); } else { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -985,6 +1001,7 @@ public function createInstallationAccessToken( $data = json_decode((string) $response->getBody()); return InstallationAccessToken::fromApiResponse($data); } else { + $this->maybeThrowAccessDenied($response); throw new Exception($this->parseErrorResponse($response)); } } @@ -1058,6 +1075,50 @@ private function parseJsonAndMerge(array $repos, string $json): array return $repos; } + /** + * Detect the "Resource not accessible by integration" 403 and throw a typed exception. + * + * GitHub returns this body when an Actions workflow's GITHUB_TOKEN is missing the + * required scope (e.g. `pull-requests: write` or `checks: write`). The typed + * exception lets consumers print a maintainer-actionable hint without parsing + * generic messages. Non-403 statuses and 403 statuses with other body shapes + * are passed through; the caller falls through to parseErrorResponse(). + * + * @param ResponseInterface $response The HTTP response + * @return void + * @throws GithubApiAccessDeniedException + */ + private function maybeThrowAccessDenied(ResponseInterface $response): void + { + if ($response->getStatusCode() !== 403) { + return; + } + + $body = (string) $response->getBody(); + if ($body === '') { + return; + } + + $decoded = json_decode($body); + if (!is_object($decoded) || !isset($decoded->message) || !is_string($decoded->message)) { + return; + } + + if (!str_contains($decoded->message, 'Resource not accessible by integration')) { + return; + } + + $hint = 'The GitHub token does not have permission for this operation. ' + . 'Ensure the token has the required scope (e.g. `pull-requests:write`, `checks:write`). ' + . 'See https://docs.github.com/en/rest/authentication.'; + + throw new GithubApiAccessDeniedException( + statusCode: 403, + responseBody: $body, + hint: $hint + ); + } + /** * Parse GitHub API error response and create detailed exception message * diff --git a/test/unit/GithubApiClientAccessDeniedTest.php b/test/unit/GithubApiClientAccessDeniedTest.php new file mode 100644 index 0000000..854dd03 --- /dev/null +++ b/test/unit/GithubApiClientAccessDeniedTest.php @@ -0,0 +1,169 @@ +createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + + $request = $this->createMock(RequestInterface::class); + $stream = $this->createMock(StreamInterface::class); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($stream); + + $responseBody->method('__toString')->willReturn($bodyJson); + $response->method('getStatusCode')->willReturn(403); + $response->method('getReasonPhrase')->willReturn('Forbidden'); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + return [$httpClient, $requestFactory, $streamFactory, $response, $responseBody]; + } + + public function testThrowsTypedExceptionOn403WithMatchingBody(): void + { + $body = (string) json_encode([ + 'message' => 'Resource not accessible by integration', + 'documentation_url' => 'https://docs.github.com/rest', + ]); + + [$httpClient, $requestFactory, $streamFactory] = $this->makeMocksFor403($body); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + $params = new CreateCheckRunParams(name: 'PHPUnit', headSha: 'abc'); + + try { + $client->createCheckRun($repo, $params); + $this->fail('Expected GithubApiAccessDeniedException'); + } catch (GithubApiAccessDeniedException $e) { + $this->assertSame(403, $e->statusCode); + $this->assertSame($body, $e->responseBody); + $this->assertNotSame('', $e->hint); + $this->assertStringContainsString('permission', $e->hint); + } + } + + public function testFallsThroughToPlainExceptionOn403WithOtherBody(): void + { + $body = (string) json_encode([ + 'message' => 'API rate limit exceeded', + 'documentation_url' => 'https://docs.github.com/rest', + ]); + + [$httpClient, $requestFactory, $streamFactory] = $this->makeMocksFor403($body); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + $params = new CreateCheckRunParams(name: 'PHPUnit', headSha: 'abc'); + + try { + $client->createCheckRun($repo, $params); + $this->fail('Expected exception'); + } catch (GithubApiAccessDeniedException $e) { + $this->fail('Should not throw the typed exception for non-matching 403 body'); + } catch (Exception $e) { + $this->assertStringContainsString('403', $e->getMessage()); + $this->assertStringContainsString('rate limit', $e->getMessage()); + } + } + + public function testFallsThroughOn403WithEmptyBody(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeMocksFor403(''); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + $params = new CreateCheckRunParams(name: 'PHPUnit', headSha: 'abc'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('403 Forbidden'); + + $client->createCheckRun($repo, $params); + } + + public function testNon403FallsThroughUnchanged(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + + $request = $this->createMock(RequestInterface::class); + $stream = $this->createMock(StreamInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($stream); + + $response->method('getStatusCode')->willReturn(422); + $response->method('getReasonPhrase')->willReturn('Unprocessable Entity'); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + $params = new CreateCheckRunParams(name: 'PHPUnit', headSha: 'abc'); + + try { + $client->createCheckRun($repo, $params); + $this->fail('Expected exception'); + } catch (GithubApiAccessDeniedException $e) { + $this->fail('Should not throw the typed exception for non-403'); + } catch (Exception $e) { + $this->assertStringContainsString('422', $e->getMessage()); + } + } +} From 859f475dd735a9834e7d601a776bd0ca3d7d3f1b Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sat, 20 Jun 2026 18:32:02 +0100 Subject: [PATCH 3/7] feat(reviewcomment): add per-line PR review-comment CRUD --- src/CreateReviewCommentParams.php | 80 +++++ src/CreateReviewCommentRequestFactory.php | 56 ++++ src/DeleteReviewCommentRequestFactory.php | 51 ++++ src/GithubApiClient.php | 134 +++++++++ src/GithubReviewComment.php | 68 +++++ src/GithubReviewCommentFactory.php | 31 ++ src/GithubReviewCommentList.php | 71 +++++ src/ListReviewCommentsRequestFactory.php | 48 +++ src/UpdateReviewCommentRequestFactory.php | 59 ++++ test/unit/CreateReviewCommentParamsTest.php | 112 +++++++ .../unit/GithubApiClientReviewCommentTest.php | 276 ++++++++++++++++++ test/unit/GithubReviewCommentTest.php | 102 +++++++ 12 files changed, 1088 insertions(+) create mode 100644 src/CreateReviewCommentParams.php create mode 100644 src/CreateReviewCommentRequestFactory.php create mode 100644 src/DeleteReviewCommentRequestFactory.php create mode 100644 src/GithubReviewComment.php create mode 100644 src/GithubReviewCommentFactory.php create mode 100644 src/GithubReviewCommentList.php create mode 100644 src/ListReviewCommentsRequestFactory.php create mode 100644 src/UpdateReviewCommentRequestFactory.php create mode 100644 test/unit/CreateReviewCommentParamsTest.php create mode 100644 test/unit/GithubApiClientReviewCommentTest.php create mode 100644 test/unit/GithubReviewCommentTest.php diff --git a/src/CreateReviewCommentParams.php b/src/CreateReviewCommentParams.php new file mode 100644 index 0000000..487b44a --- /dev/null +++ b/src/CreateReviewCommentParams.php @@ -0,0 +1,80 @@ + + */ + public function toArray(): array + { + $data = [ + 'body' => $this->body, + 'commit_id' => $this->commitId, + 'path' => $this->path, + 'line' => $this->line, + 'side' => $this->side, + 'subject_type' => $this->subjectType, + ]; + + if ($this->startLine !== null) { + $data['start_line'] = $this->startLine; + } + + if ($this->startSide !== null) { + $data['start_side'] = $this->startSide; + } + + if ($this->inReplyTo !== null) { + $data['in_reply_to'] = $this->inReplyTo; + } + + return $data; + } +} diff --git a/src/CreateReviewCommentRequestFactory.php b/src/CreateReviewCommentRequestFactory.php new file mode 100644 index 0000000..e221def --- /dev/null +++ b/src/CreateReviewCommentRequestFactory.php @@ -0,0 +1,56 @@ +repo->owner, + $this->repo->name, + $this->pullNumber + ); + + $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/DeleteReviewCommentRequestFactory.php b/src/DeleteReviewCommentRequestFactory.php new file mode 100644 index 0000000..c3778b3 --- /dev/null +++ b/src/DeleteReviewCommentRequestFactory.php @@ -0,0 +1,51 @@ +repo->owner, + $this->repo->name, + $this->commentId + ); + + $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/GithubApiClient.php b/src/GithubApiClient.php index 4b8d0da..5204ecb 100644 --- a/src/GithubApiClient.php +++ b/src/GithubApiClient.php @@ -421,6 +421,140 @@ public function createReview(GithubRepository $repo, int $number, CreateReviewPa } } + /** + * Create a per-line review comment on a pull request + * + * Distinct from createPullRequestComment, which posts to the conversation + * thread. Review comments anchor to a specific file path and diff line. + * + * @param GithubRepository $repo The repository + * @param int $prNumber The pull request number + * @param CreateReviewCommentParams $params The review-comment parameters + * @return GithubReviewComment The created review comment + * @throws Exception + */ + public function createReviewComment(GithubRepository $repo, int $prNumber, CreateReviewCommentParams $params): GithubReviewComment + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for createReviewComment. Please provide it in the constructor.'); + } + + $requestFactory = new CreateReviewCommentRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $prNumber, + $params + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 201) { + $data = json_decode((string) $response->getBody()); + return GithubReviewComment::fromApiResponse($data); + } else { + $this->maybeThrowAccessDenied($response); + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * List per-line review comments on a pull request + * + * @param GithubRepository $repo The repository + * @param int $prNumber The pull request number + * @return GithubReviewCommentList + * @throws Exception + */ + public function listReviewComments(GithubRepository $repo, int $prNumber): GithubReviewCommentList + { + $requestFactory = new ListReviewCommentsRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $prNumber + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $factory = new GithubReviewCommentFactory(); + $comments = []; + foreach ($data as $item) { + $comments[] = $factory->createFromApiResponse($item); + } + return new GithubReviewCommentList($comments); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Update a per-line review comment + * + * Note: addressed by comment id only (no PR number in the URL). + * + * @param GithubRepository $repo The repository + * @param int $commentId The review-comment ID + * @param string $body The new comment body + * @return GithubReviewComment The updated review comment + * @throws Exception + */ + public function updateReviewComment(GithubRepository $repo, int $commentId, string $body): GithubReviewComment + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for updateReviewComment. Please provide it in the constructor.'); + } + + $requestFactory = new UpdateReviewCommentRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $commentId, + $body + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + return GithubReviewComment::fromApiResponse($data); + } else { + $this->maybeThrowAccessDenied($response); + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Delete a per-line review comment + * + * Note: addressed by comment id only (no PR number in the URL). + * + * @param GithubRepository $repo The repository + * @param int $commentId The review-comment ID + * @return void + * @throws Exception + */ + public function deleteReviewComment(GithubRepository $repo, int $commentId): void + { + $requestFactory = new DeleteReviewCommentRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $commentId + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() !== 204) { + $this->maybeThrowAccessDenied($response); + throw new Exception($this->parseErrorResponse($response)); + } + } + /** * Get combined status for a commit * diff --git a/src/GithubReviewComment.php b/src/GithubReviewComment.php new file mode 100644 index 0000000..aca8244 --- /dev/null +++ b/src/GithubReviewComment.php @@ -0,0 +1,68 @@ +path, $this->line, $this->userLogin); + } + + /** + * 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, + body: $data->body ?? '', + path: $data->path ?? '', + line: $data->line ?? 0, + startLine: $data->start_line ?? null, + side: $data->side ?? '', + commitId: $data->commit_id ?? '', + userLogin: $data->user->login ?? '', + htmlUrl: $data->html_url ?? '', + createdAt: $data->created_at ?? '', + updatedAt: $data->updated_at ?? '', + ); + } +} diff --git a/src/GithubReviewCommentFactory.php b/src/GithubReviewCommentFactory.php new file mode 100644 index 0000000..76b56f6 --- /dev/null +++ b/src/GithubReviewCommentFactory.php @@ -0,0 +1,31 @@ + + */ +class GithubReviewCommentList implements Iterator, Countable +{ + private int $position = 0; + + /** + * @param array $comments + */ + public function __construct( + private readonly array $comments = [] + ) {} + + public function current(): GithubReviewComment + { + return $this->comments[$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->comments[$this->position]); + } + + public function count(): int + { + return count($this->comments); + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->comments; + } +} diff --git a/src/ListReviewCommentsRequestFactory.php b/src/ListReviewCommentsRequestFactory.php new file mode 100644 index 0000000..b1d0f3f --- /dev/null +++ b/src/ListReviewCommentsRequestFactory.php @@ -0,0 +1,48 @@ +repo->owner, + $this->repo->name, + $this->pullNumber + ); + + $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/UpdateReviewCommentRequestFactory.php b/src/UpdateReviewCommentRequestFactory.php new file mode 100644 index 0000000..b687f52 --- /dev/null +++ b/src/UpdateReviewCommentRequestFactory.php @@ -0,0 +1,59 @@ +repo->owner, + $this->repo->name, + $this->commentId + ); + + $jsonBody = json_encode(['body' => $this->body], 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/CreateReviewCommentParamsTest.php b/test/unit/CreateReviewCommentParamsTest.php new file mode 100644 index 0000000..049523a --- /dev/null +++ b/test/unit/CreateReviewCommentParamsTest.php @@ -0,0 +1,112 @@ +assertSame('RIGHT', $params->side); + $this->assertSame('line', $params->subjectType); + $this->assertNull($params->startLine); + $this->assertNull($params->startSide); + $this->assertNull($params->inReplyTo); + } + + public function testToArrayEmitsRequiredFieldsWithSnakeCase(): void + { + $params = new CreateReviewCommentParams( + body: 'Issue here', + commitId: 'deadbeef', + path: 'lib/Bar.php', + line: 7, + ); + + $array = $params->toArray(); + + $this->assertSame('Issue here', $array['body']); + $this->assertSame('deadbeef', $array['commit_id']); + $this->assertSame('lib/Bar.php', $array['path']); + $this->assertSame(7, $array['line']); + $this->assertSame('RIGHT', $array['side']); + $this->assertSame('line', $array['subject_type']); + $this->assertArrayNotHasKey('commitId', $array); + $this->assertArrayNotHasKey('subjectType', $array); + $this->assertArrayNotHasKey('start_line', $array); + $this->assertArrayNotHasKey('start_side', $array); + $this->assertArrayNotHasKey('in_reply_to', $array); + } + + public function testToArrayWithMultiLineFields(): void + { + $params = new CreateReviewCommentParams( + body: 'Block issue', + commitId: 'sha', + path: 'src/X.php', + line: 50, + startLine: 45, + startSide: 'RIGHT', + ); + + $array = $params->toArray(); + + $this->assertSame(45, $array['start_line']); + $this->assertSame('RIGHT', $array['start_side']); + } + + public function testToArrayWithInReplyTo(): void + { + $params = new CreateReviewCommentParams( + body: 'Reply', + commitId: 'sha', + path: 'src/X.php', + line: 1, + inReplyTo: 9999, + ); + + $array = $params->toArray(); + + $this->assertSame(9999, $array['in_reply_to']); + } + + public function testSideAndSubjectTypeOverrides(): void + { + $params = new CreateReviewCommentParams( + body: 'Old line gone', + commitId: 'sha', + path: 'src/X.php', + line: 3, + side: 'LEFT', + subjectType: 'file', + ); + + $array = $params->toArray(); + + $this->assertSame('LEFT', $array['side']); + $this->assertSame('file', $array['subject_type']); + } +} diff --git a/test/unit/GithubApiClientReviewCommentTest.php b/test/unit/GithubApiClientReviewCommentTest.php new file mode 100644 index 0000000..40dbfca --- /dev/null +++ b/test/unit/GithubApiClientReviewCommentTest.php @@ -0,0 +1,276 @@ +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 + */ + private function reviewCommentBody(int $id = 77): array + { + return [ + 'id' => $id, + 'body' => 'Found it', + 'path' => 'src/Foo.php', + 'line' => 12, + 'start_line' => null, + 'side' => 'RIGHT', + 'commit_id' => 'sha-abc', + 'user' => ['login' => 'octocat'], + 'html_url' => 'https://example.org/r/' . $id, + 'created_at' => '2026-06-24T10:00:00Z', + 'updated_at' => '2026-06-24T10:00:00Z', + ]; + } + + public function testCreateReviewCommentSuccess(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode($this->reviewCommentBody())); + $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'); + $params = new CreateReviewCommentParams( + body: 'Found it', + commitId: 'sha-abc', + path: 'src/Foo.php', + line: 12, + ); + + $comment = $client->createReviewComment($repo, 1, $params); + + $this->assertInstanceOf(GithubReviewComment::class, $comment); + $this->assertSame(77, $comment->id); + $this->assertSame('src/Foo.php', $comment->path); + } + + public function testCreateReviewCommentRequiresStreamFactory(): 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'); + $params = new CreateReviewCommentParams(body: 'x', commitId: 's', path: 'p', line: 1); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('StreamFactory is required for createReviewComment'); + + $client->createReviewComment($repo, 1, $params); + } + + public function testCreateReviewCommentThrowsOn422(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(422); + $response->method('getReasonPhrase')->willReturn('Unprocessable Entity'); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + $params = new CreateReviewCommentParams(body: 'x', commitId: 's', path: 'p', line: 1); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('422 Unprocessable Entity'); + + $client->createReviewComment($repo, 1, $params); + } + + public function testListReviewCommentsSuccess(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $payload = [$this->reviewCommentBody(1), $this->reviewCommentBody(2)]; + $responseBody->method('__toString')->willReturn((string) json_encode($payload)); + $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->listReviewComments($repo, 1); + + $this->assertInstanceOf(GithubReviewCommentList::class, $list); + $this->assertCount(2, $list); + $array = $list->toArray(); + $this->assertSame(1, $array[0]->id); + $this->assertSame(2, $array[1]->id); + } + + public function testListReviewCommentsThrowsOn404(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $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->listReviewComments($repo, 9999); + } + + public function testUpdateReviewCommentSuccess(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $body = $this->reviewCommentBody(); + $body['body'] = 'Edited body'; + $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'); + + $comment = $client->updateReviewComment($repo, 77, 'Edited body'); + + $this->assertSame('Edited body', $comment->body); + } + + public function testUpdateReviewCommentRequiresStreamFactory(): 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 updateReviewComment'); + + $client->updateReviewComment($repo, 77, 'new body'); + } + + public function testDeleteReviewCommentSuccess(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $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->deleteReviewComment($repo, 77); + + $this->addToAssertionCount(1); + } + + public function testDeleteReviewCommentThrowsOnNon204(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $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->deleteReviewComment($repo, 99999); + } +} diff --git a/test/unit/GithubReviewCommentTest.php b/test/unit/GithubReviewCommentTest.php new file mode 100644 index 0000000..9c33600 --- /dev/null +++ b/test/unit/GithubReviewCommentTest.php @@ -0,0 +1,102 @@ +login = 'octocat'; + + $data = new stdClass(); + $data->id = 77; + $data->body = 'Found it'; + $data->path = 'src/Foo.php'; + $data->line = 12; + $data->start_line = 10; + $data->side = 'RIGHT'; + $data->commit_id = 'sha-abc'; + $data->user = $user; + $data->html_url = 'https://github.com/owner/repo/pull/1#discussion_r77'; + $data->created_at = '2026-06-24T10:00:00Z'; + $data->updated_at = '2026-06-24T10:05:00Z'; + + $comment = GithubReviewComment::fromApiResponse($data); + + $this->assertSame(77, $comment->id); + $this->assertSame('Found it', $comment->body); + $this->assertSame('src/Foo.php', $comment->path); + $this->assertSame(12, $comment->line); + $this->assertSame(10, $comment->startLine); + $this->assertSame('RIGHT', $comment->side); + $this->assertSame('sha-abc', $comment->commitId); + $this->assertSame('octocat', $comment->userLogin); + $this->assertSame('https://github.com/owner/repo/pull/1#discussion_r77', $comment->htmlUrl); + $this->assertSame('2026-06-24T10:00:00Z', $comment->createdAt); + $this->assertSame('2026-06-24T10:05:00Z', $comment->updatedAt); + } + + public function testFromApiResponseNullStartLineForSingleLine(): void + { + $user = new stdClass(); + $user->login = 'bot'; + + $data = new stdClass(); + $data->id = 1; + $data->body = ''; + $data->path = 'a'; + $data->line = 1; + $data->side = 'RIGHT'; + $data->commit_id = 'x'; + $data->user = $user; + $data->html_url = ''; + $data->created_at = ''; + $data->updated_at = ''; + // start_line intentionally omitted + + $comment = GithubReviewComment::fromApiResponse($data); + + $this->assertNull($comment->startLine); + } + + public function testStringableFormat(): void + { + $user = new stdClass(); + $user->login = 'reviewer'; + + $data = new stdClass(); + $data->id = 1; + $data->body = 'x'; + $data->path = 'lib/Bar.php'; + $data->line = 99; + $data->side = 'RIGHT'; + $data->commit_id = ''; + $data->user = $user; + $data->html_url = ''; + $data->created_at = ''; + $data->updated_at = ''; + + $comment = GithubReviewComment::fromApiResponse($data); + + $this->assertSame('lib/Bar.php:99 by reviewer', (string) $comment); + } +} From 84396e733e86b7e5538896c92a4265faaed6d714 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sat, 20 Jun 2026 19:16:28 +0100 Subject: [PATCH 4/7] fix(pagination): handle empty Link header without TypeError --- src/GithubApiPagination.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/GithubApiPagination.php b/src/GithubApiPagination.php index c2a6690..e637136 100644 --- a/src/GithubApiPagination.php +++ b/src/GithubApiPagination.php @@ -19,6 +19,10 @@ public function __construct(private readonly RequestInterface $request, private { // Github separates links by comma-space rather than having a comma-separated list of link header values $headerLine = $this->response->getHeaderLine('Link'); + if ($headerLine === '') { + // No Link header → no pagination relations. Single-page or non-paginated response. + return; + } $headers = explode(', ', $headerLine); foreach ($headers as $header) { [$origLink, $origRel] = explode('; ', $header); From 7ed80208e24c631409a7cafa2674d8f6ca0ff9c8 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sat, 20 Jun 2026 20:04:21 +0100 Subject: [PATCH 5/7] feat(label): add repository-level label CRUD --- src/CreateLabelParams.php | 50 +++ src/CreateLabelRequestFactory.php | 54 +++ src/DeleteLabelRequestFactory.php | 48 +++ src/GetLabelRequestFactory.php | 49 +++ src/GithubApiClient.php | 163 +++++++++ src/ListRepositoryLabelsRequestFactory.php | 46 +++ src/UpdateLabelParams.php | 59 ++++ src/UpdateLabelRequestFactory.php | 59 ++++ test/unit/CreateLabelParamsTest.php | 55 +++ .../GithubApiClientRepositoryLabelsTest.php | 314 ++++++++++++++++++ test/unit/UpdateLabelParamsTest.php | 53 +++ 11 files changed, 950 insertions(+) create mode 100644 src/CreateLabelParams.php create mode 100644 src/CreateLabelRequestFactory.php create mode 100644 src/DeleteLabelRequestFactory.php create mode 100644 src/GetLabelRequestFactory.php create mode 100644 src/ListRepositoryLabelsRequestFactory.php create mode 100644 src/UpdateLabelParams.php create mode 100644 src/UpdateLabelRequestFactory.php create mode 100644 test/unit/CreateLabelParamsTest.php create mode 100644 test/unit/GithubApiClientRepositoryLabelsTest.php create mode 100644 test/unit/UpdateLabelParamsTest.php diff --git a/src/CreateLabelParams.php b/src/CreateLabelParams.php new file mode 100644 index 0000000..cca6844 --- /dev/null +++ b/src/CreateLabelParams.php @@ -0,0 +1,50 @@ + + */ + public function toArray(): array + { + $data = [ + 'name' => $this->name, + 'color' => $this->color, + ]; + + if ($this->description !== '') { + $data['description'] = $this->description; + } + + return $data; + } +} diff --git a/src/CreateLabelRequestFactory.php b/src/CreateLabelRequestFactory.php new file mode 100644 index 0000000..57e909c --- /dev/null +++ b/src/CreateLabelRequestFactory.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/DeleteLabelRequestFactory.php b/src/DeleteLabelRequestFactory.php new file mode 100644 index 0000000..36eea0a --- /dev/null +++ b/src/DeleteLabelRequestFactory.php @@ -0,0 +1,48 @@ +repo->owner, + $this->repo->name, + rawurlencode($this->labelName) + ); + + $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/GetLabelRequestFactory.php b/src/GetLabelRequestFactory.php new file mode 100644 index 0000000..08ee807 --- /dev/null +++ b/src/GetLabelRequestFactory.php @@ -0,0 +1,49 @@ +repo->owner, + $this->repo->name, + rawurlencode($this->labelName) + ); + + $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 5204ecb..72e49ef 100644 --- a/src/GithubApiClient.php +++ b/src/GithubApiClient.php @@ -849,6 +849,169 @@ public function removeLabel(GithubRepository $repo, int $number, string $labelNa } } + /** + * List repository-level label definitions + * + * Distinct from listIssueLabels (which lists assignments on one issue/PR); + * this lists the labels that exist in the repository. + * + * @param GithubRepository $repo The repository + * @return GithubLabelList + * @throws Exception + */ + public function listRepositoryLabels(GithubRepository $repo): GithubLabelList + { + $requestFactory = new ListRepositoryLabelsRequestFactory( + $this->requestFactory, + $this->config, + $repo + ); + $request = $requestFactory->create(); + $labels = []; + $labelFactory = new GithubLabelFactory(); + while (true) { + $response = $this->httpClient->sendRequest($request); + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + foreach ($data as $labelData) { + $labels[] = $labelFactory->createFromApiResponse($labelData); + } + $pagination = new GithubApiPagination($request, $response); + if (!$pagination->hasNextLink()) { + break; + } + $request = $pagination->nextRequest(); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + + return new GithubLabelList($labels); + } + + /** + * Get a single repository-level label by name + * + * @param GithubRepository $repo The repository + * @param string $name The label name + * @return GithubLabel + * @throws Exception + */ + public function getLabel(GithubRepository $repo, string $name): GithubLabel + { + $requestFactory = new GetLabelRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $name + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + return GithubLabel::fromApiResponse($data); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Create a repository-level label definition + * + * @param GithubRepository $repo The repository + * @param CreateLabelParams $params The label parameters + * @return GithubLabel The created label + * @throws Exception + */ + public function createLabel(GithubRepository $repo, CreateLabelParams $params): GithubLabel + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for createLabel. Please provide it in the constructor.'); + } + + $requestFactory = new CreateLabelRequestFactory( + $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 GithubLabel::fromApiResponse($data); + } else { + $this->maybeThrowAccessDenied($response); + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Update a repository-level label definition + * + * The currentName URL segment is the lookup key; a non-null name in + * UpdateLabelParams renames the label. + * + * @param GithubRepository $repo The repository + * @param string $currentName The current label name (URL key) + * @param UpdateLabelParams $params The update parameters + * @return GithubLabel The updated label + * @throws Exception + */ + public function updateLabel(GithubRepository $repo, string $currentName, UpdateLabelParams $params): GithubLabel + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for updateLabel. Please provide it in the constructor.'); + } + + $requestFactory = new UpdateLabelRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $currentName, + $params + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + return GithubLabel::fromApiResponse($data); + } else { + $this->maybeThrowAccessDenied($response); + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Delete a repository-level label definition + * + * @param GithubRepository $repo The repository + * @param string $name The label name + * @return void + * @throws Exception + */ + public function deleteLabel(GithubRepository $repo, string $name): void + { + $requestFactory = new DeleteLabelRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $name + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() !== 204) { + $this->maybeThrowAccessDenied($response); + throw new Exception($this->parseErrorResponse($response)); + } + } + /** * Merge a pull request * diff --git a/src/ListRepositoryLabelsRequestFactory.php b/src/ListRepositoryLabelsRequestFactory.php new file mode 100644 index 0000000..66e84f9 --- /dev/null +++ b/src/ListRepositoryLabelsRequestFactory.php @@ -0,0 +1,46 @@ +repo->owner, + $this->repo->name + ); + + $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/UpdateLabelParams.php b/src/UpdateLabelParams.php new file mode 100644 index 0000000..9acc52a --- /dev/null +++ b/src/UpdateLabelParams.php @@ -0,0 +1,59 @@ + + */ + public function toArray(): array + { + $data = []; + + if ($this->name !== null) { + $data['name'] = $this->name; + } + + if ($this->color !== null) { + $data['color'] = $this->color; + } + + if ($this->description !== null) { + $data['description'] = $this->description; + } + + return $data; + } +} diff --git a/src/UpdateLabelRequestFactory.php b/src/UpdateLabelRequestFactory.php new file mode 100644 index 0000000..4e61010 --- /dev/null +++ b/src/UpdateLabelRequestFactory.php @@ -0,0 +1,59 @@ +repo->owner, + $this->repo->name, + rawurlencode($this->currentName) + ); + + $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/CreateLabelParamsTest.php b/test/unit/CreateLabelParamsTest.php new file mode 100644 index 0000000..b88e964 --- /dev/null +++ b/test/unit/CreateLabelParamsTest.php @@ -0,0 +1,55 @@ +assertSame(['name' => 'bug', 'color' => 'd73a4a'], $params->toArray()); + } + + public function testDescriptionEmittedWhenNonEmpty(): void + { + $params = new CreateLabelParams(name: 'bug', color: 'd73a4a', description: 'Something broken'); + + $this->assertSame( + ['name' => 'bug', 'color' => 'd73a4a', 'description' => 'Something broken'], + $params->toArray() + ); + } + + public function testDescriptionOmittedWhenEmpty(): void + { + $params = new CreateLabelParams(name: 'bug', color: 'd73a4a', description: ''); + + $this->assertArrayNotHasKey('description', $params->toArray()); + } + + public function testColorShapeNotValidated(): void + { + // The library does not validate the hex format; GitHub rejects. + $params = new CreateLabelParams(name: 'x', color: 'not-hex'); + + $this->assertSame('not-hex', $params->toArray()['color']); + } +} diff --git a/test/unit/GithubApiClientRepositoryLabelsTest.php b/test/unit/GithubApiClientRepositoryLabelsTest.php new file mode 100644 index 0000000..272e588 --- /dev/null +++ b/test/unit/GithubApiClientRepositoryLabelsTest.php @@ -0,0 +1,314 @@ +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 labelBody(string $name = 'bug'): array + { + return ['name' => $name, 'color' => 'd73a4a', 'description' => 'Something broken']; + } + + public function testListRepositoryLabelsSuccessSinglePage(): void + { + [$httpClient, $requestFactory, $request] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode( + [$this->labelBody('bug'), $this->labelBody('enhancement')] + )); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($responseBody); + // Empty Link header → pagination sees no next, loop exits after one round. + $response->method('getHeaderLine')->willReturn(''); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $list = $client->listRepositoryLabels($repo); + + $this->assertInstanceOf(GithubLabelList::class, $list); + $this->assertCount(2, $list); + $array = $list->toArray(); + $this->assertSame('bug', $array[0]->name); + $this->assertSame('enhancement', $array[1]->name); + } + + public function testListRepositoryLabelsThrowsOnNon200(): void + { + [$httpClient, $requestFactory, $request] = $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->listRepositoryLabels($repo); + } + + public function testGetLabelSuccess(): void + { + [$httpClient, $requestFactory, $request] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode($this->labelBody('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); + $repo = GithubRepository::fromFullName('owner/repo'); + + $label = $client->getLabel($repo, 'bug'); + + $this->assertInstanceOf(GithubLabel::class, $label); + $this->assertSame('bug', $label->name); + } + + public function testGetLabelWithSpacesAndSlashesInName(): void + { + // Names like "type: bug/regression" must round-trip; rawurlencode applies + // in the factory. Verified by reaching the success branch without error. + [$httpClient, $requestFactory, $request] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode( + $this->labelBody('type: bug/regression') + )); + $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'); + + $label = $client->getLabel($repo, 'type: bug/regression'); + + $this->assertSame('type: bug/regression', $label->name); + } + + public function testGetLabelThrowsOn404(): void + { + [$httpClient, $requestFactory, $request] = $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->getLabel($repo, 'missing'); + } + + public function testCreateLabelSuccess(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode($this->labelBody('bug'))); + $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'); + $params = new CreateLabelParams(name: 'bug', color: 'd73a4a', description: 'Something broken'); + + $label = $client->createLabel($repo, $params); + + $this->assertSame('bug', $label->name); + $this->assertSame('d73a4a', $label->color); + } + + public function testCreateLabelRequiresStreamFactory(): 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 createLabel'); + + $client->createLabel($repo, new CreateLabelParams(name: 'x', color: 'fff')); + } + + public function testCreateLabelThrowsOn422(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(422); + $response->method('getReasonPhrase')->willReturn('Unprocessable Entity'); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('422 Unprocessable Entity'); + + $client->createLabel($repo, new CreateLabelParams(name: 'dup', color: 'fff')); + } + + public function testUpdateLabelSuccessRename(): void + { + [$httpClient, $requestFactory, $streamFactory] = $this->makeWriteMocks(); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $responseBody->method('__toString')->willReturn((string) json_encode($this->labelBody('critical-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'); + + // URL key 'bug' looks up; body name 'critical-bug' renames. + $label = $client->updateLabel($repo, 'bug', new UpdateLabelParams(name: 'critical-bug')); + + $this->assertSame('critical-bug', $label->name); + } + + public function testUpdateLabelRequiresStreamFactory(): 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 updateLabel'); + + $client->updateLabel($repo, 'bug', new UpdateLabelParams(color: 'fff')); + } + + public function testDeleteLabelSuccess(): void + { + [$httpClient, $requestFactory, $request] = $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->deleteLabel($repo, 'bug'); + + $this->addToAssertionCount(1); + } + + public function testDeleteLabelThrowsOnNon204(): void + { + [$httpClient, $requestFactory, $request] = $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->deleteLabel($repo, 'missing'); + } +} diff --git a/test/unit/UpdateLabelParamsTest.php b/test/unit/UpdateLabelParamsTest.php new file mode 100644 index 0000000..2d30172 --- /dev/null +++ b/test/unit/UpdateLabelParamsTest.php @@ -0,0 +1,53 @@ +assertSame([], $params->toArray()); + } + + public function testPartialUpdateOnlyEmitsSetFields(): void + { + $params = new UpdateLabelParams(color: 'ff0000'); + + $this->assertSame(['color' => 'ff0000'], $params->toArray()); + } + + public function testRenameUsesBodyName(): void + { + // Rename: URL key stays the lookup; body 'name' is the new name. + $params = new UpdateLabelParams(name: 'critical-bug'); + + $this->assertSame(['name' => 'critical-bug'], $params->toArray()); + } + + public function testEmptyDescriptionEmitsAsLiteralEmpty(): void + { + // null means "do not change", empty string means "set to empty". + $params = new UpdateLabelParams(description: ''); + + $this->assertSame(['description' => ''], $params->toArray()); + } +} From 1d9e94eb9e0c44f207fc0e1d8d5589bc0e658f2d Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sat, 20 Jun 2026 20:39:34 +0100 Subject: [PATCH 6/7] feat: removeAllLabels method --- bin/demo-client.php | 3 +- bin/demo-token-info.php | 5 +- src/Auth/PrivateKey.php | 5 +- src/CreateReviewParams.php | 6 +- src/GithubApiClient.php | 31 +++++++- src/GithubPullRequestList.php | 2 +- src/GithubRepositoryList.php | 2 +- src/RateLimit.php | 14 ++-- src/RemoveAllLabelsRequestFactory.php | 51 ++++++++++++ .../GithubApiClientRemoveAllLabelsTest.php | 78 +++++++++++++++++++ 10 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 src/RemoveAllLabelsRequestFactory.php create mode 100644 test/unit/GithubApiClientRemoveAllLabelsTest.php diff --git a/bin/demo-client.php b/bin/demo-client.php index f10a6a6..ab5f7cd 100755 --- a/bin/demo-client.php +++ b/bin/demo-client.php @@ -22,6 +22,7 @@ use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; +use Exception; // Bootstrap the injector $strGithubApiToken = (string) getenv('GITHUB_TOKEN'); @@ -181,7 +182,7 @@ echo " ✓ Reopened PR #{$reopenedPr->number}, state: {$reopenedPr->state}\n"; } - } catch (\Exception $e) { + } catch (Exception $e) { echo " ✗ Error: {$e->getMessage()}\n"; echo " Note: Make sure the head branch exists and differs from base branch\n"; } diff --git a/bin/demo-token-info.php b/bin/demo-token-info.php index 7a7f1a6..bdbe574 100755 --- a/bin/demo-token-info.php +++ b/bin/demo-token-info.php @@ -19,6 +19,7 @@ use Horde\Http\ResponseFactory; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; +use Exception; // Check for GitHub token $strGithubApiToken = (string) getenv('GITHUB_TOKEN'); @@ -60,7 +61,7 @@ } echo "\n"; -} catch (\Exception $e) { +} catch (Exception $e) { echo " └─ ✗ Error: " . $e->getMessage() . "\n\n"; } @@ -89,7 +90,7 @@ } echo "\n"; -} catch (\Exception $e) { +} catch (Exception $e) { echo " └─ ✗ Error: " . $e->getMessage() . "\n\n"; } diff --git a/src/Auth/PrivateKey.php b/src/Auth/PrivateKey.php index 3ff0acd..6ce075a 100644 --- a/src/Auth/PrivateKey.php +++ b/src/Auth/PrivateKey.php @@ -17,6 +17,7 @@ namespace Horde\GithubApiClient\Auth; use InvalidArgumentException; +use OpenSSLAsymmetricKey; class PrivateKey { @@ -57,9 +58,9 @@ public static function fromFile(string $filePath): self } /** - * @return \OpenSSLAsymmetricKey + * @return OpenSSLAsymmetricKey */ - public function getResource(): \OpenSSLAsymmetricKey + public function getResource(): OpenSSLAsymmetricKey { $resource = openssl_pkey_get_private($this->content); if ($resource === false) { diff --git a/src/CreateReviewParams.php b/src/CreateReviewParams.php index 7338c40..b76853a 100644 --- a/src/CreateReviewParams.php +++ b/src/CreateReviewParams.php @@ -4,6 +4,8 @@ namespace Horde\GithubApiClient; +use InvalidArgumentException; + /** * Data transfer object for creating a GitHub pull request review * @@ -29,13 +31,13 @@ public function __construct( public readonly string $commitId = '', ) { if (!in_array($event, ['APPROVE', 'REQUEST_CHANGES', 'COMMENT'])) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( "Invalid event: {$event}. Must be APPROVE, REQUEST_CHANGES, or COMMENT" ); } if ($event === 'REQUEST_CHANGES' && $body === '') { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( 'Body is required when event is REQUEST_CHANGES' ); } diff --git a/src/GithubApiClient.php b/src/GithubApiClient.php index 72e49ef..d238739 100644 --- a/src/GithubApiClient.php +++ b/src/GithubApiClient.php @@ -849,6 +849,35 @@ public function removeLabel(GithubRepository $repo, int $number, string $labelNa } } + /** + * Remove all labels from an issue or pull request + * + * Symmetric with removeLabel(...) but clears every label assignment at once. + * Useful for CI flows that reset state ("clear all lane-outcome labels, + * then add the new ones"). + * + * @param GithubRepository $repo The repository + * @param int $number The issue or pull request number + * @return void + * @throws Exception + */ + public function removeAllLabels(GithubRepository $repo, int $number): void + { + $requestFactory = new RemoveAllLabelsRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $number + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() !== 204) { + $this->maybeThrowAccessDenied($response); + throw new Exception($this->parseErrorResponse($response)); + } + } + /** * List repository-level label definitions * @@ -1463,7 +1492,7 @@ private function parseErrorResponse(ResponseInterface $response): string } return $baseMessage; - } catch (\Exception $e) { + } catch (Exception $e) { // If anything goes wrong parsing, return base message return $baseMessage; } diff --git a/src/GithubPullRequestList.php b/src/GithubPullRequestList.php index 5019f6f..a9bb65c 100644 --- a/src/GithubPullRequestList.php +++ b/src/GithubPullRequestList.php @@ -11,7 +11,7 @@ use OutOfBoundsException; use Stringable; -/** @implements \IteratorAggregate */ +/** @implements IteratorAggregate */ class GithubPullRequestList implements IteratorAggregate, Countable { /** diff --git a/src/GithubRepositoryList.php b/src/GithubRepositoryList.php index 1330a1d..0594494 100644 --- a/src/GithubRepositoryList.php +++ b/src/GithubRepositoryList.php @@ -10,7 +10,7 @@ use OutOfBoundsException; use Stringable; -/** @implements \IteratorAggregate */ +/** @implements IteratorAggregate */ class GithubRepositoryList implements IteratorAggregate { /** diff --git a/src/RateLimit.php b/src/RateLimit.php index f1c2e9d..8a983ed 100755 --- a/src/RateLimit.php +++ b/src/RateLimit.php @@ -4,6 +4,10 @@ namespace Horde\GithubApiClient; +use DateTimeImmutable; +use InvalidArgumentException; +use RuntimeException; + /** * Represents GitHub API rate limit information */ @@ -24,7 +28,7 @@ public function __construct( */ public static function fromApiResponse(object $response): self { - $core = $response->resources->core ?? throw new \InvalidArgumentException('Invalid rate limit response structure'); + $core = $response->resources->core ?? throw new InvalidArgumentException('Invalid rate limit response structure'); return new self( limit: $core->limit ?? 0, @@ -70,11 +74,11 @@ public function getUsagePercentage(): float /** * Get reset time as DateTime * - * @return \DateTimeImmutable + * @return DateTimeImmutable */ - public function getResetDateTime(): \DateTimeImmutable + public function getResetDateTime(): DateTimeImmutable { - return \DateTimeImmutable::createFromFormat('U', (string) $this->reset) - ?: throw new \RuntimeException('Failed to create DateTime from reset timestamp'); + return DateTimeImmutable::createFromFormat('U', (string) $this->reset) + ?: throw new RuntimeException('Failed to create DateTime from reset timestamp'); } } diff --git a/src/RemoveAllLabelsRequestFactory.php b/src/RemoveAllLabelsRequestFactory.php new file mode 100644 index 0000000..554f6c1 --- /dev/null +++ b/src/RemoveAllLabelsRequestFactory.php @@ -0,0 +1,51 @@ +repo->owner, + $this->repo->name, + $this->issueNumber + ); + + $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/test/unit/GithubApiClientRemoveAllLabelsTest.php b/test/unit/GithubApiClientRemoveAllLabelsTest.php new file mode 100644 index 0000000..685e6a4 --- /dev/null +++ b/test/unit/GithubApiClientRemoveAllLabelsTest.php @@ -0,0 +1,78 @@ +createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $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->removeAllLabels($repo, 42); + + $this->addToAssertionCount(1); + } + + public function testRemoveAllLabelsThrowsOnNon204(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $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->removeAllLabels($repo, 9999); + } +} From 06afb3b719b1ebf40541f521cdaea1bbd3aecb63 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sat, 20 Jun 2026 21:25:44 +0100 Subject: [PATCH 7/7] test: merge tests/ into test/ and style FQCN usage --- .horde.yml | 1 + test/unit/CreateReviewParamsTest.php | 5 +- test/unit/GithubApiClientAppAuthTest.php | 13 +- .../unit/GithubApiClientErrorHandlingTest.php | 13 +- .../GithubApiClientGetCurrentUserTest.php | 5 +- test/unit/GithubApiConfigTest.php | 3 +- test/unit/GithubRepositoryTest.php | 12 +- test/unit/InstallationAccessTokenTest.php | 3 +- test/unit/PreAuthenticatedClientTest.php | 158 ++++++++++++++++-- {tests => test}/unit/RateLimitTest.php | 9 +- {tests => test}/unit/TokenScopesTest.php | 6 +- tests/unit/GithubApiClientTest.php | 32 ---- 12 files changed, 186 insertions(+), 74 deletions(-) rename {tests => test}/unit/RateLimitTest.php (94%) rename {tests => test}/unit/TokenScopesTest.php (96%) delete mode 100644 tests/unit/GithubApiClientTest.php diff --git a/.horde.yml b/.horde.yml index 183987c..abc70ca 100644 --- a/.horde.yml +++ b/.horde.yml @@ -40,3 +40,4 @@ quality: phpstan: level: 5 vendor: horde +keywords: [] diff --git a/test/unit/CreateReviewParamsTest.php b/test/unit/CreateReviewParamsTest.php index f86ad2a..0f6c33c 100644 --- a/test/unit/CreateReviewParamsTest.php +++ b/test/unit/CreateReviewParamsTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; +use InvalidArgumentException; /** * Copyright 2026 The Horde Project (http://www.horde.org/) @@ -83,7 +84,7 @@ public function testConstructionWithCommitId(): void public function testInvalidEventThrowsException(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid event: INVALID'); new CreateReviewParams( @@ -93,7 +94,7 @@ public function testInvalidEventThrowsException(): void public function testRequestChangesWithoutBodyThrowsException(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Body is required when event is REQUEST_CHANGES'); new CreateReviewParams( diff --git a/test/unit/GithubApiClientAppAuthTest.php b/test/unit/GithubApiClientAppAuthTest.php index 78c6486..33e0e96 100644 --- a/test/unit/GithubApiClientAppAuthTest.php +++ b/test/unit/GithubApiClientAppAuthTest.php @@ -19,6 +19,7 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; +use Exception; /** * Copyright 2026 The Horde Project (http://www.horde.org/) @@ -124,7 +125,7 @@ public function testCreateInstallationAccessTokenRequiresStreamFactory(): void $client = new GithubApiClient($httpClient, $requestFactory, $config, null); - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('StreamFactory is required for createInstallationAccessToken'); $client->createInstallationAccessToken(12345); @@ -156,7 +157,7 @@ public function testCreateInstallationAccessTokenThrowsOn401Unauthorized(): void $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('401 Unauthorized'); $client->createInstallationAccessToken(12345); @@ -188,7 +189,7 @@ public function testCreateInstallationAccessTokenThrowsOn403Forbidden(): void $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('403 Forbidden'); $client->createInstallationAccessToken(12345); @@ -220,7 +221,7 @@ public function testCreateInstallationAccessTokenThrowsOn404NotFound(): void $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('404 Not Found'); $client->createInstallationAccessToken(99999); @@ -357,7 +358,7 @@ public function testListInstallationsErrorHandling(): void $client = new GithubApiClient($httpClient, $requestFactory, $config); - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('401 Unauthorized'); $client->listInstallations(); @@ -429,7 +430,7 @@ public function testGetAuthenticatedAppErrorHandling(): void $client = new GithubApiClient($httpClient, $requestFactory, $config); - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('401 Unauthorized'); $client->getAuthenticatedApp(); diff --git a/test/unit/GithubApiClientErrorHandlingTest.php b/test/unit/GithubApiClientErrorHandlingTest.php index 91e63b7..9a37dbd 100644 --- a/test/unit/GithubApiClientErrorHandlingTest.php +++ b/test/unit/GithubApiClientErrorHandlingTest.php @@ -17,6 +17,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; use Psr\Http\Message\StreamFactoryInterface; +use Exception; #[CoversClass(GithubApiClient::class)] #[AllowMockObjectsWithoutExpectations] @@ -57,7 +58,7 @@ public function testCreatePullRequestThrowsOn422UnprocessableEntity(): void base: 'main' ); - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('422 Unprocessable Entity'); $client->createPullRequest($repo, $params); @@ -93,7 +94,7 @@ public function testCreatePullRequestThrowsOn404NotFound(): void base: 'main' ); - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('404 Not Found'); $client->createPullRequest($repo, $params); @@ -129,7 +130,7 @@ public function testCreatePullRequestThrowsOn401Unauthorized(): void base: 'main' ); - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('401 Unauthorized'); $client->createPullRequest($repo, $params); @@ -165,7 +166,7 @@ public function testCreatePullRequestThrowsOn403Forbidden(): void base: 'main' ); - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('403 Forbidden'); $client->createPullRequest($repo, $params); @@ -203,9 +204,9 @@ public function testCreateReviewThrowsDetailedErrorOn422(): void $httpClient->method('sendRequest')->willReturn($response); $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); - $repo = \Horde\GithubApiClient\GithubRepository::fromFullName('horde/hordectl'); + $repo = GithubRepository::fromFullName('horde/hordectl'); - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('422 Unprocessable Entity: Review Can not approve your own pull request'); $params = new \Horde\GithubApiClient\CreateReviewParams(event: 'APPROVE', body: ''); diff --git a/test/unit/GithubApiClientGetCurrentUserTest.php b/test/unit/GithubApiClientGetCurrentUserTest.php index b0989ec..43a290d 100644 --- a/test/unit/GithubApiClientGetCurrentUserTest.php +++ b/test/unit/GithubApiClientGetCurrentUserTest.php @@ -15,6 +15,7 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; +use Exception; /** * Copyright 2026 The Horde Project (http://www.horde.org/) @@ -97,7 +98,7 @@ public function testGetCurrentUserThrowsOn401Unauthorized(): void $client = new GithubApiClient($httpClient, $requestFactory, $config); - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('401 Unauthorized: Bad credentials'); $client->getCurrentUser(); @@ -130,7 +131,7 @@ public function testGetCurrentUserThrowsOn403Forbidden(): void $client = new GithubApiClient($httpClient, $requestFactory, $config); - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('403 Forbidden: Resource not accessible by personal access token'); $client->getCurrentUser(); diff --git a/test/unit/GithubApiConfigTest.php b/test/unit/GithubApiConfigTest.php index 06f468a..55980e5 100644 --- a/test/unit/GithubApiConfigTest.php +++ b/test/unit/GithubApiConfigTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use ReflectionClass; /** * Copyright 2026 The Horde Project (http://www.horde.org/) @@ -85,7 +86,7 @@ public function testPropertiesAreReadonly(): void { $config = new GithubApiConfig(); - $reflection = new \ReflectionClass($config); + $reflection = new ReflectionClass($config); $properties = $reflection->getProperties(); foreach ($properties as $property) { diff --git a/test/unit/GithubRepositoryTest.php b/test/unit/GithubRepositoryTest.php index 385e129..a725a50 100644 --- a/test/unit/GithubRepositoryTest.php +++ b/test/unit/GithubRepositoryTest.php @@ -8,6 +8,8 @@ use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use InvalidArgumentException; +use ReflectionProperty; #[CoversClass(GithubRepository::class)] #[AllowMockObjectsWithoutExpectations] @@ -42,7 +44,7 @@ public function testFromFullNameWithHyphenatedNames(): void public function testFromFullNameThrowsOnEmptyString(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Full name cannot be empty'); GithubRepository::fromFullName(''); @@ -50,7 +52,7 @@ public function testFromFullNameThrowsOnEmptyString(): void public function testFromFullNameThrowsOnInvalidFormat(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid full name format'); GithubRepository::fromFullName('invalid-no-slash'); @@ -58,7 +60,7 @@ public function testFromFullNameThrowsOnInvalidFormat(): void public function testFromFullNameThrowsOnTooManySlashes(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid full name format'); GithubRepository::fromFullName('owner/repo/extra'); @@ -112,10 +114,10 @@ public function testOwnerAndNameAreReadonly(): void $repo = GithubRepository::fromFullName('github/gitignore'); // Verify properties are readonly (this will be caught by PHP at runtime) - $reflection = new \ReflectionProperty(GithubRepository::class, 'owner'); + $reflection = new ReflectionProperty(GithubRepository::class, 'owner'); $this->assertTrue($reflection->isReadOnly()); - $reflection = new \ReflectionProperty(GithubRepository::class, 'name'); + $reflection = new ReflectionProperty(GithubRepository::class, 'name'); $this->assertTrue($reflection->isReadOnly()); } } diff --git a/test/unit/InstallationAccessTokenTest.php b/test/unit/InstallationAccessTokenTest.php index 8d31ada..5b41365 100644 --- a/test/unit/InstallationAccessTokenTest.php +++ b/test/unit/InstallationAccessTokenTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; +use ReflectionClass; /** * Copyright 2026 The Horde Project (http://www.horde.org/) @@ -130,7 +131,7 @@ public function testDoesNotImplementStringable(): void ); // Verify token doesn't implement Stringable (for security) - $reflection = new \ReflectionClass($token); + $reflection = new ReflectionClass($token); $interfaces = $reflection->getInterfaceNames(); $this->assertNotContains('Stringable', $interfaces); diff --git a/test/unit/PreAuthenticatedClientTest.php b/test/unit/PreAuthenticatedClientTest.php index 7e8426d..8b689b9 100644 --- a/test/unit/PreAuthenticatedClientTest.php +++ b/test/unit/PreAuthenticatedClientTest.php @@ -21,6 +21,7 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\StreamInterface; +use ReflectionProperty; /** * Copyright 2026 The Horde Project (http://www.horde.org/) @@ -65,7 +66,7 @@ public function testWithAuthenticatedClientUsesCustomEndpoint(): void endpoint: 'https://github.example.com/api/v3', ); - $reflection = new \ReflectionProperty(GithubApiClient::class, 'config'); + $reflection = new ReflectionProperty(GithubApiClient::class, 'config'); $config = $reflection->getValue($client); self::assertSame('https://github.example.com/api/v3', $config->endpoint); @@ -275,19 +276,148 @@ public function getHeader(string $name): array return $this->headers[$name] ?? []; } - public function getHeaders(): array { return $this->headers; } - public function getProtocolVersion(): string { return '1.1'; } - public function withProtocolVersion(string $version): static { return $this; } - public function withAddedHeader(string $name, $value): static { return $this; } - public function withoutHeader(string $name): static { return $this; } - public function getBody(): StreamInterface { return new class implements StreamInterface { public function __toString(): string { return ''; } public function close(): void {} public function detach() { return null; } public function getSize(): ?int { return 0; } public function tell(): int { return 0; } public function eof(): bool { return true; } public function isSeekable(): bool { return false; } public function seek(int $offset, int $whence = SEEK_SET): void {} public function rewind(): void {} public function isWritable(): bool { return false; } public function write(string $string): int { return 0; } public function isReadable(): bool { return false; } public function read(int $length): string { return ''; } public function getContents(): string { return ''; } public function getMetadata(?string $key = null) { return null; } }; } - public function withBody(StreamInterface $body): static { return $this; } - public function getRequestTarget(): string { return '/'; } - public function withRequestTarget(string $requestTarget): static { return $this; } - public function getMethod(): string { return $this->method; } - public function withMethod(string $method): static { $c = clone $this; $c->method = $method; return $c; } - public function getUri(): \Psr\Http\Message\UriInterface { return new class implements \Psr\Http\Message\UriInterface { public function getScheme(): string { return ''; } public function getAuthority(): string { return ''; } public function getUserInfo(): string { return ''; } public function getHost(): string { return ''; } public function getPort(): ?int { return null; } public function getPath(): string { return ''; } public function getQuery(): string { return ''; } public function getFragment(): string { return ''; } public function withScheme(string $scheme): static { return $this; } public function withUserInfo(string $user, ?string $password = null): static { return $this; } public function withHost(string $host): static { return $this; } public function withPort(?int $port): static { return $this; } public function withPath(string $path): static { return $this; } public function withQuery(string $query): static { return $this; } public function withFragment(string $fragment): static { return $this; } public function __toString(): string { return ''; } }; } - public function withUri(\Psr\Http\Message\UriInterface $uri, bool $preserveHost = false): static { return $this; } + public function getHeaders(): array + { + return $this->headers; + } + public function getProtocolVersion(): string + { + return '1.1'; + } + public function withProtocolVersion(string $version): static + { + return $this; + } + public function withAddedHeader(string $name, $value): static + { + return $this; + } + public function withoutHeader(string $name): static + { + return $this; + } + public function getBody(): StreamInterface + { + return new class implements StreamInterface { + public function __toString(): string + { + return ''; + } public function close(): void {} public function detach() + { + return null; + } public function getSize(): ?int + { + return 0; + } public function tell(): int + { + return 0; + } public function eof(): bool + { + return true; + } public function isSeekable(): bool + { + return false; + } public function seek(int $offset, int $whence = SEEK_SET): void {} public function rewind(): void {} public function isWritable(): bool + { + return false; + } public function write(string $string): int + { + return 0; + } public function isReadable(): bool + { + return false; + } public function read(int $length): string + { + return ''; + } public function getContents(): string + { + return ''; + } public function getMetadata(?string $key = null) + { + return null; + } + }; + } + public function withBody(StreamInterface $body): static + { + return $this; + } + public function getRequestTarget(): string + { + return '/'; + } + public function withRequestTarget(string $requestTarget): static + { + return $this; + } + public function getMethod(): string + { + return $this->method; + } + public function withMethod(string $method): static + { + $c = clone $this; + $c->method = $method; + return $c; + } + public function getUri(): \Psr\Http\Message\UriInterface + { + return new class implements \Psr\Http\Message\UriInterface { + public function getScheme(): string + { + return ''; + } public function getAuthority(): string + { + return ''; + } public function getUserInfo(): string + { + return ''; + } public function getHost(): string + { + return ''; + } public function getPort(): ?int + { + return null; + } public function getPath(): string + { + return ''; + } public function getQuery(): string + { + return ''; + } public function getFragment(): string + { + return ''; + } public function withScheme(string $scheme): static + { + return $this; + } public function withUserInfo(string $user, ?string $password = null): static + { + return $this; + } public function withHost(string $host): static + { + return $this; + } public function withPort(?int $port): static + { + return $this; + } public function withPath(string $path): static + { + return $this; + } public function withQuery(string $query): static + { + return $this; + } public function withFragment(string $fragment): static + { + return $this; + } public function __toString(): string + { + return ''; + } + }; + } + public function withUri(\Psr\Http\Message\UriInterface $uri, bool $preserveHost = false): static + { + return $this; + } }; } } diff --git a/tests/unit/RateLimitTest.php b/test/unit/RateLimitTest.php similarity index 94% rename from tests/unit/RateLimitTest.php rename to test/unit/RateLimitTest.php index 4f4c47c..f5b41fa 100755 --- a/tests/unit/RateLimitTest.php +++ b/test/unit/RateLimitTest.php @@ -2,12 +2,15 @@ declare(strict_types=1); -namespace Horde\GithubApiClient; +namespace Horde\GithubApiClient\Test\Unit; +use Horde\GithubApiClient\RateLimit; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use InvalidArgumentException; +use DateTimeImmutable; -#[\PHPUnit\Framework\Attributes\CoversClass(\Horde\GithubApiClient\RateLimit::class)] +#[CoversClass(RateLimit::class)] final class RateLimitTest extends TestCase { public function testConstructorSetsProperties(): void @@ -148,7 +151,7 @@ public function testGetResetDateTimeReturnsCorrectDateTime(): void $dateTime = $rateLimit->getResetDateTime(); - $this->assertInstanceOf(\DateTimeImmutable::class, $dateTime); + $this->assertInstanceOf(DateTimeImmutable::class, $dateTime); $this->assertSame('1609459200', $dateTime->format('U')); } } diff --git a/tests/unit/TokenScopesTest.php b/test/unit/TokenScopesTest.php similarity index 96% rename from tests/unit/TokenScopesTest.php rename to test/unit/TokenScopesTest.php index a92c219..584a52c 100755 --- a/tests/unit/TokenScopesTest.php +++ b/test/unit/TokenScopesTest.php @@ -2,11 +2,13 @@ declare(strict_types=1); -namespace Horde\GithubApiClient; +namespace Horde\GithubApiClient\Test\Unit; +use Horde\GithubApiClient\TokenScopes; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -#[\PHPUnit\Framework\Attributes\CoversClass(\Horde\GithubApiClient\TokenScopes::class)] +#[CoversClass(TokenScopes::class)] final class TokenScopesTest extends TestCase { public function testConstructorFiltersDuplicatesAndNonStrings(): void diff --git a/tests/unit/GithubApiClientTest.php b/tests/unit/GithubApiClientTest.php deleted file mode 100644 index 4791026..0000000 --- a/tests/unit/GithubApiClientTest.php +++ /dev/null @@ -1,32 +0,0 @@ -assertInstanceOf(GithubApiClient::class, $apiClient); - - } -}