Skip to content

Commit 1bfc5c4

Browse files
committed
fix: use per-edge cursors and probe loop to guarantee items never empty when hasNext true
1 parent ca33d73 commit 1bfc5c4

1 file changed

Lines changed: 73 additions & 41 deletions

File tree

src/VCS/Adapter/Git/GitHub.php

Lines changed: 73 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -791,16 +791,30 @@ public function listBranches(string $owner, string $repositoryName, int $perPage
791791
/**
792792
* @return array{items: array<string>, hasNext: bool, nextCursor: string|null}
793793
*/
794+
/**
795+
* Fetches one logical page of prefix-matching branches.
796+
*
797+
* GitHub's GraphQL query parameter does substring matching, so we request edges
798+
* (which carry per-item cursors) and apply str_starts_with client-side. We collect
799+
* up to $perPage + 1 matching edges across as many GitHub API pages as needed:
800+
* - If we find the +1 probe item, hasNext=true and nextCursor points to the cursor
801+
* of the last returned item, so the next call resumes exactly where we stopped.
802+
* - If GitHub is exhausted before the probe, hasNext=false.
803+
* This ensures items is never empty while hasNext is true.
804+
*
805+
* @return array{items: array<string>, hasNext: bool, nextCursor: string|null}
806+
*/
794807
private function listBranchesPage(string $owner, string $repositoryName, int $perPage, ?string $cursor, string $search): array
795808
{
796-
// refPrefix must be a complete path namespace (e.g. "refs/heads/"); the separate
797-
// query param handles prefix filtering on branch names.
798-
$query = <<<'GRAPHQL'
809+
$gql = <<<'GRAPHQL'
799810
query ListBranches($owner: String!, $name: String!, $first: Int!, $after: String, $query: String) {
800811
repository(owner: $owner, name: $name) {
801812
refs(refPrefix: "refs/heads/", first: $first, after: $after, orderBy: {field: ALPHABETICAL, direction: ASC}, query: $query) {
802-
nodes {
803-
name
813+
edges {
814+
cursor
815+
node {
816+
name
817+
}
804818
}
805819
pageInfo {
806820
hasNextPage
@@ -811,51 +825,69 @@ private function listBranchesPage(string $owner, string $repositoryName, int $pe
811825
}
812826
GRAPHQL;
813827

814-
$response = $this->call(self::METHOD_POST, '/graphql', ['Authorization' => "Bearer $this->accessToken"], [
815-
'query' => $query,
816-
'variables' => [
817-
'owner' => $owner,
818-
'name' => $repositoryName,
819-
'first' => $perPage,
820-
'after' => $cursor,
821-
'query' => $search !== '' ? $search : null,
822-
],
823-
]);
828+
/** @var array<array{name: string, cursor: string}> $collected */
829+
$collected = [];
830+
$currentCursor = $cursor;
824831

825-
$statusCode = $response['headers']['status-code'] ?? 0;
826-
$responseBody = $response['body'] ?? [];
832+
do {
833+
$response = $this->call(self::METHOD_POST, '/graphql', ['Authorization' => "Bearer $this->accessToken"], [
834+
'query' => $gql,
835+
'variables' => [
836+
'owner' => $owner,
837+
'name' => $repositoryName,
838+
'first' => $perPage,
839+
'after' => $currentCursor,
840+
'query' => $search !== '' ? $search : null,
841+
],
842+
]);
827843

828-
if ($statusCode < 200 || $statusCode >= 300 || !is_array($responseBody) || array_key_exists('errors', $responseBody)) {
829-
return [
830-
'items' => [],
831-
'hasNext' => false,
832-
'nextCursor' => null,
833-
];
834-
}
844+
$statusCode = $response['headers']['status-code'] ?? 0;
845+
$responseBody = $response['body'] ?? [];
846+
847+
if ($statusCode < 200 || $statusCode >= 300 || !is_array($responseBody) || array_key_exists('errors', $responseBody)) {
848+
return ['items' => [], 'hasNext' => false, 'nextCursor' => null];
849+
}
835850

836-
$refs = $responseBody['data']['repository']['refs'] ?? null;
851+
$refs = $responseBody['data']['repository']['refs'] ?? null;
837852

838-
if (!is_array($refs)) {
839-
return [
840-
'items' => [],
841-
'hasNext' => false,
842-
'nextCursor' => null,
843-
];
844-
}
853+
if (!is_array($refs)) {
854+
return ['items' => [], 'hasNext' => false, 'nextCursor' => null];
855+
}
845856

846-
$pageInfo = $refs['pageInfo'] ?? [];
847-
$hasNext = $pageInfo['hasNextPage'] ?? false;
857+
$pageInfo = $refs['pageInfo'] ?? [];
858+
$hasNextPage = (bool) ($pageInfo['hasNextPage'] ?? false);
859+
$currentCursor = $pageInfo['endCursor'] ?? null;
860+
861+
$probeFound = false;
862+
foreach ($refs['edges'] ?? [] as $edge) {
863+
$name = $edge['node']['name'] ?? '';
864+
if ($search === '' || str_starts_with($name, $search)) {
865+
$collected[] = ['name' => $name, 'cursor' => $edge['cursor'] ?? ''];
866+
if (count($collected) > $perPage) {
867+
$probeFound = true;
868+
break;
869+
}
870+
}
871+
}
848872

849-
// GitHub's query param does substring matching; post-filter to enforce prefix semantics.
850-
$names = array_map(fn ($branch) => $branch['name'] ?? '', $refs['nodes'] ?? []);
851-
if ($search !== '') {
852-
$names = array_values(array_filter($names, fn ($name) => str_starts_with($name, $search)));
873+
if ($probeFound) {
874+
break;
875+
}
876+
} while ($hasNextPage);
877+
878+
if (count($collected) > $perPage) {
879+
$toReturn = array_slice($collected, 0, $perPage);
880+
return [
881+
'items' => array_column($toReturn, 'name'),
882+
'hasNext' => true,
883+
'nextCursor' => $toReturn[$perPage - 1]['cursor'],
884+
];
853885
}
854886

855887
return [
856-
'items' => array_values($names),
857-
'hasNext' => $hasNext,
858-
'nextCursor' => $hasNext ? ($pageInfo['endCursor'] ?? null) : null,
888+
'items' => array_column($collected, 'name'),
889+
'hasNext' => false,
890+
'nextCursor' => null,
859891
];
860892
}
861893

0 commit comments

Comments
 (0)