-
Notifications
You must be signed in to change notification settings - Fork 6
Improve GitHub branch listing pagination #102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
385420b
be94f82
364c1f6
6e63c0a
f85fa75
09c3882
98e2e9b
a63ae11
ca33d73
1bfc5c4
a9f1df7
e519452
ba15102
a4adbc5
2bce5fe
9275809
f51aa66
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string> 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<string>, 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<array{name: string, cursor: string}> $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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do I understand this query parameter is reasin to use GraphQL? Can we try same endpoint with HTTP to have proof query isnt accepted there?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tested this. REST branches API has no search support and only supports page-based pagination. We need GraphQL here for both branch search and cursor-based pagination (nextCursor). Added comments above the query documenting both limitations. |
||
| ], | ||
| ]); | ||
|
|
||
| $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."); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Curious, does Gitea have refs endpoints too? I recall they try to be compatible with GitHub, I would expect we can urilize same thing there.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nope, Gitea does not support server-side branch filtering
Parameters like q or search are ignored server-side (verified against Codeberg/Forgejo). Since Gitea also lacks GraphQL support, fetching all branches and filtering client-side is currently unavoidable. Added a comment documenting this behavior.