From 385420bda3788e2a44401a8d414961a7f76e3a0a Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 14 May 2026 16:56:23 +0530 Subject: [PATCH 01/17] Improve GitHub branch listing pagination --- src/VCS/Adapter.php | 7 ++- src/VCS/Adapter/Git/GitHub.php | 96 ++++++++++++++++++++++++++++---- src/VCS/Adapter/Git/GitLab.php | 2 +- src/VCS/Adapter/Git/Gitea.php | 2 +- src/VCS/Adapter/Git/Gogs.php | 2 +- tests/VCS/Adapter/GitHubTest.php | 58 +++++++++++++++---- 6 files changed, 142 insertions(+), 25 deletions(-) diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index be9504de..e7c479b9 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -225,9 +225,12 @@ abstract public function getRepositoryName(string $repositoryId): string; * * @param string $owner Owner name of the repository * @param string $repositoryName Name of the repository - * @return array List of branch names as array + * @param int $perPage Number of branches to fetch per page + * @param int|string|null $page Page number or cursor to start fetching from + * @param string $search Branch name search query + * @return array|array{items: array, hasNext: bool, nextCursor?: string|null} List of branch names or branch names with pagination metadata */ - 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 diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index a06337ee..39047098 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -747,27 +747,103 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, * @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 List of branch names as array + * @param int|string|null $page Page number or GraphQL cursor to start fetching from + * @param string $search Branch name search query + * @return array{items: array, hasNext: bool, nextCursor: string|null} List of branch names and pagination metadata */ - 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); + + if ($currentPage === $page) { + return $result; + } + + if ($result['hasNext'] === false) { + return [ + 'items' => [], + 'hasNext' => false, + 'nextCursor' => null, + ]; + } + + $cursor = $result['nextCursor']; + } - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "Bearer $this->accessToken"], [ - 'page' => $page, - 'per_page' => $perPage, + return $result; + } + + /** + * @return array{items: array, hasNext: bool, nextCursor: string|null} + */ + private function listBranchesPage(string $owner, string $repositoryName, int $perPage, ?string $cursor, string $search): array + { + $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, query: $query, orderBy: {field: ALPHABETICAL, direction: ASC}) { + 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 === '' ? null : $search, + ], ]); $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, + ]; } - return array_values(array_map(fn ($branch) => $branch['name'] ?? '', $responseBody)); + $pageInfo = $refs['pageInfo'] ?? []; + $hasNext = $pageInfo['hasNextPage'] ?? false; + + return [ + 'items' => array_values(array_map(fn ($branch) => $branch['name'] ?? '', $refs['nodes'] ?? [])), + 'hasNext' => $hasNext, + 'nextCursor' => $hasNext ? ($pageInfo['endCursor'] ?? null) : null, + ]; } /** diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 21bdb20e..500aa7de 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -700,7 +700,7 @@ 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 { $ownerPath = $this->getOwnerPath($owner); $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index f428650a..909fbc14 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -731,7 +731,7 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, * @param string $repositoryName Name of the repository * @return array Array of branch names */ - 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; diff --git a/src/VCS/Adapter/Git/Gogs.php b/src/VCS/Adapter/Git/Gogs.php index 6d642270..835c9ee0 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -505,7 +505,7 @@ public function getCommitStatuses(string $owner, string $repositoryName, string * * @return array */ - 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 { $url = "/repos/{$owner}/{$repositoryName}/branches"; diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index f929b3a5..c9dc3664 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -473,11 +473,17 @@ public function testListBranches(): void try { $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $branches = $this->vcsAdapter->listBranches(static::$owner, $repositoryName); + /** @var GitHub $adapter */ + $adapter = $this->vcsAdapter; + $branches = $adapter->listBranches(static::$owner, $repositoryName); $this->assertIsArray($branches); - $this->assertNotEmpty($branches); - $this->assertContains(static::$defaultBranch, $branches); + $this->assertArrayHasKey('items', $branches); + $this->assertArrayHasKey('hasNext', $branches); + $this->assertNotEmpty($branches['items']); + $this->assertFalse($branches['hasNext']); + $this->assertNull($branches['nextCursor']); + $this->assertContains(static::$defaultBranch, $branches['items']); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -541,13 +547,37 @@ public function testListBranchesPagination(): void $adapter = $this->vcsAdapter; $page1 = $adapter->listBranches(static::$owner, $repositoryName, 1, 1); - $this->assertSame(['branch-a'], $page1); + $this->assertSame(['branch-a'], $page1['items']); + $this->assertTrue($page1['hasNext']); + $this->assertNotEmpty($page1['nextCursor']); $page2 = $adapter->listBranches(static::$owner, $repositoryName, 1, 2); - $this->assertSame(['branch-b'], $page2); + $this->assertSame(['branch-b'], $page2['items']); + $this->assertTrue($page2['hasNext']); + $this->assertNotEmpty($page2['nextCursor']); + + $cursorPage2 = $adapter->listBranches(static::$owner, $repositoryName, 1, $page1['nextCursor']); + $this->assertSame($page2, $cursorPage2); + + $page3 = $adapter->listBranches(static::$owner, $repositoryName, 1, 3); + $this->assertSame([static::$defaultBranch], $page3['items']); + $this->assertFalse($page3['hasNext']); + $this->assertNull($page3['nextCursor']); $all = $adapter->listBranches(static::$owner, $repositoryName, 100, 1); - $this->assertEqualsCanonicalizing([static::$defaultBranch, 'branch-a', 'branch-b'], $all); + $this->assertEqualsCanonicalizing([static::$defaultBranch, 'branch-a', 'branch-b'], $all['items']); + $this->assertFalse($all['hasNext']); + $this->assertNull($all['nextCursor']); + + $searchPage1 = $adapter->listBranches(static::$owner, $repositoryName, 1, 1, 'branch'); + $this->assertSame(['branch-a'], $searchPage1['items']); + $this->assertTrue($searchPage1['hasNext']); + $this->assertNotEmpty($searchPage1['nextCursor']); + + $searchPage2 = $adapter->listBranches(static::$owner, $repositoryName, 1, $searchPage1['nextCursor'], 'branch'); + $this->assertSame(['branch-b'], $searchPage2['items']); + $this->assertFalse($searchPage2['hasNext']); + $this->assertNull($searchPage2['nextCursor']); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -559,10 +589,14 @@ public function testListBranchesEmptyRepository(): void $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); try { - $branches = $this->vcsAdapter->listBranches(static::$owner, $repositoryName); + /** @var GitHub $adapter */ + $adapter = $this->vcsAdapter; + $branches = $adapter->listBranches(static::$owner, $repositoryName); $this->assertIsArray($branches); - $this->assertEmpty($branches); + $this->assertSame([], $branches['items']); + $this->assertFalse($branches['hasNext']); + $this->assertNull($branches['nextCursor']); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -570,10 +604,14 @@ public function testListBranchesEmptyRepository(): void public function testListBranchesNonExistingRepository(): void { - $branches = $this->vcsAdapter->listBranches(static::$owner, 'non-existing-repo-' . \uniqid()); + /** @var GitHub $adapter */ + $adapter = $this->vcsAdapter; + $branches = $adapter->listBranches(static::$owner, 'non-existing-repo-' . \uniqid()); $this->assertIsArray($branches); - $this->assertEmpty($branches); + $this->assertSame([], $branches['items']); + $this->assertFalse($branches['hasNext']); + $this->assertNull($branches['nextCursor']); } public function testGetLatestCommit(): void From be94f82652f1d5bf8dc1bf22800cf22e4a2abddd Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 14 May 2026 17:03:42 +0530 Subject: [PATCH 02/17] Use prefix search for GitHub branches --- src/VCS/Adapter.php | 2 +- src/VCS/Adapter/Git/GitHub.php | 9 +++++---- tests/VCS/Adapter/GitHubTest.php | 5 +++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index e7c479b9..6c1a9986 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -227,7 +227,7 @@ abstract public function getRepositoryName(string $repositoryId): string; * @param string $repositoryName Name of the repository * @param int $perPage Number of branches to fetch per page * @param int|string|null $page Page number or cursor to start fetching from - * @param string $search Branch name search query + * @param string $search Branch name prefix search query * @return array|array{items: array, hasNext: bool, nextCursor?: string|null} List of branch names or branch names with pagination metadata */ abstract public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int|string|null $page = 1, string $search = ''): array; diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 39047098..f20a964f 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -748,7 +748,7 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, * @param string $repositoryName Name of the GitHub repository * @param int $perPage Number of branches to fetch per page * @param int|string|null $page Page number or GraphQL cursor to start fetching from - * @param string $search Branch name search query + * @param string $search Branch name prefix search query * @return array{items: array, hasNext: bool, nextCursor: string|null} List of branch names and pagination metadata */ public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int|string|null $page = 1, string $search = ''): array @@ -788,10 +788,11 @@ public function listBranches(string $owner, string $repositoryName, int $perPage */ private function listBranchesPage(string $owner, string $repositoryName, int $perPage, ?string $cursor, string $search): array { + $refPrefix = 'refs/heads/' . $search; $query = <<<'GRAPHQL' -query ListBranches($owner: String!, $name: String!, $first: Int!, $after: String, $query: String) { +query ListBranches($owner: String!, $name: String!, $refPrefix: String!, $first: Int!, $after: String) { repository(owner: $owner, name: $name) { - refs(refPrefix: "refs/heads/", first: $first, after: $after, query: $query, orderBy: {field: ALPHABETICAL, direction: ASC}) { + refs(refPrefix: $refPrefix, first: $first, after: $after, orderBy: {field: ALPHABETICAL, direction: ASC}) { nodes { name } @@ -809,9 +810,9 @@ private function listBranchesPage(string $owner, string $repositoryName, int $pe 'variables' => [ 'owner' => $owner, 'name' => $repositoryName, + 'refPrefix' => $refPrefix, 'first' => $perPage, 'after' => $cursor, - 'query' => $search === '' ? null : $search, ], ]); diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index c9dc3664..55288aeb 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -578,6 +578,11 @@ public function testListBranchesPagination(): void $this->assertSame(['branch-b'], $searchPage2['items']); $this->assertFalse($searchPage2['hasNext']); $this->assertNull($searchPage2['nextCursor']); + + $substringSearch = $adapter->listBranches(static::$owner, $repositoryName, 100, 1, 'ranch'); + $this->assertSame([], $substringSearch['items']); + $this->assertFalse($substringSearch['hasNext']); + $this->assertNull($substringSearch['nextCursor']); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } From 364c1f6fc95f88ecf909ebc5b1b6fb839627b278 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 14 May 2026 17:10:38 +0530 Subject: [PATCH 03/17] Honor branch pagination in other adapters --- src/VCS/Adapter/Git/GitLab.php | 14 ++++++++++---- src/VCS/Adapter/Git/Gitea.php | 14 ++++++++++---- src/VCS/Adapter/Git/Gogs.php | 8 +++++++- tests/VCS/Adapter/GitLabTest.php | 11 +++++++++++ tests/VCS/Adapter/GiteaTest.php | 11 +++++++++++ 5 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 500aa7de..3ca35425 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -704,11 +704,13 @@ public function listBranches(string $owner, string $repositoryName, int $perPage { $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; @@ -722,10 +724,14 @@ public function listBranches(string $owner, string $repositoryName, int $perPage 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))); + } + + return array_slice($branches, ($requestedPage - 1) * $perPage, $perPage); } public function getCommit(string $owner, string $repositoryName, string $commitHash): array diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 909fbc14..32c27aad 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -734,11 +734,13 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, 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); @@ -770,12 +772,16 @@ public function listBranches(string $owner, string $repositoryName, int $perPage } } - if ($pageCount < $perPage) { + if ($pageCount < $apiPerPage) { break; } } - return $allBranches; + if ($search !== '') { + $allBranches = array_values(array_filter($allBranches, fn ($branch) => str_starts_with($branch, $search))); + } + + return array_slice($allBranches, ($requestedPage - 1) * $requestedPerPage, $requestedPerPage); } /** diff --git a/src/VCS/Adapter/Git/Gogs.php b/src/VCS/Adapter/Git/Gogs.php index 835c9ee0..dc54f068 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -507,6 +507,8 @@ public function getCommitStatuses(string $owner, string $repositoryName, string */ 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; $url = "/repos/{$owner}/{$repositoryName}/branches"; $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); @@ -535,6 +537,10 @@ public function listBranches(string $owner, string $repositoryName, int $perPage } } - return $branches; + if ($search !== '') { + $branches = array_values(array_filter($branches, fn ($branch) => str_starts_with($branch, $search))); + } + + return array_slice($branches, ($page - 1) * $perPage, $perPage); } } diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 84e5918f..617ec25d 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -1051,6 +1051,17 @@ public function testListBranches(): void $this->assertContains(static::$defaultBranch, $result); $this->assertContains('feature-branch', $result); $this->assertContains('another-branch', $result); + + $page1 = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 1, 1); + $page2 = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 1, 2); + $this->assertSame([$result[0]], $page1); + $this->assertSame([$result[1]], $page2); + + $searchResult = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 100, 1, 'feature'); + $this->assertSame(['feature-branch'], $searchResult); + + $substringSearch = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 100, 1, 'eature'); + $this->assertSame([], $substringSearch); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 507b0c51..a6e75f04 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -1462,6 +1462,17 @@ public function testListBranches(): void $this->assertContains('feature-1', $branches); $this->assertContains('feature-2', $branches); $this->assertGreaterThanOrEqual(3, count($branches)); + + $page1 = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 1, 1); + $page2 = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 1, 2); + $this->assertSame([$branches[0]], $page1); + $this->assertSame([$branches[1]], $page2); + + $searchResult = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 100, 1, 'feature'); + $this->assertEqualsCanonicalizing(['feature-1', 'feature-2'], $searchResult); + + $substringSearch = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 100, 1, 'eature'); + $this->assertSame([], $substringSearch); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } From 6e63c0a7aa792b2712f77bed33c225828930ebcf Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 14 May 2026 17:42:36 +0530 Subject: [PATCH 04/17] Preserve default branch listing behavior --- src/VCS/Adapter/Git/GitLab.php | 4 ++++ src/VCS/Adapter/Git/Gitea.php | 4 ++++ src/VCS/Adapter/Git/Gogs.php | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 3ca35425..6e54dfa4 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -731,6 +731,10 @@ public function listBranches(string $owner, string $repositoryName, int $perPage $branches = array_values(array_filter($branches, fn ($branch) => str_starts_with($branch, $search))); } + if ($search === '' && $requestedPage === 1 && $perPage === 100) { + return $branches; + } + return array_slice($branches, ($requestedPage - 1) * $perPage, $perPage); } diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 32c27aad..4a1f3817 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -781,6 +781,10 @@ public function listBranches(string $owner, string $repositoryName, int $perPage $allBranches = array_values(array_filter($allBranches, fn ($branch) => str_starts_with($branch, $search))); } + if ($search === '' && $requestedPage === 1 && $requestedPerPage === 100) { + return $allBranches; + } + return array_slice($allBranches, ($requestedPage - 1) * $requestedPerPage, $requestedPerPage); } diff --git a/src/VCS/Adapter/Git/Gogs.php b/src/VCS/Adapter/Git/Gogs.php index dc54f068..7922185a 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -541,6 +541,10 @@ public function listBranches(string $owner, string $repositoryName, int $perPage $branches = array_values(array_filter($branches, fn ($branch) => str_starts_with($branch, $search))); } + if ($search === '' && $page === 1 && $perPage === 100) { + return $branches; + } + return array_slice($branches, ($page - 1) * $perPage, $perPage); } } From f85fa754947574c18583795cc46939460e178cb6 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 25 May 2026 18:34:13 +0530 Subject: [PATCH 05/17] Unify listBranches return shape across all adapters All providers now return array{items, hasNext, nextCursor} so callers get a consistent shape regardless of provider. GitHub uses true GraphQL cursor pagination; GitLab/Gitea/Gogs/Forgejo compute hasNext from the client-side slice and always return nextCursor: null. Error-path early returns and the Gogs internal branch-existence check are updated to match. Tests updated and testListBranchesNonExistingRepository added for GitLab and Gitea (inherited by Gogs and Forgejo). --- src/VCS/Adapter.php | 17 +++++++--- src/VCS/Adapter/Git/GitHub.php | 19 +++++++---- src/VCS/Adapter/Git/GitLab.php | 12 ++++--- src/VCS/Adapter/Git/Gitea.php | 12 ++++--- src/VCS/Adapter/Git/Gogs.php | 18 +++++----- tests/VCS/Adapter/GitLabTest.php | 51 +++++++++++++++++++++------- tests/VCS/Adapter/GiteaTest.php | 58 ++++++++++++++++++++++---------- 7 files changed, 127 insertions(+), 60 deletions(-) diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index 6c1a9986..adff0cc8 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -221,14 +221,21 @@ 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 - * @param int $perPage Number of branches to fetch per page - * @param int|string|null $page Page number or cursor to start fetching from - * @param string $search Branch name prefix search query - * @return array|array{items: array, hasNext: bool, nextCursor?: string|null} List of branch names or branch names with pagination metadata + * @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, hasNext: bool, nextCursor: string|null} */ abstract public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int|string|null $page = 1, string $search = ''): array; diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index f20a964f..e554c4a6 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -742,14 +742,19 @@ 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|string|null $page Page number or GraphQL cursor to start fetching from - * @param string $search Branch name prefix search query - * @return array{items: array, hasNext: bool, nextCursor: string|null} List of branch names and pagination metadata + * 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, hasNext: bool, nextCursor: string|null} */ public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int|string|null $page = 1, string $search = ''): array { diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 6e54dfa4..f1ce3fd8 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -715,7 +715,7 @@ public function listBranches(string $owner, string $repositoryName, int $perPage $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)) { @@ -731,11 +731,13 @@ public function listBranches(string $owner, string $repositoryName, int $perPage $branches = array_values(array_filter($branches, fn ($branch) => str_starts_with($branch, $search))); } - if ($search === '' && $requestedPage === 1 && $perPage === 100) { - return $branches; - } + $offset = ($requestedPage - 1) * $perPage; - return array_slice($branches, ($requestedPage - 1) * $perPage, $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 diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 4a1f3817..96773332 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -748,7 +748,7 @@ public function listBranches(string $owner, string $repositoryName, int $perPage $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; if ($responseHeadersStatusCode === 404) { - return []; + return ['items' => [], 'hasNext' => false, 'nextCursor' => null]; } if ($responseHeadersStatusCode >= 400) { @@ -781,11 +781,13 @@ public function listBranches(string $owner, string $repositoryName, int $perPage $allBranches = array_values(array_filter($allBranches, fn ($branch) => str_starts_with($branch, $search))); } - if ($search === '' && $requestedPage === 1 && $requestedPerPage === 100) { - return $allBranches; - } + $offset = ($requestedPage - 1) * $requestedPerPage; - return array_slice($allBranches, ($requestedPage - 1) * $requestedPerPage, $requestedPerPage); + return [ + 'items' => array_values(array_slice($allBranches, $offset, $requestedPerPage)), + 'hasNext' => ($offset + $requestedPerPage) < count($allBranches), + 'nextCursor' => null, + ]; } /** diff --git a/src/VCS/Adapter/Git/Gogs.php b/src/VCS/Adapter/Git/Gogs.php index 7922185a..177ff19f 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -261,8 +261,8 @@ 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)) { + $result = $this->listBranches($owner, $repositoryName); + if (!in_array($branch, $result['items'], true)) { throw new Exception("Branch '{$branch}' not found"); } @@ -517,7 +517,7 @@ public function listBranches(string $owner, string $repositoryName, int $perPage $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; if ($responseHeadersStatusCode === 404) { - return []; + return ['items' => [], 'hasNext' => false, 'nextCursor' => null]; } if ($responseHeadersStatusCode >= 400) { @@ -527,7 +527,7 @@ public function listBranches(string $owner, string $repositoryName, int $perPage $responseBody = $response['body'] ?? []; if (!is_array($responseBody)) { - return []; + return ['items' => [], 'hasNext' => false, 'nextCursor' => null]; } $branches = []; @@ -541,10 +541,12 @@ public function listBranches(string $owner, string $repositoryName, int $perPage $branches = array_values(array_filter($branches, fn ($branch) => str_starts_with($branch, $search))); } - if ($search === '' && $page === 1 && $perPage === 100) { - return $branches; - } + $offset = ($page - 1) * $perPage; - return array_slice($branches, ($page - 1) * $perPage, $perPage); + return [ + 'items' => array_values(array_slice($branches, $offset, $perPage)), + 'hasNext' => ($offset + $perPage) < count($branches), + 'nextCursor' => null, + ]; } } diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 617ec25d..33d008ac 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -1046,22 +1046,37 @@ public function testListBranches(): void $result = $this->vcsAdapter->listBranches(static::$owner, $repositoryName); $this->assertIsArray($result); - $this->assertNotEmpty($result); + $this->assertArrayHasKey('items', $result); + $this->assertArrayHasKey('hasNext', $result); + $this->assertArrayHasKey('nextCursor', $result); + $this->assertNotEmpty($result['items']); + $this->assertNull($result['nextCursor']); - $this->assertContains(static::$defaultBranch, $result); - $this->assertContains('feature-branch', $result); - $this->assertContains('another-branch', $result); + $this->assertContains(static::$defaultBranch, $result['items']); + $this->assertContains('feature-branch', $result['items']); + $this->assertContains('another-branch', $result['items']); + // Offset pagination: each page of size 1 reports more items ahead $page1 = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 1, 1); $page2 = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 1, 2); - $this->assertSame([$result[0]], $page1); - $this->assertSame([$result[1]], $page2); - + $this->assertSame([$result['items'][0]], $page1['items']); + $this->assertTrue($page1['hasNext']); + $this->assertNull($page1['nextCursor']); + $this->assertSame([$result['items'][1]], $page2['items']); + $this->assertTrue($page2['hasNext']); + $this->assertNull($page2['nextCursor']); + + // Prefix search $searchResult = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 100, 1, 'feature'); - $this->assertSame(['feature-branch'], $searchResult); + $this->assertSame(['feature-branch'], $searchResult['items']); + $this->assertFalse($searchResult['hasNext']); + $this->assertNull($searchResult['nextCursor']); + // Substring (non-prefix) search returns nothing $substringSearch = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 100, 1, 'eature'); - $this->assertSame([], $substringSearch); + $this->assertSame([], $substringSearch['items']); + $this->assertFalse($substringSearch['hasNext']); + $this->assertNull($substringSearch['nextCursor']); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -1074,15 +1089,27 @@ public function testListBranchesEmptyRepo(): void try { $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - $branches = $this->vcsAdapter->listBranches(static::$owner, $repositoryName); + $result = $this->vcsAdapter->listBranches(static::$owner, $repositoryName); - $this->assertIsArray($branches); - $this->assertEmpty($branches); + $this->assertIsArray($result); + $this->assertSame([], $result['items']); + $this->assertFalse($result['hasNext']); + $this->assertNull($result['nextCursor']); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } + public function testListBranchesNonExistingRepository(): void + { + $result = $this->vcsAdapter->listBranches(static::$owner, 'non-existing-repo-' . \uniqid()); + + $this->assertIsArray($result); + $this->assertSame([], $result['items']); + $this->assertFalse($result['hasNext']); + $this->assertNull($result['nextCursor']); + } + public function testListRepositoryLanguages(): void { $repositoryName = 'test-list-repository-languages-' . \uniqid(); diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index a6e75f04..53344f49 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -1437,47 +1437,69 @@ public function testListBranches(): void $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); try { - // Create initial file on main branch $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - - // Create additional branches $this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'feature-1', static::$defaultBranch); $this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'feature-2', static::$defaultBranch); - $branches = []; + $result = []; $maxAttempts = 10; for ($attempt = 0; $attempt < $maxAttempts; $attempt++) { - $branches = $this->vcsAdapter->listBranches(static::$owner, $repositoryName); + $result = $this->vcsAdapter->listBranches(static::$owner, $repositoryName); - if (in_array('feature-1', $branches, true) && in_array('feature-2', $branches, true)) { + if (in_array('feature-1', $result['items'], true) && in_array('feature-2', $result['items'], true)) { break; } usleep(500000); } - $this->assertIsArray($branches); - $this->assertNotEmpty($branches); - $this->assertContains(static::$defaultBranch, $branches); - $this->assertContains('feature-1', $branches); - $this->assertContains('feature-2', $branches); - $this->assertGreaterThanOrEqual(3, count($branches)); - + $this->assertIsArray($result); + $this->assertArrayHasKey('items', $result); + $this->assertArrayHasKey('hasNext', $result); + $this->assertArrayHasKey('nextCursor', $result); + $this->assertNotEmpty($result['items']); + $this->assertNull($result['nextCursor']); + $this->assertContains(static::$defaultBranch, $result['items']); + $this->assertContains('feature-1', $result['items']); + $this->assertContains('feature-2', $result['items']); + $this->assertGreaterThanOrEqual(3, count($result['items'])); + + // Offset pagination: size-1 pages report hasNext until the last slice $page1 = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 1, 1); $page2 = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 1, 2); - $this->assertSame([$branches[0]], $page1); - $this->assertSame([$branches[1]], $page2); - + $this->assertSame([$result['items'][0]], $page1['items']); + $this->assertTrue($page1['hasNext']); + $this->assertNull($page1['nextCursor']); + $this->assertSame([$result['items'][1]], $page2['items']); + $this->assertTrue($page2['hasNext']); + $this->assertNull($page2['nextCursor']); + + // Prefix search $searchResult = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 100, 1, 'feature'); - $this->assertEqualsCanonicalizing(['feature-1', 'feature-2'], $searchResult); + $this->assertEqualsCanonicalizing(['feature-1', 'feature-2'], $searchResult['items']); + $this->assertFalse($searchResult['hasNext']); + $this->assertNull($searchResult['nextCursor']); + // Substring (non-prefix) search returns nothing $substringSearch = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 100, 1, 'eature'); - $this->assertSame([], $substringSearch); + $this->assertSame([], $substringSearch['items']); + $this->assertFalse($substringSearch['hasNext']); + $this->assertNull($substringSearch['nextCursor']); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } + public function testListBranchesNonExistingRepository(): void + { + $result = $this->vcsAdapter->listBranches(static::$owner, 'non-existing-repo-' . \uniqid()); + + $this->assertIsArray($result); + $this->assertSame([], $result['items']); + $this->assertFalse($result['hasNext']); + $this->assertNull($result['nextCursor']); + } + public function testCreateTag(): void { $repositoryName = 'test-create-tag-' . \uniqid(); From 09c388236152bfd4d952a92b7a75d3c06fbd0bf4 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 25 May 2026 18:38:56 +0530 Subject: [PATCH 06/17] fix: update testListBranchesEmptyRepo assertions to structured shape --- tests/VCS/Adapter/GiteaTest.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 53344f49..0ce1dd61 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -70,10 +70,12 @@ public function testListBranchesEmptyRepo(): void $this->vcsAdapter->createRepository($owner, $repositoryName, false); try { - $branches = $this->vcsAdapter->listBranches($owner, $repositoryName); + $result = $this->vcsAdapter->listBranches($owner, $repositoryName); - $this->assertIsArray($branches); - $this->assertEmpty($branches); + $this->assertIsArray($result); + $this->assertSame([], $result['items']); + $this->assertFalse($result['hasNext']); + $this->assertNull($result['nextCursor']); } finally { $this->vcsAdapter->deleteRepository($owner, $repositoryName); } From 98e2e9b2d3293c5a336f1a13fa09883bf197bd66 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 25 May 2026 18:41:51 +0530 Subject: [PATCH 07/17] fix: update listBranches docblocks in Gitea and Gogs to fix PHPStan errors --- src/VCS/Adapter/Git/Gitea.php | 2 +- src/VCS/Adapter/Git/Gogs.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 96773332..cacf25ae 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -729,7 +729,7 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, * * @param string $owner Owner of the repository * @param string $repositoryName Name of the repository - * @return array Array of branch names + * @return array{items: array, hasNext: bool, nextCursor: string|null} */ public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int|string|null $page = 1, string $search = ''): array { diff --git a/src/VCS/Adapter/Git/Gogs.php b/src/VCS/Adapter/Git/Gogs.php index 177ff19f..8a05225f 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -501,9 +501,9 @@ 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 + * @return array{items: array, hasNext: bool, nextCursor: string|null} */ public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int|string|null $page = 1, string $search = ''): array { From a63ae11ddfa19a5b14b38a83a4533fd4f87a3275 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 25 May 2026 18:47:13 +0530 Subject: [PATCH 08/17] fix: use GraphQL query param for branch prefix search instead of refPrefix --- src/VCS/Adapter/Git/GitHub.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index e554c4a6..bde9331f 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -793,11 +793,12 @@ public function listBranches(string $owner, string $repositoryName, int $perPage */ private function listBranchesPage(string $owner, string $repositoryName, int $perPage, ?string $cursor, string $search): array { - $refPrefix = 'refs/heads/' . $search; + // 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!, $refPrefix: String!, $first: Int!, $after: String) { +query ListBranches($owner: String!, $name: String!, $first: Int!, $after: String, $query: String) { repository(owner: $owner, name: $name) { - refs(refPrefix: $refPrefix, first: $first, after: $after, orderBy: {field: ALPHABETICAL, direction: ASC}) { + refs(refPrefix: "refs/heads/", first: $first, after: $after, orderBy: {field: ALPHABETICAL, direction: ASC}, query: $query) { nodes { name } @@ -815,9 +816,9 @@ private function listBranchesPage(string $owner, string $repositoryName, int $pe 'variables' => [ 'owner' => $owner, 'name' => $repositoryName, - 'refPrefix' => $refPrefix, 'first' => $perPage, 'after' => $cursor, + 'query' => $search !== '' ? $search : null, ], ]); From ca33d731f9f51d00396199341e5b8dd365f5936a Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 25 May 2026 19:02:38 +0530 Subject: [PATCH 09/17] fix: enforce prefix search semantics and fix Gogs branch existence scan --- src/VCS/Adapter/Git/GitHub.php | 8 +++++++- src/VCS/Adapter/Git/Gogs.php | 15 ++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index bde9331f..5cdc28f2 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -846,8 +846,14 @@ private function listBranchesPage(string $owner, string $repositoryName, int $pe $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 [ - 'items' => array_values(array_map(fn ($branch) => $branch['name'] ?? '', $refs['nodes'] ?? [])), + 'items' => array_values($names), 'hasNext' => $hasNext, 'nextCursor' => $hasNext ? ($pageInfo['endCursor'] ?? null) : null, ]; diff --git a/src/VCS/Adapter/Git/Gogs.php b/src/VCS/Adapter/Git/Gogs.php index 8a05225f..43f4d78e 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -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 - $result = $this->listBranches($owner, $repositoryName); - if (!in_array($branch, $result['items'], 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"); } From 1bfc5c43cf6c5c6f8ab56d39dce1e4c515d00071 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 25 May 2026 19:44:11 +0530 Subject: [PATCH 10/17] fix: use per-edge cursors and probe loop to guarantee items never empty when hasNext true --- src/VCS/Adapter/Git/GitHub.php | 114 +++++++++++++++++++++------------ 1 file changed, 73 insertions(+), 41 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 5cdc28f2..d6c0eea5 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -791,16 +791,30 @@ public function listBranches(string $owner, string $repositoryName, int $perPage /** * @return array{items: array, hasNext: bool, nextCursor: string|null} */ + /** + * Fetches one logical page of prefix-matching branches. + * + * GitHub's GraphQL query parameter does substring matching, so we request edges + * (which carry per-item cursors) and apply str_starts_with client-side. We collect + * up to $perPage + 1 matching edges across as many GitHub API 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. + * + * @return array{items: array, 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' + $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) { - nodes { - name + edges { + cursor + node { + name + } } pageInfo { hasNextPage @@ -811,51 +825,69 @@ private function listBranchesPage(string $owner, string $repositoryName, int $pe } 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, - ], - ]); + /** @var array $collected */ + $collected = []; + $currentCursor = $cursor; - $statusCode = $response['headers']['status-code'] ?? 0; - $responseBody = $response['body'] ?? []; + 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, + ], + ]); - if ($statusCode < 200 || $statusCode >= 300 || !is_array($responseBody) || array_key_exists('errors', $responseBody)) { - return [ - 'items' => [], - 'hasNext' => false, - 'nextCursor' => null, - ]; - } + $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]; + } - $refs = $responseBody['data']['repository']['refs'] ?? null; + $refs = $responseBody['data']['repository']['refs'] ?? null; - if (!is_array($refs)) { - return [ - 'items' => [], - 'hasNext' => false, - 'nextCursor' => null, - ]; - } + if (!is_array($refs)) { + return ['items' => [], 'hasNext' => false, 'nextCursor' => null]; + } - $pageInfo = $refs['pageInfo'] ?? []; - $hasNext = $pageInfo['hasNextPage'] ?? false; + $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; + } + } + } - // 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))); + 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 [ - 'items' => array_values($names), - 'hasNext' => $hasNext, - 'nextCursor' => $hasNext ? ($pageInfo['endCursor'] ?? null) : null, + 'items' => array_column($collected, 'name'), + 'hasNext' => false, + 'nextCursor' => null, ]; } From a9f1df78ec216c4117626c431b4fcd6f02d89e91 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 25 May 2026 19:54:01 +0530 Subject: [PATCH 11/17] fix: remove N-sequential-call loop; GitHub pagination is cursor-only --- src/VCS/Adapter.php | 2 +- src/VCS/Adapter/Git/GitHub.php | 32 +++++--------------------------- tests/VCS/Adapter/GitHubTest.php | 8 +++----- 3 files changed, 9 insertions(+), 33 deletions(-) diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index adff0cc8..d0aff4b3 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -233,7 +233,7 @@ abstract public function getRepositoryName(string $repositoryId): string; * @param string $owner Owner name of the repository * @param string $repositoryName Name of the repository * @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 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, hasNext: bool, nextCursor: string|null} */ diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index d6c0eea5..05f3f6f6 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -752,7 +752,10 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, * @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 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, hasNext: bool, nextCursor: string|null} */ @@ -760,32 +763,7 @@ public function listBranches(string $owner, string $repositoryName, int $perPage { $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); - - if ($currentPage === $page) { - return $result; - } - - if ($result['hasNext'] === false) { - return [ - 'items' => [], - 'hasNext' => false, - 'nextCursor' => null, - ]; - } - - $cursor = $result['nextCursor']; - } - - return $result; + return $this->listBranchesPage($owner, $repositoryName, $perPage, $cursor, $search); } /** diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 55288aeb..916eddd8 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -546,20 +546,18 @@ public function testListBranchesPagination(): void /** @var GitHub $adapter */ $adapter = $this->vcsAdapter; + // Cursor-based navigation: always use nextCursor from the previous response $page1 = $adapter->listBranches(static::$owner, $repositoryName, 1, 1); $this->assertSame(['branch-a'], $page1['items']); $this->assertTrue($page1['hasNext']); $this->assertNotEmpty($page1['nextCursor']); - $page2 = $adapter->listBranches(static::$owner, $repositoryName, 1, 2); + $page2 = $adapter->listBranches(static::$owner, $repositoryName, 1, $page1['nextCursor']); $this->assertSame(['branch-b'], $page2['items']); $this->assertTrue($page2['hasNext']); $this->assertNotEmpty($page2['nextCursor']); - $cursorPage2 = $adapter->listBranches(static::$owner, $repositoryName, 1, $page1['nextCursor']); - $this->assertSame($page2, $cursorPage2); - - $page3 = $adapter->listBranches(static::$owner, $repositoryName, 1, 3); + $page3 = $adapter->listBranches(static::$owner, $repositoryName, 1, $page2['nextCursor']); $this->assertSame([static::$defaultBranch], $page3['items']); $this->assertFalse($page3['hasNext']); $this->assertNull($page3['nextCursor']); From e519452154667471fbad2c3fdbf6f327bc374da1 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 25 May 2026 20:00:28 +0530 Subject: [PATCH 12/17] fix: tolerate null author in getLatestCommit for GitHub App commits --- src/VCS/Adapter/Git/GitHub.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 05f3f6f6..4850fffc 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -930,15 +930,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."); } From ba15102b64d31a6d9c1be5c11bbf9d87d8c03fd7 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 26 May 2026 17:46:03 +0530 Subject: [PATCH 13/17] =?UTF-8?q?Inline=20listBranchesPage=20into=20listBr?= =?UTF-8?q?anches=20=E2=80=94=20remove=20single-use=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The private helper was only called from one place; the logic is now directly inside listBranches with an explanatory comment block. --- src/VCS/Adapter/Git/GitHub.php | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 4850fffc..c46a5430 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -763,27 +763,7 @@ public function listBranches(string $owner, string $repositoryName, int $perPage { $perPage = min(max($perPage, 1), 100); $cursor = is_string($page) ? $page : null; - return $this->listBranchesPage($owner, $repositoryName, $perPage, $cursor, $search); - } - /** - * @return array{items: array, hasNext: bool, nextCursor: string|null} - */ - /** - * Fetches one logical page of prefix-matching branches. - * - * GitHub's GraphQL query parameter does substring matching, so we request edges - * (which carry per-item cursors) and apply str_starts_with client-side. We collect - * up to $perPage + 1 matching edges across as many GitHub API 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. - * - * @return array{items: array, hasNext: bool, nextCursor: string|null} - */ - private function listBranchesPage(string $owner, string $repositoryName, int $perPage, ?string $cursor, string $search): array - { $gql = <<<'GRAPHQL' query ListBranches($owner: String!, $name: String!, $first: Int!, $after: String, $query: String) { repository(owner: $owner, name: $name) { @@ -803,9 +783,19 @@ private function listBranchesPage(string $owner, string $repositoryName, int $pe } GRAPHQL; + // GitHub's GraphQL query param does substring matching, so we request edges + // (which carry per-item cursors) and enforce prefix semantics client-side with + // str_starts_with. We collect up to $perPage + 1 matching edges across as many + // GitHub API 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 $collected */ $collected = []; $currentCursor = $cursor; + $hasNextPage = false; do { $response = $this->call(self::METHOD_POST, '/graphql', ['Authorization' => "Bearer $this->accessToken"], [ From a4adbc5629aeac4329798927d50467d3aae476d4 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 26 May 2026 17:48:29 +0530 Subject: [PATCH 14/17] Document why GraphQL is used over REST in listBranches Two REST limitations make GraphQL necessary: 1. REST branches endpoint has no search/filter param (query is GraphQL-only) 2. REST only supports integer page offsets (per-edge cursors are GraphQL-only) --- src/VCS/Adapter/Git/GitHub.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index c46a5430..4a34bb03 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -783,10 +783,15 @@ public function listBranches(string $owner, string $repositoryName, int $perPage } GRAPHQL; - // GitHub's GraphQL query param does substring matching, so we request edges - // (which carry per-item cursors) and enforce prefix semantics client-side with - // str_starts_with. We collect up to $perPage + 1 matching edges across as many - // GitHub API pages as needed: + // 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. From 2bce5fe5282530cdc7290dcb1a68cd6b268e1144 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 26 May 2026 17:50:53 +0530 Subject: [PATCH 15/17] Use GitLab server-side prefix search in listBranches GitLab's branches API accepts search=^term to filter branches that begin with term, so we no longer need to fetch all pages before filtering. A single paginated request is made; hasNext is read from X-Next-Page. --- src/VCS/Adapter/Git/GitLab.php | 51 ++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index f1ce3fd8..96af8b12 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -707,35 +707,38 @@ public function listBranches(string $owner, string $repositoryName, int $perPage $perPage = min(max($perPage, 1), 100); $requestedPage = is_int($page) ? max($page, 1) : 1; - $branches = []; - $currentPage = 1; - do { - $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 ['items' => [], 'hasNext' => false, 'nextCursor' => null]; - } - $responseBody = $response['body'] ?? []; - if (!is_array($responseBody) || empty($responseBody)) { - break; - } - foreach ($responseBody as $branch) { - $branches[] = $branch['name'] ?? ''; - } - $currentPage++; - } while (count($responseBody) === 100); - + $query = "per_page={$perPage}&page={$requestedPage}"; if ($search !== '') { - $branches = array_values(array_filter($branches, fn ($branch) => str_starts_with($branch, $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 = []; + foreach ($responseBody as $branch) { + $branches[] = $branch['name'] ?? ''; } - $offset = ($requestedPage - 1) * $perPage; + // X-Next-Page is an empty string when there is no further page + $hasNext = !empty($responseHeaders['x-next-page']); return [ - 'items' => array_values(array_slice($branches, $offset, $perPage)), - 'hasNext' => ($offset + $perPage) < count($branches), + 'items' => $branches, + 'hasNext' => $hasNext, 'nextCursor' => null, ]; } From 9275809fc2d20f705312ee46c8b0b805acad9555 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 26 May 2026 17:52:21 +0530 Subject: [PATCH 16/17] Rename \$page to \$requestedPage in Gogs listBranches for clarity The parameter was being reassigned and then used 10 lines later for client-side slicing, which looked like dead code. Renaming it and adding a comment makes the intent clear. --- src/VCS/Adapter/Git/Gogs.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/VCS/Adapter/Git/Gogs.php b/src/VCS/Adapter/Git/Gogs.php index 43f4d78e..b1334be5 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -516,8 +516,10 @@ public function getCommitStatuses(string $owner, string $repositoryName, string */ 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); - $page = is_int($page) ? max($page, 1) : 1; + $requestedPage = is_int($page) ? max($page, 1) : 1; $url = "/repos/{$owner}/{$repositoryName}/branches"; $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); @@ -550,7 +552,7 @@ public function listBranches(string $owner, string $repositoryName, int $perPage $branches = array_values(array_filter($branches, fn ($branch) => str_starts_with($branch, $search))); } - $offset = ($page - 1) * $perPage; + $offset = ($requestedPage - 1) * $perPage; return [ 'items' => array_values(array_slice($branches, $offset, $perPage)), From f51aa66a16df46a0ae2f2fceb21ff2454e230c75 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 26 May 2026 17:56:06 +0530 Subject: [PATCH 17/17] Document that Gitea has no server-side branch search in listBranches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live test on Codeberg (Forgejo/Gitea) confirmed the q param is silently ignored — fetch-all + client-side str_starts_with is the only option. --- src/VCS/Adapter/Git/Gitea.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index cacf25ae..88b3c4c9 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -733,6 +733,9 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, */ 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 = []; $requestedPerPage = min(max($perPage, 1), 100); $requestedPage = is_int($page) ? max($page, 1) : 1;