Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 For GitHub: pass 1 for the first page; for subsequent pages always pass the opaque cursor string from nextCursor — GitHub has no concept of integer offset pages and any integer other than 1 is treated as page 1. For other providers: 1-based integer page number; string cursors are ignored and treated as page 1.
* @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
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.
* @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) {
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.

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,
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.

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?

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.

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

/**
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
55 changes: 35 additions & 20 deletions src/VCS/Adapter/Git/GitLab.php
Original file line number Diff line number Diff line change
Expand Up @@ -700,32 +700,47 @@ 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;

$query = "per_page={$perPage}&page={$requestedPage}";
if ($search !== '') {
// GitLab's search param accepts ^term to match branches that begin with
// term, giving us server-side prefix semantics without fetching all branches.
$query .= '&search=' . urlencode('^' . $search);
}

$url = "/projects/{$projectPath}/repository/branches?{$query}";
$response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]);
$responseHeaders = $response['headers'] ?? [];
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;

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

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

$branches = [];
$page = 1;
do {
$pagedUrl = "/projects/{$projectPath}/repository/branches?per_page=100&page={$page}";
$response = $this->call(self::METHOD_GET, $pagedUrl, ['PRIVATE-TOKEN' => $this->accessToken]);
$responseHeaders = $response['headers'] ?? [];
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
if ($responseHeadersStatusCode >= 400) {
return [];
}
$responseBody = $response['body'] ?? [];
if (!is_array($responseBody) || empty($responseBody)) {
break;
}
foreach ($responseBody as $branch) {
$branches[] = $branch['name'] ?? '';
}
$page++;
} while (count($responseBody) === 100);
foreach ($responseBody as $branch) {
$branches[] = $branch['name'] ?? '';
}

return $branches;
// X-Next-Page is an empty string when there is no further page
$hasNext = !empty($responseHeaders['x-next-page']);

return [
'items' => $branches,
'hasNext' => $hasNext,
'nextCursor' => null,
];
}

public function getCommit(string $owner, string $repositoryName, string $commitHash): array
Expand Down
29 changes: 22 additions & 7 deletions src/VCS/Adapter/Git/Gitea.php
Original file line number Diff line number Diff line change
Expand Up @@ -729,24 +729,29 @@ 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
{
// Gitea's branches endpoint only supports page/limit pagination — it has no
// server-side search or filter parameter (a q param is silently ignored).
// We must fetch all pages and apply str_starts_with client-side.
$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 +775,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
41 changes: 32 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,16 @@ 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
{
// Gogs returns all branches in one response with no server-side pagination or
// search support, so we fetch everything and slice/filter client-side.
$perPage = min(max($perPage, 1), 100);
$requestedPage = is_int($page) ? max($page, 1) : 1;
$url = "/repos/{$owner}/{$repositoryName}/branches";

$response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]);
Expand All @@ -515,7 +528,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 +538,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 +548,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 = ($requestedPage - 1) * $perPage;

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