From d8036b8fad1d59d0f046946b8235cc4887b95a8d Mon Sep 17 00:00:00 2001 From: CA Prasad Zawar Date: Tue, 7 Apr 2026 16:47:39 +0530 Subject: [PATCH 1/2] feat: add generateCloneCommand, getLatestCommit, getCommit, createTag, createFile for GitLab adapter --- src/VCS/Adapter/Git/GitLab.php | 145 ++++++++++++++++++++++- tests/VCS/Adapter/GitLabTest.php | 195 ++++++++++++++++++++++++++++++- 2 files changed, 331 insertions(+), 9 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 844b5c1f..01ec57d4 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -198,7 +198,29 @@ public function listRepositoryLanguages(string $owner, string $repositoryName): public function createFile(string $owner, string $repositoryName, string $filepath, string $content, string $message = 'Add file', string $branch = ''): array { - throw new Exception("Not implemented"); + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + $encodedFilepath = urlencode($filepath); + $url = "/projects/{$projectPath}/repository/files/{$encodedFilepath}"; + + $payload = [ + 'branch' => empty($branch) ? 'main' : $branch, + 'content' => base64_encode($content), + 'encoding' => 'base64', + 'commit_message' => $message, + 'author_name' => 'utopia', + 'author_email' => 'utopia@example.com', + ]; + + $response = $this->call(self::METHOD_POST, $url, ['PRIVATE-TOKEN' => $this->accessToken], $payload); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to create file {$filepath}: HTTP {$responseHeadersStatusCode}"); + } + + return $response['body'] ?? []; } public function createBranch(string $owner, string $repositoryName, string $newBranchName, string $oldBranchName): array @@ -263,12 +285,59 @@ public function listBranches(string $owner, string $repositoryName): array public function getCommit(string $owner, string $repositoryName, string $commitHash): array { - throw new Exception("Not implemented"); + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + $url = "/projects/{$projectPath}/repository/commits/" . urlencode($commitHash); + + $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Commit not found or inaccessible"); + } + + $commit = $response['body'] ?? []; + + return [ + 'commitAuthor' => $commit['author_name'] ?? 'Unknown', + 'commitMessage' => $commit['message'] ?? 'No message', + 'commitHash' => $commit['id'] ?? '', + 'commitUrl' => $commit['web_url'] ?? '', + 'commitAuthorAvatar' => '', + 'commitAuthorUrl' => '', + ]; } public function getLatestCommit(string $owner, string $repositoryName, string $branch): array { - throw new Exception("Not implemented"); + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + $url = "/projects/{$projectPath}/repository/commits?ref_name=" . urlencode($branch) . "&limit=1"; + + $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to get latest commit: HTTP {$responseHeadersStatusCode}"); + } + + $responseBody = $response['body'] ?? []; + if (empty($responseBody[0])) { + throw new Exception("Latest commit response is missing required information."); + } + + $commit = $responseBody[0]; + + return [ + 'commitAuthor' => $commit['author_name'] ?? 'Unknown', + 'commitMessage' => $commit['message'] ?? 'No message', + 'commitHash' => $commit['id'] ?? '', + 'commitUrl' => $commit['web_url'] ?? '', + 'commitAuthorAvatar' => '', + 'commitAuthorUrl' => '', + ]; } public function updateCommitStatus(string $repositoryName, string $commitHash, string $owner, string $state, string $description = '', string $target_url = '', string $context = ''): void @@ -278,7 +347,52 @@ public function updateCommitStatus(string $repositoryName, string $commitHash, s public function generateCloneCommand(string $owner, string $repositoryName, string $version, string $versionType, string $directory, string $rootDirectory): string { - throw new Exception("Not implemented"); + if (empty($rootDirectory) || $rootDirectory === '/') { + $rootDirectory = '*'; + } + + $ownerPath = $this->getOwnerPath($owner); + + // GitLab clone URL format: http://oauth2:{token}@host/owner/repo.git + $baseUrl = $this->gitlabUrl; + if (!empty($this->accessToken)) { + $baseUrl = str_replace('://', '://oauth2:' . urlencode($this->accessToken) . '@', $this->gitlabUrl); + } + + $cloneUrl = escapeshellarg("{$baseUrl}/{$ownerPath}/{$repositoryName}.git"); + $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}", + "git config core.sparseCheckout true", + "echo {$rootDirectory} >> .git/info/sparse-checkout", + "git config --add remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'", + "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/{$version} && git checkout FETCH_HEAD"; + break; + default: + throw new Exception("Unsupported clone type: {$versionType}"); + } + + return implode(' && ', $commands); } public function getEvent(string $event, string $payload): array @@ -293,7 +407,28 @@ public function validateWebhookEvent(string $payload, string $signature, string public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array { - throw new Exception("Not implemented"); + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + $url = "/projects/{$projectPath}/repository/tags"; + + $payload = [ + 'tag_name' => $tagName, + 'ref' => $target, + ]; + + if (!empty($message)) { + $payload['message'] = $message; + } + + $response = $this->call(self::METHOD_POST, $url, ['PRIVATE-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'] ?? []; } public function getCommitStatuses(string $owner, string $repositoryName, string $commitHash): array diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 5a7d7c17..335d44e7 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -139,22 +139,209 @@ public function testUpdateComment(): void public function testGenerateCloneCommand(): void { - $this->markTestSkipped('Not implemented for GitLab yet'); + $repositoryName = 'test-clone-command-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $directory = '/tmp/test-clone-' . \uniqid(); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + $command = $this->vcsAdapter->generateCloneCommand( + static::$owner, + $repositoryName, + static::$defaultBranch, + \Utopia\VCS\Adapter\Git::CLONE_TYPE_BRANCH, + $directory, + '/' + ); + + $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); + + $output = []; + \exec($command . ' 2>&1', $output, $exitCode); + $this->assertSame(0, $exitCode, implode("\n", $output)); + $this->assertFileExists($directory . '/README.md'); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + if (\is_dir($directory)) { + \exec('rm -rf ' . escapeshellarg($directory)); + } + } } public function testGenerateCloneCommandWithCommitHash(): void { - $this->markTestSkipped('Not implemented for GitLab yet'); + $repositoryName = 'test-clone-commit-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + $commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); + $commitHash = $commit['commitHash']; + + $directory = '/tmp/test-clone-commit-' . \uniqid(); + $command = $this->vcsAdapter->generateCloneCommand( + static::$owner, + $repositoryName, + $commitHash, + \Utopia\VCS\Adapter\Git::CLONE_TYPE_COMMIT, + $directory, + '/' + ); + + $this->assertIsString($command); + $this->assertStringContainsString('git fetch --depth=1', $command); + $this->assertStringContainsString($commitHash, $command); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } } public function testGenerateCloneCommandWithTag(): void { - $this->markTestSkipped('Not implemented for GitLab yet'); + $repositoryName = 'test-clone-tag-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + $commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); + $commitHash = $commit['commitHash']; + + $this->vcsAdapter->createTag(static::$owner, $repositoryName, 'v1.0.0', $commitHash); + + $directory = '/tmp/test-clone-tag-' . \uniqid(); + $command = $this->vcsAdapter->generateCloneCommand( + static::$owner, + $repositoryName, + 'v1.0.0', + \Utopia\VCS\Adapter\Git::CLONE_TYPE_TAG, + $directory, + '/' + ); + + $this->assertIsString($command); + $this->assertStringContainsString('refs/tags', $command); + $this->assertStringContainsString('v1.0.0', $command); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } } public function testGenerateCloneCommandWithInvalidRepository(): void { - $this->markTestSkipped('Not implemented for GitLab yet'); + $directory = '/tmp/test-clone-invalid-' . \uniqid(); + + try { + $command = $this->vcsAdapter->generateCloneCommand( + static::$owner, + 'nonexistent-repo-' . \uniqid(), + static::$defaultBranch, + \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'); + } finally { + if (\is_dir($directory)) { + \exec('rm -rf ' . escapeshellarg($directory)); + } + } + } + + public function testGetCommit(): void + { + $repositoryName = 'test-get-commit-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $customMessage = 'Test commit message'; + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test', $customMessage); + + $latestCommit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); + $commitHash = $latestCommit['commitHash']; + + $result = $this->vcsAdapter->getCommit(static::$owner, $repositoryName, $commitHash); + + $this->assertIsArray($result); + $this->assertArrayHasKey('commitHash', $result); + $this->assertArrayHasKey('commitMessage', $result); + $this->assertArrayHasKey('commitAuthor', $result); + $this->assertArrayHasKey('commitUrl', $result); + $this->assertArrayHasKey('commitAuthorAvatar', $result); + $this->assertArrayHasKey('commitAuthorUrl', $result); + $this->assertSame($commitHash, $result['commitHash']); + $this->assertStringStartsWith($customMessage, $result['commitMessage']); + $this->assertNotEmpty($result['commitUrl']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetLatestCommit(): void + { + $repositoryName = 'test-get-latest-commit-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $firstMessage = 'First commit'; + $secondMessage = 'Second commit'; + + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test', $firstMessage); + $commit1 = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); + + $this->assertIsArray($commit1); + $this->assertNotEmpty($commit1['commitHash']); + $this->assertStringStartsWith($firstMessage, $commit1['commitMessage']); + $this->assertNotEmpty($commit1['commitUrl']); + + $commit1Hash = $commit1['commitHash']; + + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'test.txt', 'test', $secondMessage); + $commit2 = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); + + $this->assertStringStartsWith($secondMessage, $commit2['commitMessage']); + $this->assertNotSame($commit1Hash, $commit2['commitHash']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetCommitWithInvalidHash(): void + { + $repositoryName = 'test-get-commit-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->expectException(\Exception::class); + $this->vcsAdapter->getCommit(static::$owner, $repositoryName, 'invalid-sha-12345'); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetLatestCommitWithInvalidBranch(): void + { + $repositoryName = 'test-get-latest-commit-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + try { + $this->expectException(\Exception::class); + $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, 'non-existing-branch'); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } } public function testWebhookPushEvent(): void From 29fcd1dbddd1e6da2cbf698879ed44dc9267a7c8 Mon Sep 17 00:00:00 2001 From: CA Prasad Zawar Date: Thu, 9 Apr 2026 17:55:00 +0530 Subject: [PATCH 2/2] fix: shell injection in CLONE_TYPE_TAG and use per_page for commits API --- src/VCS/Adapter/Git/GitLab.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 01ec57d4..15eeb98c 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -313,7 +313,7 @@ public function getLatestCommit(string $owner, string $repositoryName, string $b { $ownerPath = $this->getOwnerPath($owner); $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); - $url = "/projects/{$projectPath}/repository/commits?ref_name=" . urlencode($branch) . "&limit=1"; + $url = "/projects/{$projectPath}/repository/commits?ref_name=" . urlencode($branch) . "&per_page=1"; $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); @@ -386,7 +386,7 @@ 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/{$version} && git checkout FETCH_HEAD"; + $commands[] = "git fetch --depth=1 origin refs/tags/{$tagName} && git checkout FETCH_HEAD"; break; default: throw new Exception("Unsupported clone type: {$versionType}");