diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index ab8c48cf..1c542f90 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 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): 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 d9b45f6c..611d6f98 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -747,27 +747,104 @@ 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 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 $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 + { + $refPrefix = 'refs/heads/' . $search; + $query = <<<'GRAPHQL' +query ListBranches($owner: String!, $name: String!, $refPrefix: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + refs(refPrefix: $refPrefix, first: $first, after: $after, 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, + 'refPrefix' => $refPrefix, + 'first' => $perPage, + 'after' => $cursor, + ], ]); $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 988cd3e7..78223d72 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -524,15 +524,17 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, throw new Exception("Not implemented"); } - 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; $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; @@ -546,10 +548,18 @@ public function listBranches(string $owner, string $repositoryName): array foreach ($responseBody as $branch) { $branches[] = $branch['name'] ?? ''; } - $page++; + $currentPage++; } while (count($responseBody) === 100); - return $branches; + if ($search !== '') { + $branches = array_values(array_filter($branches, fn ($branch) => str_starts_with($branch, $search))); + } + + if ($search === '' && $requestedPage === 1 && $perPage === 100) { + return $branches; + } + + 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 bc6544ef..54f9f31a 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -715,14 +715,16 @@ 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; + $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"]); @@ -754,12 +756,20 @@ 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))); + } + + 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 4a060ece..2aba1e43 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -490,8 +490,10 @@ 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 { + $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"]); @@ -520,6 +522,14 @@ 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))); + } + + if ($search === '' && $page === 1 && $perPage === 100) { + return $branches; + } + + return array_slice($branches, ($page - 1) * $perPage, $perPage); } } diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index f87ff182..90633566 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -471,11 +471,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); } @@ -539,13 +545,42 @@ 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']); + + $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); } @@ -557,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); } @@ -568,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 fa4600ff..6b5d4c25 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -870,6 +870,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 4fafbf91..42cac81b 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -1438,6 +1438,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); }