Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
385420b
Improve GitHub branch listing pagination
HarshMN2345 May 14, 2026
be94f82
Use prefix search for GitHub branches
HarshMN2345 May 14, 2026
364c1f6
Honor branch pagination in other adapters
HarshMN2345 May 14, 2026
6e63c0a
Preserve default branch listing behavior
HarshMN2345 May 14, 2026
f85fa75
Unify listBranches return shape across all adapters
HarshMN2345 May 25, 2026
09c3882
fix: update testListBranchesEmptyRepo assertions to structured shape
HarshMN2345 May 25, 2026
98e2e9b
fix: update listBranches docblocks in Gitea and Gogs to fix PHPStan e…
HarshMN2345 May 25, 2026
a63ae11
fix: use GraphQL query param for branch prefix search instead of refP…
HarshMN2345 May 25, 2026
ca33d73
fix: enforce prefix search semantics and fix Gogs branch existence scan
HarshMN2345 May 25, 2026
1bfc5c4
fix: use per-edge cursors and probe loop to guarantee items never emp…
HarshMN2345 May 25, 2026
a9f1df7
fix: remove N-sequential-call loop; GitHub pagination is cursor-only
HarshMN2345 May 25, 2026
e519452
fix: tolerate null author in getLatestCommit for GitHub App commits
HarshMN2345 May 25, 2026
ba15102
Inline listBranchesPage into listBranches — remove single-use helper
HarshMN2345 May 26, 2026
a4adbc5
Document why GraphQL is used over REST in listBranches
HarshMN2345 May 26, 2026
2bce5fe
Use GitLab server-side prefix search in listBranches
HarshMN2345 May 26, 2026
9275809
Rename \$page to \$requestedPage in Gogs listBranches for clarity
HarshMN2345 May 26, 2026
f51aa66
Document that Gitea has no server-side branch search in listBranches
HarshMN2345 May 26, 2026
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
16 changes: 13 additions & 3 deletions src/VCS/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,23 @@ abstract public function getEvent(string $event, string $payload): array;
abstract public function getRepositoryName(string $repositoryId): string;

/**
* Lists branches for a given repository
* Lists branches for a given repository.
*
* Search is prefix-based: 'feat' matches 'feature-branch' but not 'my-feature'.
* GitHub uses true server-side cursor pagination via GraphQL; pass the returned
* nextCursor as $page on subsequent calls to advance the page.
* Other providers (GitLab, Gitea, Gogs, Forgejo) fetch all matching branches
* client-side and slice; for them nextCursor is always null, but hasNext
* correctly reflects whether more items exist beyond the current slice.
*
* @param string $owner Owner name of the repository
* @param string $repositoryName Name of the repository
* @return array<string> List of branch names as array
* @param int $perPage Number of results per page, clamped to [1, 100]
* @param int|string|null $page 1-based integer page number, or an opaque cursor string from a previous nextCursor (cursor form only supported by GitHub)
* @param string $search Prefix filter for branch names; empty string returns all branches
* @return array{items: array<string>, hasNext: bool, nextCursor: string|null}
*/
abstract public function listBranches(string $owner, string $repositoryName): array;
abstract public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int|string|null $page = 1, string $search = ''): array;

/**
* Updates status check of each commit
Expand Down
117 changes: 103 additions & 14 deletions src/VCS/Adapter/Git/GitHub.php
Original file line number Diff line number Diff line change
Expand Up @@ -742,32 +742,121 @@ 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 1-based page number or opaque GraphQL cursor
* @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;
$page = is_int($page) ? max($page, 1) : 1;
$result = [
'items' => [],
'hasNext' => false,
'nextCursor' => null,
];

for ($currentPage = 1; $currentPage <= $page; $currentPage++) {
$result = $this->listBranchesPage($owner, $repositoryName, $perPage, $cursor, $search);

$response = $this->call(self::METHOD_GET, $url, ['Authorization' => "Bearer $this->accessToken"], [
'page' => $page,
'per_page' => $perPage,
if ($currentPage === $page) {
return $result;
}

if ($result['hasNext'] === false) {
return [
'items' => [],
'hasNext' => false,
'nextCursor' => null,
];
}

$cursor = $result['nextCursor'];
}

return $result;
}

/**
* @return array{items: array<string>, hasNext: bool, nextCursor: string|null}
*/
private function listBranchesPage(string $owner, string $repositoryName, int $perPage, ?string $cursor, string $search): array
{
// refPrefix must be a complete path namespace (e.g. "refs/heads/"); the separate
// query param handles prefix filtering on branch names.
$query = <<<'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) {

Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
Member Author

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.

nodes {
name
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
GRAPHQL;

$response = $this->call(self::METHOD_POST, '/graphql', ['Authorization' => "Bearer $this->accessToken"], [
'query' => $query,
'variables' => [
'owner' => $owner,
'name' => $repositoryName,
'first' => $perPage,
'after' => $cursor,
'query' => $search !== '' ? $search : null,
],
]);

$statusCode = $response['headers']['status-code'] ?? 0;
$responseBody = $response['body'] ?? [];

if ($statusCode < 200 || $statusCode >= 300 || !is_array($responseBody)) {
return [];
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;

if (!is_array($refs)) {
return [
'items' => [],
'hasNext' => false,
'nextCursor' => null,
];
}

$pageInfo = $refs['pageInfo'] ?? [];
$hasNext = $pageInfo['hasNextPage'] ?? false;

// GitHub's query param does substring matching; post-filter to enforce prefix semantics.
$names = array_map(fn ($branch) => $branch['name'] ?? '', $refs['nodes'] ?? []);
if ($search !== '') {
$names = array_values(array_filter($names, fn ($name) => str_starts_with($name, $search)));
}

return array_values(array_map(fn ($branch) => $branch['name'] ?? '', $responseBody));
return [
'items' => array_values($names),
'hasNext' => $hasNext,
'nextCursor' => $hasNext ? ($pageInfo['endCursor'] ?? null) : null,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
];
}

/**
Expand Down
24 changes: 18 additions & 6 deletions src/VCS/Adapter/Git/GitLab.php
Original file line number Diff line number Diff line change
Expand Up @@ -700,20 +700,22 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName,
];
}

public function listBranches(string $owner, string $repositoryName): array
public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int|string|null $page = 1, string $search = ''): array
Comment thread
HarshMN2345 marked this conversation as resolved.
{
$ownerPath = $this->getOwnerPath($owner);
$projectPath = urlencode("{$ownerPath}/{$repositoryName}");
$perPage = min(max($perPage, 1), 100);
$requestedPage = is_int($page) ? max($page, 1) : 1;

$branches = [];
$page = 1;
$currentPage = 1;
do {
$pagedUrl = "/projects/{$projectPath}/repository/branches?per_page=100&page={$page}";
$pagedUrl = "/projects/{$projectPath}/repository/branches?per_page=100&page={$currentPage}";
$response = $this->call(self::METHOD_GET, $pagedUrl, ['PRIVATE-TOKEN' => $this->accessToken]);
$responseHeaders = $response['headers'] ?? [];
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
if ($responseHeadersStatusCode >= 400) {
return [];
return ['items' => [], 'hasNext' => false, 'nextCursor' => null];
}
$responseBody = $response['body'] ?? [];
if (!is_array($responseBody) || empty($responseBody)) {
Expand All @@ -722,10 +724,20 @@ public function listBranches(string $owner, string $repositoryName): array
foreach ($responseBody as $branch) {
$branches[] = $branch['name'] ?? '';
}
$page++;
$currentPage++;
} while (count($responseBody) === 100);

return $branches;
if ($search !== '') {
$branches = array_values(array_filter($branches, fn ($branch) => str_starts_with($branch, $search)));
}

$offset = ($requestedPage - 1) * $perPage;

return [
'items' => array_values(array_slice($branches, $offset, $perPage)),
'hasNext' => ($offset + $perPage) < count($branches),
'nextCursor' => null,
];
}

public function getCommit(string $owner, string $repositoryName, string $commitHash): array
Expand Down
26 changes: 19 additions & 7 deletions src/VCS/Adapter/Git/Gitea.php
Original file line number Diff line number Diff line change
Expand Up @@ -729,24 +729,26 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName,
*
* @param string $owner Owner of the repository
* @param string $repositoryName Name of the repository
* @return array<string> Array of branch names
* @return array{items: array<string>, hasNext: bool, nextCursor: string|null}
*/
public function listBranches(string $owner, string $repositoryName): array
public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int|string|null $page = 1, string $search = ''): array
{
$allBranches = [];
$perPage = 50;
$requestedPerPage = min(max($perPage, 1), 100);
$requestedPage = is_int($page) ? max($page, 1) : 1;
$apiPerPage = 50;
$maxPages = 100;

for ($currentPage = 1; $currentPage <= $maxPages; $currentPage++) {
$url = "/repos/{$owner}/{$repositoryName}/branches?page={$currentPage}&limit={$perPage}";
$url = "/repos/{$owner}/{$repositoryName}/branches?page={$currentPage}&limit={$apiPerPage}";

$response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"], decode: false);

$responseHeaders = $response['headers'] ?? [];
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;

if ($responseHeadersStatusCode === 404) {
return [];
return ['items' => [], 'hasNext' => false, 'nextCursor' => null];
}

if ($responseHeadersStatusCode >= 400) {
Expand All @@ -770,12 +772,22 @@ public function listBranches(string $owner, string $repositoryName): array
}
}

if ($pageCount < $perPage) {
if ($pageCount < $apiPerPage) {
break;
}
}

return $allBranches;
if ($search !== '') {
$allBranches = array_values(array_filter($allBranches, fn ($branch) => str_starts_with($branch, $search)));
}

$offset = ($requestedPage - 1) * $requestedPerPage;

return [
'items' => array_values(array_slice($allBranches, $offset, $requestedPerPage)),
'hasNext' => ($offset + $requestedPerPage) < count($allBranches),
'nextCursor' => null,
];
}

/**
Expand Down
39 changes: 30 additions & 9 deletions src/VCS/Adapter/Git/Gogs.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,18 @@ public function getCommit(string $owner, string $repositoryName, string $commitH
*/
public function getLatestCommit(string $owner, string $repositoryName, string $branch): array
{
// Gogs ignores sha param — verify branch exists first
$branches = $this->listBranches($owner, $repositoryName);
if (!in_array($branch, $branches, true)) {
// Gogs ignores sha param — verify branch exists by scanning all pages
$page = 1;
$found = false;
do {
$result = $this->listBranches($owner, $repositoryName, 100, $page);
if (in_array($branch, $result['items'], true)) {
$found = true;
break;
}
$page++;
} while ($result['hasNext']);
if (!$found) {
throw new Exception("Branch '{$branch}' not found");
}

Expand Down Expand Up @@ -501,12 +510,14 @@ public function getCommitStatuses(string $owner, string $repositoryName, string
/**
* List branches
*
* Gogs supports listing branches but without pagination parameters.
* Gogs API returns all branches in a single request (no pagination support).
*
* @return array<string>
* @return array{items: array<string>, hasNext: bool, nextCursor: string|null}
*/
public function listBranches(string $owner, string $repositoryName): array
public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int|string|null $page = 1, string $search = ''): array
{
$perPage = min(max($perPage, 1), 100);
$page = is_int($page) ? max($page, 1) : 1;
Comment thread
HarshMN2345 marked this conversation as resolved.
Outdated
$url = "/repos/{$owner}/{$repositoryName}/branches";

$response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]);
Expand All @@ -515,7 +526,7 @@ public function listBranches(string $owner, string $repositoryName): array
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;

if ($responseHeadersStatusCode === 404) {
return [];
return ['items' => [], 'hasNext' => false, 'nextCursor' => null];
}

if ($responseHeadersStatusCode >= 400) {
Expand All @@ -525,7 +536,7 @@ public function listBranches(string $owner, string $repositoryName): array
$responseBody = $response['body'] ?? [];

if (!is_array($responseBody)) {
return [];
return ['items' => [], 'hasNext' => false, 'nextCursor' => null];
}

$branches = [];
Expand All @@ -535,6 +546,16 @@ public function listBranches(string $owner, string $repositoryName): array
}
}

return $branches;
if ($search !== '') {
$branches = array_values(array_filter($branches, fn ($branch) => str_starts_with($branch, $search)));
}

$offset = ($page - 1) * $perPage;

return [
'items' => array_values(array_slice($branches, $offset, $perPage)),
'hasNext' => ($offset + $perPage) < count($branches),
'nextCursor' => null,
];
}
}
Loading
Loading