diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index be9504de..d0aff4b3 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -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 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, 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 diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index a06337ee..4a34bb03 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -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 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, 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) { + 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 $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, + ], + ]); - $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, + ]; } /** @@ -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."); } diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 21bdb20e..96af8b12 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -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 { $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 diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index f428650a..88b3c4c9 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -729,16 +729,21 @@ 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): 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); @@ -746,7 +751,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) { @@ -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, + ]; } /** diff --git a/src/VCS/Adapter/Git/Gogs.php b/src/VCS/Adapter/Git/Gogs.php index 6d642270..b1334be5 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 - $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"); } @@ -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 + * @return array{items: array, 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"]); @@ -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) { @@ -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 = []; @@ -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, + ]; } } diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index f929b3a5..916eddd8 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); } @@ -540,14 +546,41 @@ 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); + $this->assertSame(['branch-a'], $page1['items']); + $this->assertTrue($page1['hasNext']); + $this->assertNotEmpty($page1['nextCursor']); + + $page2 = $adapter->listBranches(static::$owner, $repositoryName, 1, $page1['nextCursor']); + $this->assertSame(['branch-b'], $page2['items']); + $this->assertTrue($page2['hasNext']); + $this->assertNotEmpty($page2['nextCursor']); - $page2 = $adapter->listBranches(static::$owner, $repositoryName, 1, 2); - $this->assertSame(['branch-b'], $page2); + $page3 = $adapter->listBranches(static::$owner, $repositoryName, 1, $page2['nextCursor']); + $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']); + + $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); } @@ -559,10 +592,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 +607,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 diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 84e5918f..33d008ac 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -1046,11 +1046,37 @@ public function testListBranches(): void $result = $this->vcsAdapter->listBranches(static::$owner, $repositoryName); $this->assertIsArray($result); - $this->assertNotEmpty($result); - - $this->assertContains(static::$defaultBranch, $result); - $this->assertContains('feature-branch', $result); - $this->assertContains('another-branch', $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-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['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['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['items']); + $this->assertFalse($substringSearch['hasNext']); + $this->assertNull($substringSearch['nextCursor']); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -1063,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 507b0c51..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); } @@ -1437,36 +1439,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([$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['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['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();