From 4e33e095760083fb6aada1b89fea65ebc3958ebe Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 11 Mar 2026 11:35:20 +0530 Subject: [PATCH 1/5] feat: Add Gitea repository operations endpoints - Implement searchRepositories with client-side owner filtering - Implement listBranches to list all branches in repository - Implement getOwnerName (returns installationId for Gitea) - Add comprehensive tests for all three methods - Tests include try/finally cleanup and timing delays for branch creation --- src/VCS/Adapter/Git/Gitea.php | 72 ++++++++++++++++++++++++++++++--- tests/VCS/Adapter/GiteaTest.php | 67 ++++++++++++++++++++++++++++-- 2 files changed, 130 insertions(+), 9 deletions(-) diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 568869a4..10610da4 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -117,12 +117,47 @@ 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"); + $queryParams = [ + 'page' => $page, + 'limit' => $per_page, + ]; + + 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"]); + + $responseBody = $response['body'] ?? []; + + // Filter by owner client-side + $allRepos = $responseBody['data'] ?? []; + $filteredRepos = array_filter($allRepos, function ($repo) use ($owner) { + $repoOwner = $repo['owner']['login'] ?? ''; + return $repoOwner === $owner; + }); + + $filteredRepos = array_values($filteredRepos); // Re-index + + return [ + 'items' => $filteredRepos, + 'total' => count($filteredRepos), + ]; } public function getInstallationRepository(string $repositoryName): array @@ -367,9 +402,16 @@ public function getUser(string $username): array throw new Exception("Not implemented yet"); } + /** + * Get owner name + * @param string $installationId In Gitea context, this is the owner name itself + * @return string Owner name + */ public function getOwnerName(string $installationId): string { - throw new Exception("Not implemented yet"); + // Gitea doesn't have GitHub App installation concept + // Return the installationId as-is since it represents the owner + return $installationId; } public function getPullRequest(string $owner, string $repositoryName, int $pullRequestNumber): array @@ -382,9 +424,27 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, throw new Exception("Not implemented yet"); } + /** + * 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"); + $url = "/repos/{$owner}/{$repositoryName}/branches"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + + $responseBody = $response['body'] ?? []; + + $names = []; + foreach ($responseBody as $branch) { + $names[] = $branch['name'] ?? ''; + } + + return $names; } /** diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index be115b90..0a39b43f 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -469,7 +469,39 @@ 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 testDeleteRepository(): void @@ -500,7 +532,12 @@ public function testDeleteNonExistingRepositoryFails(): void public function testGetOwnerName(): void { - $this->markTestSkipped('Will be implemented in follow-up PR'); + // For Gitea, getOwnerName simply returns the installationId parameter + // since Gitea doesn't have GitHub App installation concept + $result = $this->vcsAdapter->getOwnerName(self::$owner); + + $this->assertIsString($result); + $this->assertSame(self::$owner, $result); } public function testGetPullRequestFromBranch(): void @@ -515,7 +552,31 @@ public function testCreateComment(): 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'); + sleep(1); + $this->vcsAdapter->createBranch(self::$owner, $repositoryName, 'feature-2', 'main'); + sleep(1); + + // Test listBranches + $branches = $this->vcsAdapter->listBranches(self::$owner, $repositoryName); + + $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 From 3152bc5aabecf651e8e3ba2a7ec86816fc76be72 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 11 Mar 2026 11:55:31 +0530 Subject: [PATCH 2/5] fix: Address bot review feedback on repository operations - searchRepositories: Fetch all pages before filtering to get accurate totals - listBranches: Add HTTP status checks and array validation --- src/VCS/Adapter/Git/Gitea.php | 67 +++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 10610da4..8b7a09cd 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -129,37 +129,56 @@ public function createOrganization(string $orgName): string */ public function searchRepositories(string $installationId, string $owner, int $page, int $per_page, string $search = ''): array { - $queryParams = [ - 'page' => $page, - 'limit' => $per_page, - ]; + $allRepos = []; + $currentPage = 1; - if (!empty($search)) { - $queryParams['q'] = $search; - } + while (true) { + $queryParams = [ + 'page' => $currentPage, + 'limit' => 100, + ]; - $query = http_build_query($queryParams); - $url = "/repos/search?{$query}"; + if (!empty($search)) { + $queryParams['q'] = $search; + } - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + $query = http_build_query($queryParams); + $url = "/repos/search?{$query}"; - $responseBody = $response['body'] ?? []; + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + + $responseBody = $response['body'] ?? []; + $repos = $responseBody['data'] ?? []; + + if (empty($repos)) { + break; + } + + $allRepos = array_merge($allRepos, $repos); + + if (count($repos) < 100) { + break; + } + + $currentPage++; + } - // Filter by owner client-side - $allRepos = $responseBody['data'] ?? []; $filteredRepos = array_filter($allRepos, function ($repo) use ($owner) { $repoOwner = $repo['owner']['login'] ?? ''; return $repoOwner === $owner; }); - $filteredRepos = array_values($filteredRepos); // Re-index + $filteredRepos = array_values($filteredRepos); + + $total = count($filteredRepos); + $offset = ($page - 1) * $per_page; + $pagedRepos = array_slice($filteredRepos, $offset, $per_page); return [ - 'items' => $filteredRepos, - 'total' => count($filteredRepos), + 'items' => $pagedRepos, + 'total' => $total, ]; } - public function getInstallationRepository(string $repositoryName): array { throw new Exception("Not implemented yet"); @@ -437,11 +456,23 @@ public function listBranches(string $owner, string $repositoryName): array $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + return []; + } + $responseBody = $response['body'] ?? []; + if (!is_array($responseBody)) { + return []; + } + $names = []; foreach ($responseBody as $branch) { - $names[] = $branch['name'] ?? ''; + if (is_array($branch) && array_key_exists('name', $branch)) { + $names[] = $branch['name'] ?? ''; + } } return $names; From ff723d499d52cec784c468cf6f50e627ef154165 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 11 Mar 2026 12:09:43 +0530 Subject: [PATCH 3/5] fix: Add error handling in searchRepositories loop - Check HTTP status code in pagination loop - Throw exception if data key is missing - Prevent silent failures from being treated as empty results --- src/VCS/Adapter/Git/Gitea.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 8b7a09cd..e66c16e5 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -147,8 +147,19 @@ public function searchRepositories(string $installationId, string $owner, int $p $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'] ?? []; - $repos = $responseBody['data'] ?? []; + + if (!array_key_exists('data', $responseBody)) { + throw new Exception("Repositories list missing in the response."); + } + + $repos = $responseBody['data']; if (empty($repos)) { break; @@ -179,6 +190,7 @@ public function searchRepositories(string $installationId, string $owner, int $p 'total' => $total, ]; } + public function getInstallationRepository(string $repositoryName): array { throw new Exception("Not implemented yet"); From 35603528ffdeb094a09ce2109d708145c959a86e Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 17 Mar 2026 21:39:45 +0530 Subject: [PATCH 4/5] updated with suggestions --- src/VCS/Adapter/Git/Gitea.php | 92 ++++++++++++++++++++------------- tests/VCS/Adapter/GiteaTest.php | 65 +++++++++++++++++++---- 2 files changed, 113 insertions(+), 44 deletions(-) diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index e66c16e5..30089161 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -129,10 +129,14 @@ public function createOrganization(string $orgName): string */ public function searchRepositories(string $installationId, string $owner, int $page, int $per_page, string $search = ''): array { - $allRepos = []; + $filteredRepos = []; $currentPage = 1; + $maxPages = 50; - while (true) { + $neededForPage = $page * $per_page; + $maxToCollect = $neededForPage + $per_page; + + while ($currentPage <= $maxPages) { $queryParams = [ 'page' => $currentPage, 'limit' => 100, @@ -155,8 +159,12 @@ public function searchRepositories(string $installationId, string $owner, int $p $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 the response."); + throw new Exception("Repositories list missing in response: " . json_encode($responseBody)); } $repos = $responseBody['data']; @@ -165,7 +173,16 @@ public function searchRepositories(string $installationId, string $owner, int $p break; } - $allRepos = array_merge($allRepos, $repos); + foreach ($repos as $repo) { + $repoOwner = $repo['owner']['login'] ?? ''; + if ($repoOwner === $owner) { + $filteredRepos[] = $repo; + + if (count($filteredRepos) >= $maxToCollect) { + break 2; + } + } + } if (count($repos) < 100) { break; @@ -174,13 +191,6 @@ public function searchRepositories(string $installationId, string $owner, int $p $currentPage++; } - $filteredRepos = array_filter($allRepos, function ($repo) use ($owner) { - $repoOwner = $repo['owner']['login'] ?? ''; - return $repoOwner === $owner; - }); - - $filteredRepos = array_values($filteredRepos); - $total = count($filteredRepos); $offset = ($page - 1) * $per_page; $pagedRepos = array_slice($filteredRepos, $offset, $per_page); @@ -433,16 +443,9 @@ public function getUser(string $username): array throw new Exception("Not implemented yet"); } - /** - * Get owner name - * @param string $installationId In Gitea context, this is the owner name itself - * @return string Owner name - */ public function getOwnerName(string $installationId): string { - // Gitea doesn't have GitHub App installation concept - // Return the installationId as-is since it represents the owner - return $installationId; + throw new Exception("getOwnerName() is not applicable for Gitea"); } public function getPullRequest(string $owner, string $repositoryName, int $pullRequestNumber): array @@ -464,30 +467,49 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, */ public function listBranches(string $owner, string $repositoryName): array { - $url = "/repos/{$owner}/{$repositoryName}/branches"; + $allBranches = []; + $perPage = 50; + $maxPages = 100; - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + for ($currentPage = 1; $currentPage <= $maxPages; $currentPage++) { + $url = "/repos/{$owner}/{$repositoryName}/branches?page={$currentPage}&limit={$perPage}"; - $responseHeaders = $response['headers'] ?? []; - $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; - if ($responseHeadersStatusCode >= 400) { - return []; - } + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); - $responseBody = $response['body'] ?? []; + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; - if (!is_array($responseBody)) { - return []; - } + if ($responseHeadersStatusCode === 404) { + return []; + } + + if ($responseHeadersStatusCode >= 400) { + if ($currentPage === 1) { + throw new Exception("Failed to list branches: HTTP {$responseHeadersStatusCode}"); + } + break; + } - $names = []; - foreach ($responseBody as $branch) { - if (is_array($branch) && array_key_exists('name', $branch)) { - $names[] = $branch['name'] ?? ''; + $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 $names; + return $allBranches; } /** diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 0a39b43f..49d0cfbc 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -504,6 +504,48 @@ public function testSearchRepositories(): void } } + 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 { + // Test limit=1 only returns 1 repo + $result = $this->vcsAdapter->searchRepositories('', self::$owner, 1, 1, 'test-pagination'); + + $this->assertSame(1, count($result['items'])); + $this->assertGreaterThanOrEqual(2, $result['total']); + + // Test page 2 + $result2 = $this->vcsAdapter->searchRepositories('', self::$owner, 2, 1, 'test-pagination'); + $this->assertSame(1, count($result2['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 { $repositoryName = 'test-delete-repository-' . \uniqid(); @@ -532,12 +574,10 @@ public function testDeleteNonExistingRepositoryFails(): void public function testGetOwnerName(): void { - // For Gitea, getOwnerName simply returns the installationId parameter - // since Gitea doesn't have GitHub App installation concept - $result = $this->vcsAdapter->getOwnerName(self::$owner); + $this->expectException(\Exception::class); + $this->expectExceptionMessage('not applicable for Gitea'); - $this->assertIsString($result); - $this->assertSame(self::$owner, $result); + $this->vcsAdapter->getOwnerName(''); } public function testGetPullRequestFromBranch(): void @@ -561,12 +601,19 @@ public function testListBranches(): void // Create additional branches $this->vcsAdapter->createBranch(self::$owner, $repositoryName, 'feature-1', 'main'); - sleep(1); $this->vcsAdapter->createBranch(self::$owner, $repositoryName, 'feature-2', 'main'); - sleep(1); - // Test listBranches - $branches = $this->vcsAdapter->listBranches(self::$owner, $repositoryName); + $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); From 8d35393e5309408a6557447c50a7bdcb061ace4d Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 19 Mar 2026 10:59:31 +0530 Subject: [PATCH 5/5] updated with suggestion of pagination test and the owner name test --- tests/VCS/Adapter/GiteaTest.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 95754aaa..4405bab3 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -658,15 +658,18 @@ public function testSearchRepositoriesPagination(): void $this->vcsAdapter->createRepository(self::$owner, $repo2, false); try { - // Test limit=1 only returns 1 repo $result = $this->vcsAdapter->searchRepositories('', self::$owner, 1, 1, 'test-pagination'); $this->assertSame(1, count($result['items'])); $this->assertGreaterThanOrEqual(2, $result['total']); - // Test page 2 $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); @@ -725,6 +728,14 @@ public function testGetOwnerName(): void $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 { $repositoryName = 'test-get-pr-from-branch-' . \uniqid();