diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 8f13071e..c7c3fd10 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -117,12 +117,88 @@ public function createOrganization(string $orgName): string return $responseBody['name'] ?? ''; } - // Stub methods to satisfy abstract class requirements - // These will be implemented in follow-up PRs - + /** + * Search repositories in organization + * + * @param string $installationId Not used in Gitea (kept for interface compatibility) + * @param string $owner Organization or user name + * @param int $page Page number for pagination + * @param int $per_page Number of results per page + * @param string $search Search query to filter repository names + * @return array Array with 'items' (repositories) and 'total' count + */ public function searchRepositories(string $installationId, string $owner, int $page, int $per_page, string $search = ''): array { - throw new Exception("Not implemented yet"); + $filteredRepos = []; + $currentPage = 1; + $maxPages = 50; + + $neededForPage = $page * $per_page; + $maxToCollect = $neededForPage + $per_page; + + while ($currentPage <= $maxPages) { + $queryParams = [ + 'page' => $currentPage, + 'limit' => 100, + ]; + + if (!empty($search)) { + $queryParams['q'] = $search; + } + + $query = http_build_query($queryParams); + $url = "/repos/search?{$query}"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Repository search failed with status code {$responseHeadersStatusCode}"); + } + + $responseBody = $response['body'] ?? []; + + if (!is_array($responseBody)) { + throw new Exception('Unexpected response body: ' . json_encode($responseBody)); + } + + if (!array_key_exists('data', $responseBody)) { + throw new Exception("Repositories list missing in response: " . json_encode($responseBody)); + } + + $repos = $responseBody['data']; + + if (empty($repos)) { + break; + } + + foreach ($repos as $repo) { + $repoOwner = $repo['owner']['login'] ?? ''; + if ($repoOwner === $owner) { + $filteredRepos[] = $repo; + + if (count($filteredRepos) >= $maxToCollect) { + break 2; + } + } + } + + if (count($repos) < 100) { + break; + } + + $currentPage++; + } + + $total = count($filteredRepos); + $offset = ($page - 1) * $per_page; + $pagedRepos = array_slice($filteredRepos, $offset, $per_page); + + return [ + 'items' => $pagedRepos, + 'total' => $total, + ]; } public function getInstallationRepository(string $repositoryName): array @@ -457,7 +533,7 @@ public function getUser(string $username): array public function getOwnerName(string $installationId): string { - throw new Exception("Not implemented yet"); + throw new Exception("getOwnerName() is not applicable for Gitea"); } public function getPullRequest(string $owner, string $repositoryName, int $pullRequestNumber): array @@ -493,9 +569,58 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, return $responseBody[0] ?? []; } + /** + * List all branches in a repository + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @return array Array of branch names + */ public function listBranches(string $owner, string $repositoryName): array { - throw new Exception("Not implemented yet"); + $allBranches = []; + $perPage = 50; + $maxPages = 100; + + for ($currentPage = 1; $currentPage <= $maxPages; $currentPage++) { + $url = "/repos/{$owner}/{$repositoryName}/branches?page={$currentPage}&limit={$perPage}"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + + if ($responseHeadersStatusCode === 404) { + return []; + } + + if ($responseHeadersStatusCode >= 400) { + if ($currentPage === 1) { + throw new Exception("Failed to list branches: HTTP {$responseHeadersStatusCode}"); + } + break; + } + + $responseBody = $response['body'] ?? []; + + if (!is_array($responseBody)) { + break; + } + + $pageCount = 0; + foreach ($responseBody as $branch) { + if (is_array($branch) && array_key_exists('name', $branch)) { + $allBranches[] = $branch['name'] ?? ''; + $pageCount++; + } + } + + if ($pageCount < $perPage) { + break; + } + } + + return $allBranches; } /** diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 4cf47237..4405bab3 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -614,7 +614,84 @@ public function testGetEvent(): void } public function testSearchRepositories(): void { - $this->markTestSkipped('Will be implemented in follow-up PR'); + // Create multiple repositories + $repo1Name = 'test-search-repo1-' . \uniqid(); + $repo2Name = 'test-search-repo2-' . \uniqid(); + $repo3Name = 'other-repo-' . \uniqid(); + + $this->vcsAdapter->createRepository(self::$owner, $repo1Name, false); + $this->vcsAdapter->createRepository(self::$owner, $repo2Name, false); + $this->vcsAdapter->createRepository(self::$owner, $repo3Name, false); + + try { + // Search without filter - should return all + $result = $this->vcsAdapter->searchRepositories('', self::$owner, 1, 10); + + $this->assertIsArray($result); + $this->assertArrayHasKey('items', $result); + $this->assertArrayHasKey('total', $result); + $this->assertGreaterThanOrEqual(3, $result['total']); + + // Search with filter + $result = $this->vcsAdapter->searchRepositories('', self::$owner, 1, 10, 'test-search'); + + $this->assertIsArray($result); + $this->assertGreaterThanOrEqual(2, $result['total']); + + // Verify the filtered repos are in results + $repoNames = array_column($result['items'], 'name'); + $this->assertContains($repo1Name, $repoNames); + $this->assertContains($repo2Name, $repoNames); + } finally { + $this->vcsAdapter->deleteRepository(self::$owner, $repo1Name); + $this->vcsAdapter->deleteRepository(self::$owner, $repo2Name); + $this->vcsAdapter->deleteRepository(self::$owner, $repo3Name); + } + } + + public function testSearchRepositoriesPagination(): void + { + $repo1 = 'test-pagination-1-' . \uniqid(); + $repo2 = 'test-pagination-2-' . \uniqid(); + + $this->vcsAdapter->createRepository(self::$owner, $repo1, false); + $this->vcsAdapter->createRepository(self::$owner, $repo2, false); + + try { + $result = $this->vcsAdapter->searchRepositories('', self::$owner, 1, 1, 'test-pagination'); + + $this->assertSame(1, count($result['items'])); + $this->assertGreaterThanOrEqual(2, $result['total']); + + $result2 = $this->vcsAdapter->searchRepositories('', self::$owner, 2, 1, 'test-pagination'); + $this->assertSame(1, count($result2['items'])); + + $result20 = $this->vcsAdapter->searchRepositories('', self::$owner, 20, 1, 'test-pagination'); + $this->assertIsArray($result20); + $this->assertEmpty($result20['items']); + + } finally { + $this->vcsAdapter->deleteRepository(self::$owner, $repo1); + $this->vcsAdapter->deleteRepository(self::$owner, $repo2); + } + } + + public function testSearchRepositoriesNoResults(): void + { + $result = $this->vcsAdapter->searchRepositories('', self::$owner, 1, 10, 'nonexistent-repo-xyz-' . \uniqid()); + + $this->assertIsArray($result); + $this->assertEmpty($result['items']); + $this->assertSame(0, $result['total']); + } + + public function testSearchRepositoriesInvalidOwner(): void + { + $result = $this->vcsAdapter->searchRepositories('', 'nonexistent-owner-' . \uniqid(), 1, 10); + + $this->assertIsArray($result); + $this->assertEmpty($result['items']); + $this->assertSame(0, $result['total']); } public function testDeleteRepository(): void @@ -645,7 +722,18 @@ public function testDeleteNonExistingRepositoryFails(): void public function testGetOwnerName(): void { - $this->markTestSkipped('Will be implemented in follow-up PR'); + $this->expectException(\Exception::class); + $this->expectExceptionMessage('not applicable for Gitea'); + + $this->vcsAdapter->getOwnerName(''); + } + + public function testGetOwnerNameWithRandomInput(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('not applicable for Gitea'); + + $this->vcsAdapter->getOwnerName('random-gibberish-' . \uniqid()); } public function testGetPullRequestFromBranch(): void @@ -760,7 +848,38 @@ public function testCreateFileOnBranch(): void public function testListBranches(): void { - $this->markTestSkipped('Will be implemented in follow-up PR'); + $repositoryName = 'test-list-branches-' . \uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + + try { + // Create initial file on main branch + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test'); + + // Create additional branches + $this->vcsAdapter->createBranch(self::$owner, $repositoryName, 'feature-1', 'main'); + $this->vcsAdapter->createBranch(self::$owner, $repositoryName, 'feature-2', 'main'); + + $branches = []; + $maxAttempts = 10; + for ($attempt = 0; $attempt < $maxAttempts; $attempt++) { + $branches = $this->vcsAdapter->listBranches(self::$owner, $repositoryName); + + if (in_array('feature-1', $branches, true) && in_array('feature-2', $branches, true)) { + break; + } + + usleep(500000); + } + + $this->assertIsArray($branches); + $this->assertNotEmpty($branches); + $this->assertContains('main', $branches); + $this->assertContains('feature-1', $branches); + $this->assertContains('feature-2', $branches); + $this->assertGreaterThanOrEqual(3, count($branches)); + } finally { + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } } public function testListRepositoryLanguages(): void