From c9482b4e6800f88910113036b53e41d3e6beb044 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 2 Jun 2026 14:05:45 +0530 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] =?UTF-8?q?Simplify=20listBranches=20=E2=80=94=20singl?= =?UTF-8?q?e=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 5/7] 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 6/7] 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 7/7] =?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 {