diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 1af30388..382d40ca 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -742,32 +742,81 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, } /** - * Lists branches for a given repository + * Lists branches using GitHub GraphQL repository.refs with prefix search and cursor pagination. * - * @param string $owner Owner name of the repository - * @param string $repositoryName Name of the GitHub repository - * @param int $perPage Number of branches to fetch per page - * @param int $page Page number to start fetching from - * @return array List of branch names as array + * GraphQL refs(query:) does server-side substring filtering — 'ranch' matches 'branch-x'. + * Pass a cursor string from a previous nextCursor as $page to resume pagination; any integer + * value is treated as the first page. perPage is clamped to [1, 100]. + * + * We use GraphQL instead of REST because: + * 1. REST GET /repos/{owner}/{repo}/branches has no search/filter parameter. + * 2. REST only supports integer page offsets; GraphQL edges carry cursors for exact resumption. + * + * @param string $owner + * @param string $repositoryName + * @param int $perPage Clamped to [1, 100] + * @param int|string|null $page Pass a cursor string from nextCursor to resume; integers treated as page 1 + * @param string $search Prefix filter; empty returns all branches + * @return array{items: array, hasNext: bool, nextCursor: string|null} */ - public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int $page = 1): array + public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int|string|null $page = 1, string $search = ''): array { - $url = "/repos/$owner/$repositoryName/branches"; $perPage = min(max($perPage, 1), 100); - - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "Bearer $this->accessToken"], [ - 'page' => $page, - 'per_page' => $perPage, + $cursor = is_string($page) ? $page : null; + + $gql = <<<'GRAPHQL' +query ListBranches($owner: String!, $name: String!, $first: Int!, $after: String, $query: String) { + repository(owner: $owner, name: $name) { + refs(refPrefix: "refs/heads/", first: $first, after: $after, orderBy: {field: ALPHABETICAL, direction: ASC}, query: $query) { + edges { + cursor + node { + name + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +} +GRAPHQL; + + $response = $this->call(self::METHOD_POST, '/graphql', ['Authorization' => "Bearer $this->accessToken"], [ + 'query' => $gql, + 'variables' => [ + 'owner' => $owner, + 'name' => $repositoryName, + 'first' => $perPage, + 'after' => $cursor, + 'query' => $search !== '' ? $search : null, + ], ]); $statusCode = $response['headers']['status-code'] ?? 0; $responseBody = $response['body'] ?? []; - if ($statusCode < 200 || $statusCode >= 300 || !is_array($responseBody)) { - return []; + if ($statusCode < 200 || $statusCode >= 300 || !is_array($responseBody) || array_key_exists('errors', $responseBody)) { + return ['items' => [], 'hasNext' => false, 'nextCursor' => null]; + } + + $repository = $responseBody['data']['repository'] ?? null; + $refs = is_array($repository) ? ($repository['refs'] ?? null) : null; + + if (!is_array($refs)) { + return ['items' => [], 'hasNext' => false, 'nextCursor' => null]; } - return array_values(array_map(fn ($branch) => $branch['name'] ?? '', $responseBody)); + $edges = $refs['edges'] ?? []; + $pageInfo = $refs['pageInfo'] ?? []; + $hasNext = (bool) ($pageInfo['hasNextPage'] ?? false); + + return [ + 'items' => array_map(fn ($edge) => $edge['node']['name'] ?? '', $edges), + 'hasNext' => $hasNext, + 'nextCursor' => $hasNext ? ($pageInfo['endCursor'] ?? null) : null, + ]; } /** @@ -831,15 +880,15 @@ public function getLatestCommit(string $owner, string $repositoryName, string $b $responseBody = $response['body'] ?? []; $responseBodyCommit = $responseBody['commit'] ?? []; $responseBodyCommitAuthor = $responseBodyCommit['author'] ?? []; - $responseBodyAuthor = $responseBody['author'] ?? []; + // GitHub sets author to null for commits from App installations whose email + // does not match any GitHub user — treat it as an empty array to allow fallbacks. + $responseBodyAuthor = is_array($responseBody['author'] ?? null) ? $responseBody['author'] : []; if ( !array_key_exists('name', $responseBodyCommitAuthor) || !array_key_exists('message', $responseBodyCommit) || !array_key_exists('sha', $responseBody) || - !array_key_exists('html_url', $responseBody) || - !array_key_exists('avatar_url', $responseBodyAuthor) || - !array_key_exists('html_url', $responseBodyAuthor) + !array_key_exists('html_url', $responseBody) ) { throw new Exception("Latest commit response is missing required information."); } diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index f1c4b2fc..ecf8bc81 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -473,11 +473,17 @@ public function testListBranches(): void try { $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $branches = $this->vcsAdapter->listBranches(static::$owner, $repositoryName); + /** @var GitHub $adapter */ + $adapter = $this->vcsAdapter; + $branches = $adapter->listBranches(static::$owner, $repositoryName); $this->assertIsArray($branches); - $this->assertNotEmpty($branches); - $this->assertContains(static::$defaultBranch, $branches); + $this->assertArrayHasKey('items', $branches); + $this->assertArrayHasKey('hasNext', $branches); + $this->assertNotEmpty($branches['items']); + $this->assertFalse($branches['hasNext']); + $this->assertNull($branches['nextCursor']); + $this->assertContains(static::$defaultBranch, $branches['items']); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -540,14 +546,42 @@ public function testListBranchesPagination(): void /** @var GitHub $adapter */ $adapter = $this->vcsAdapter; + // Cursor-based navigation: always use nextCursor from the previous response $page1 = $adapter->listBranches(static::$owner, $repositoryName, 1, 1); - $this->assertSame(['branch-a'], $page1); + $this->assertSame(['branch-a'], $page1['items']); + $this->assertTrue($page1['hasNext']); + $this->assertNotEmpty($page1['nextCursor']); + + $page2 = $adapter->listBranches(static::$owner, $repositoryName, 1, $page1['nextCursor']); + $this->assertSame(['branch-b'], $page2['items']); + $this->assertTrue($page2['hasNext']); + $this->assertNotEmpty($page2['nextCursor']); - $page2 = $adapter->listBranches(static::$owner, $repositoryName, 1, 2); - $this->assertSame(['branch-b'], $page2); + $page3 = $adapter->listBranches(static::$owner, $repositoryName, 1, $page2['nextCursor']); + $this->assertSame([static::$defaultBranch], $page3['items']); + $this->assertFalse($page3['hasNext']); + $this->assertNull($page3['nextCursor']); $all = $adapter->listBranches(static::$owner, $repositoryName, 100, 1); - $this->assertEqualsCanonicalizing([static::$defaultBranch, 'branch-a', 'branch-b'], $all); + $this->assertEqualsCanonicalizing([static::$defaultBranch, 'branch-a', 'branch-b'], $all['items']); + $this->assertFalse($all['hasNext']); + $this->assertNull($all['nextCursor']); + + $searchPage1 = $adapter->listBranches(static::$owner, $repositoryName, 1, 1, 'branch'); + $this->assertSame(['branch-a'], $searchPage1['items']); + $this->assertTrue($searchPage1['hasNext']); + $this->assertNotEmpty($searchPage1['nextCursor']); + + $searchPage2 = $adapter->listBranches(static::$owner, $repositoryName, 1, $searchPage1['nextCursor'], 'branch'); + $this->assertSame(['branch-b'], $searchPage2['items']); + $this->assertFalse($searchPage2['hasNext']); + $this->assertNull($searchPage2['nextCursor']); + + // GitHub refs(query:) does substring matching, so 'ranch' matches 'branch-a' and 'branch-b' + $substringSearch = $adapter->listBranches(static::$owner, $repositoryName, 100, 1, 'ranch'); + $this->assertEqualsCanonicalizing(['branch-a', 'branch-b'], $substringSearch['items']); + $this->assertFalse($substringSearch['hasNext']); + $this->assertNull($substringSearch['nextCursor']); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -559,10 +593,14 @@ public function testListBranchesEmptyRepository(): void $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); try { - $branches = $this->vcsAdapter->listBranches(static::$owner, $repositoryName); + /** @var GitHub $adapter */ + $adapter = $this->vcsAdapter; + $branches = $adapter->listBranches(static::$owner, $repositoryName); $this->assertIsArray($branches); - $this->assertEmpty($branches); + $this->assertSame([], $branches['items']); + $this->assertFalse($branches['hasNext']); + $this->assertNull($branches['nextCursor']); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -570,10 +608,14 @@ public function testListBranchesEmptyRepository(): void public function testListBranchesNonExistingRepository(): void { - $branches = $this->vcsAdapter->listBranches(static::$owner, 'non-existing-repo-' . \uniqid()); + /** @var GitHub $adapter */ + $adapter = $this->vcsAdapter; + $branches = $adapter->listBranches(static::$owner, 'non-existing-repo-' . \uniqid()); $this->assertIsArray($branches); - $this->assertEmpty($branches); + $this->assertSame([], $branches['items']); + $this->assertFalse($branches['hasNext']); + $this->assertNull($branches['nextCursor']); } public function testGetLatestCommit(): void @@ -649,6 +691,187 @@ public function testUpdateCommitStatus(): void } } + public function testGenerateCloneCommand(): void + { + $repositoryName = 'test-clone-command-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $directory = '/tmp/test-clone-' . \uniqid(); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + $command = $this->vcsAdapter->generateCloneCommand( + static::$owner, + $repositoryName, + static::$defaultBranch, + GitHub::CLONE_TYPE_BRANCH, + $directory, + '*' + ); + + $this->assertIsString($command); + $this->assertStringContainsString('sparse-checkout', $command); + $this->assertStringContainsString($repositoryName, $command); + + $output = []; + \exec($command . ' 2>&1', $output, $exitCode); + $this->assertSame(0, $exitCode, implode("\n", $output)); + $this->assertFileExists($directory . '/README.md'); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + if (\is_dir($directory)) { + \exec('rm -rf ' . escapeshellarg($directory)); + } + } + } + + public function testGenerateCloneCommandWithCommitHash(): void + { + $repositoryName = 'test-clone-commit-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + $commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); + $commitHash = $commit['commitHash']; + + $directory = '/tmp/test-clone-commit-' . \uniqid(); + $command = $this->vcsAdapter->generateCloneCommand( + static::$owner, + $repositoryName, + $commitHash, + GitHub::CLONE_TYPE_COMMIT, + $directory, + '*' + ); + + $this->assertIsString($command); + $this->assertStringContainsString('sparse-checkout', $command); + + $output = []; + \exec($command . ' 2>&1', $output, $exitCode); + $this->assertSame(0, $exitCode, implode("\n", $output)); + $this->assertFileExists($directory . '/README.md'); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGenerateCloneCommandWithInvalidRepository(): void + { + $directory = '/tmp/test-clone-invalid-' . \uniqid(); + + try { + $command = $this->vcsAdapter->generateCloneCommand( + static::$owner, + 'nonexistent-repo-' . \uniqid(), + static::$defaultBranch, + GitHub::CLONE_TYPE_BRANCH, + $directory, + '*' + ); + + $output = []; + \exec($command . ' 2>&1', $output, $exitCode); + + $cloneFailed = ($exitCode !== 0) || !file_exists($directory . '/README.md'); + $this->assertTrue($cloneFailed, 'Clone should have failed for nonexistent repository'); + } finally { + if (\is_dir($directory)) { + \exec('rm -rf ' . escapeshellarg($directory)); + } + } + } + + public function testGetOwnerName(): void + { + $result = $this->vcsAdapter->getOwnerName(static::$installationId); + + $this->assertIsString($result); + $this->assertNotEmpty($result); + $this->assertSame(static::$owner, $result); + } + + public function testSearchRepositories(): void + { + $repo1Name = 'test-search-repo1-' . \uniqid(); + $repo2Name = 'test-search-repo2-' . \uniqid(); + + $this->vcsAdapter->createRepository(static::$owner, $repo1Name, false); + $this->vcsAdapter->createRepository(static::$owner, $repo2Name, false); + + try { + $result = []; + $this->assertEventually(function () use (&$result) { + $result = $this->vcsAdapter->searchRepositories(static::$owner, 1, 10); + $this->assertGreaterThanOrEqual(2, $result['total']); + }, 30000, 2000); + + $this->assertIsArray($result); + $this->assertArrayHasKey('items', $result); + $this->assertArrayHasKey('total', $result); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repo1Name); + $this->vcsAdapter->deleteRepository(static::$owner, $repo2Name); + } + } + + public function testHasAccessToAllRepositories(): void + { + $result = $this->vcsAdapter->hasAccessToAllRepositories(); + $this->assertIsBool($result); + } + + public function testGetInstallationRepository(): void + { + $repositoryName = 'test-installation-repo-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $repo = $this->vcsAdapter->getInstallationRepository($repositoryName); + $this->assertIsArray($repo); + $this->assertSame($repositoryName, $repo['name']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetPullRequest(): void + { + $this->markTestSkipped('createBranch and createPullRequest not implemented in GitHub adapter'); + } + + public function testGetPullRequestFiles(): void + { + $this->markTestSkipped('createBranch and createPullRequest not implemented in GitHub adapter'); + } + + public function testGetPullRequestWithInvalidNumber(): void + { + $this->markTestSkipped('createBranch and createPullRequest not implemented in GitHub adapter'); + } + + public function testGetPullRequestFromBranch(): void + { + $this->markTestSkipped('createBranch and createPullRequest not implemented in GitHub adapter'); + } + + public function testGetComment(): void + { + $this->markTestSkipped('Requires existing PR — createPullRequest not implemented in GitHub adapter'); + } + + public function testCreateComment(): void + { + $this->markTestSkipped('Requires existing PR — createPullRequest not implemented in GitHub adapter'); + } + + public function testUpdateComment(): void + { + $this->markTestSkipped('Requires existing PR — createPullRequest not implemented in GitHub adapter'); + } + public function testCreateCheckRun(): void { $repositoryName = 'test-create-check-run-' . \uniqid(); @@ -931,185 +1154,4 @@ public function testUpdateCheckRunWithMissingConclusion(): void $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } - - public function testGenerateCloneCommand(): void - { - $repositoryName = 'test-clone-command-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - $directory = '/tmp/test-clone-' . \uniqid(); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - - $command = $this->vcsAdapter->generateCloneCommand( - static::$owner, - $repositoryName, - static::$defaultBranch, - GitHub::CLONE_TYPE_BRANCH, - $directory, - '*' - ); - - $this->assertIsString($command); - $this->assertStringContainsString('sparse-checkout', $command); - $this->assertStringContainsString($repositoryName, $command); - - $output = []; - \exec($command . ' 2>&1', $output, $exitCode); - $this->assertSame(0, $exitCode, implode("\n", $output)); - $this->assertFileExists($directory . '/README.md'); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - if (\is_dir($directory)) { - \exec('rm -rf ' . escapeshellarg($directory)); - } - } - } - - public function testGenerateCloneCommandWithCommitHash(): void - { - $repositoryName = 'test-clone-commit-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - - $commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); - $commitHash = $commit['commitHash']; - - $directory = '/tmp/test-clone-commit-' . \uniqid(); - $command = $this->vcsAdapter->generateCloneCommand( - static::$owner, - $repositoryName, - $commitHash, - GitHub::CLONE_TYPE_COMMIT, - $directory, - '*' - ); - - $this->assertIsString($command); - $this->assertStringContainsString('sparse-checkout', $command); - - $output = []; - \exec($command . ' 2>&1', $output, $exitCode); - $this->assertSame(0, $exitCode, implode("\n", $output)); - $this->assertFileExists($directory . '/README.md'); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testGenerateCloneCommandWithInvalidRepository(): void - { - $directory = '/tmp/test-clone-invalid-' . \uniqid(); - - try { - $command = $this->vcsAdapter->generateCloneCommand( - static::$owner, - 'nonexistent-repo-' . \uniqid(), - static::$defaultBranch, - GitHub::CLONE_TYPE_BRANCH, - $directory, - '*' - ); - - $output = []; - \exec($command . ' 2>&1', $output, $exitCode); - - $cloneFailed = ($exitCode !== 0) || !file_exists($directory . '/README.md'); - $this->assertTrue($cloneFailed, 'Clone should have failed for nonexistent repository'); - } finally { - if (\is_dir($directory)) { - \exec('rm -rf ' . escapeshellarg($directory)); - } - } - } - - public function testGetOwnerName(): void - { - $result = $this->vcsAdapter->getOwnerName(static::$installationId); - - $this->assertIsString($result); - $this->assertNotEmpty($result); - $this->assertSame(static::$owner, $result); - } - - public function testSearchRepositories(): void - { - $repo1Name = 'test-search-repo1-' . \uniqid(); - $repo2Name = 'test-search-repo2-' . \uniqid(); - - $this->vcsAdapter->createRepository(static::$owner, $repo1Name, false); - $this->vcsAdapter->createRepository(static::$owner, $repo2Name, false); - - try { - $result = []; - $this->assertEventually(function () use (&$result) { - $result = $this->vcsAdapter->searchRepositories(static::$owner, 1, 10); - $this->assertGreaterThanOrEqual(2, $result['total']); - }, 30000, 2000); - - $this->assertIsArray($result); - $this->assertArrayHasKey('items', $result); - $this->assertArrayHasKey('total', $result); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repo1Name); - $this->vcsAdapter->deleteRepository(static::$owner, $repo2Name); - } - } - - public function testHasAccessToAllRepositories(): void - { - $result = $this->vcsAdapter->hasAccessToAllRepositories(); - $this->assertIsBool($result); - } - - public function testGetInstallationRepository(): void - { - $repositoryName = 'test-installation-repo-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $repo = $this->vcsAdapter->getInstallationRepository($repositoryName); - $this->assertIsArray($repo); - $this->assertSame($repositoryName, $repo['name']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testGetPullRequest(): void - { - $this->markTestSkipped('createBranch and createPullRequest not implemented in GitHub adapter'); - } - - public function testGetPullRequestFiles(): void - { - $this->markTestSkipped('createBranch and createPullRequest not implemented in GitHub adapter'); - } - - public function testGetPullRequestWithInvalidNumber(): void - { - $this->markTestSkipped('createBranch and createPullRequest not implemented in GitHub adapter'); - } - - public function testGetPullRequestFromBranch(): void - { - $this->markTestSkipped('createBranch and createPullRequest not implemented in GitHub adapter'); - } - - public function testGetComment(): void - { - $this->markTestSkipped('Requires existing PR — createPullRequest not implemented in GitHub adapter'); - } - - public function testCreateComment(): void - { - $this->markTestSkipped('Requires existing PR — createPullRequest not implemented in GitHub adapter'); - } - - public function testUpdateComment(): void - { - $this->markTestSkipped('Requires existing PR — createPullRequest not implemented in GitHub adapter'); - } }