Skip to content
Closed
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
85 changes: 80 additions & 5 deletions src/VCS/Adapter/Git/Gitea.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
use Utopia\Cache\Cache;
use Utopia\VCS\Adapter\Git;
use Utopia\VCS\Exception\RepositoryNotFound;
use Utopia\VCS\Exception\FileNotFound;

class Gitea extends Git
{
public const CONTENTS_FILE = 'file';

public const CONTENTS_DIRECTORY = 'dir';

Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a trailing whitespace at the end of this line. This is inconsistent with code formatting standards.

Suggested change

Copilot uses AI. Check for mistakes.
protected string $endpoint = 'http://gitea:3000/api/v1';

protected string $accessToken;
Expand Down Expand Up @@ -147,24 +152,94 @@ public function getRepositoryName(string $repositoryId): string

public function getRepositoryTree(string $owner, string $repositoryName, string $branch, bool $recursive = false): array
{
throw new Exception("Not implemented yet");
$url = "/repos/{$owner}/{$repositoryName}/git/trees/{$branch}" . ($recursive ? '?recursive=1' : '');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge URL-encode branch names in tree endpoint requests

Branch names with slashes (for example feature/foo) are valid, but interpolating $branch directly into /git/trees/{branch} turns part of the branch name into extra URL path segments. In that case Gitea can return 404 for an existing branch, and this method will incorrectly return an empty tree. Encode the branch path segment before concatenating it into the URL.

Useful? React with 👍 / 👎.


$response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]);

if (($response['headers']['status-code'] ?? 0) === 404) {
return [];
}

return array_column($response['body']['tree'] ?? [], 'path');
Comment on lines 153 to +163
Copy link

@coderabbitai coderabbitai bot Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for truncated flag handling across the codebase
echo "=== Searching for 'truncated' handling ==="
rg -n --type=php "truncated" 

echo ""
echo "=== Searching for getRepositoryTree call sites ==="
rg -n --type=php -A3 "getRepositoryTree"

echo ""
echo "=== Checking the Gitea.php file structure ==="
wc -l src/VCS/Adapter/Git/Gitea.php

echo ""
echo "=== Checking GitHub.php for comparison ==="
rg -n --type=php "getRepositoryTree" src/VCS/Adapter/Git/GitHub.php

Repository: utopia-php/vcs

Length of output: 4211


🏁 Script executed:

#!/bin/bash
# Get more context on both implementations

echo "=== GitHub.php getRepositoryTree full implementation ==="
sed -n '171,190p' src/VCS/Adapter/Git/GitHub.php

echo ""
echo "=== Gitea.php getRepositoryTree implementation ==="
sed -n '153,163p' src/VCS/Adapter/Git/Gitea.php

echo ""
echo "=== Abstract method definition in Adapter.php ==="
sed -n '215,225p' src/VCS/Adapter.php

echo ""
echo "=== Check if there are any comments or docs about the branch parameter ==="
sed -n '208,220p' src/VCS/Adapter.php

Repository: utopia-php/vcs

Length of output: 2643


🏁 Script executed:

#!/bin/bash
# Check if GitHub adapter also has truncated issue and look for any API response examples

echo "=== GitHub.php full getRepositoryTree method ==="
sed -n '171,185p' src/VCS/Adapter/Git/GitHub.php

echo ""
echo "=== Check test files for any truncated field references ==="
rg -n "truncated" tests/

echo ""
echo "=== Look for any mocked API responses in tests ==="
rg -n "tree.*\[" tests/VCS/Adapter/GiteaTest.php | head -20

echo ""
echo "=== Check Gitea test setup and mocked responses ==="
sed -n '1,50p' tests/VCS/Adapter/GiteaTest.php

Repository: utopia-php/vcs

Length of output: 2127


🏁 Script executed:

#!/bin/bash
# Get more details about the test setup and API behavior

echo "=== Full Gitea test method for getRepositoryTree ==="
sed -n '204,235p' tests/VCS/Adapter/GiteaTest.php

echo ""
echo "=== Full GitHub test method for getRepositoryTree ==="
sed -n '187,210p' tests/VCS/Adapter/GitHubTest.php

echo ""
echo "=== Check if there are any comments in GitHub.php about truncated ==="
sed -n '165,180p' src/VCS/Adapter/Git/GitHub.php

echo ""
echo "=== Check if test repos are large enough to trigger truncated ==="
rg -n "test.*repository\|recursive" tests/VCS/Adapter/GiteaTest.php | head -20

Repository: utopia-php/vcs

Length of output: 3549


🏁 Script executed:

#!/bin/bash
# Final verification - check API documentation or comments about what these endpoints accept

echo "=== Check for any README or documentation about API parameters ==="
find . -name "README*" -o -name "*.md" | head -10

echo ""
echo "=== Check if there are any type hints or parameter validation ==="
sed -n '150,165p' src/VCS/Adapter/Git/Gitea.php

echo ""
echo "=== Verify both adapters handle the response identically ==="
echo "GitHub array_column call:"
sed -n '182,182p' src/VCS/Adapter/Git/GitHub.php
echo ""
echo "Gitea array_column call:"
sed -n '163,163p' src/VCS/Adapter/Git/Gitea.php

echo ""
echo "=== Check the actual response structure from real API calls in tests ==="
rg -B5 -A5 "tree\]\['tree'\]" tests/

Repository: utopia-php/vcs

Length of output: 1110


🏁 Script executed:

#!/bin/bash
# Verify the actual API endpoint expectations and response structure

echo "=== Check the abstract method documentation more thoroughly ==="
sed -n '208,226p' src/VCS/Adapter.php

echo ""
echo "=== Search for any Gitea API documentation or comments in code ==="
rg -n "git/trees\|api.*tree" src/VCS/Adapter/Git/Gitea.php

echo ""
echo "=== Check how Gitea resolves 'branch' parameter - check the test that uses 'non-existing-branch' ==="
sed -n '228,234p' tests/VCS/Adapter/GiteaTest.php

echo ""
echo "=== Search for response body structure parsing ==="
rg -n "\['body'\]\['tree'\]" src/VCS/Adapter/Git/

Repository: utopia-php/vcs

Length of output: 1607


🏁 Script executed:

#!/bin/bash
# Final check: verify if both adapters are expected to behave the same way regarding truncated

echo "=== Check if there's any discussion or issue about truncated handling ==="
rg -n "recursive.*tree\|tree.*recursive" src/VCS/Adapter/Git/

echo ""
echo "=== Verify the exact parameter being passed is documented as 'branch' ==="
sed -n '208,216p' src/VCS/Adapter.php

echo ""
echo "=== Check if GitHub tests also use branch names (not SHAs) ==="
grep -n "getRepositoryTree" tests/VCS/Adapter/GitHubTest.php | head -5

Repository: utopia-php/vcs

Length of output: 1188


getRepositoryTree does not check the truncated flag in API responses.

When recursive=true and the repository tree is large, Gitea (and GitHub) may set truncated=true in the response to indicate the returned array is incomplete. The current implementation returns array_column($response['body']['tree'] ?? [], 'path') without checking $response['body']['truncated'], so callers receive silently incomplete tree listings on large repositories.

Both Gitea and GitHub adapters have this issue. Consider adding a check:

if (!empty($response['body']['truncated'])) {
    throw new \RuntimeException("Repository tree response is truncated; too many entries for a single recursive call.");
}

Alternatively, document that trees may be incomplete and callers should handle paginated fetching for large repositories.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/VCS/Adapter/Git/Gitea.php` around lines 153 - 163, In getRepositoryTree
in Gitea.php, detect when the API returned a truncated tree by checking
$response['body']['truncated'] after the call and before returning results; if
truncated is truthy, throw a RuntimeException with a clear message (e.g.,
"Repository tree response is truncated; too many entries for a single recursive
call.") so callers don't get silently incomplete listings — update
getRepositoryTree to perform this check and throw when recursive=true and
truncated is set.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches GitHub adapter behavior. But I think We should handle truncated flag consistently in both need guidance on this @Meldiron

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

}

public function listRepositoryLanguages(string $owner, string $repositoryName): array
{
throw new Exception("Not implemented yet");
$url = "/repos/{$owner}/{$repositoryName}/languages";

$response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]);

if (isset($response['body'])) {
return array_keys($response['body']);
Comment on lines +172 to +173

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard language parsing on successful responses

This method returns array_keys($response['body']) for any JSON body, including error payloads from non-200 responses (e.g. 403/404), so callers can receive values like message/url as if they were repository languages. That misreports language detection and masks API failures for missing or unauthorized repos; the response status should be checked before extracting language keys.

Useful? React with 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches the GitHub adapter implementation. If we want to add status code checking, we can do this

}

return [];
}

public function getRepositoryContent(string $owner, string $repositoryName, string $path, string $ref = ''): array
{
throw new Exception("Not implemented yet");
$url = "/repos/{$owner}/{$repositoryName}/contents/{$path}";
if (!empty($ref)) {
$url .= "?ref={$ref}";
}

$response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]);

if (($response['headers']['status-code'] ?? 0) !== 200) {
throw new FileNotFound();
}

$encoding = $response['body']['encoding'] ?? '';
$content = '';

if ($encoding === 'base64') {
$content = base64_decode($response['body']['content'] ?? '');
} else {
throw new FileNotFound();
}
Comment on lines +195 to +199
Copy link

@coderabbitai coderabbitai bot Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

base64_decode result not checked for false before use.

PHP's base64_decode returns the decoded data or false on failure. In non-strict mode (the default here), if the strict parameter is true, base64_decode() will return false if the input contains characters outside the base64 alphabet; otherwise invalid characters are silently discarded. While a false return is practically impossible for well-formed API responses, propagating an unguarded false into the content key (typed as string by the return contract) is still worth a one-liner guard — especially since the else branch already throws FileNotFound for unexpected encoding types.

🛡️ Proposed fix
         if ($encoding === 'base64') {
-            $content = base64_decode($response['body']['content'] ?? '');
+            $decoded = base64_decode($response['body']['content'] ?? '');
+            if ($decoded === false) {
+                throw new FileNotFound();
+            }
+            $content = $decoded;
         } else {
             throw new FileNotFound();
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ($encoding === 'base64') {
$content = base64_decode($response['body']['content'] ?? '');
} else {
throw new FileNotFound();
}
if ($encoding === 'base64') {
$decoded = base64_decode($response['body']['content'] ?? '');
if ($decoded === false) {
throw new FileNotFound();
}
$content = $decoded;
} else {
throw new FileNotFound();
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/VCS/Adapter/Git/Gitea.php` around lines 195 - 199, The base64 decoding
result in Gitea.php must be validated: in the branch that checks if ($encoding
=== 'base64') (around the code that sets $content = base64_decode(...)), verify
that base64_decode did not return false and handle that case (e.g., throw
FileNotFound or a more specific exception) instead of letting a false be
assigned to $content; update the code that reads $response['body']['content'] to
decode, check for === false, and only set/return the decoded string when valid,
otherwise throw FileNotFound to match the existing error handling.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitHub adapter doesn't check this either. Should I add defensive checking here?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the GitHub adapter file
fd -t f "GitHub.php" src/

# Search for base64_decode usage in the GitHub adapter
rg -n "base64_decode" src/VCS/Adapter/Git/GitHub.php -C 5

Length of output: 342


@jaysomani You're correct—the GitHub adapter doesn't check for false either (Line 230 in src/VCS/Adapter/Git/GitHub.php). Both implementations follow the same pattern.

For consistency across adapters, you can keep the current implementation as-is. The practical risk is minimal since both GitHub and Gitea APIs return well-formed base64 content, so base64_decode returning false would be extremely unlikely in practice.

That said, the defensive check would be technically more correct from a type-safety perspective. If you'd prefer to add it, you could:

  1. Add it to the Gitea adapter only (your current PR)
  2. Add it to both adapters for consistency (would require touching the GitHub adapter too)

Either approach is reasonable. Since this is an edge case with trusted API responses, I'd lean toward keeping it consistent with the GitHub adapter (no check) unless you have a specific reason to add extra defensive validation.

Your call! 👍


🧬 Code Graph Analysis Results

[src/VCS/Adapter/GitHub.php:51-78]

public function initializeVariables(string $installationId, string $privateKey, ?string $appId = null, ?string $accessToken = null, ?string $refreshToken = null): void
{
$this->installationId = $installationId;

$response = $this->cache->load($installationId, 60 * 9); // 10 minutes, but 1 minute earlier to be safe
if ($response == false) {
    $this->generateAccessToken($privateKey, $appId);

    $tokens = \json_encode([
        'jwtToken' => $this->jwtToken,
        'accessToken' => $this->accessToken,
    ]) ?: '{}';

    $this->cache->save($installationId, $tokens);
} else {
    $parsed = \json_decode($response, true);
    $this->jwtToken = $parsed['jwtToken'] ?? '';
    $this->accessToken = $parsed['accessToken'] ?? '';
}

}

[src/VCS/Adapter/GitHub.php:375-401]

protected function generateAccessToken(string $privateKey, ?string $appId): void
{
/**
* @var resource $privateKeyObj
*/
$privateKeyObj = \openssl_pkey_get_private($privateKey);

$appIdentifier = $appId;

$iat = time();
$exp = $iat + 10 * 60;
$payload = [
    'iat' => $iat,
    'exp' => $exp,
    'iss' => $appIdentifier,
];

// generate access token
$jwt = new JWT($privateKeyObj, 'RS256');
$token = $jwt->encode($payload);
$this->jwtToken = $token;
$res = $this->call(self::METHOD_POST, '/app/installations/' . $this->installationId . '/access_tokens', ['Authorization' => 'Bearer ' . $token]);
if (!isset($res['body']['token'])) {
    throw new Exception('Failed to retrieve access token from GitHub API.');
}
$this->accessToken = $res['body']['token'];

}

[src/VCS/Adapter/GitHub.php:85-95]

public function createRepository(string $owner, string $repositoryName, bool $private): array
{
$url = "/orgs/{$owner}/repos";

$response = $this->call(self::METHOD_POST, $url, ['Authorization' => "Bearer $this->accessToken"], [
    'name' => $repositoryName,
    'private' => $private,
]);

return $response['body'] ?? [];

}


return [
'sha' => $response['body']['sha'] ?? '',
'size' => $response['body']['size'] ?? 0,
'content' => $content
];
}

public function listRepositoryContents(string $owner, string $repositoryName, string $path = '', string $ref = ''): array
{
throw new Exception("Not implemented yet");
}
$url = "/repos/{$owner}/{$repositoryName}/contents";
if (!empty($path)) {
$url .= "/{$path}";
}
if (!empty($ref)) {
$url .= "?ref={$ref}";
}

$response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]);

if (($response['headers']['status-code'] ?? 0) === 404) {
return [];
}

$items = [];
if (!empty($response['body'][0])) {
$items = $response['body'];
} elseif (!empty($response['body'])) {
$items = [$response['body']];
}

$contents = [];
foreach ($items as $item) {
$type = $item['type'] ?? 'file';
$contents[] = [
'name' => $item['name'] ?? '',
'size' => $item['size'] ?? 0,
'type' => $type === 'file' ? self::CONTENTS_FILE : self::CONTENTS_DIRECTORY
];
}

return $contents;
}
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing blank line between method definitions. There should be a blank line between the end of listRepositoryContents and the start of deleteRepository to follow the codebase formatting conventions.

Suggested change
}
}

Copilot uses AI. Check for mistakes.
public function deleteRepository(string $owner, string $repositoryName): bool
{
$url = "/repos/{$owner}/{$repositoryName}";
Expand Down
200 changes: 196 additions & 4 deletions tests/VCS/Adapter/GiteaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,30 @@ private function setupGitea(): void
}
}

/**
* Helper method to create a file in a repository
*/
private function createFile(string $owner, string $repo, string $filepath, string $content, string $message = 'Add file'): void
{
$giteaUrl = System::getEnv('TESTS_GITEA_URL', 'http://gitea:3000') ?? '';
$url = "{$giteaUrl}/api/v1/repos/{$owner}/{$repo}/contents/{$filepath}";

$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: token ' . self::$accessToken,
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'content' => base64_encode($content),
'message' => $message
]));

curl_exec($ch);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createFile helper method doesn't check for curl errors or verify the response. If the file creation fails (e.g., due to network issues or API errors), the test will continue silently and may produce misleading test results. Consider checking curl_exec return value and the HTTP response code to ensure the file was actually created.

Copilot uses AI. Check for mistakes.
curl_close($ch);
}

public function testCreateRepository(): void
{
$owner = self::$owner;
Expand Down Expand Up @@ -168,17 +192,159 @@ public function testGetRepositoryWithNonExistingOwner(): void

public function testGetRepositoryTree(): void
{
$this->markTestSkipped('Will be implemented in follow-up PR');
$repositoryName = 'test-get-repository-tree-' . \uniqid();
$this->vcsAdapter->createRepository(self::$owner, $repositoryName, false);

// Create files in repo
$this->createFile(self::$owner, $repositoryName, 'README.md', '# Test Repo');
$this->createFile(self::$owner, $repositoryName, 'src/main.php', '<?php echo "hello";');
$this->createFile(self::$owner, $repositoryName, 'src/lib.php', '<?php // library');

// Test non-recursive (should only show root level)
$tree = $this->vcsAdapter->getRepositoryTree(self::$owner, $repositoryName, 'main', false);

$this->assertIsArray($tree);
$this->assertContains('README.md', $tree);
$this->assertContains('src', $tree);
$this->assertCount(2, $tree); // Only README.md and src folder at root

// Test recursive (should show all files including nested)
$treeRecursive = $this->vcsAdapter->getRepositoryTree(self::$owner, $repositoryName, 'main', true);

$this->assertIsArray($treeRecursive);
$this->assertContains('README.md', $treeRecursive);
$this->assertContains('src', $treeRecursive);
$this->assertContains('src/main.php', $treeRecursive);
$this->assertContains('src/lib.php', $treeRecursive);
$this->assertGreaterThanOrEqual(4, count($treeRecursive));

$this->vcsAdapter->deleteRepository(self::$owner, $repositoryName);
}

public function testGetRepositoryTreeWithInvalidBranch(): void
{
$repositoryName = 'test-get-repository-tree-invalid-' . \uniqid();
$this->vcsAdapter->createRepository(self::$owner, $repositoryName, false);
$this->createFile(self::$owner, $repositoryName, 'README.md', '# Test');

$tree = $this->vcsAdapter->getRepositoryTree(self::$owner, $repositoryName, 'non-existing-branch', false);

$this->assertIsArray($tree);
$this->assertEmpty($tree);

$this->vcsAdapter->deleteRepository(self::$owner, $repositoryName);
}

public function testGetRepositoryContent(): void
{
$this->markTestSkipped('Will be implemented in follow-up PR');
$repositoryName = 'test-get-repository-content-' . \uniqid();
$this->vcsAdapter->createRepository(self::$owner, $repositoryName, false);

$fileContent = '# Hello World';
$this->createFile(self::$owner, $repositoryName, 'README.md', $fileContent);

$result = $this->vcsAdapter->getRepositoryContent(self::$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->assertIsString($result['sha']);
$this->assertGreaterThan(0, $result['size']);

$this->vcsAdapter->deleteRepository(self::$owner, $repositoryName);
}

public function testGetRepositoryContentWithRef(): void
{
$repositoryName = 'test-get-repository-content-ref-' . \uniqid();
$this->vcsAdapter->createRepository(self::$owner, $repositoryName, false);

$this->createFile(self::$owner, $repositoryName, 'test.txt', 'main branch content');

$result = $this->vcsAdapter->getRepositoryContent(self::$owner, $repositoryName, 'test.txt', 'main');

$this->assertIsArray($result);
$this->assertSame('main branch content', $result['content']);

$this->vcsAdapter->deleteRepository(self::$owner, $repositoryName);
}

public function testGetRepositoryContentFileNotFound(): void
{
$repositoryName = 'test-get-repository-content-not-found-' . \uniqid();
$this->vcsAdapter->createRepository(self::$owner, $repositoryName, false);
$this->createFile(self::$owner, $repositoryName, 'README.md', '# Test');

$this->expectException(\Utopia\VCS\Exception\FileNotFound::class);
$this->vcsAdapter->getRepositoryContent(self::$owner, $repositoryName, 'non-existing.txt');

$this->vcsAdapter->deleteRepository(self::$owner, $repositoryName);
Comment on lines +281 to +283
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test will not clean up the repository if the expectException assertion fails (which is expected in this test). The deleteRepository call on line 283 will never be reached after an exception is thrown. Consider using a try-finally block or PHPUnit's tearDown method to ensure cleanup happens.

Suggested change
$this->vcsAdapter->getRepositoryContent(self::$owner, $repositoryName, 'non-existing.txt');
$this->vcsAdapter->deleteRepository(self::$owner, $repositoryName);
try {
$this->vcsAdapter->getRepositoryContent(self::$owner, $repositoryName, 'non-existing.txt');
} finally {
$this->vcsAdapter->deleteRepository(self::$owner, $repositoryName);
}

Copilot uses AI. Check for mistakes.
}

public function testListRepositoryContents(): void
{
$this->markTestSkipped('Will be implemented in follow-up PR');
$repositoryName = 'test-list-repository-contents-' . \uniqid();
$this->vcsAdapter->createRepository(self::$owner, $repositoryName, false);

$this->createFile(self::$owner, $repositoryName, 'README.md', '# Test');
$this->createFile(self::$owner, $repositoryName, 'file1.txt', 'content1');
$this->createFile(self::$owner, $repositoryName, 'src/main.php', '<?php');

// List root directory
$contents = $this->vcsAdapter->listRepositoryContents(self::$owner, $repositoryName);

$this->assertIsArray($contents);
$this->assertCount(3, $contents); // README.md, file1.txt, src folder

$names = array_column($contents, 'name');
$this->assertContains('README.md', $names);
$this->assertContains('file1.txt', $names);
$this->assertContains('src', $names);

// Verify types
foreach ($contents as $item) {
$this->assertArrayHasKey('name', $item);
$this->assertArrayHasKey('type', $item);
$this->assertArrayHasKey('size', $item);
}

$this->vcsAdapter->deleteRepository(self::$owner, $repositoryName);
}

public function testListRepositoryContentsInSubdirectory(): void
{
$repositoryName = 'test-list-repository-contents-subdir-' . \uniqid();
$this->vcsAdapter->createRepository(self::$owner, $repositoryName, false);

$this->createFile(self::$owner, $repositoryName, 'src/file1.php', '<?php');
$this->createFile(self::$owner, $repositoryName, 'src/file2.php', '<?php');

$contents = $this->vcsAdapter->listRepositoryContents(self::$owner, $repositoryName, 'src');

$this->assertIsArray($contents);
$this->assertCount(2, $contents);

$names = array_column($contents, 'name');
$this->assertContains('file1.php', $names);
$this->assertContains('file2.php', $names);

$this->vcsAdapter->deleteRepository(self::$owner, $repositoryName);
}

public function testListRepositoryContentsNonExistingPath(): void
{
$repositoryName = 'test-list-repository-contents-invalid-' . \uniqid();
$this->vcsAdapter->createRepository(self::$owner, $repositoryName, false);
$this->createFile(self::$owner, $repositoryName, 'README.md', '# Test');

$contents = $this->vcsAdapter->listRepositoryContents(self::$owner, $repositoryName, 'non-existing-path');

$this->assertIsArray($contents);
$this->assertEmpty($contents);

$this->vcsAdapter->deleteRepository(self::$owner, $repositoryName);
}

public function testGetPullRequest(): void
Expand Down Expand Up @@ -273,6 +439,32 @@ public function testListBranches(): void

public function testListRepositoryLanguages(): void
{
$this->markTestSkipped('Will be implemented in follow-up PR');
$repositoryName = 'test-list-repository-languages-' . \uniqid();
$this->vcsAdapter->createRepository(self::$owner, $repositoryName, false);

$this->createFile(self::$owner, $repositoryName, 'main.php', '<?php echo "test";');
$this->createFile(self::$owner, $repositoryName, 'script.js', 'console.log("test");');
$this->createFile(self::$owner, $repositoryName, 'style.css', 'body { margin: 0; }');

$languages = $this->vcsAdapter->listRepositoryLanguages(self::$owner, $repositoryName);

$this->assertIsArray($languages);
$this->assertNotEmpty($languages);
$this->assertContains('PHP', $languages);

$this->vcsAdapter->deleteRepository(self::$owner, $repositoryName);
}

public function testListRepositoryLanguagesEmptyRepo(): void
{
$repositoryName = 'test-list-repository-languages-empty-' . \uniqid();
$this->vcsAdapter->createRepository(self::$owner, $repositoryName, false);

$languages = $this->vcsAdapter->listRepositoryLanguages(self::$owner, $repositoryName);

$this->assertIsArray($languages);
$this->assertEmpty($languages);

$this->vcsAdapter->deleteRepository(self::$owner, $repositoryName);
}
}
Loading