Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 140 additions & 5 deletions src/VCS/Adapter/Git/GitLab.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) . "&per_page=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
Expand All @@ -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/{$tagName} && git checkout FETCH_HEAD";
break;
default:
throw new Exception("Unsupported clone type: {$versionType}");
}

return implode(' && ', $commands);
}

public function getEvent(string $event, string $payload): array
Expand All @@ -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
Expand Down
195 changes: 191 additions & 4 deletions tests/VCS/Adapter/GitLabTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down