Skip to content

Commit 3a84574

Browse files
committed
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).
1 parent e0d567c commit 3a84574

7 files changed

Lines changed: 122 additions & 57 deletions

File tree

src/VCS/Adapter.php

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,14 +221,21 @@ abstract public function getEvent(string $event, string $payload): array;
221221
abstract public function getRepositoryName(string $repositoryId): string;
222222

223223
/**
224-
* Lists branches for a given repository
224+
* Lists branches for a given repository.
225+
*
226+
* Search is prefix-based: 'feat' matches 'feature-branch' but not 'my-feature'.
227+
* GitHub uses true server-side cursor pagination via GraphQL; pass the returned
228+
* nextCursor as $page on subsequent calls to advance the page.
229+
* Other providers (GitLab, Gitea, Gogs, Forgejo) fetch all matching branches
230+
* client-side and slice; for them nextCursor is always null, but hasNext
231+
* correctly reflects whether more items exist beyond the current slice.
225232
*
226233
* @param string $owner Owner name of the repository
227234
* @param string $repositoryName Name of the repository
228-
* @param int $perPage Number of branches to fetch per page
229-
* @param int|string|null $page Page number or cursor to start fetching from
230-
* @param string $search Branch name prefix search query
231-
* @return array<string>|array{items: array<string>, hasNext: bool, nextCursor?: string|null} List of branch names or branch names with pagination metadata
235+
* @param int $perPage Number of results per page, clamped to [1, 100]
236+
* @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)
237+
* @param string $search Prefix filter for branch names; empty string returns all branches
238+
* @return array{items: array<string>, hasNext: bool, nextCursor: string|null}
232239
*/
233240
abstract public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int|string|null $page = 1, string $search = ''): array;
234241

src/VCS/Adapter/Git/GitHub.php

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -742,14 +742,19 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName,
742742
}
743743

744744
/**
745-
* Lists branches for a given repository
745+
* Lists branches using GitHub GraphQL repository.refs with prefix search and cursor pagination.
746746
*
747-
* @param string $owner Owner name of the repository
748-
* @param string $repositoryName Name of the GitHub repository
749-
* @param int $perPage Number of branches to fetch per page
750-
* @param int|string|null $page Page number or GraphQL cursor to start fetching from
751-
* @param string $search Branch name prefix search query
752-
* @return array{items: array<string>, hasNext: bool, nextCursor: string|null} List of branch names and pagination metadata
747+
* Search matches branch names by prefix only ('feat' → 'feature-x', not 'my-feature').
748+
* Pass an integer $page to walk forward page-by-page (each step costs one extra GraphQL call
749+
* to resolve the cursor chain); pass a cursor string from a previous nextCursor to jump
750+
* directly. perPage is clamped to [1, 100].
751+
*
752+
* @param string $owner
753+
* @param string $repositoryName
754+
* @param int $perPage Clamped to [1, 100]
755+
* @param int|string|null $page 1-based page number or opaque GraphQL cursor
756+
* @param string $search Prefix filter; empty returns all branches
757+
* @return array{items: array<string>, hasNext: bool, nextCursor: string|null}
753758
*/
754759
public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int|string|null $page = 1, string $search = ''): array
755760
{

src/VCS/Adapter/Git/GitLab.php

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,7 @@ public function listBranches(string $owner, string $repositoryName, int $perPage
539539
$responseHeaders = $response['headers'] ?? [];
540540
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
541541
if ($responseHeadersStatusCode >= 400) {
542-
return [];
542+
return ['items' => [], 'hasNext' => false, 'nextCursor' => null];
543543
}
544544
$responseBody = $response['body'] ?? [];
545545
if (!is_array($responseBody) || empty($responseBody)) {
@@ -555,11 +555,13 @@ public function listBranches(string $owner, string $repositoryName, int $perPage
555555
$branches = array_values(array_filter($branches, fn ($branch) => str_starts_with($branch, $search)));
556556
}
557557

558-
if ($search === '' && $requestedPage === 1 && $perPage === 100) {
559-
return $branches;
560-
}
558+
$offset = ($requestedPage - 1) * $perPage;
561559

562-
return array_slice($branches, ($requestedPage - 1) * $perPage, $perPage);
560+
return [
561+
'items' => array_values(array_slice($branches, $offset, $perPage)),
562+
'hasNext' => ($offset + $perPage) < count($branches),
563+
'nextCursor' => null,
564+
];
563565
}
564566

565567
public function getCommit(string $owner, string $repositoryName, string $commitHash): array

src/VCS/Adapter/Git/Gitea.php

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -732,7 +732,7 @@ public function listBranches(string $owner, string $repositoryName, int $perPage
732732
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
733733

734734
if ($responseHeadersStatusCode === 404) {
735-
return [];
735+
return ['items' => [], 'hasNext' => false, 'nextCursor' => null];
736736
}
737737

738738
if ($responseHeadersStatusCode >= 400) {
@@ -765,11 +765,13 @@ public function listBranches(string $owner, string $repositoryName, int $perPage
765765
$allBranches = array_values(array_filter($allBranches, fn ($branch) => str_starts_with($branch, $search)));
766766
}
767767

768-
if ($search === '' && $requestedPage === 1 && $requestedPerPage === 100) {
769-
return $allBranches;
770-
}
768+
$offset = ($requestedPage - 1) * $requestedPerPage;
771769

772-
return array_slice($allBranches, ($requestedPage - 1) * $requestedPerPage, $requestedPerPage);
770+
return [
771+
'items' => array_values(array_slice($allBranches, $offset, $requestedPerPage)),
772+
'hasNext' => ($offset + $requestedPerPage) < count($allBranches),
773+
'nextCursor' => null,
774+
];
773775
}
774776

775777
/**

src/VCS/Adapter/Git/Gogs.php

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,8 @@ public function getCommit(string $owner, string $repositoryName, string $commitH
246246
public function getLatestCommit(string $owner, string $repositoryName, string $branch): array
247247
{
248248
// Gogs ignores sha param — verify branch exists first
249-
$branches = $this->listBranches($owner, $repositoryName);
250-
if (!in_array($branch, $branches, true)) {
249+
$result = $this->listBranches($owner, $repositoryName);
250+
if (!in_array($branch, $result['items'], true)) {
251251
throw new Exception("Branch '{$branch}' not found");
252252
}
253253

@@ -502,7 +502,7 @@ public function listBranches(string $owner, string $repositoryName, int $perPage
502502
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
503503

504504
if ($responseHeadersStatusCode === 404) {
505-
return [];
505+
return ['items' => [], 'hasNext' => false, 'nextCursor' => null];
506506
}
507507

508508
if ($responseHeadersStatusCode >= 400) {
@@ -512,7 +512,7 @@ public function listBranches(string $owner, string $repositoryName, int $perPage
512512
$responseBody = $response['body'] ?? [];
513513

514514
if (!is_array($responseBody)) {
515-
return [];
515+
return ['items' => [], 'hasNext' => false, 'nextCursor' => null];
516516
}
517517

518518
$branches = [];
@@ -526,10 +526,12 @@ public function listBranches(string $owner, string $repositoryName, int $perPage
526526
$branches = array_values(array_filter($branches, fn ($branch) => str_starts_with($branch, $search)));
527527
}
528528

529-
if ($search === '' && $page === 1 && $perPage === 100) {
530-
return $branches;
531-
}
529+
$offset = ($page - 1) * $perPage;
532530

533-
return array_slice($branches, ($page - 1) * $perPage, $perPage);
531+
return [
532+
'items' => array_values(array_slice($branches, $offset, $perPage)),
533+
'hasNext' => ($offset + $perPage) < count($branches),
534+
'nextCursor' => null,
535+
];
534536
}
535537
}

tests/VCS/Adapter/GitLabTest.php

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -865,27 +865,52 @@ public function testListBranches(): void
865865
$result = $this->vcsAdapter->listBranches(static::$owner, $repositoryName);
866866

867867
$this->assertIsArray($result);
868-
$this->assertNotEmpty($result);
868+
$this->assertArrayHasKey('items', $result);
869+
$this->assertArrayHasKey('hasNext', $result);
870+
$this->assertArrayHasKey('nextCursor', $result);
871+
$this->assertNotEmpty($result['items']);
872+
$this->assertNull($result['nextCursor']);
869873

870-
$this->assertContains(static::$defaultBranch, $result);
871-
$this->assertContains('feature-branch', $result);
872-
$this->assertContains('another-branch', $result);
874+
$this->assertContains(static::$defaultBranch, $result['items']);
875+
$this->assertContains('feature-branch', $result['items']);
876+
$this->assertContains('another-branch', $result['items']);
873877

878+
// Offset pagination: each page of size 1 reports more items ahead
874879
$page1 = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 1, 1);
875880
$page2 = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 1, 2);
876-
$this->assertSame([$result[0]], $page1);
877-
$this->assertSame([$result[1]], $page2);
878-
881+
$this->assertSame([$result['items'][0]], $page1['items']);
882+
$this->assertTrue($page1['hasNext']);
883+
$this->assertNull($page1['nextCursor']);
884+
$this->assertSame([$result['items'][1]], $page2['items']);
885+
$this->assertTrue($page2['hasNext']);
886+
$this->assertNull($page2['nextCursor']);
887+
888+
// Prefix search
879889
$searchResult = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 100, 1, 'feature');
880-
$this->assertSame(['feature-branch'], $searchResult);
890+
$this->assertSame(['feature-branch'], $searchResult['items']);
891+
$this->assertFalse($searchResult['hasNext']);
892+
$this->assertNull($searchResult['nextCursor']);
881893

894+
// Substring (non-prefix) search returns nothing
882895
$substringSearch = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 100, 1, 'eature');
883-
$this->assertSame([], $substringSearch);
896+
$this->assertSame([], $substringSearch['items']);
897+
$this->assertFalse($substringSearch['hasNext']);
898+
$this->assertNull($substringSearch['nextCursor']);
884899
} finally {
885900
$this->vcsAdapter->deleteRepository(static::$owner, $repositoryName);
886901
}
887902
}
888903

904+
public function testListBranchesNonExistingRepository(): void
905+
{
906+
$result = $this->vcsAdapter->listBranches(static::$owner, 'non-existing-repo-' . \uniqid());
907+
908+
$this->assertIsArray($result);
909+
$this->assertSame([], $result['items']);
910+
$this->assertFalse($result['hasNext']);
911+
$this->assertNull($result['nextCursor']);
912+
}
913+
889914
public function testListRepositoryLanguages(): void
890915
{
891916
$repositoryName = 'test-list-repository-languages-' . \uniqid();

tests/VCS/Adapter/GiteaTest.php

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1413,47 +1413,69 @@ public function testListBranches(): void
14131413
$this->vcsAdapter->createRepository(static::$owner, $repositoryName, false);
14141414

14151415
try {
1416-
// Create initial file on main branch
14171416
$this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test');
1418-
1419-
// Create additional branches
14201417
$this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'feature-1', static::$defaultBranch);
14211418
$this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'feature-2', static::$defaultBranch);
14221419

1423-
$branches = [];
1420+
$result = [];
14241421
$maxAttempts = 10;
14251422
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
1426-
$branches = $this->vcsAdapter->listBranches(static::$owner, $repositoryName);
1423+
$result = $this->vcsAdapter->listBranches(static::$owner, $repositoryName);
14271424

1428-
if (in_array('feature-1', $branches, true) && in_array('feature-2', $branches, true)) {
1425+
if (in_array('feature-1', $result['items'], true) && in_array('feature-2', $result['items'], true)) {
14291426
break;
14301427
}
14311428

14321429
usleep(500000);
14331430
}
14341431

1435-
$this->assertIsArray($branches);
1436-
$this->assertNotEmpty($branches);
1437-
$this->assertContains(static::$defaultBranch, $branches);
1438-
$this->assertContains('feature-1', $branches);
1439-
$this->assertContains('feature-2', $branches);
1440-
$this->assertGreaterThanOrEqual(3, count($branches));
1441-
1432+
$this->assertIsArray($result);
1433+
$this->assertArrayHasKey('items', $result);
1434+
$this->assertArrayHasKey('hasNext', $result);
1435+
$this->assertArrayHasKey('nextCursor', $result);
1436+
$this->assertNotEmpty($result['items']);
1437+
$this->assertNull($result['nextCursor']);
1438+
$this->assertContains(static::$defaultBranch, $result['items']);
1439+
$this->assertContains('feature-1', $result['items']);
1440+
$this->assertContains('feature-2', $result['items']);
1441+
$this->assertGreaterThanOrEqual(3, count($result['items']));
1442+
1443+
// Offset pagination: size-1 pages report hasNext until the last slice
14421444
$page1 = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 1, 1);
14431445
$page2 = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 1, 2);
1444-
$this->assertSame([$branches[0]], $page1);
1445-
$this->assertSame([$branches[1]], $page2);
1446-
1446+
$this->assertSame([$result['items'][0]], $page1['items']);
1447+
$this->assertTrue($page1['hasNext']);
1448+
$this->assertNull($page1['nextCursor']);
1449+
$this->assertSame([$result['items'][1]], $page2['items']);
1450+
$this->assertTrue($page2['hasNext']);
1451+
$this->assertNull($page2['nextCursor']);
1452+
1453+
// Prefix search
14471454
$searchResult = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 100, 1, 'feature');
1448-
$this->assertEqualsCanonicalizing(['feature-1', 'feature-2'], $searchResult);
1455+
$this->assertEqualsCanonicalizing(['feature-1', 'feature-2'], $searchResult['items']);
1456+
$this->assertFalse($searchResult['hasNext']);
1457+
$this->assertNull($searchResult['nextCursor']);
14491458

1459+
// Substring (non-prefix) search returns nothing
14501460
$substringSearch = $this->vcsAdapter->listBranches(static::$owner, $repositoryName, 100, 1, 'eature');
1451-
$this->assertSame([], $substringSearch);
1461+
$this->assertSame([], $substringSearch['items']);
1462+
$this->assertFalse($substringSearch['hasNext']);
1463+
$this->assertNull($substringSearch['nextCursor']);
14521464
} finally {
14531465
$this->vcsAdapter->deleteRepository(static::$owner, $repositoryName);
14541466
}
14551467
}
14561468

1469+
public function testListBranchesNonExistingRepository(): void
1470+
{
1471+
$result = $this->vcsAdapter->listBranches(static::$owner, 'non-existing-repo-' . \uniqid());
1472+
1473+
$this->assertIsArray($result);
1474+
$this->assertSame([], $result['items']);
1475+
$this->assertFalse($result['hasNext']);
1476+
$this->assertNull($result['nextCursor']);
1477+
}
1478+
14571479
public function testCreateTag(): void
14581480
{
14591481
$repositoryName = 'test-create-tag-' . \uniqid();

0 commit comments

Comments
 (0)