From 5d2793079cb412fe1532ba4d2e58111147110b28 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 14 Sep 2026 15:05:40 +0530 Subject: [PATCH] fix(github): list repositories for private installation owners --- src/VCS/Adapter/Git/GitHub.php | 30 ++++---- tests/Fixtures/GitHubRepositories.php | 28 +++++++ tests/Unit/GitHubRepositoriesTest.php | 103 ++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 tests/Fixtures/GitHubRepositories.php create mode 100644 tests/Unit/GitHubRepositoriesTest.php diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index bd85601a..8378c31c 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -256,25 +256,27 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; - // The search API rejects a query naming an owner that does not exist, - // which is a missing owner rather than a failure worth reporting. - if ($statusCode === 422) { - return ['items' => [], 'total' => 0]; - } + if ($statusCode !== 422) { + if ($statusCode >= 400) { + throw new Exception("Failed to search repositories: HTTP {$statusCode}", $statusCode); + } - if ($statusCode >= 400) { - throw new Exception("Failed to search repositories: HTTP {$statusCode}", $statusCode); - } + $responseBody = $response['body'] ?? []; - $responseBody = $response['body'] ?? []; + return [ + 'items' => $responseBody['items'] ?? [], + 'total' => $responseBody['total_count'] ?? 0, + ]; + } - return [ - 'items' => $responseBody['items'] ?? [], - 'total' => $responseBody['total_count'] ?? 0, - ]; + // Private profiles cannot be searched, but their installation can + // still list repositories. Keep unknown owners returning no results. + if (strcasecmp($owner, $this->getOwnerName($this->installationId)) !== 0) { + return ['items' => [], 'total' => 0]; + } } - // Installation has access to specific repositories, we need to perform client-side filtering. + // Restricted installations and unsearchable owners use the installation API. $url = '/installation/repositories'; $repositories = []; diff --git a/tests/Fixtures/GitHubRepositories.php b/tests/Fixtures/GitHubRepositories.php new file mode 100644 index 00000000..eac0949b --- /dev/null +++ b/tests/Fixtures/GitHubRepositories.php @@ -0,0 +1,28 @@ +installationId = '1234'; + $this->jwtToken = 'app-token'; + $this->accessToken = 'installation-token'; + } + + protected function call(string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, bool $followRedirects = true): array + { + $this->requests[] = ['path' => $path, 'params' => $params]; + return array_shift($this->responses) ?? throw new \RuntimeException('Unexpected provider request'); + } +} diff --git a/tests/Unit/GitHubRepositoriesTest.php b/tests/Unit/GitHubRepositoriesTest.php new file mode 100644 index 00000000..0eb8e9f2 --- /dev/null +++ b/tests/Unit/GitHubRepositoriesTest.php @@ -0,0 +1,103 @@ + 2, 'name' => 'private-repository']]; + $adapter = new GitHubRepositories([ + ['body' => ['repository_selection' => 'all']], + ['headers' => ['status-code' => 422]], + ['body' => ['account' => ['login' => 'Private-Owner']]], + ['body' => ['repositories' => $repositories, 'total_count' => 2]], + ]); + + $result = $adapter->searchRepositories('private-owner', 2, 1); + + $this->assertSame(['items' => $repositories, 'total' => 2], $result); + $this->assertSame('/installation/repositories', $adapter->requests[3]['path']); + $this->assertSame(['page' => 2, 'per_page' => 1], $adapter->requests[3]['params']); + } + + public function testSearchPrivateProfileFiltersBeforePagination(): void + { + $firstPage = array_map(fn (int $id) => ['id' => $id, 'name' => 'unrelated-' . $id], range(1, 100)); + $firstPage[99]['name'] = 'match-first'; + $match = ['id' => 101, 'name' => 'MATCH-second']; + $adapter = new GitHubRepositories([ + ['body' => ['repository_selection' => 'all']], + ['headers' => ['status-code' => 422]], + ['body' => ['account' => ['login' => 'private-owner']]], + ['body' => ['repositories' => $firstPage]], + ['body' => ['repositories' => [$match]]], + ]); + + $result = $adapter->searchRepositories('private-owner', 2, 1, 'match'); + + $this->assertSame(['items' => [$match], 'total' => 2], $result); + $this->assertSame(['page' => 1, 'per_page' => 100], $adapter->requests[3]['params']); + $this->assertSame(['page' => 2, 'per_page' => 100], $adapter->requests[4]['params']); + } + + public function testSearchUnknownOwner(): void + { + $adapter = new GitHubRepositories([ + ['body' => ['repository_selection' => 'all']], + ['headers' => ['status-code' => 422]], + ['body' => ['account' => ['login' => 'private-owner']]], + ]); + + $this->assertSame(['items' => [], 'total' => 0], $adapter->searchRepositories('unknown-owner', 1, 10)); + } + + public function testSearchUsesSuccessfulProviderResponse(): void + { + $repositories = [['id' => 1, 'name' => 'repository']]; + $adapter = new GitHubRepositories([ + ['body' => ['repository_selection' => 'all']], + ['headers' => ['status-code' => 200], 'body' => ['items' => $repositories, 'total_count' => 1]], + ]); + + $this->assertSame(['items' => $repositories, 'total' => 1], $adapter->searchRepositories('owner', 1, 10)); + } + + #[DataProvider('providerErrors')] + public function testSearchProviderErrors(int $status): void + { + $adapter = new GitHubRepositories([ + ['body' => ['repository_selection' => 'all']], + ['headers' => ['status-code' => $status]], + ]); + + $this->expectException(\Exception::class); + $this->expectExceptionCode($status); + $adapter->searchRepositories('owner', 1, 10); + } + + public static function providerErrors(): array + { + return [[403], [429], [500]]; + } + + public function testPrivateProfileListingError(): void + { + $adapter = new GitHubRepositories([ + ['body' => ['repository_selection' => 'all']], + ['headers' => ['status-code' => 422]], + ['body' => ['account' => ['login' => 'private-owner']]], + ['headers' => ['status-code' => 403]], + ]); + + $this->expectException(\Exception::class); + $this->expectExceptionCode(403); + $adapter->searchRepositories('private-owner', 1, 10); + } +}