From 82b4791276d1a46fec188b68510eb78407980ede Mon Sep 17 00:00:00 2001 From: CA Prasad Zawar Date: Thu, 9 Apr 2026 16:23:53 +0530 Subject: [PATCH 1/2] feat: add repository content methods for GitLab adapter --- docker | 0 src/VCS/Adapter/Git/GitLab.php | 124 +++++++++++++++++++++- tests/VCS/Adapter/GitLabTest.php | 172 ++++++++++++++++++++++++++++++- 3 files changed, 288 insertions(+), 8 deletions(-) create mode 100644 docker diff --git a/docker b/docker new file mode 100644 index 00000000..e69de29b diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 15eeb98c..abba5d0a 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -178,22 +178,121 @@ public function getRepositoryName(string $repositoryId): string public function getRepositoryTree(string $owner, string $repositoryName, string $branch, bool $recursive = false): array { - throw new Exception("Not implemented"); + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + $url = "/projects/{$projectPath}/repository/tree?ref=" . urlencode($branch); + + if ($recursive) { + $url .= "&recursive=true&per_page=100"; + } + + $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode === 404) { + return []; + } + + if ($responseHeadersStatusCode >= 400) { + return []; + } + + $responseBody = $response['body'] ?? []; + if (!is_array($responseBody)) { + return []; + } + + return array_column($responseBody, 'path'); } public function getRepositoryContent(string $owner, string $repositoryName, string $path, string $ref = ''): array { - throw new Exception("Not implemented"); + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + $encodedPath = urlencode($path); + $url = "/projects/{$projectPath}/repository/files/{$encodedPath}?ref=" . urlencode(empty($ref) ? 'main' : $ref); + + $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode !== 200) { + throw new \Utopia\VCS\Exception\FileNotFound(); + } + + $responseBody = $response['body'] ?? []; + + $content = ''; + if (($responseBody['encoding'] ?? '') === 'base64') { + $content = base64_decode($responseBody['content'] ?? ''); + } else { + throw new \Utopia\VCS\Exception\FileNotFound(); + } + + return [ + 'sha' => $responseBody['blob_id'] ?? '', + 'size' => $responseBody['size'] ?? 0, + 'content' => $content, + ]; } public function listRepositoryContents(string $owner, string $repositoryName, string $path = '', string $ref = ''): array { - throw new Exception("Not implemented"); + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + $url = "/projects/{$projectPath}/repository/tree?ref=" . urlencode(empty($ref) ? 'main' : $ref); + + if (!empty($path)) { + $url .= "&path=" . urlencode($path); + } + + $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + return []; + } + + $responseBody = $response['body'] ?? []; + if (!is_array($responseBody)) { + return []; + } + + $contents = []; + foreach ($responseBody as $item) { + $type = ($item['type'] ?? '') === 'blob' ? self::CONTENTS_FILE : self::CONTENTS_DIRECTORY; + $contents[] = [ + 'name' => $item['name'] ?? '', + 'size' => 0, + 'type' => $type, + ]; + } + + return $contents; } public function listRepositoryLanguages(string $owner, string $repositoryName): array { - throw new Exception("Not implemented"); + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + $url = "/projects/{$projectPath}/languages"; + + $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + return []; + } + + $responseBody = $response['body'] ?? []; + if (!is_array($responseBody)) { + return []; + } + + return array_keys($responseBody); } public function createFile(string $owner, string $repositoryName, string $filepath, string $content, string $message = 'Add file', string $branch = ''): array @@ -225,7 +324,22 @@ public function createFile(string $owner, string $repositoryName, string $filepa public function createBranch(string $owner, string $repositoryName, string $newBranchName, string $oldBranchName): array { - throw new Exception("Not implemented"); + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + $url = "/projects/{$projectPath}/repository/branches"; + + $response = $this->call(self::METHOD_POST, $url, ['PRIVATE-TOKEN' => $this->accessToken], [ + 'branch' => $newBranchName, + 'ref' => $oldBranchName, + ]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to create branch {$newBranchName}: HTTP {$responseHeadersStatusCode}"); + } + + return $response['body'] ?? []; } public function createPullRequest(string $owner, string $repositoryName, string $title, string $head, string $base, string $body = ''): array diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 335d44e7..36fa31b9 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -94,6 +94,22 @@ public function testGetRepository(): void } } + public function testListRepositoryContentsNonExistingPath(): void + { + $repositoryName = 'test-list-repository-contents-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + try { + $contents = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, 'non-existing-path'); + + $this->assertIsArray($contents); + $this->assertEmpty($contents); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testDeleteRepository(): void { $repositoryName = 'test-delete-repository-' . \uniqid(); @@ -386,7 +402,101 @@ public function testGetPullRequestWithInvalidNumber(): void public function testGetRepositoryTree(): void { - $this->markTestSkipped('Not implemented for GitLab yet'); + $repositoryName = 'test-get-repository-tree-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'src/main.php', 'vcsAdapter->createFile(static::$owner, $repositoryName, 'src/lib.php', 'vcsAdapter->getRepositoryTree(static::$owner, $repositoryName, static::$defaultBranch, false); + + $this->assertIsArray($tree); + $this->assertContains('README.md', $tree); + $this->assertContains('src', $tree); + $this->assertCount(2, $tree); + + // Recursive — all files + $treeRecursive = $this->vcsAdapter->getRepositoryTree(static::$owner, $repositoryName, static::$defaultBranch, true); + + $this->assertIsArray($treeRecursive); + $this->assertContains('README.md', $treeRecursive); + $this->assertContains('src/main.php', $treeRecursive); + $this->assertContains('src/lib.php', $treeRecursive); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetRepositoryTreeWithInvalidBranch(): void + { + $repositoryName = 'test-get-repository-tree-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + try { + $tree = $this->vcsAdapter->getRepositoryTree(static::$owner, $repositoryName, 'non-existing-branch', false); + + $this->assertIsArray($tree); + $this->assertEmpty($tree); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetRepositoryContent(): void + { + $repositoryName = 'test-get-repository-content-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $fileContent = '# Hello World'; + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', $fileContent); + + $result = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, 'README.md'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('content', $result); + $this->assertArrayHasKey('sha', $result); + $this->assertArrayHasKey('size', $result); + $this->assertSame($fileContent, $result['content']); + $this->assertGreaterThan(0, $result['size']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetRepositoryContentWithRef(): void + { + $repositoryName = 'test-get-repository-content-ref-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'test.txt', 'main branch content'); + + $result = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, 'test.txt', static::$defaultBranch); + + $this->assertIsArray($result); + $this->assertSame('main branch content', $result['content']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetRepositoryContentFileNotFound(): void + { + $repositoryName = 'test-get-repository-content-not-found-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + try { + $this->expectException(\Utopia\VCS\Exception\FileNotFound::class); + $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, 'non-existing.txt'); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } } public function testListBranches(): void @@ -396,11 +506,67 @@ public function testListBranches(): void public function testListRepositoryLanguages(): void { - $this->markTestSkipped('Not implemented for GitLab yet'); + $repositoryName = 'test-list-repository-languages-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'main.php', 'vcsAdapter->createFile(static::$owner, $repositoryName, 'script.js', 'console.log("test");'); + + sleep(5); // ← increase from 2 to 5 + + $languages = $this->vcsAdapter->listRepositoryLanguages(static::$owner, $repositoryName); + + $this->assertIsArray($languages); + $this->assertNotEmpty($languages); + $this->assertContains('PHP', $languages); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testListRepositoryLanguagesEmptyRepo(): void + { + $repositoryName = 'test-list-repository-languages-empty-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $languages = $this->vcsAdapter->listRepositoryLanguages(static::$owner, $repositoryName); + + $this->assertIsArray($languages); + $this->assertEmpty($languages); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } } public function testListRepositoryContents(): void { - $this->markTestSkipped('Not implemented for GitLab yet'); + $repositoryName = 'test-list-repository-contents-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'file1.txt', 'content1'); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'src/main.php', 'vcsAdapter->listRepositoryContents(static::$owner, $repositoryName); + + $this->assertIsArray($contents); + $this->assertCount(3, $contents); + + $names = array_column($contents, 'name'); + $this->assertContains('README.md', $names); + $this->assertContains('file1.txt', $names); + $this->assertContains('src', $names); + + foreach ($contents as $item) { + $this->assertArrayHasKey('name', $item); + $this->assertArrayHasKey('type', $item); + $this->assertArrayHasKey('size', $item); + } + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } } } From a184d5cd7d46b0f5b6cb63018732a0e0720cc27a Mon Sep 17 00:00:00 2001 From: Jayesh Somani Date: Sat, 11 Apr 2026 15:28:57 +0530 Subject: [PATCH 2/2] updated with suggestions --- docker | 0 src/VCS/Adapter/Git/GitLab.php | 35 +++++++++++++++++++++++++--------- 2 files changed, 26 insertions(+), 9 deletions(-) delete mode 100644 docker diff --git a/docker b/docker deleted file mode 100644 index e69de29b..00000000 diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index abba5d0a..a696c430 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -183,17 +183,30 @@ public function getRepositoryTree(string $owner, string $repositoryName, string $url = "/projects/{$projectPath}/repository/tree?ref=" . urlencode($branch); if ($recursive) { - $url .= "&recursive=true&per_page=100"; + $page = 1; + $allItems = []; + do { + $pagedUrl = $url . "&recursive=true&per_page=100&page={$page}"; + $response = $this->call(self::METHOD_GET, $pagedUrl, ['PRIVATE-TOKEN' => $this->accessToken]); + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + return []; + } + $responseBody = $response['body'] ?? []; + if (!is_array($responseBody) || empty($responseBody)) { + break; + } + $allItems = array_merge($allItems, $responseBody); + $page++; + } while (count($responseBody) === 100); + return array_column($allItems, 'path'); } $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; - if ($responseHeadersStatusCode === 404) { - return []; - } - if ($responseHeadersStatusCode >= 400) { return []; } @@ -211,7 +224,7 @@ public function getRepositoryContent(string $owner, string $repositoryName, stri $ownerPath = $this->getOwnerPath($owner); $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); $encodedPath = urlencode($path); - $url = "/projects/{$projectPath}/repository/files/{$encodedPath}?ref=" . urlencode(empty($ref) ? 'main' : $ref); + $url = "/projects/{$projectPath}/repository/files/{$encodedPath}?ref=" . urlencode(empty($ref) ? 'HEAD' : $ref); $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); @@ -225,7 +238,11 @@ public function getRepositoryContent(string $owner, string $repositoryName, stri $content = ''; if (($responseBody['encoding'] ?? '') === 'base64') { - $content = base64_decode($responseBody['content'] ?? ''); + $rawContent = $responseBody['content'] ?? ''; + $content = base64_decode($rawContent, true); + if ($content === false) { + throw new \Utopia\VCS\Exception\FileNotFound(); + } } else { throw new \Utopia\VCS\Exception\FileNotFound(); } @@ -241,10 +258,10 @@ public function listRepositoryContents(string $owner, string $repositoryName, st { $ownerPath = $this->getOwnerPath($owner); $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); - $url = "/projects/{$projectPath}/repository/tree?ref=" . urlencode(empty($ref) ? 'main' : $ref); + $url = "/projects/{$projectPath}/repository/tree" . (empty($ref) ? '' : '?ref=' . urlencode($ref)); if (!empty($path)) { - $url .= "&path=" . urlencode($path); + $url .= (empty($ref) ? '?' : '&') . 'path=' . urlencode($path); } $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]);