Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 115 additions & 21 deletions src/VCS/Adapter/Git/GitHub.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
* @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
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
{
$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
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
// 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,
],
]);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

$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,
];
}

/**
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -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.");
}
Expand Down
Loading
Loading