From c9482b4e6800f88910113036b53e41d3e6beb044 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 2 Jun 2026 14:05:45 +0530 Subject: [PATCH 01/17] Add server-side branch prefix search and cursor pagination for GitHub Uses GraphQL refs query with query variable for prefix filtering and cursor-based pagination instead of fetching all branches client-side. --- src/VCS/Adapter/Git/GitHub.php | 315 ++++++++++------------------ tests/VCS/Adapter/GitHubTest.php | 346 +++++-------------------------- 2 files changed, 167 insertions(+), 494 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 1af30388..4a34bb03 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -742,32 +742,126 @@ 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 + * Search matches branch names by prefix only ('feat' → 'feature-x', not 'my-feature'). + * Pass an integer $page to walk forward page-by-page (each step costs one extra GraphQL call + * to resolve the cursor chain); pass a cursor string from a previous nextCursor to jump + * directly. perPage is clamped to [1, 100]. + * + * @param string $owner + * @param string $repositoryName + * @param int $perPage Clamped to [1, 100] + * @param int|string|null $page Pass 1 (or null) for the first page. For subsequent pages + * always pass the opaque cursor string from the previous nextCursor — GitHub uses + * cursor-based GraphQL pagination and has no concept of integer page offsets. + * Any integer value other than 1 is 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); + $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; + + // We use GraphQL instead of REST for two reasons that the REST API cannot satisfy: + // 1. Server-side search narrowing: REST GET /repos/{owner}/{repo}/branches has no + // search or filter parameter at all; GraphQL refs() accepts a `query` variable. + // 2. Per-edge cursors: REST only supports integer ?page=N offsets; GraphQL edges + // carry individual cursors so we can resume from an exact item across calls. + // + // GraphQL `query` does substring matching, so we additionally enforce prefix + // semantics client-side with str_starts_with. We collect up to $perPage + 1 + // matching edges across as many GraphQL pages as needed: + // - If we find the +1 probe item, hasNext=true and nextCursor points to the + // cursor of the last returned item, so the next call resumes exactly where + // we stopped. + // - If GitHub is exhausted before the probe, hasNext=false. + // This ensures items is never empty while hasNext is true. + /** @var array $collected */ + $collected = []; + $currentCursor = $cursor; + $hasNextPage = false; + + do { + $response = $this->call(self::METHOD_POST, '/graphql', ['Authorization' => "Bearer $this->accessToken"], [ + 'query' => $gql, + 'variables' => [ + 'owner' => $owner, + 'name' => $repositoryName, + 'first' => $perPage, + 'after' => $currentCursor, + 'query' => $search !== '' ? $search : null, + ], + ]); - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "Bearer $this->accessToken"], [ - 'page' => $page, - 'per_page' => $perPage, - ]); + $statusCode = $response['headers']['status-code'] ?? 0; + $responseBody = $response['body'] ?? []; - $statusCode = $response['headers']['status-code'] ?? 0; - $responseBody = $response['body'] ?? []; + if ($statusCode < 200 || $statusCode >= 300 || !is_array($responseBody) || array_key_exists('errors', $responseBody)) { + return ['items' => [], 'hasNext' => false, 'nextCursor' => null]; + } - if ($statusCode < 200 || $statusCode >= 300 || !is_array($responseBody)) { - return []; + $refs = $responseBody['data']['repository']['refs'] ?? null; + + if (!is_array($refs)) { + return ['items' => [], 'hasNext' => false, 'nextCursor' => null]; + } + + $pageInfo = $refs['pageInfo'] ?? []; + $hasNextPage = (bool) ($pageInfo['hasNextPage'] ?? false); + $currentCursor = $pageInfo['endCursor'] ?? null; + + $probeFound = false; + foreach ($refs['edges'] ?? [] as $edge) { + $name = $edge['node']['name'] ?? ''; + if ($search === '' || str_starts_with($name, $search)) { + $collected[] = ['name' => $name, 'cursor' => $edge['cursor'] ?? '']; + if (count($collected) > $perPage) { + $probeFound = true; + break; + } + } + } + + if ($probeFound) { + break; + } + } while ($hasNextPage); + + if (count($collected) > $perPage) { + $toReturn = array_slice($collected, 0, $perPage); + return [ + 'items' => array_column($toReturn, 'name'), + 'hasNext' => true, + 'nextCursor' => $toReturn[$perPage - 1]['cursor'], + ]; } - return array_values(array_map(fn ($branch) => $branch['name'] ?? '', $responseBody)); + return [ + 'items' => array_column($collected, 'name'), + 'hasNext' => false, + 'nextCursor' => null, + ]; } /** @@ -831,15 +925,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."); } @@ -872,185 +966,6 @@ public function updateCommitStatus(string $repositoryName, string $commitHash, s $this->call(self::METHOD_POST, $url, ['Authorization' => "Bearer $this->accessToken"], $body); } - /** - * Creates a check run for a commit. - * status can be one of: queued, in_progress, completed - * conclusion (required when status=completed) can be one of: action_required, cancelled, failure, neutral, success, skipped, timed_out - * - * @param array $annotations - * @param array $images - * @param array $actions - * @return array - */ - public function createCheckRun( - string $owner, - string $repositoryName, - string $headSha, - string $name, - string $status = 'queued', - string $conclusion = '', - string $title = '', - string $summary = '', - string $text = '', - array $annotations = [], - array $images = [], - array $actions = [], - string $detailsUrl = '', - string $externalId = '', - string $startedAt = '', - string $completedAt = '', - ): array { - $url = "/repos/$owner/$repositoryName/check-runs"; - - if ($status === 'completed' && empty($conclusion)) { - throw new Exception("conclusion is required when status is 'completed'"); - } - - // Conclusion requires status=completed; auto-set completed_at if not provided. - if (!empty($conclusion)) { - $status = 'completed'; - if (empty($completedAt)) { - $completedAt = gmdate('Y-m-d\TH:i:s\Z'); - } - } - - $body = array_merge( - [ - 'name' => $name, - 'head_sha' => $headSha, - 'status' => $status, - ], - array_filter([ - 'conclusion' => $conclusion, - 'completed_at' => $completedAt, - 'details_url' => $detailsUrl, - 'external_id' => $externalId, - 'started_at' => $startedAt, - ], fn ($value) => !empty($value)) - ); - - // Output requires both title and summary. - if (!empty($title) && !empty($summary)) { - $output = array_filter(['title' => $title, 'summary' => $summary, 'text' => $text], fn ($value) => !empty($value)); - if (!empty($annotations)) { - $output['annotations'] = $annotations; - } - if (!empty($images)) { - $output['images'] = $images; - } - $body['output'] = $output; - } - - if (!empty($actions)) { - $body['actions'] = $actions; - } - - $response = $this->call(self::METHOD_POST, $url, ['Authorization' => "Bearer $this->accessToken"], $body); - - $responseHeadersStatusCode = $response['headers']['status-code'] ?? 0; - if ($responseHeadersStatusCode >= 400) { - throw new Exception("Failed to create check run: HTTP $responseHeadersStatusCode"); - } - - return $response['body'] ?? []; - } - - /** - * Gets a check run by ID. - * - * @return array - */ - public function getCheckRun(string $owner, string $repositoryName, int $checkRunId): array - { - $url = "/repos/$owner/$repositoryName/check-runs/$checkRunId"; - - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "Bearer $this->accessToken"]); - - $responseHeadersStatusCode = $response['headers']['status-code'] ?? 0; - if ($responseHeadersStatusCode >= 400) { - throw new Exception("Failed to get check run $checkRunId: HTTP $responseHeadersStatusCode"); - } - - return $response['body'] ?? []; - } - - /** - * Updates an existing check run. - * status can be one of: queued, in_progress, completed - * conclusion (required when status=completed) can be one of: action_required, cancelled, failure, neutral, success, skipped, timed_out - * - * @param array $annotations - * @param array $images - * @return array - */ - public function updateCheckRun( - string $owner, - string $repositoryName, - int $checkRunId, - string $name = '', - string $status = '', - string $conclusion = '', - string $title = '', - string $summary = '', - string $text = '', - array $annotations = [], - array $images = [], - array $actions = [], - string $detailsUrl = '', - string $externalId = '', - string $startedAt = '', - string $completedAt = '', - ): array { - $url = "/repos/$owner/$repositoryName/check-runs/$checkRunId"; - - if ($status === 'completed' && empty($conclusion)) { - throw new Exception("conclusion is required when status is 'completed'"); - } - - // Conclusion requires status=completed; auto-set completed_at if not provided. - if (!empty($conclusion)) { - $status = 'completed'; - if (empty($completedAt)) { - $completedAt = gmdate('Y-m-d\TH:i:s\Z'); - } - } - - $body = array_filter([ - 'name' => $name, - 'status' => $status, - 'details_url' => $detailsUrl, - 'external_id' => $externalId, - 'started_at' => $startedAt, - 'conclusion' => $conclusion, - 'completed_at' => $completedAt, - ], fn ($value) => !empty($value)); - - // Output requires both title and summary. - if (!empty($title) && !empty($summary)) { - $output = array_filter(['title' => $title, 'summary' => $summary, 'text' => $text], fn ($value) => !empty($value)); - if (!empty($annotations)) { - $output['annotations'] = $annotations; - } - if (!empty($images)) { - $output['images'] = $images; - } - $body['output'] = $output; - } - - if (!empty($actions)) { - $body['actions'] = $actions; - } - - $response = $this->call(self::METHOD_PATCH, $url, ['Authorization' => "Bearer $this->accessToken"], $body); - - $responseHeadersStatusCode = $response['headers']['status-code'] ?? 0; - if ($responseHeadersStatusCode >= 400) { - throw new Exception("Failed to update check run $checkRunId: HTTP $responseHeadersStatusCode"); - } - - return $response['body'] ?? []; - } - /** * Generates a clone command using app access token */ diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index f1c4b2fc..916eddd8 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,41 @@ 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']); + + $substringSearch = $adapter->listBranches(static::$owner, $repositoryName, 100, 1, 'ranch'); + $this->assertSame([], $substringSearch['items']); + $this->assertFalse($substringSearch['hasNext']); + $this->assertNull($substringSearch['nextCursor']); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -559,10 +592,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 +607,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,289 +690,6 @@ public function testUpdateCommitStatus(): void } } - public function testCreateCheckRun(): void - { - $repositoryName = 'test-create-check-run-' . \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']; - - $checkRun = $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - headSha: $commitHash, - name: 'ci/build', - status: 'in_progress', - startedAt: gmdate('Y-m-d\TH:i:s\Z'), - ); - - $this->assertArrayHasKey('id', $checkRun); - $this->assertIsInt($checkRun['id']); - $this->assertEquals('ci/build', $checkRun['name']); - $this->assertEquals('in_progress', $checkRun['status']); - $this->assertNull($checkRun['conclusion']); - $this->assertEquals($commitHash, $checkRun['head_sha']); - $this->assertNotEmpty($checkRun['url']); - $this->assertNotEmpty($checkRun['html_url']); - $this->assertNotEmpty($checkRun['started_at']); - $this->assertNull($checkRun['completed_at']); - - $fetched = $this->vcsAdapter->getCheckRun(static::$owner, $repositoryName, $checkRun['id']); - $this->assertEquals($checkRun['id'], $fetched['id']); - $this->assertEquals('ci/build', $fetched['name']); - $this->assertEquals('in_progress', $fetched['status']); - $this->assertNull($fetched['conclusion']); - $this->assertEquals($commitHash, $fetched['head_sha']); - $this->assertNotEmpty($fetched['url']); - $this->assertNotEmpty($fetched['html_url']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testCreateCheckRunWithInvalidRepository(): void - { - $this->expectException(\Exception::class); - $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: 'non-existing-repository-' . \uniqid(), - headSha: 'a' . str_repeat('0', 39), - name: 'ci/build', - ); - } - - public function testGetCheckRunWithInvalidId(): void - { - $repositoryName = 'test-get-check-run-invalid-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->expectException(\Exception::class); - $this->vcsAdapter->getCheckRun(static::$owner, $repositoryName, 999999999); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testCreateTwoCheckRunsOnSameCommit(): void - { - $repositoryName = 'test-two-check-runs-same-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']; - - $first = $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - headSha: $commitHash, - name: 'ci/build', - status: 'in_progress', - ); - - $second = $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - headSha: $commitHash, - name: 'ci/build', - status: 'in_progress', - ); - - $this->assertArrayHasKey('id', $first); - $this->assertArrayHasKey('id', $second); - $this->assertNotEquals($first['id'], $second['id']); - $this->assertEquals($commitHash, $first['head_sha']); - $this->assertEquals($commitHash, $second['head_sha']); - $this->assertEquals('ci/build', $first['name']); - $this->assertEquals('ci/build', $second['name']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testCreateCheckRunsWithSameNameOnDifferentCommits(): void - { - $repositoryName = 'test-check-runs-different-commits-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commit1 = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); - $commitHash1 = $commit1['commitHash']; - - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'second.md', '# Second'); - $commit2 = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); - $commitHash2 = $commit2['commitHash']; - - $first = $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - headSha: $commitHash1, - name: 'ci/build', - status: 'in_progress', - ); - - $second = $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - headSha: $commitHash2, - name: 'ci/build', - status: 'in_progress', - ); - - $this->assertArrayHasKey('id', $first); - $this->assertArrayHasKey('id', $second); - $this->assertNotEquals($first['id'], $second['id']); - $this->assertEquals($commitHash1, $first['head_sha']); - $this->assertEquals($commitHash2, $second['head_sha']); - $this->assertEquals('ci/build', $first['name']); - $this->assertEquals('ci/build', $second['name']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testCreateCheckRunCompleted(): void - { - $repositoryName = 'test-create-check-run-completed-' . \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']; - - $checkRun = $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - headSha: $commitHash, - name: 'ci/build', - conclusion: 'success', - title: 'Build passed', - summary: 'All checks passed successfully.', - ); - - $this->assertArrayHasKey('id', $checkRun); - $this->assertIsInt($checkRun['id']); - $this->assertEquals('ci/build', $checkRun['name']); - $this->assertEquals('completed', $checkRun['status']); - $this->assertEquals('success', $checkRun['conclusion']); - $this->assertEquals($commitHash, $checkRun['head_sha']); - $this->assertNotEmpty($checkRun['url']); - $this->assertNotEmpty($checkRun['html_url']); - $this->assertNotEmpty($checkRun['completed_at']); - $this->assertEquals('Build passed', $checkRun['output']['title']); - $this->assertEquals('All checks passed successfully.', $checkRun['output']['summary']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testUpdateCheckRun(): void - { - $repositoryName = 'test-update-check-run-' . \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']; - - $checkRun = $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - headSha: $commitHash, - name: 'ci/build', - status: 'in_progress', - startedAt: gmdate('Y-m-d\TH:i:s\Z'), - ); - - $this->assertArrayHasKey('id', $checkRun); - $this->assertEquals('in_progress', $checkRun['status']); - - $updated = $this->vcsAdapter->updateCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - checkRunId: $checkRun['id'], - status: 'completed', - conclusion: 'neutral', - title: 'Deployment skipped', - summary: 'Deployment skipped because the branch does not match the configured branch triggers.', - completedAt: gmdate('Y-m-d\TH:i:s\Z'), - ); - - $this->assertEquals($checkRun['id'], $updated['id']); - $this->assertEquals('completed', $updated['status']); - $this->assertEquals('neutral', $updated['conclusion']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testUpdateCheckRunWithInvalidRepository(): void - { - $this->expectException(\Exception::class); - $this->vcsAdapter->updateCheckRun( - owner: static::$owner, - repositoryName: 'non-existing-repository-' . \uniqid(), - checkRunId: 999999999, - conclusion: 'success', - ); - } - - public function testUpdateCheckRunWithInvalidId(): void - { - $repositoryName = 'test-update-check-run-invalid-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->expectException(\Exception::class); - $this->vcsAdapter->updateCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - checkRunId: 999999999, - conclusion: 'success', - ); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testUpdateCheckRunWithMissingConclusion(): void - { - $repositoryName = 'test-update-check-run-no-conclusion-' . \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']; - - $checkRun = $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - headSha: $commitHash, - name: 'ci/build', - status: 'in_progress', - ); - - $this->expectException(\Exception::class); - $this->vcsAdapter->updateCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - checkRunId: $checkRun['id'], - status: 'completed', - ); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - public function testGenerateCloneCommand(): void { $repositoryName = 'test-clone-command-' . \uniqid(); From bbbb5f95095b2aa2e173cf623f91c7312a3c5b0c Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 2 Jun 2026 14:09:15 +0530 Subject: [PATCH 02/17] Restore accidentally removed check run tests --- tests/VCS/Adapter/GitHubTest.php | 283 +++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 916eddd8..ee268521 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -870,4 +870,287 @@ 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(); + $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']; + + $checkRun = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + status: 'in_progress', + startedAt: gmdate('Y-m-d\TH:i:s\Z'), + ); + + $this->assertArrayHasKey('id', $checkRun); + $this->assertIsInt($checkRun['id']); + $this->assertEquals('ci/build', $checkRun['name']); + $this->assertEquals('in_progress', $checkRun['status']); + $this->assertNull($checkRun['conclusion']); + $this->assertEquals($commitHash, $checkRun['head_sha']); + $this->assertNotEmpty($checkRun['url']); + $this->assertNotEmpty($checkRun['html_url']); + $this->assertNotEmpty($checkRun['started_at']); + $this->assertNull($checkRun['completed_at']); + + $fetched = $this->vcsAdapter->getCheckRun(static::$owner, $repositoryName, $checkRun['id']); + $this->assertEquals($checkRun['id'], $fetched['id']); + $this->assertEquals('ci/build', $fetched['name']); + $this->assertEquals('in_progress', $fetched['status']); + $this->assertNull($fetched['conclusion']); + $this->assertEquals($commitHash, $fetched['head_sha']); + $this->assertNotEmpty($fetched['url']); + $this->assertNotEmpty($fetched['html_url']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testCreateCheckRunWithInvalidRepository(): void + { + $this->expectException(\Exception::class); + $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: 'non-existing-repository-' . \uniqid(), + headSha: 'a' . str_repeat('0', 39), + name: 'ci/build', + ); + } + + public function testGetCheckRunWithInvalidId(): void + { + $repositoryName = 'test-get-check-run-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->expectException(\Exception::class); + $this->vcsAdapter->getCheckRun(static::$owner, $repositoryName, 999999999); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testCreateTwoCheckRunsOnSameCommit(): void + { + $repositoryName = 'test-two-check-runs-same-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']; + + $first = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + status: 'in_progress', + ); + + $second = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + status: 'in_progress', + ); + + $this->assertArrayHasKey('id', $first); + $this->assertArrayHasKey('id', $second); + $this->assertNotEquals($first['id'], $second['id']); + $this->assertEquals($commitHash, $first['head_sha']); + $this->assertEquals($commitHash, $second['head_sha']); + $this->assertEquals('ci/build', $first['name']); + $this->assertEquals('ci/build', $second['name']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testCreateCheckRunsWithSameNameOnDifferentCommits(): void + { + $repositoryName = 'test-check-runs-different-commits-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commit1 = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); + $commitHash1 = $commit1['commitHash']; + + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'second.md', '# Second'); + $commit2 = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); + $commitHash2 = $commit2['commitHash']; + + $first = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash1, + name: 'ci/build', + status: 'in_progress', + ); + + $second = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash2, + name: 'ci/build', + status: 'in_progress', + ); + + $this->assertArrayHasKey('id', $first); + $this->assertArrayHasKey('id', $second); + $this->assertNotEquals($first['id'], $second['id']); + $this->assertEquals($commitHash1, $first['head_sha']); + $this->assertEquals($commitHash2, $second['head_sha']); + $this->assertEquals('ci/build', $first['name']); + $this->assertEquals('ci/build', $second['name']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testCreateCheckRunCompleted(): void + { + $repositoryName = 'test-create-check-run-completed-' . \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']; + + $checkRun = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + conclusion: 'success', + title: 'Build passed', + summary: 'All checks passed successfully.', + ); + + $this->assertArrayHasKey('id', $checkRun); + $this->assertIsInt($checkRun['id']); + $this->assertEquals('ci/build', $checkRun['name']); + $this->assertEquals('completed', $checkRun['status']); + $this->assertEquals('success', $checkRun['conclusion']); + $this->assertEquals($commitHash, $checkRun['head_sha']); + $this->assertNotEmpty($checkRun['url']); + $this->assertNotEmpty($checkRun['html_url']); + $this->assertNotEmpty($checkRun['completed_at']); + $this->assertEquals('Build passed', $checkRun['output']['title']); + $this->assertEquals('All checks passed successfully.', $checkRun['output']['summary']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testUpdateCheckRun(): void + { + $repositoryName = 'test-update-check-run-' . \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']; + + $checkRun = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + status: 'in_progress', + startedAt: gmdate('Y-m-d\TH:i:s\Z'), + ); + + $this->assertArrayHasKey('id', $checkRun); + $this->assertEquals('in_progress', $checkRun['status']); + + $updated = $this->vcsAdapter->updateCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + checkRunId: $checkRun['id'], + status: 'completed', + conclusion: 'neutral', + title: 'Deployment skipped', + summary: 'Deployment skipped because the branch does not match the configured branch triggers.', + completedAt: gmdate('Y-m-d\TH:i:s\Z'), + ); + + $this->assertEquals($checkRun['id'], $updated['id']); + $this->assertEquals('completed', $updated['status']); + $this->assertEquals('neutral', $updated['conclusion']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testUpdateCheckRunWithInvalidRepository(): void + { + $this->expectException(\Exception::class); + $this->vcsAdapter->updateCheckRun( + owner: static::$owner, + repositoryName: 'non-existing-repository-' . \uniqid(), + checkRunId: 999999999, + conclusion: 'success', + ); + } + + public function testUpdateCheckRunWithInvalidId(): void + { + $repositoryName = 'test-update-check-run-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->expectException(\Exception::class); + $this->vcsAdapter->updateCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + checkRunId: 999999999, + conclusion: 'success', + ); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testUpdateCheckRunWithMissingConclusion(): void + { + $repositoryName = 'test-update-check-run-no-conclusion-' . \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']; + + $checkRun = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + status: 'in_progress', + ); + + $this->expectException(\Exception::class); + $this->vcsAdapter->updateCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + checkRunId: $checkRun['id'], + status: 'completed', + ); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } } From 4a5f4324909a48ec4fcb91a17334a24f9e22de99 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 2 Jun 2026 14:12:38 +0530 Subject: [PATCH 03/17] Restore accidentally removed createCheckRun, getCheckRun, updateCheckRun --- src/VCS/Adapter/Git/GitHub.php | 179 +++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 4a34bb03..13b35f32 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -966,6 +966,185 @@ public function updateCommitStatus(string $repositoryName, string $commitHash, s $this->call(self::METHOD_POST, $url, ['Authorization' => "Bearer $this->accessToken"], $body); } + /** + * Creates a check run for a commit. + * status can be one of: queued, in_progress, completed + * conclusion (required when status=completed) can be one of: action_required, cancelled, failure, neutral, success, skipped, timed_out + * + * @param array $annotations + * @param array $images + * @param array $actions + * @return array + */ + public function createCheckRun( + string $owner, + string $repositoryName, + string $headSha, + string $name, + string $status = 'queued', + string $conclusion = '', + string $title = '', + string $summary = '', + string $text = '', + array $annotations = [], + array $images = [], + array $actions = [], + string $detailsUrl = '', + string $externalId = '', + string $startedAt = '', + string $completedAt = '', + ): array { + $url = "/repos/$owner/$repositoryName/check-runs"; + + if ($status === 'completed' && empty($conclusion)) { + throw new Exception("conclusion is required when status is 'completed'"); + } + + // Conclusion requires status=completed; auto-set completed_at if not provided. + if (!empty($conclusion)) { + $status = 'completed'; + if (empty($completedAt)) { + $completedAt = gmdate('Y-m-d\TH:i:s\Z'); + } + } + + $body = array_merge( + [ + 'name' => $name, + 'head_sha' => $headSha, + 'status' => $status, + ], + array_filter([ + 'conclusion' => $conclusion, + 'completed_at' => $completedAt, + 'details_url' => $detailsUrl, + 'external_id' => $externalId, + 'started_at' => $startedAt, + ], fn ($value) => !empty($value)) + ); + + // Output requires both title and summary. + if (!empty($title) && !empty($summary)) { + $output = array_filter(['title' => $title, 'summary' => $summary, 'text' => $text], fn ($value) => !empty($value)); + if (!empty($annotations)) { + $output['annotations'] = $annotations; + } + if (!empty($images)) { + $output['images'] = $images; + } + $body['output'] = $output; + } + + if (!empty($actions)) { + $body['actions'] = $actions; + } + + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => "Bearer $this->accessToken"], $body); + + $responseHeadersStatusCode = $response['headers']['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to create check run: HTTP $responseHeadersStatusCode"); + } + + return $response['body'] ?? []; + } + + /** + * Gets a check run by ID. + * + * @return array + */ + public function getCheckRun(string $owner, string $repositoryName, int $checkRunId): array + { + $url = "/repos/$owner/$repositoryName/check-runs/$checkRunId"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "Bearer $this->accessToken"]); + + $responseHeadersStatusCode = $response['headers']['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to get check run $checkRunId: HTTP $responseHeadersStatusCode"); + } + + return $response['body'] ?? []; + } + + /** + * Updates an existing check run. + * status can be one of: queued, in_progress, completed + * conclusion (required when status=completed) can be one of: action_required, cancelled, failure, neutral, success, skipped, timed_out + * + * @param array $annotations + * @param array $images + * @return array + */ + public function updateCheckRun( + string $owner, + string $repositoryName, + int $checkRunId, + string $name = '', + string $status = '', + string $conclusion = '', + string $title = '', + string $summary = '', + string $text = '', + array $annotations = [], + array $images = [], + array $actions = [], + string $detailsUrl = '', + string $externalId = '', + string $startedAt = '', + string $completedAt = '', + ): array { + $url = "/repos/$owner/$repositoryName/check-runs/$checkRunId"; + + if ($status === 'completed' && empty($conclusion)) { + throw new Exception("conclusion is required when status is 'completed'"); + } + + // Conclusion requires status=completed; auto-set completed_at if not provided. + if (!empty($conclusion)) { + $status = 'completed'; + if (empty($completedAt)) { + $completedAt = gmdate('Y-m-d\TH:i:s\Z'); + } + } + + $body = array_filter([ + 'name' => $name, + 'status' => $status, + 'details_url' => $detailsUrl, + 'external_id' => $externalId, + 'started_at' => $startedAt, + 'conclusion' => $conclusion, + 'completed_at' => $completedAt, + ], fn ($value) => !empty($value)); + + // Output requires both title and summary. + if (!empty($title) && !empty($summary)) { + $output = array_filter(['title' => $title, 'summary' => $summary, 'text' => $text], fn ($value) => !empty($value)); + if (!empty($annotations)) { + $output['annotations'] = $annotations; + } + if (!empty($images)) { + $output['images'] = $images; + } + $body['output'] = $output; + } + + if (!empty($actions)) { + $body['actions'] = $actions; + } + + $response = $this->call(self::METHOD_PATCH, $url, ['Authorization' => "Bearer $this->accessToken"], $body); + + $responseHeadersStatusCode = $response['headers']['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to update check run $checkRunId: HTTP $responseHeadersStatusCode"); + } + + return $response['body'] ?? []; + } + /** * Generates a clone command using app access token */ From fc4fd54647120fd7990da15e0618b640336f8124 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 2 Jun 2026 14:27:37 +0530 Subject: [PATCH 04/17] =?UTF-8?q?Simplify=20listBranches=20=E2=80=94=20sin?= =?UTF-8?q?gle=20GraphQL=20call,=20remove=20do-while=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/VCS/Adapter/Git/GitHub.php | 112 ++++++++++----------------------- 1 file changed, 33 insertions(+), 79 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 13b35f32..869e8612 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -744,18 +744,18 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, /** * Lists branches using GitHub GraphQL repository.refs with prefix search and cursor pagination. * - * Search matches branch names by prefix only ('feat' → 'feature-x', not 'my-feature'). - * Pass an integer $page to walk forward page-by-page (each step costs one extra GraphQL call - * to resolve the cursor chain); pass a cursor string from a previous nextCursor to jump - * directly. perPage is clamped to [1, 100]. + * GraphQL refs(query:) does server-side prefix filtering, so no client-side filtering is needed. + * 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 1 (or null) for the first page. For subsequent pages - * always pass the opaque cursor string from the previous nextCursor — GitHub uses - * cursor-based GraphQL pagination and has no concept of integer page offsets. - * Any integer value other than 1 is treated as page 1. + * @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} */ @@ -783,84 +783,38 @@ public function listBranches(string $owner, string $repositoryName, int $perPage } GRAPHQL; - // We use GraphQL instead of REST for two reasons that the REST API cannot satisfy: - // 1. Server-side search narrowing: REST GET /repos/{owner}/{repo}/branches has no - // search or filter parameter at all; GraphQL refs() accepts a `query` variable. - // 2. Per-edge cursors: REST only supports integer ?page=N offsets; GraphQL edges - // carry individual cursors so we can resume from an exact item across calls. - // - // GraphQL `query` does substring matching, so we additionally enforce prefix - // semantics client-side with str_starts_with. We collect up to $perPage + 1 - // matching edges across as many GraphQL pages as needed: - // - If we find the +1 probe item, hasNext=true and nextCursor points to the - // cursor of the last returned item, so the next call resumes exactly where - // we stopped. - // - If GitHub is exhausted before the probe, hasNext=false. - // This ensures items is never empty while hasNext is true. - /** @var array $collected */ - $collected = []; - $currentCursor = $cursor; - $hasNextPage = false; - - do { - $response = $this->call(self::METHOD_POST, '/graphql', ['Authorization' => "Bearer $this->accessToken"], [ - 'query' => $gql, - 'variables' => [ - 'owner' => $owner, - 'name' => $repositoryName, - 'first' => $perPage, - 'after' => $currentCursor, - 'query' => $search !== '' ? $search : null, - ], - ]); - - $statusCode = $response['headers']['status-code'] ?? 0; - $responseBody = $response['body'] ?? []; - - if ($statusCode < 200 || $statusCode >= 300 || !is_array($responseBody) || array_key_exists('errors', $responseBody)) { - return ['items' => [], 'hasNext' => false, 'nextCursor' => null]; - } - - $refs = $responseBody['data']['repository']['refs'] ?? null; + $response = $this->call(self::METHOD_POST, '/graphql', ['Authorization' => "Bearer $this->accessToken"], [ + 'query' => $gql, + 'variables' => [ + 'owner' => $owner, + 'name' => $repositoryName, + 'first' => $perPage + 1, + 'after' => $cursor, + 'query' => $search !== '' ? $search : null, + ], + ]); - if (!is_array($refs)) { - return ['items' => [], 'hasNext' => false, 'nextCursor' => null]; - } + $statusCode = $response['headers']['status-code'] ?? 0; + $responseBody = $response['body'] ?? []; - $pageInfo = $refs['pageInfo'] ?? []; - $hasNextPage = (bool) ($pageInfo['hasNextPage'] ?? false); - $currentCursor = $pageInfo['endCursor'] ?? null; - - $probeFound = false; - foreach ($refs['edges'] ?? [] as $edge) { - $name = $edge['node']['name'] ?? ''; - if ($search === '' || str_starts_with($name, $search)) { - $collected[] = ['name' => $name, 'cursor' => $edge['cursor'] ?? '']; - if (count($collected) > $perPage) { - $probeFound = true; - break; - } - } - } + if ($statusCode < 200 || $statusCode >= 300 || !is_array($responseBody) || array_key_exists('errors', $responseBody)) { + return ['items' => [], 'hasNext' => false, 'nextCursor' => null]; + } - if ($probeFound) { - break; - } - } while ($hasNextPage); + $refs = $responseBody['data']['repository']['refs'] ?? null; - if (count($collected) > $perPage) { - $toReturn = array_slice($collected, 0, $perPage); - return [ - 'items' => array_column($toReturn, 'name'), - 'hasNext' => true, - 'nextCursor' => $toReturn[$perPage - 1]['cursor'], - ]; + if (!is_array($refs)) { + return ['items' => [], 'hasNext' => false, 'nextCursor' => null]; } + $edges = $refs['edges'] ?? []; + $hasNext = count($edges) > $perPage; + $edges = array_slice($edges, 0, $perPage); + return [ - 'items' => array_column($collected, 'name'), - 'hasNext' => false, - 'nextCursor' => null, + 'items' => array_map(fn ($edge) => $edge['node']['name'] ?? '', $edges), + 'hasNext' => $hasNext, + 'nextCursor' => $hasNext ? ($edges[$perPage - 1]['cursor'] ?? null) : null, ]; } From 846f123f8fe50e8b126378263f79ea3c02106d23 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 2 Jun 2026 14:44:55 +0530 Subject: [PATCH 05/17] Fix listBranches: use pageInfo.hasNextPage instead of probe item to avoid first:101 --- src/VCS/Adapter/Git/GitHub.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 869e8612..ff683525 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -788,7 +788,7 @@ public function listBranches(string $owner, string $repositoryName, int $perPage 'variables' => [ 'owner' => $owner, 'name' => $repositoryName, - 'first' => $perPage + 1, + 'first' => $perPage, 'after' => $cursor, 'query' => $search !== '' ? $search : null, ], @@ -808,13 +808,13 @@ public function listBranches(string $owner, string $repositoryName, int $perPage } $edges = $refs['edges'] ?? []; - $hasNext = count($edges) > $perPage; - $edges = array_slice($edges, 0, $perPage); + $pageInfo = $refs['pageInfo'] ?? []; + $hasNext = (bool) ($pageInfo['hasNextPage'] ?? false); return [ 'items' => array_map(fn ($edge) => $edge['node']['name'] ?? '', $edges), 'hasNext' => $hasNext, - 'nextCursor' => $hasNext ? ($edges[$perPage - 1]['cursor'] ?? null) : null, + 'nextCursor' => $hasNext ? ($pageInfo['endCursor'] ?? null) : null, ]; } From f46e4fd201e90e47fcdab0c0332f30bd55051554 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 2 Jun 2026 15:09:53 +0530 Subject: [PATCH 06/17] Fix listBranches: enforce prefix search client-side, fix null-repo warning --- src/VCS/Adapter/Git/GitHub.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index ff683525..4d998b65 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -744,7 +744,7 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, /** * Lists branches using GitHub GraphQL repository.refs with prefix search and cursor pagination. * - * GraphQL refs(query:) does server-side prefix filtering, so no client-side filtering is needed. + * GraphQL refs(query:) does server-side substring filtering; prefix semantics are enforced client-side with str_starts_with. * 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]. * @@ -801,7 +801,8 @@ public function listBranches(string $owner, string $repositoryName, int $perPage return ['items' => [], 'hasNext' => false, 'nextCursor' => null]; } - $refs = $responseBody['data']['repository']['refs'] ?? null; + $repository = $responseBody['data']['repository'] ?? null; + $refs = is_array($repository) ? ($repository['refs'] ?? null) : null; if (!is_array($refs)) { return ['items' => [], 'hasNext' => false, 'nextCursor' => null]; @@ -811,6 +812,11 @@ public function listBranches(string $owner, string $repositoryName, int $perPage $pageInfo = $refs['pageInfo'] ?? []; $hasNext = (bool) ($pageInfo['hasNextPage'] ?? false); + // GitHub refs(query:) does substring matching; enforce prefix semantics client-side. + if ($search !== '') { + $edges = array_values(array_filter($edges, fn ($edge) => str_starts_with($edge['node']['name'] ?? '', $search))); + } + return [ 'items' => array_map(fn ($edge) => $edge['node']['name'] ?? '', $edges), 'hasNext' => $hasNext, From c2a8fc183d8ff9488e76dd5f80202aa9cfc7a428 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 2 Jun 2026 15:20:04 +0530 Subject: [PATCH 07/17] =?UTF-8?q?Remove=20client-side=20prefix=20filter=20?= =?UTF-8?q?=E2=80=94=20trust=20GitHub=20refs(query:)=20substring=20matchin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/VCS/Adapter/Git/GitHub.php | 7 +------ tests/VCS/Adapter/GitHubTest.php | 3 ++- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 4d998b65..382d40ca 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -744,7 +744,7 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, /** * Lists branches using GitHub GraphQL repository.refs with prefix search and cursor pagination. * - * GraphQL refs(query:) does server-side substring filtering; prefix semantics are enforced client-side with str_starts_with. + * 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]. * @@ -812,11 +812,6 @@ public function listBranches(string $owner, string $repositoryName, int $perPage $pageInfo = $refs['pageInfo'] ?? []; $hasNext = (bool) ($pageInfo['hasNextPage'] ?? false); - // GitHub refs(query:) does substring matching; enforce prefix semantics client-side. - if ($search !== '') { - $edges = array_values(array_filter($edges, fn ($edge) => str_starts_with($edge['node']['name'] ?? '', $search))); - } - return [ 'items' => array_map(fn ($edge) => $edge['node']['name'] ?? '', $edges), 'hasNext' => $hasNext, diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index ee268521..ecf8bc81 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -577,8 +577,9 @@ public function testListBranchesPagination(): void $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->assertSame([], $substringSearch['items']); + $this->assertEqualsCanonicalizing(['branch-a', 'branch-b'], $substringSearch['items']); $this->assertFalse($substringSearch['hasNext']); $this->assertNull($substringSearch['nextCursor']); } finally { From 7f68ce679559e503704f24263c7c9bf59dbe5b9f Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 4 Jun 2026 18:05:57 +0530 Subject: [PATCH 08/17] Revert listBranches return to flat array, keep GraphQL for search --- src/VCS/Adapter/Git/GitHub.php | 55 +++++++++++++++++++------------- tests/VCS/Adapter/GitHubTest.php | 54 +++++++++---------------------- 2 files changed, 48 insertions(+), 61 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 382d40ca..94ddf539 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -742,40 +742,53 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, } /** - * Lists branches using GitHub GraphQL repository.refs with prefix search and cursor pagination. + * Lists branches for a given repository, optionally filtered by a search string. * + * Uses GraphQL instead of REST because the REST endpoint has no search/filter parameter. * 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} + * @param int $page Page number (1-based) + * @param string $search Substring filter; empty returns all branches + * @return array List of branch names */ - public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int|string|null $page = 1, string $search = ''): array + public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int $page = 1, string $search = ''): array { $perPage = min(max($perPage, 1), 100); - $cursor = is_string($page) ? $page : null; + $after = null; + + // Advance cursor to the requested page by walking forward page-by-page. + if ($page > 1) { + for ($i = 1; $i < $page; $i++) { + $result = $this->fetchBranchPage($owner, $repositoryName, $perPage, $after, $search); + if (empty($result['endCursor'])) { + return []; + } + $after = $result['endCursor']; + } + } + $result = $this->fetchBranchPage($owner, $repositoryName, $perPage, $after, $search); + return $result['names']; + } + + /** + * @return array{names: array, endCursor: string|null} + */ + private function fetchBranchPage(string $owner, string $repositoryName, int $perPage, ?string $after, string $search): array + { $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 } } @@ -789,7 +802,7 @@ public function listBranches(string $owner, string $repositoryName, int $perPage 'owner' => $owner, 'name' => $repositoryName, 'first' => $perPage, - 'after' => $cursor, + 'after' => $after, 'query' => $search !== '' ? $search : null, ], ]); @@ -798,24 +811,22 @@ public function listBranches(string $owner, string $repositoryName, int $perPage $responseBody = $response['body'] ?? []; if ($statusCode < 200 || $statusCode >= 300 || !is_array($responseBody) || array_key_exists('errors', $responseBody)) { - return ['items' => [], 'hasNext' => false, 'nextCursor' => null]; + return ['names' => [], 'endCursor' => 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 ['names' => [], 'endCursor' => null]; } $edges = $refs['edges'] ?? []; - $pageInfo = $refs['pageInfo'] ?? []; - $hasNext = (bool) ($pageInfo['hasNextPage'] ?? false); + $endCursor = $refs['pageInfo']['endCursor'] ?? null; return [ - 'items' => array_map(fn ($edge) => $edge['node']['name'] ?? '', $edges), - 'hasNext' => $hasNext, - 'nextCursor' => $hasNext ? ($pageInfo['endCursor'] ?? null) : null, + 'names' => array_map(fn ($edge) => $edge['node']['name'] ?? '', $edges), + 'endCursor' => $endCursor, ]; } diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index ecf8bc81..198e2108 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -478,12 +478,8 @@ public function testListBranches(): void $branches = $adapter->listBranches(static::$owner, $repositoryName); $this->assertIsArray($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']); + $this->assertNotEmpty($branches); + $this->assertContains(static::$defaultBranch, $branches); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -546,42 +542,26 @@ public function testListBranchesPagination(): void /** @var GitHub $adapter */ $adapter = $this->vcsAdapter; - // Cursor-based navigation: always use nextCursor from the previous response + // Page-based navigation $page1 = $adapter->listBranches(static::$owner, $repositoryName, 1, 1); - $this->assertSame(['branch-a'], $page1['items']); - $this->assertTrue($page1['hasNext']); - $this->assertNotEmpty($page1['nextCursor']); + $this->assertSame(['branch-a'], $page1); - $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']); + $page3 = $adapter->listBranches(static::$owner, $repositoryName, 1, 3); + $this->assertSame([static::$defaultBranch], $page3); $all = $adapter->listBranches(static::$owner, $repositoryName, 100, 1); - $this->assertEqualsCanonicalizing([static::$defaultBranch, 'branch-a', 'branch-b'], $all['items']); - $this->assertFalse($all['hasNext']); - $this->assertNull($all['nextCursor']); + $this->assertEqualsCanonicalizing([static::$defaultBranch, 'branch-a', 'branch-b'], $all); - $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']); + // Search by substring + $searchResults = $adapter->listBranches(static::$owner, $repositoryName, 100, 1, 'branch'); + $this->assertEqualsCanonicalizing(['branch-a', 'branch-b'], $searchResults); // 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']); + $this->assertEqualsCanonicalizing(['branch-a', 'branch-b'], $substringSearch); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -598,9 +578,7 @@ public function testListBranchesEmptyRepository(): void $branches = $adapter->listBranches(static::$owner, $repositoryName); $this->assertIsArray($branches); - $this->assertSame([], $branches['items']); - $this->assertFalse($branches['hasNext']); - $this->assertNull($branches['nextCursor']); + $this->assertEmpty($branches); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -613,9 +591,7 @@ public function testListBranchesNonExistingRepository(): void $branches = $adapter->listBranches(static::$owner, 'non-existing-repo-' . \uniqid()); $this->assertIsArray($branches); - $this->assertSame([], $branches['items']); - $this->assertFalse($branches['hasNext']); - $this->assertNull($branches['nextCursor']); + $this->assertEmpty($branches); } public function testGetLatestCommit(): void From 51d9e1c22113b2ae2f1a20a6365ab3af0d9eacc7 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 4 Jun 2026 18:08:48 +0530 Subject: [PATCH 09/17] Add search parameter to listBranches --- tests/VCS/Adapter/GitHubTest.php | 384 +++++++++++++++---------------- 1 file changed, 185 insertions(+), 199 deletions(-) diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 198e2108..a13ce7ac 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -473,9 +473,7 @@ public function testListBranches(): void try { $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - /** @var GitHub $adapter */ - $adapter = $this->vcsAdapter; - $branches = $adapter->listBranches(static::$owner, $repositoryName); + $branches = $this->vcsAdapter->listBranches(static::$owner, $repositoryName); $this->assertIsArray($branches); $this->assertNotEmpty($branches); @@ -542,26 +540,18 @@ public function testListBranchesPagination(): void /** @var GitHub $adapter */ $adapter = $this->vcsAdapter; - // Page-based navigation $page1 = $adapter->listBranches(static::$owner, $repositoryName, 1, 1); $this->assertSame(['branch-a'], $page1); $page2 = $adapter->listBranches(static::$owner, $repositoryName, 1, 2); $this->assertSame(['branch-b'], $page2); - $page3 = $adapter->listBranches(static::$owner, $repositoryName, 1, 3); - $this->assertSame([static::$defaultBranch], $page3); - $all = $adapter->listBranches(static::$owner, $repositoryName, 100, 1); $this->assertEqualsCanonicalizing([static::$defaultBranch, 'branch-a', 'branch-b'], $all); - // Search by substring + // Search filters branches by substring server-side $searchResults = $adapter->listBranches(static::$owner, $repositoryName, 100, 1, 'branch'); $this->assertEqualsCanonicalizing(['branch-a', 'branch-b'], $searchResults); - - // 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); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -573,9 +563,7 @@ public function testListBranchesEmptyRepository(): void $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); try { - /** @var GitHub $adapter */ - $adapter = $this->vcsAdapter; - $branches = $adapter->listBranches(static::$owner, $repositoryName); + $branches = $this->vcsAdapter->listBranches(static::$owner, $repositoryName); $this->assertIsArray($branches); $this->assertEmpty($branches); @@ -586,9 +574,7 @@ public function testListBranchesEmptyRepository(): void public function testListBranchesNonExistingRepository(): void { - /** @var GitHub $adapter */ - $adapter = $this->vcsAdapter; - $branches = $adapter->listBranches(static::$owner, 'non-existing-repo-' . \uniqid()); + $branches = $this->vcsAdapter->listBranches(static::$owner, 'non-existing-repo-' . \uniqid()); $this->assertIsArray($branches); $this->assertEmpty($branches); @@ -667,187 +653,6 @@ 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(); @@ -1130,4 +935,185 @@ 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'); + } } From 182d6a34ad166aa1234f8be933786d2ea9165a6f Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 4 Jun 2026 18:11:55 +0530 Subject: [PATCH 10/17] Add page 3 and substring search assertions to listBranches test --- tests/VCS/Adapter/GitHubTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index a13ce7ac..38f2d16c 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -546,12 +546,19 @@ public function testListBranchesPagination(): void $page2 = $adapter->listBranches(static::$owner, $repositoryName, 1, 2); $this->assertSame(['branch-b'], $page2); + $page3 = $adapter->listBranches(static::$owner, $repositoryName, 1, 3); + $this->assertSame([static::$defaultBranch], $page3); + $all = $adapter->listBranches(static::$owner, $repositoryName, 100, 1); $this->assertEqualsCanonicalizing([static::$defaultBranch, 'branch-a', 'branch-b'], $all); // Search filters branches by substring server-side $searchResults = $adapter->listBranches(static::$owner, $repositoryName, 100, 1, 'branch'); $this->assertEqualsCanonicalizing(['branch-a', 'branch-b'], $searchResults); + + // 'ranch' is a substring of 'branch-a' and 'branch-b' + $substringSearch = $adapter->listBranches(static::$owner, $repositoryName, 100, 1, 'ranch'); + $this->assertEqualsCanonicalizing(['branch-a', 'branch-b'], $substringSearch); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } From 848cad9c499927cb00ca0e361dd98db6563f2561 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 4 Jun 2026 18:14:19 +0530 Subject: [PATCH 11/17] Add pagination and search tests for listBranches --- tests/VCS/Adapter/GitHubTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 38f2d16c..fbff44db 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -552,11 +552,11 @@ public function testListBranchesPagination(): void $all = $adapter->listBranches(static::$owner, $repositoryName, 100, 1); $this->assertEqualsCanonicalizing([static::$defaultBranch, 'branch-a', 'branch-b'], $all); - // Search filters branches by substring server-side + // Search by substring server-side $searchResults = $adapter->listBranches(static::$owner, $repositoryName, 100, 1, 'branch'); $this->assertEqualsCanonicalizing(['branch-a', 'branch-b'], $searchResults); - // 'ranch' is a substring of 'branch-a' and 'branch-b' + // 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); } finally { From 866aacbed70941d30268f7a009b60a5c2b665d81 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 4 Jun 2026 18:20:09 +0530 Subject: [PATCH 12/17] clean up comments --- src/VCS/Adapter/Git/GitHub.php | 1 - tests/VCS/Adapter/GitHubTest.php | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 94ddf539..d82d51f3 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -759,7 +759,6 @@ public function listBranches(string $owner, string $repositoryName, int $perPage $perPage = min(max($perPage, 1), 100); $after = null; - // Advance cursor to the requested page by walking forward page-by-page. if ($page > 1) { for ($i = 1; $i < $page; $i++) { $result = $this->fetchBranchPage($owner, $repositoryName, $perPage, $after, $search); diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index fbff44db..42e69b0e 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -552,11 +552,9 @@ public function testListBranchesPagination(): void $all = $adapter->listBranches(static::$owner, $repositoryName, 100, 1); $this->assertEqualsCanonicalizing([static::$defaultBranch, 'branch-a', 'branch-b'], $all); - // Search by substring server-side $searchResults = $adapter->listBranches(static::$owner, $repositoryName, 100, 1, 'branch'); $this->assertEqualsCanonicalizing(['branch-a', 'branch-b'], $searchResults); - // 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); } finally { From 531067a18af274098722fac50c29b80b3ad72683 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 4 Jun 2026 18:22:16 +0530 Subject: [PATCH 13/17] restore ranch comment --- tests/VCS/Adapter/GitHubTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 42e69b0e..d037d91f 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -555,6 +555,7 @@ public function testListBranchesPagination(): void $searchResults = $adapter->listBranches(static::$owner, $repositoryName, 100, 1, 'branch'); $this->assertEqualsCanonicalizing(['branch-a', 'branch-b'], $searchResults); + // 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); } finally { From f6dc0a3a506e806faaa188f5b1637bf968514788 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 5 Jun 2026 13:34:09 +0530 Subject: [PATCH 14/17] =?UTF-8?q?simplify=20listBranches=20=E2=80=94=20sin?= =?UTF-8?q?gle=20GraphQL=20call,=20no=20loop,=20no=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/VCS/Adapter/Git/GitHub.php | 40 ++++---------------------------- tests/VCS/Adapter/GitHubTest.php | 9 ------- 2 files changed, 5 insertions(+), 44 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index d82d51f3..1518e3da 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -757,39 +757,16 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int $page = 1, string $search = ''): array { $perPage = min(max($perPage, 1), 100); - $after = null; - if ($page > 1) { - for ($i = 1; $i < $page; $i++) { - $result = $this->fetchBranchPage($owner, $repositoryName, $perPage, $after, $search); - if (empty($result['endCursor'])) { - return []; - } - $after = $result['endCursor']; - } - } - - $result = $this->fetchBranchPage($owner, $repositoryName, $perPage, $after, $search); - return $result['names']; - } - - /** - * @return array{names: array, endCursor: string|null} - */ - private function fetchBranchPage(string $owner, string $repositoryName, int $perPage, ?string $after, string $search): array - { $gql = <<<'GRAPHQL' -query ListBranches($owner: String!, $name: String!, $first: Int!, $after: String, $query: String) { +query ListBranches($owner: String!, $name: String!, $first: Int!, $query: String) { repository(owner: $owner, name: $name) { - refs(refPrefix: "refs/heads/", first: $first, after: $after, orderBy: {field: ALPHABETICAL, direction: ASC}, query: $query) { + refs(refPrefix: "refs/heads/", first: $first, orderBy: {field: ALPHABETICAL, direction: ASC}, query: $query) { edges { node { name } } - pageInfo { - endCursor - } } } } @@ -801,7 +778,6 @@ private function fetchBranchPage(string $owner, string $repositoryName, int $per 'owner' => $owner, 'name' => $repositoryName, 'first' => $perPage, - 'after' => $after, 'query' => $search !== '' ? $search : null, ], ]); @@ -810,23 +786,17 @@ private function fetchBranchPage(string $owner, string $repositoryName, int $per $responseBody = $response['body'] ?? []; if ($statusCode < 200 || $statusCode >= 300 || !is_array($responseBody) || array_key_exists('errors', $responseBody)) { - return ['names' => [], 'endCursor' => null]; + return []; } $repository = $responseBody['data']['repository'] ?? null; $refs = is_array($repository) ? ($repository['refs'] ?? null) : null; if (!is_array($refs)) { - return ['names' => [], 'endCursor' => null]; + return []; } - $edges = $refs['edges'] ?? []; - $endCursor = $refs['pageInfo']['endCursor'] ?? null; - - return [ - 'names' => array_map(fn ($edge) => $edge['node']['name'] ?? '', $edges), - 'endCursor' => $endCursor, - ]; + return array_map(fn ($edge) => $edge['node']['name'] ?? '', $refs['edges'] ?? []); } /** diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index d037d91f..91266416 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -540,15 +540,6 @@ public function testListBranchesPagination(): void /** @var GitHub $adapter */ $adapter = $this->vcsAdapter; - $page1 = $adapter->listBranches(static::$owner, $repositoryName, 1, 1); - $this->assertSame(['branch-a'], $page1); - - $page2 = $adapter->listBranches(static::$owner, $repositoryName, 1, 2); - $this->assertSame(['branch-b'], $page2); - - $page3 = $adapter->listBranches(static::$owner, $repositoryName, 1, 3); - $this->assertSame([static::$defaultBranch], $page3); - $all = $adapter->listBranches(static::$owner, $repositoryName, 100, 1); $this->assertEqualsCanonicalizing([static::$defaultBranch, 'branch-a', 'branch-b'], $all); From 7478997d6be12bf0a9fd853c7df907ecc87daf43 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 5 Jun 2026 13:38:24 +0530 Subject: [PATCH 15/17] remove comments --- src/VCS/Adapter/Git/GitHub.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 1518e3da..10b23658 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -744,9 +744,6 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, /** * Lists branches for a given repository, optionally filtered by a search string. * - * Uses GraphQL instead of REST because the REST endpoint has no search/filter parameter. - * GraphQL refs(query:) does server-side substring filtering — 'ranch' matches 'branch-x'. - * * @param string $owner * @param string $repositoryName * @param int $perPage Clamped to [1, 100] @@ -860,8 +857,6 @@ public function getLatestCommit(string $owner, string $repositoryName, string $b $responseBody = $response['body'] ?? []; $responseBodyCommit = $responseBody['commit'] ?? []; $responseBodyCommitAuthor = $responseBodyCommit['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 ( From b91d73e532983ef4ad02c52bbf77ac971d2992a0 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 5 Jun 2026 13:59:36 +0530 Subject: [PATCH 16/17] add offset pagination support --- src/VCS/Adapter/Git/GitHub.php | 40 ++++++++++++++++++++++++++++++-- tests/VCS/Adapter/GitHubTest.php | 9 +++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 10b23658..ed125181 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -754,11 +754,46 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int $page = 1, string $search = ''): array { $perPage = min(max($perPage, 1), 100); + $cursor = null; - $gql = <<<'GRAPHQL' -query ListBranches($owner: String!, $name: String!, $first: Int!, $query: String) { + if ($page > 1) { + $skip = ($page - 1) * $perPage; + $cursorGql = <<<'GRAPHQL' +query ListBranchesCursor($owner: String!, $name: String!, $first: Int!, $query: String) { repository(owner: $owner, name: $name) { refs(refPrefix: "refs/heads/", first: $first, orderBy: {field: ALPHABETICAL, direction: ASC}, query: $query) { + pageInfo { + endCursor + hasNextPage + } + } + } +} +GRAPHQL; + $cursorResponse = $this->call(self::METHOD_POST, '/graphql', ['Authorization' => "Bearer $this->accessToken"], [ + 'query' => $cursorGql, + 'variables' => [ + 'owner' => $owner, + 'name' => $repositoryName, + 'first' => $skip, + 'query' => $search !== '' ? $search : null, + ], + ]); + + $cursorBody = $cursorResponse['body'] ?? []; + $cursorRefs = $cursorBody['data']['repository']['refs'] ?? null; + + if (!is_array($cursorRefs) || !($cursorRefs['pageInfo']['hasNextPage'] ?? false)) { + return []; + } + + $cursor = $cursorRefs['pageInfo']['endCursor'] ?? 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 { node { name @@ -775,6 +810,7 @@ public function listBranches(string $owner, string $repositoryName, int $perPage 'owner' => $owner, 'name' => $repositoryName, 'first' => $perPage, + 'after' => $cursor, 'query' => $search !== '' ? $search : null, ], ]); diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 91266416..d037d91f 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -540,6 +540,15 @@ public function testListBranchesPagination(): void /** @var GitHub $adapter */ $adapter = $this->vcsAdapter; + $page1 = $adapter->listBranches(static::$owner, $repositoryName, 1, 1); + $this->assertSame(['branch-a'], $page1); + + $page2 = $adapter->listBranches(static::$owner, $repositoryName, 1, 2); + $this->assertSame(['branch-b'], $page2); + + $page3 = $adapter->listBranches(static::$owner, $repositoryName, 1, 3); + $this->assertSame([static::$defaultBranch], $page3); + $all = $adapter->listBranches(static::$owner, $repositoryName, 100, 1); $this->assertEqualsCanonicalizing([static::$defaultBranch, 'branch-a', 'branch-b'], $all); From 5e2c6d86ab5d3a3b9975f2dcb9033e544849f16c Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 5 Jun 2026 14:02:12 +0530 Subject: [PATCH 17/17] revert pagination --- src/VCS/Adapter/Git/GitHub.php | 40 ++------------------------------ tests/VCS/Adapter/GitHubTest.php | 9 ------- 2 files changed, 2 insertions(+), 47 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index ed125181..10b23658 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -754,46 +754,11 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int $page = 1, string $search = ''): array { $perPage = min(max($perPage, 1), 100); - $cursor = null; - - if ($page > 1) { - $skip = ($page - 1) * $perPage; - $cursorGql = <<<'GRAPHQL' -query ListBranchesCursor($owner: String!, $name: String!, $first: Int!, $query: String) { - repository(owner: $owner, name: $name) { - refs(refPrefix: "refs/heads/", first: $first, orderBy: {field: ALPHABETICAL, direction: ASC}, query: $query) { - pageInfo { - endCursor - hasNextPage - } - } - } -} -GRAPHQL; - $cursorResponse = $this->call(self::METHOD_POST, '/graphql', ['Authorization' => "Bearer $this->accessToken"], [ - 'query' => $cursorGql, - 'variables' => [ - 'owner' => $owner, - 'name' => $repositoryName, - 'first' => $skip, - 'query' => $search !== '' ? $search : null, - ], - ]); - - $cursorBody = $cursorResponse['body'] ?? []; - $cursorRefs = $cursorBody['data']['repository']['refs'] ?? null; - - if (!is_array($cursorRefs) || !($cursorRefs['pageInfo']['hasNextPage'] ?? false)) { - return []; - } - - $cursor = $cursorRefs['pageInfo']['endCursor'] ?? null; - } $gql = <<<'GRAPHQL' -query ListBranches($owner: String!, $name: String!, $first: Int!, $after: String, $query: String) { +query ListBranches($owner: String!, $name: String!, $first: Int!, $query: String) { repository(owner: $owner, name: $name) { - refs(refPrefix: "refs/heads/", first: $first, after: $after, orderBy: {field: ALPHABETICAL, direction: ASC}, query: $query) { + refs(refPrefix: "refs/heads/", first: $first, orderBy: {field: ALPHABETICAL, direction: ASC}, query: $query) { edges { node { name @@ -810,7 +775,6 @@ public function listBranches(string $owner, string $repositoryName, int $perPage 'owner' => $owner, 'name' => $repositoryName, 'first' => $perPage, - 'after' => $cursor, 'query' => $search !== '' ? $search : null, ], ]); diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index d037d91f..91266416 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -540,15 +540,6 @@ public function testListBranchesPagination(): void /** @var GitHub $adapter */ $adapter = $this->vcsAdapter; - $page1 = $adapter->listBranches(static::$owner, $repositoryName, 1, 1); - $this->assertSame(['branch-a'], $page1); - - $page2 = $adapter->listBranches(static::$owner, $repositoryName, 1, 2); - $this->assertSame(['branch-b'], $page2); - - $page3 = $adapter->listBranches(static::$owner, $repositoryName, 1, 3); - $this->assertSame([static::$defaultBranch], $page3); - $all = $adapter->listBranches(static::$owner, $repositoryName, 100, 1); $this->assertEqualsCanonicalizing([static::$defaultBranch, 'branch-a', 'branch-b'], $all);