From d6b99991abf1b38c2c9b89d6a74ecb0010997985 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 16 Mar 2026 19:15:03 +0530 Subject: [PATCH 1/3] feat: Add Gitea git operations and user endpoints - Implement updateCommitStatus for CI/CD status updates - Implement generateCloneCommand for branch/commit/tag cloning - Implement getUser to fetch user information - Implement createTag to create repository tags - Mark getInstallationRepository as not applicable (throws exception) - Add comprehensive tests for all methods - Include error cases and try/finally cleanup - Follow PR #64 null safety pattern --- src/VCS/Adapter/Git/Gitea.php | 171 ++++++++++++++++++++++++++++- tests/VCS/Adapter/GiteaTest.php | 186 +++++++++++++++++++++++++++++++- 2 files changed, 350 insertions(+), 7 deletions(-) diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 568869a4..72ed5b88 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -125,9 +125,19 @@ public function searchRepositories(string $installationId, string $owner, int $p throw new Exception("Not implemented yet"); } + /** + * Get installation repository + * + * Note: Gitea doesn't have GitHub App installations. + * This method is not applicable and throws an exception. + * + * @param string $repositoryName Name of the repository + * @return array + * @throws Exception Always throws as installations don't exist in Gitea + */ public function getInstallationRepository(string $repositoryName): array { - throw new Exception("Not implemented yet"); + throw new Exception("getInstallationRepository is not applicable for Gitea - use getRepository() with owner and repo name instead"); } public function getRepository(string $owner, string $repositoryName): array @@ -362,9 +372,25 @@ public function updateComment(string $owner, string $repositoryName, int $commen throw new Exception("Not implemented yet"); } + /** + * Get user information + * + * @param string $username Username to look up + * @return array User information + */ public function getUser(string $username): array { - throw new Exception("Not implemented yet"); + $url = "/users/{$username}"; + + $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("Failed to get user information: HTTP {$responseHeadersStatusCode}"); + } + + return $response['body'] ?? []; } public function getOwnerName(string $installationId): string @@ -467,14 +493,112 @@ public function getLatestCommit(string $owner, string $repositoryName, string $b ]; } + /** + * Update commit status + * + * @param string $repositoryName Name of the repository + * @param string $commitHash SHA of the commit + * @param string $owner Owner of the repository + * @param string $state Status: success, error, failure, pending, warning + * @param string $description Status description + * @param string $target_url Target URL for status + * @param string $context Status context/identifier + * @return void + */ public function updateCommitStatus(string $repositoryName, string $commitHash, string $owner, string $state, string $description = '', string $target_url = '', string $context = ''): void { - throw new Exception("Not implemented yet"); + $url = "/repos/{$owner}/{$repositoryName}/statuses/{$commitHash}"; + + $body = [ + 'state' => $state, + ]; + + if (!empty($description)) { + $body['description'] = $description; + } + + if (!empty($target_url)) { + $body['target_url'] = $target_url; + } + + if (!empty($context)) { + $body['context'] = $context; + } + + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => "token $this->accessToken"], $body); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to update commit status: HTTP {$responseHeadersStatusCode}"); + } } + /** + * Generate git clone command + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param string $version Branch name, commit hash, or tag + * @param string $versionType Type: branch, commit, or tag + * @param string $directory Directory to clone into + * @param string $rootDirectory Root directory for sparse checkout + * @return string Shell command to execute + */ public function generateCloneCommand(string $owner, string $repositoryName, string $version, string $versionType, string $directory, string $rootDirectory): string { - throw new Exception("Not implemented yet"); + if (empty($rootDirectory)) { + $rootDirectory = '*'; + } + + // URL encode components + $owner = urlencode($owner); + $repositoryName = urlencode($repositoryName); + $accessToken = !empty($this->accessToken) ? ':' . urlencode($this->accessToken) : ''; + + // Construct clone URL with token + $cloneUrl = "{$this->giteaUrl}/{$owner}/{$repositoryName}"; + if (!empty($accessToken)) { + // Insert token into URL: http://token@gitea:3000/owner/repo + $cloneUrl = str_replace('://', "://{$owner}{$accessToken}@", $this->giteaUrl) . "/{$owner}/{$repositoryName}"; + } + + $directory = escapeshellarg($directory); + $rootDirectory = escapeshellarg($rootDirectory); + + $commands = [ + "mkdir -p {$directory}", + "cd {$directory}", + "git config --global init.defaultBranch main", + "git init", + "git remote add origin {$cloneUrl}", + // Enable sparse checkout + "git config core.sparseCheckout true", + "echo {$rootDirectory} >> .git/info/sparse-checkout", + // Disable fetching of refs we don't need + "git config --add remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'", + // Disable fetching of tags + "git config remote.origin.tagopt --no-tags", + ]; + + switch ($versionType) { + case self::CLONE_TYPE_BRANCH: + $branchName = escapeshellarg($version); + $commands[] = "if git ls-remote --exit-code --heads origin {$branchName}; then git pull --depth=1 origin {$branchName} && git checkout {$branchName}; else git checkout -b {$branchName}; fi"; + break; + case self::CLONE_TYPE_COMMIT: + $commitHash = escapeshellarg($version); + $commands[] = "git fetch --depth=1 origin {$commitHash} && git checkout {$commitHash}"; + break; + case self::CLONE_TYPE_TAG: + $tagName = escapeshellarg($version); + $commands[] = "git fetch --depth=1 origin refs/tags/$(git ls-remote --tags origin {$tagName} | tail -n 1 | awk -F '/' '{print \$3}') && git checkout FETCH_HEAD"; + break; + } + + $fullCommand = implode(" && ", $commands); + + return $fullCommand; } public function getEvent(string $event, string $payload): array @@ -486,4 +610,43 @@ public function validateWebhookEvent(string $payload, string $signature, string { throw new Exception("Not implemented yet"); } + + /** + * Create a tag in a repository + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param string $tagName Name of the tag (e.g., 'v1.0.0') + * @param string $target Target commit SHA or branch name + * @param string $message Tag message (optional) + * @return array Response from API + */ + public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array + { + $url = "/repos/{$owner}/{$repositoryName}/tags"; + + $payload = [ + 'tag_name' => $tagName, + 'target' => $target, + ]; + + if (!empty($message)) { + $payload['message'] = $message; + } + + $response = $this->call( + self::METHOD_POST, + $url, + ['Authorization' => "token $this->accessToken"], + $payload + ); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to create tag {$tagName}: HTTP {$responseHeadersStatusCode}"); + } + + return $response['body'] ?? []; + } } diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index be115b90..38f27980 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -345,17 +345,95 @@ public function testGetPullRequest(): void public function testGenerateCloneCommand(): void { - $this->markTestSkipped('Will be implemented in follow-up PR'); + $repositoryName = 'test-clone-command-' . \uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test'); + + $command = $this->vcsAdapter->generateCloneCommand( + self::$owner, + $repositoryName, + 'main', + \Utopia\VCS\Adapter\Git::CLONE_TYPE_BRANCH, + '/tmp/test-clone-' . \uniqid(), + '*' + ); + + $this->assertIsString($command); + $this->assertStringContainsString('git init', $command); + $this->assertStringContainsString('git remote add origin', $command); + $this->assertStringContainsString('git config core.sparseCheckout true', $command); + $this->assertStringContainsString($repositoryName, $command); + } finally { + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } } public function testGenerateCloneCommandWithCommitHash(): void { - $this->markTestSkipped('Will be implemented in follow-up PR'); + $repositoryName = 'test-clone-commit-' . \uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test'); + + $commit = $this->vcsAdapter->getLatestCommit(self::$owner, $repositoryName, 'main'); + $commitHash = $commit['commitHash']; + + $command = $this->vcsAdapter->generateCloneCommand( + self::$owner, + $repositoryName, + $commitHash, + \Utopia\VCS\Adapter\Git::CLONE_TYPE_COMMIT, + '/tmp/test-clone-commit-' . \uniqid(), + '*' + ); + + $this->assertIsString($command); + $this->assertStringContainsString('git fetch --depth=1', $command); + $this->assertStringContainsString($commitHash, $command); + } finally { + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } } public function testGenerateCloneCommandWithTag(): void { - $this->markTestSkipped('Will be implemented in follow-up PR'); + $repositoryName = 'test-clone-tag-' . \uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + + try { + // Create initial file and get commit hash + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test Tag'); + + $commit = $this->vcsAdapter->getLatestCommit(self::$owner, $repositoryName, 'main'); + $commitHash = $commit['commitHash']; + + // Create a tag + $this->vcsAdapter->createTag(self::$owner, $repositoryName, 'v1.0.0', $commitHash, 'Release v1.0.0'); + + // Generate clone command for the tag + $command = $this->vcsAdapter->generateCloneCommand( + self::$owner, + $repositoryName, + 'v1.0.0', + \Utopia\VCS\Adapter\Git::CLONE_TYPE_TAG, + '/tmp/test-clone-tag-' . \uniqid(), + '*' + ); + + // Verify the command contains tag-specific git commands + $this->assertIsString($command); + $this->assertStringContainsString('git init', $command); + $this->assertStringContainsString('git remote add origin', $command); + $this->assertStringContainsString('git config core.sparseCheckout true', $command); + $this->assertStringContainsString('refs/tags', $command); + $this->assertStringContainsString('v1.0.0', $command); + $this->assertStringContainsString('git checkout FETCH_HEAD', $command); + } finally { + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } } public function testUpdateComment(): void @@ -363,6 +441,53 @@ public function testUpdateComment(): void $this->markTestSkipped('Will be implemented in follow-up PR'); } + public function testUpdateCommitStatus(): void + { + $repositoryName = 'test-update-commit-status-' . \uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test'); + + $commit = $this->vcsAdapter->getLatestCommit(self::$owner, $repositoryName, 'main'); + $commitHash = $commit['commitHash']; + + // Test updating commit status - should not throw + $this->vcsAdapter->updateCommitStatus( + $repositoryName, + $commitHash, + self::$owner, + 'success', + 'Tests passed', + 'https://example.com/build/123', + 'ci/tests' + ); + + // If we get here without exception, test passes + $this->assertTrue(true); + } finally { + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } + } + + public function testUpdateCommitStatusWithInvalidCommit(): void + { + $repositoryName = 'test-update-status-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + + try { + $this->expectException(\Exception::class); + $this->vcsAdapter->updateCommitStatus( + $repositoryName, + 'invalid-commit-hash', + self::$owner, + 'success' + ); + } finally { + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } + } + public function testGetCommit(): void { $repositoryName = 'test-get-commit-' . \uniqid(); @@ -503,6 +628,32 @@ public function testGetOwnerName(): void $this->markTestSkipped('Will be implemented in follow-up PR'); } + public function testGetUser(): void + { + // Get current authenticated user's info + $ownerInfo = $this->vcsAdapter->getUser(self::$owner); + + $this->assertIsArray($ownerInfo); + $this->assertArrayHasKey('login', $ownerInfo); + $this->assertArrayHasKey('id', $ownerInfo); + $this->assertSame(self::$owner, $ownerInfo['login']); + } + + public function testGetUserWithInvalidUsername(): void + { + $this->expectException(\Exception::class); + $this->vcsAdapter->getUser('non-existent-user-' . \uniqid()); + } + + public function testGetInstallationRepository(): void + { + // This method is not applicable for Gitea + $this->expectException(\Exception::class); + $this->expectExceptionMessage('not applicable for Gitea'); + + $this->vcsAdapter->getInstallationRepository('any-repo-name'); + } + public function testGetPullRequestFromBranch(): void { $this->markTestSkipped('Will be implemented in follow-up PR'); @@ -518,6 +669,35 @@ public function testListBranches(): void $this->markTestSkipped('Will be implemented in follow-up PR'); } + public function testCreateTag(): void + { + $repositoryName = 'test-create-tag-' . \uniqid(); + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + + try { + // Create initial file + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test'); + + // Get commit hash + $commit = $this->vcsAdapter->getLatestCommit(self::$owner, $repositoryName, 'main'); + $commitHash = $commit['commitHash']; + + // Create tag + $result = $this->vcsAdapter->createTag( + self::$owner, + $repositoryName, + 'v1.0.0', + $commitHash, + 'First release' + ); + + $this->assertIsArray($result); + $this->assertNotEmpty($result); + } finally { + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } + } + public function testListRepositoryLanguages(): void { $repositoryName = 'test-list-repository-languages-' . \uniqid(); From b3e41f9dda3b77c8ee5ee8e4a2c8a1e1321fc22f Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 17 Mar 2026 22:11:52 +0530 Subject: [PATCH 2/3] updated with suggestions --- src/VCS/Adapter/Git/Gitea.php | 33 +++++++++++---------------------- tests/VCS/Adapter/GiteaTest.php | 18 +++++++++--------- 2 files changed, 20 insertions(+), 31 deletions(-) diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 72ed5b88..2f084797 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -380,14 +380,14 @@ public function updateComment(string $owner, string $repositoryName, int $commen */ public function getUser(string $username): array { - $url = "/users/{$username}"; + $url = "/users/" . rawurlencode($username); $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("Failed to get user information: HTTP {$responseHeadersStatusCode}"); + throw new Exception("Failed to get user: HTTP {$responseHeadersStatusCode}"); } return $response['body'] ?? []; @@ -545,24 +545,15 @@ public function updateCommitStatus(string $repositoryName, string $commitHash, s * @param string $rootDirectory Root directory for sparse checkout * @return string Shell command to execute */ - public function generateCloneCommand(string $owner, string $repositoryName, string $version, string $versionType, string $directory, string $rootDirectory): string + public function generateCloneCommand(string $owner, string $repositoryName, string $directory, string $rootDirectory, string $version, string $versionType, string $accessToken = ''): string { - if (empty($rootDirectory)) { - $rootDirectory = '*'; - } - - // URL encode components - $owner = urlencode($owner); - $repositoryName = urlencode($repositoryName); - $accessToken = !empty($this->accessToken) ? ':' . urlencode($this->accessToken) : ''; - - // Construct clone URL with token $cloneUrl = "{$this->giteaUrl}/{$owner}/{$repositoryName}"; if (!empty($accessToken)) { - // Insert token into URL: http://token@gitea:3000/owner/repo - $cloneUrl = str_replace('://', "://{$owner}{$accessToken}@", $this->giteaUrl) . "/{$owner}/{$repositoryName}"; + $cloneUrl = str_replace('://', "://{$owner}:{$accessToken}@", $this->giteaUrl) . "/{$owner}/{$repositoryName}"; } + // SECURITY FIX: Escape clone URL + $cloneUrl = escapeshellarg($cloneUrl); $directory = escapeshellarg($directory); $rootDirectory = escapeshellarg($rootDirectory); @@ -572,12 +563,9 @@ public function generateCloneCommand(string $owner, string $repositoryName, stri "git config --global init.defaultBranch main", "git init", "git remote add origin {$cloneUrl}", - // Enable sparse checkout "git config core.sparseCheckout true", "echo {$rootDirectory} >> .git/info/sparse-checkout", - // Disable fetching of refs we don't need "git config --add remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'", - // Disable fetching of tags "git config remote.origin.tagopt --no-tags", ]; @@ -592,13 +580,14 @@ public function generateCloneCommand(string $owner, string $repositoryName, stri break; case self::CLONE_TYPE_TAG: $tagName = escapeshellarg($version); - $commands[] = "git fetch --depth=1 origin refs/tags/$(git ls-remote --tags origin {$tagName} | tail -n 1 | awk -F '/' '{print \$3}') && git checkout FETCH_HEAD"; + // FIX: Add --refs to exclude peeled refs (v1.0.0^{}) + $commands[] = "git fetch --depth=1 origin refs/tags/$(git ls-remote --refs --tags origin {$tagName} | tail -n 1 | awk -F '/' '{print \$3}') && git checkout FETCH_HEAD"; break; + default: + throw new Exception("Unsupported clone type: {$versionType}"); } - $fullCommand = implode(" && ", $commands); - - return $fullCommand; + return implode(' && ', $commands); } public function getEvent(string $event, string $payload): array diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 38f27980..4398848e 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -354,10 +354,10 @@ public function testGenerateCloneCommand(): void $command = $this->vcsAdapter->generateCloneCommand( self::$owner, $repositoryName, - 'main', - \Utopia\VCS\Adapter\Git::CLONE_TYPE_BRANCH, '/tmp/test-clone-' . \uniqid(), - '*' + '/', + 'main', + \Utopia\VCS\Adapter\Git::CLONE_TYPE_BRANCH ); $this->assertIsString($command); @@ -384,10 +384,10 @@ public function testGenerateCloneCommandWithCommitHash(): void $command = $this->vcsAdapter->generateCloneCommand( self::$owner, $repositoryName, - $commitHash, - \Utopia\VCS\Adapter\Git::CLONE_TYPE_COMMIT, '/tmp/test-clone-commit-' . \uniqid(), - '*' + '/', + $commitHash, + \Utopia\VCS\Adapter\Git::CLONE_TYPE_COMMIT ); $this->assertIsString($command); @@ -417,10 +417,10 @@ public function testGenerateCloneCommandWithTag(): void $command = $this->vcsAdapter->generateCloneCommand( self::$owner, $repositoryName, - 'v1.0.0', - \Utopia\VCS\Adapter\Git::CLONE_TYPE_TAG, '/tmp/test-clone-tag-' . \uniqid(), - '*' + '/', + 'v1.0.0', + \Utopia\VCS\Adapter\Git::CLONE_TYPE_TAG ); // Verify the command contains tag-specific git commands From f886a5495e0a05eb61687e6faeabb1ae814fdc3a Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 20 Mar 2026 09:47:48 +0530 Subject: [PATCH 3/3] updated with suggestions --- src/VCS/Adapter/Git.php | 22 +++++++++ src/VCS/Adapter/Git/GitHub.php | 10 ++++ src/VCS/Adapter/Git/Gitea.php | 52 ++++++++++++++------ tests/VCS/Adapter/GiteaTest.php | 86 ++++++++++++++++++++++++++------- 4 files changed, 138 insertions(+), 32 deletions(-) diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index 6a4a7edf..a177640c 100644 --- a/src/VCS/Adapter/Git.php +++ b/src/VCS/Adapter/Git.php @@ -70,4 +70,26 @@ abstract public function createBranch(string $owner, string $repositoryName, str * @return array Created PR details */ abstract public function createPullRequest(string $owner, string $repositoryName, string $title, string $head, string $base, string $body = ''): array; + + /** + * Create a tag in a repository + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param string $tagName Name of the tag (e.g., 'v1.0.0') + * @param string $target Target commit SHA or branch name + * @param string $message Tag message (optional) + * @return array Created tag details + */ + abstract public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array; + + /** + * Get commit statuses + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param string $commitHash SHA of the commit + * @return array List of commit statuses + */ + abstract public function getCommitStatuses(string $owner, string $repositoryName, string $commitHash): array; } diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index b57290d1..1be7e41c 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -983,4 +983,14 @@ public function validateWebhookEvent(string $payload, string $signature, string { return $signature === ('sha256=' . hash_hmac('sha256', $payload, $signatureKey)); } + + public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array + { + throw new Exception('createTag() is not implemented for GitHub'); + } + + public function getCommitStatuses(string $owner, string $repositoryName, string $commitHash): array + { + throw new Exception('getCommitStatuses() is not implemented for GitHub'); + } } diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index c1ad2e25..f774ef71 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -771,21 +771,21 @@ public function updateCommitStatus(string $repositoryName, string $commitHash, s } /** - * Generate git clone command - * - * @param string $owner Owner of the repository - * @param string $repositoryName Name of the repository - * @param string $version Branch name, commit hash, or tag - * @param string $versionType Type: branch, commit, or tag - * @param string $directory Directory to clone into - * @param string $rootDirectory Root directory for sparse checkout - * @return string Shell command to execute - */ - public function generateCloneCommand(string $owner, string $repositoryName, string $directory, string $rootDirectory, string $version, string $versionType, string $accessToken = ''): string + * Generate git clone command + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param string $version Branch name, commit hash, or tag + * @param string $versionType Type: branch, commit, or tag + * @param string $directory Directory to clone into + * @param string $rootDirectory Root directory for sparse checkout + * @return string Shell command to execute + */ + public function generateCloneCommand(string $owner, string $repositoryName, string $version, string $versionType, string $directory, string $rootDirectory): string { $cloneUrl = "{$this->giteaUrl}/{$owner}/{$repositoryName}"; - if (!empty($accessToken)) { - $cloneUrl = str_replace('://', "://{$owner}:{$accessToken}@", $this->giteaUrl) . "/{$owner}/{$repositoryName}"; + if (!empty($this->accessToken)) { + $cloneUrl = str_replace('://', "://{$owner}:{$this->accessToken}@", $this->giteaUrl) . "/{$owner}/{$repositoryName}"; } // SECURITY FIX: Escape clone URL @@ -816,8 +816,7 @@ public function generateCloneCommand(string $owner, string $repositoryName, stri break; case self::CLONE_TYPE_TAG: $tagName = escapeshellarg($version); - // FIX: Add --refs to exclude peeled refs (v1.0.0^{}) - $commands[] = "git fetch --depth=1 origin refs/tags/$(git ls-remote --refs --tags origin {$tagName} | tail -n 1 | awk -F '/' '{print \$3}') && git checkout FETCH_HEAD"; + $commands[] = "git fetch --depth=1 origin refs/tags/{$version} && git checkout FETCH_HEAD"; break; default: throw new Exception("Unsupported clone type: {$versionType}"); @@ -874,4 +873,27 @@ public function createTag(string $owner, string $repositoryName, string $tagName return $response['body'] ?? []; } + + /** + * Get commit statuses + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param string $commitHash SHA of the commit + * @return array List of commit statuses + */ + public function getCommitStatuses(string $owner, string $repositoryName, string $commitHash): array + { + $url = "/repos/{$owner}/{$repositoryName}/commits/{$commitHash}/statuses"; + + $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("Failed to get commit statuses: HTTP {$responseHeadersStatusCode}"); + } + + return $response['body'] ?? []; + } } diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index ef50a09b..8bf5f1d4 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -499,10 +499,10 @@ public function testGenerateCloneCommand(): void $command = $this->vcsAdapter->generateCloneCommand( self::$owner, $repositoryName, - '/tmp/test-clone-' . \uniqid(), - '/', 'main', - \Utopia\VCS\Adapter\Git::CLONE_TYPE_BRANCH + \Utopia\VCS\Adapter\Git::CLONE_TYPE_BRANCH, + '/tmp/test-clone-' . \uniqid(), + '/' ); $this->assertIsString($command); @@ -529,12 +529,11 @@ public function testGenerateCloneCommandWithCommitHash(): void $command = $this->vcsAdapter->generateCloneCommand( self::$owner, $repositoryName, - '/tmp/test-clone-commit-' . \uniqid(), - '/', $commitHash, - \Utopia\VCS\Adapter\Git::CLONE_TYPE_COMMIT + \Utopia\VCS\Adapter\Git::CLONE_TYPE_COMMIT, + '/tmp/test-clone-commit-' . \uniqid(), + '/' ); - $this->assertIsString($command); $this->assertStringContainsString('git fetch --depth=1', $command); $this->assertStringContainsString($commitHash, $command); @@ -558,14 +557,13 @@ public function testGenerateCloneCommandWithTag(): void // Create a tag $this->vcsAdapter->createTag(self::$owner, $repositoryName, 'v1.0.0', $commitHash, 'Release v1.0.0'); - // Generate clone command for the tag $command = $this->vcsAdapter->generateCloneCommand( self::$owner, $repositoryName, - '/tmp/test-clone-tag-' . \uniqid(), - '/', 'v1.0.0', - \Utopia\VCS\Adapter\Git::CLONE_TYPE_TAG + \Utopia\VCS\Adapter\Git::CLONE_TYPE_TAG, + '/tmp/test-clone-tag-' . \uniqid(), + '/' ); // Verify the command contains tag-specific git commands @@ -581,6 +579,36 @@ public function testGenerateCloneCommandWithTag(): void } } + public function testGenerateCloneCommandWithInvalidRepository(): void + { + $directory = '/tmp/test-clone-invalid-' . \uniqid(); + + try { + $command = $this->vcsAdapter->generateCloneCommand( + 'nonexistent-owner-' . \uniqid(), + 'nonexistent-repo-' . \uniqid(), + 'main', + \Utopia\VCS\Adapter\Git::CLONE_TYPE_BRANCH, + $directory, + '/' + ); + + $output = []; + exec($command . ' 2>&1', $output, $exitCode); + + $cloneFailed = ($exitCode !== 0) || !file_exists($directory . '/README.md'); + + $this->assertTrue( + $cloneFailed, + 'Clone should have failed for nonexistent repository. Exit code: ' . $exitCode + ); + } finally { + if (\is_dir($directory)) { + exec('rm -rf ' . escapeshellarg($directory)); + } + } + } + public function testUpdateComment(): void { $this->markTestSkipped('Will be implemented in follow-up PR'); @@ -597,7 +625,6 @@ public function testUpdateCommitStatus(): void $commit = $this->vcsAdapter->getLatestCommit(self::$owner, $repositoryName, 'main'); $commitHash = $commit['commitHash']; - // Test updating commit status - should not throw $this->vcsAdapter->updateCommitStatus( $repositoryName, $commitHash, @@ -608,8 +635,20 @@ public function testUpdateCommitStatus(): void 'ci/tests' ); - // If we get here without exception, test passes - $this->assertTrue(true); + $statuses = $this->vcsAdapter->getCommitStatuses(self::$owner, $repositoryName, $commitHash); + $this->assertIsArray($statuses); + $this->assertNotEmpty($statuses); + + $found = false; + foreach ($statuses as $status) { + if (($status['context'] ?? '') === 'ci/tests') { + $this->assertSame('success', $status['status'] ?? ''); + $this->assertSame('Tests passed', $status['description'] ?? ''); + $found = true; + break; + } + } + $this->assertTrue($found, 'Expected status with context ci/tests was not found'); } finally { $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); } @@ -633,6 +672,17 @@ public function testUpdateCommitStatusWithInvalidCommit(): void } } + public function testUpdateCommitStatusWithNonExistingRepository(): void + { + $this->expectException(\Exception::class); + $this->vcsAdapter->updateCommitStatus( + 'nonexistent-repo-' . \uniqid(), + 'abc123def456abc123def456abc123def456abc123', + self::$owner, + 'success' + ); + } + public function testGetCommit(): void { $repositoryName = 'test-get-commit-' . \uniqid(); @@ -1039,14 +1089,11 @@ public function testCreateTag(): void $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); try { - // Create initial file $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test'); - // Get commit hash $commit = $this->vcsAdapter->getLatestCommit(self::$owner, $repositoryName, 'main'); $commitHash = $commit['commitHash']; - // Create tag $result = $this->vcsAdapter->createTag( self::$owner, $repositoryName, @@ -1057,6 +1104,11 @@ public function testCreateTag(): void $this->assertIsArray($result); $this->assertNotEmpty($result); + $this->assertArrayHasKey('name', $result); + $this->assertSame('v1.0.0', $result['name']); + $this->assertArrayHasKey('commit', $result); + $this->assertArrayHasKey('sha', $result['commit']); + $this->assertSame($commitHash, $result['commit']['sha']); } finally { $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); }