Skip to content

Commit 86e397d

Browse files
authored
Merge pull request #86 from jaysomani/feat/gitlab-adapter-clone
Feat/gitlab adapter clone
2 parents c544e5a + 29fcd1d commit 86e397d

File tree

2 files changed

+331
-9
lines changed

2 files changed

+331
-9
lines changed

src/VCS/Adapter/Git/GitLab.php

Lines changed: 140 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,29 @@ public function listRepositoryLanguages(string $owner, string $repositoryName):
198198

199199
public function createFile(string $owner, string $repositoryName, string $filepath, string $content, string $message = 'Add file', string $branch = ''): array
200200
{
201-
throw new Exception("Not implemented");
201+
$ownerPath = $this->getOwnerPath($owner);
202+
$projectPath = urlencode("{$ownerPath}/{$repositoryName}");
203+
$encodedFilepath = urlencode($filepath);
204+
$url = "/projects/{$projectPath}/repository/files/{$encodedFilepath}";
205+
206+
$payload = [
207+
'branch' => empty($branch) ? 'main' : $branch,
208+
'content' => base64_encode($content),
209+
'encoding' => 'base64',
210+
'commit_message' => $message,
211+
'author_name' => 'utopia',
212+
'author_email' => 'utopia@example.com',
213+
];
214+
215+
$response = $this->call(self::METHOD_POST, $url, ['PRIVATE-TOKEN' => $this->accessToken], $payload);
216+
217+
$responseHeaders = $response['headers'] ?? [];
218+
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
219+
if ($responseHeadersStatusCode >= 400) {
220+
throw new Exception("Failed to create file {$filepath}: HTTP {$responseHeadersStatusCode}");
221+
}
222+
223+
return $response['body'] ?? [];
202224
}
203225

204226
public function createBranch(string $owner, string $repositoryName, string $newBranchName, string $oldBranchName): array
@@ -263,12 +285,59 @@ public function listBranches(string $owner, string $repositoryName): array
263285

264286
public function getCommit(string $owner, string $repositoryName, string $commitHash): array
265287
{
266-
throw new Exception("Not implemented");
288+
$ownerPath = $this->getOwnerPath($owner);
289+
$projectPath = urlencode("{$ownerPath}/{$repositoryName}");
290+
$url = "/projects/{$projectPath}/repository/commits/" . urlencode($commitHash);
291+
292+
$response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]);
293+
294+
$responseHeaders = $response['headers'] ?? [];
295+
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
296+
if ($responseHeadersStatusCode >= 400) {
297+
throw new Exception("Commit not found or inaccessible");
298+
}
299+
300+
$commit = $response['body'] ?? [];
301+
302+
return [
303+
'commitAuthor' => $commit['author_name'] ?? 'Unknown',
304+
'commitMessage' => $commit['message'] ?? 'No message',
305+
'commitHash' => $commit['id'] ?? '',
306+
'commitUrl' => $commit['web_url'] ?? '',
307+
'commitAuthorAvatar' => '',
308+
'commitAuthorUrl' => '',
309+
];
267310
}
268311

269312
public function getLatestCommit(string $owner, string $repositoryName, string $branch): array
270313
{
271-
throw new Exception("Not implemented");
314+
$ownerPath = $this->getOwnerPath($owner);
315+
$projectPath = urlencode("{$ownerPath}/{$repositoryName}");
316+
$url = "/projects/{$projectPath}/repository/commits?ref_name=" . urlencode($branch) . "&per_page=1";
317+
318+
$response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]);
319+
320+
$responseHeaders = $response['headers'] ?? [];
321+
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
322+
if ($responseHeadersStatusCode >= 400) {
323+
throw new Exception("Failed to get latest commit: HTTP {$responseHeadersStatusCode}");
324+
}
325+
326+
$responseBody = $response['body'] ?? [];
327+
if (empty($responseBody[0])) {
328+
throw new Exception("Latest commit response is missing required information.");
329+
}
330+
331+
$commit = $responseBody[0];
332+
333+
return [
334+
'commitAuthor' => $commit['author_name'] ?? 'Unknown',
335+
'commitMessage' => $commit['message'] ?? 'No message',
336+
'commitHash' => $commit['id'] ?? '',
337+
'commitUrl' => $commit['web_url'] ?? '',
338+
'commitAuthorAvatar' => '',
339+
'commitAuthorUrl' => '',
340+
];
272341
}
273342

274343
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
278347

279348
public function generateCloneCommand(string $owner, string $repositoryName, string $version, string $versionType, string $directory, string $rootDirectory): string
280349
{
281-
throw new Exception("Not implemented");
350+
if (empty($rootDirectory) || $rootDirectory === '/') {
351+
$rootDirectory = '*';
352+
}
353+
354+
$ownerPath = $this->getOwnerPath($owner);
355+
356+
// GitLab clone URL format: http://oauth2:{token}@host/owner/repo.git
357+
$baseUrl = $this->gitlabUrl;
358+
if (!empty($this->accessToken)) {
359+
$baseUrl = str_replace('://', '://oauth2:' . urlencode($this->accessToken) . '@', $this->gitlabUrl);
360+
}
361+
362+
$cloneUrl = escapeshellarg("{$baseUrl}/{$ownerPath}/{$repositoryName}.git");
363+
$directory = escapeshellarg($directory);
364+
$rootDirectory = escapeshellarg($rootDirectory);
365+
366+
$commands = [
367+
"mkdir -p {$directory}",
368+
"cd {$directory}",
369+
"git config --global init.defaultBranch main",
370+
"git init",
371+
"git remote add origin {$cloneUrl}",
372+
"git config core.sparseCheckout true",
373+
"echo {$rootDirectory} >> .git/info/sparse-checkout",
374+
"git config --add remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'",
375+
"git config remote.origin.tagopt --no-tags",
376+
];
377+
378+
switch ($versionType) {
379+
case self::CLONE_TYPE_BRANCH:
380+
$branchName = escapeshellarg($version);
381+
$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";
382+
break;
383+
case self::CLONE_TYPE_COMMIT:
384+
$commitHash = escapeshellarg($version);
385+
$commands[] = "git fetch --depth=1 origin {$commitHash} && git checkout {$commitHash}";
386+
break;
387+
case self::CLONE_TYPE_TAG:
388+
$tagName = escapeshellarg($version);
389+
$commands[] = "git fetch --depth=1 origin refs/tags/{$tagName} && git checkout FETCH_HEAD";
390+
break;
391+
default:
392+
throw new Exception("Unsupported clone type: {$versionType}");
393+
}
394+
395+
return implode(' && ', $commands);
282396
}
283397

284398
public function getEvent(string $event, string $payload): array
@@ -293,7 +407,28 @@ public function validateWebhookEvent(string $payload, string $signature, string
293407

294408
public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array
295409
{
296-
throw new Exception("Not implemented");
410+
$ownerPath = $this->getOwnerPath($owner);
411+
$projectPath = urlencode("{$ownerPath}/{$repositoryName}");
412+
$url = "/projects/{$projectPath}/repository/tags";
413+
414+
$payload = [
415+
'tag_name' => $tagName,
416+
'ref' => $target,
417+
];
418+
419+
if (!empty($message)) {
420+
$payload['message'] = $message;
421+
}
422+
423+
$response = $this->call(self::METHOD_POST, $url, ['PRIVATE-TOKEN' => $this->accessToken], $payload);
424+
425+
$responseHeaders = $response['headers'] ?? [];
426+
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
427+
if ($responseHeadersStatusCode >= 400) {
428+
throw new Exception("Failed to create tag {$tagName}: HTTP {$responseHeadersStatusCode}");
429+
}
430+
431+
return $response['body'] ?? [];
297432
}
298433

299434
public function getCommitStatuses(string $owner, string $repositoryName, string $commitHash): array

tests/VCS/Adapter/GitLabTest.php

Lines changed: 191 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,22 +139,209 @@ public function testUpdateComment(): void
139139

140140
public function testGenerateCloneCommand(): void
141141
{
142-
$this->markTestSkipped('Not implemented for GitLab yet');
142+
$repositoryName = 'test-clone-command-' . \uniqid();
143+
$this->vcsAdapter->createRepository(static::$owner, $repositoryName, false);
144+
$directory = '/tmp/test-clone-' . \uniqid();
145+
146+
try {
147+
$this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test');
148+
149+
$command = $this->vcsAdapter->generateCloneCommand(
150+
static::$owner,
151+
$repositoryName,
152+
static::$defaultBranch,
153+
\Utopia\VCS\Adapter\Git::CLONE_TYPE_BRANCH,
154+
$directory,
155+
'/'
156+
);
157+
158+
$this->assertIsString($command);
159+
$this->assertStringContainsString('git init', $command);
160+
$this->assertStringContainsString('git remote add origin', $command);
161+
$this->assertStringContainsString('git config core.sparseCheckout true', $command);
162+
$this->assertStringContainsString($repositoryName, $command);
163+
164+
$output = [];
165+
\exec($command . ' 2>&1', $output, $exitCode);
166+
$this->assertSame(0, $exitCode, implode("\n", $output));
167+
$this->assertFileExists($directory . '/README.md');
168+
} finally {
169+
$this->vcsAdapter->deleteRepository(static::$owner, $repositoryName);
170+
if (\is_dir($directory)) {
171+
\exec('rm -rf ' . escapeshellarg($directory));
172+
}
173+
}
143174
}
144175

145176
public function testGenerateCloneCommandWithCommitHash(): void
146177
{
147-
$this->markTestSkipped('Not implemented for GitLab yet');
178+
$repositoryName = 'test-clone-commit-' . \uniqid();
179+
$this->vcsAdapter->createRepository(static::$owner, $repositoryName, false);
180+
181+
try {
182+
$this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test');
183+
184+
$commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch);
185+
$commitHash = $commit['commitHash'];
186+
187+
$directory = '/tmp/test-clone-commit-' . \uniqid();
188+
$command = $this->vcsAdapter->generateCloneCommand(
189+
static::$owner,
190+
$repositoryName,
191+
$commitHash,
192+
\Utopia\VCS\Adapter\Git::CLONE_TYPE_COMMIT,
193+
$directory,
194+
'/'
195+
);
196+
197+
$this->assertIsString($command);
198+
$this->assertStringContainsString('git fetch --depth=1', $command);
199+
$this->assertStringContainsString($commitHash, $command);
200+
} finally {
201+
$this->vcsAdapter->deleteRepository(static::$owner, $repositoryName);
202+
}
148203
}
149204

150205
public function testGenerateCloneCommandWithTag(): void
151206
{
152-
$this->markTestSkipped('Not implemented for GitLab yet');
207+
$repositoryName = 'test-clone-tag-' . \uniqid();
208+
$this->vcsAdapter->createRepository(static::$owner, $repositoryName, false);
209+
210+
try {
211+
$this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test');
212+
213+
$commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch);
214+
$commitHash = $commit['commitHash'];
215+
216+
$this->vcsAdapter->createTag(static::$owner, $repositoryName, 'v1.0.0', $commitHash);
217+
218+
$directory = '/tmp/test-clone-tag-' . \uniqid();
219+
$command = $this->vcsAdapter->generateCloneCommand(
220+
static::$owner,
221+
$repositoryName,
222+
'v1.0.0',
223+
\Utopia\VCS\Adapter\Git::CLONE_TYPE_TAG,
224+
$directory,
225+
'/'
226+
);
227+
228+
$this->assertIsString($command);
229+
$this->assertStringContainsString('refs/tags', $command);
230+
$this->assertStringContainsString('v1.0.0', $command);
231+
} finally {
232+
$this->vcsAdapter->deleteRepository(static::$owner, $repositoryName);
233+
}
153234
}
154235

155236
public function testGenerateCloneCommandWithInvalidRepository(): void
156237
{
157-
$this->markTestSkipped('Not implemented for GitLab yet');
238+
$directory = '/tmp/test-clone-invalid-' . \uniqid();
239+
240+
try {
241+
$command = $this->vcsAdapter->generateCloneCommand(
242+
static::$owner,
243+
'nonexistent-repo-' . \uniqid(),
244+
static::$defaultBranch,
245+
\Utopia\VCS\Adapter\Git::CLONE_TYPE_BRANCH,
246+
$directory,
247+
'/'
248+
);
249+
250+
$output = [];
251+
\exec($command . ' 2>&1', $output, $exitCode);
252+
253+
$cloneFailed = ($exitCode !== 0) || !file_exists($directory . '/README.md');
254+
$this->assertTrue($cloneFailed, 'Clone should have failed for nonexistent repository');
255+
} finally {
256+
if (\is_dir($directory)) {
257+
\exec('rm -rf ' . escapeshellarg($directory));
258+
}
259+
}
260+
}
261+
262+
public function testGetCommit(): void
263+
{
264+
$repositoryName = 'test-get-commit-' . \uniqid();
265+
$this->vcsAdapter->createRepository(static::$owner, $repositoryName, false);
266+
267+
try {
268+
$customMessage = 'Test commit message';
269+
$this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test', $customMessage);
270+
271+
$latestCommit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch);
272+
$commitHash = $latestCommit['commitHash'];
273+
274+
$result = $this->vcsAdapter->getCommit(static::$owner, $repositoryName, $commitHash);
275+
276+
$this->assertIsArray($result);
277+
$this->assertArrayHasKey('commitHash', $result);
278+
$this->assertArrayHasKey('commitMessage', $result);
279+
$this->assertArrayHasKey('commitAuthor', $result);
280+
$this->assertArrayHasKey('commitUrl', $result);
281+
$this->assertArrayHasKey('commitAuthorAvatar', $result);
282+
$this->assertArrayHasKey('commitAuthorUrl', $result);
283+
$this->assertSame($commitHash, $result['commitHash']);
284+
$this->assertStringStartsWith($customMessage, $result['commitMessage']);
285+
$this->assertNotEmpty($result['commitUrl']);
286+
} finally {
287+
$this->vcsAdapter->deleteRepository(static::$owner, $repositoryName);
288+
}
289+
}
290+
291+
public function testGetLatestCommit(): void
292+
{
293+
$repositoryName = 'test-get-latest-commit-' . \uniqid();
294+
$this->vcsAdapter->createRepository(static::$owner, $repositoryName, false);
295+
296+
try {
297+
$firstMessage = 'First commit';
298+
$secondMessage = 'Second commit';
299+
300+
$this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test', $firstMessage);
301+
$commit1 = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch);
302+
303+
$this->assertIsArray($commit1);
304+
$this->assertNotEmpty($commit1['commitHash']);
305+
$this->assertStringStartsWith($firstMessage, $commit1['commitMessage']);
306+
$this->assertNotEmpty($commit1['commitUrl']);
307+
308+
$commit1Hash = $commit1['commitHash'];
309+
310+
$this->vcsAdapter->createFile(static::$owner, $repositoryName, 'test.txt', 'test', $secondMessage);
311+
$commit2 = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch);
312+
313+
$this->assertStringStartsWith($secondMessage, $commit2['commitMessage']);
314+
$this->assertNotSame($commit1Hash, $commit2['commitHash']);
315+
} finally {
316+
$this->vcsAdapter->deleteRepository(static::$owner, $repositoryName);
317+
}
318+
}
319+
320+
public function testGetCommitWithInvalidHash(): void
321+
{
322+
$repositoryName = 'test-get-commit-invalid-' . \uniqid();
323+
$this->vcsAdapter->createRepository(static::$owner, $repositoryName, false);
324+
325+
try {
326+
$this->expectException(\Exception::class);
327+
$this->vcsAdapter->getCommit(static::$owner, $repositoryName, 'invalid-sha-12345');
328+
} finally {
329+
$this->vcsAdapter->deleteRepository(static::$owner, $repositoryName);
330+
}
331+
}
332+
333+
public function testGetLatestCommitWithInvalidBranch(): void
334+
{
335+
$repositoryName = 'test-get-latest-commit-invalid-' . \uniqid();
336+
$this->vcsAdapter->createRepository(static::$owner, $repositoryName, false);
337+
$this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test');
338+
339+
try {
340+
$this->expectException(\Exception::class);
341+
$this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, 'non-existing-branch');
342+
} finally {
343+
$this->vcsAdapter->deleteRepository(static::$owner, $repositoryName);
344+
}
158345
}
159346

160347
public function testWebhookPushEvent(): void

0 commit comments

Comments
 (0)