diff --git a/docker-compose.yml b/docker-compose.yml index b7bc513f..75912bc5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: - ./phpunit.xml:/usr/local/src/phpunit.xml - gitea-data:/data:ro - forgejo-data:/forgejo-data:ro + - gogs-data:/gogs-data:ro environment: - TESTS_GITHUB_PRIVATE_KEY - TESTS_GITHUB_APP_IDENTIFIER @@ -15,6 +16,7 @@ services: - TESTS_GITEA_URL=http://gitea:3000 - TESTS_GITEA_REQUEST_CATCHER_URL=http://request-catcher:5000 - TESTS_FORGEJO_URL=http://forgejo:3000 + - TESTS_GOGS_URL=http://gogs:3000 depends_on: gitea: condition: service_healthy @@ -24,6 +26,10 @@ services: condition: service_healthy forgejo-bootstrap: condition: service_completed_successfully + gogs: + condition: service_healthy + gogs-bootstrap: + condition: service_completed_successfully request-catcher: condition: service_started @@ -115,6 +121,58 @@ services: fi " + gogs: + image: gogs/gogs:0.14 + volumes: + - gogs-data:/data + - ./resources/gogs/app.ini:/data/gogs/conf/app.ini + ports: + - "3002:3000" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 15s + + gogs-bootstrap: + image: gogs/gogs:0.14 + volumes: + - gogs-data:/data + - ./resources/gogs/app.ini:/data/gogs/conf/app.ini + depends_on: + gogs: + condition: service_healthy + entrypoint: /bin/sh + environment: + - GOGS_ADMIN_USERNAME=${GOGS_ADMIN_USERNAME:-utopia} + - GOGS_ADMIN_PASSWORD=${GOGS_ADMIN_PASSWORD:-password} + - GOGS_ADMIN_EMAIL=${GOGS_ADMIN_EMAIL:-utopia@example.com} + command: + - -c + - | + USER=git /app/gogs/gogs admin create-user \ + --admin \ + --name $$GOGS_ADMIN_USERNAME \ + --password $$GOGS_ADMIN_PASSWORD \ + --email $$GOGS_ADMIN_EMAIL \ + --config /data/gogs/conf/app.ini || true + + if [ ! -f /data/gogs/token.txt ]; then + sleep 2 + TOKEN=$$(curl -s \ + -X POST \ + -u $$GOGS_ADMIN_USERNAME:$$GOGS_ADMIN_PASSWORD \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"bootstrap\"}" \ + http://gogs:3000/api/v1/users/$$GOGS_ADMIN_USERNAME/tokens \ + | grep -o '"sha1":"[^"]*"' | cut -d'"' -f4) + if [ -z "$$TOKEN" ]; then echo "Failed to get token"; exit 1; fi + mkdir -p /data/gogs + echo $$TOKEN > /data/gogs/token.txt + fi + volumes: gitea-data: - forgejo-data: \ No newline at end of file + forgejo-data: + gogs-data: \ No newline at end of file diff --git a/resources/gogs/app.ini b/resources/gogs/app.ini new file mode 100644 index 00000000..84455215 --- /dev/null +++ b/resources/gogs/app.ini @@ -0,0 +1,31 @@ +BRAND_NAME = Gogs +RUN_USER = git +RUN_MODE = prod + +[database] +TYPE = sqlite3 +PATH = /data/gogs.db + +[repository] +ROOT = /data/repositories +DEFAULT_BRANCH = master + +[server] +DOMAIN = gogs +HTTP_PORT = 3000 +EXTERNAL_URL = http://gogs:3000/ +DISABLE_SSH = true + +[security] +INSTALL_LOCK = true +SECRET_KEY = aRandomString +LOCAL_NETWORK_ALLOWLIST = * + +[webhook] +DELIVER_TIMEOUT = 10 +SKIP_TLS_VERIFY = true + +[log] +MODE = file +LEVEL = Info +ROOT_PATH = /data/gogs/log diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 0b4ada42..bc6544ef 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -101,6 +101,7 @@ public function createRepository(string $owner, string $repositoryName, bool $pr ]); return $response['body'] ?? []; + // return is_array($body) ? $body : []; } public function createOrganization(string $orgName): string diff --git a/src/VCS/Adapter/Git/Gogs.php b/src/VCS/Adapter/Git/Gogs.php new file mode 100644 index 00000000..4a060ece --- /dev/null +++ b/src/VCS/Adapter/Git/Gogs.php @@ -0,0 +1,525 @@ + Details of new repository + */ + public function createRepository(string $owner, string $repositoryName, bool $private): array + { + $url = "/org/{$owner}/repos"; + + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => "token $this->accessToken"], [ + 'name' => $repositoryName, + 'private' => $private, + 'auto_init' => true, + 'readme' => 'Default', + ]); + + return $response['body'] ?? []; + } + + /** + * Create organization for the authenticated user. + * + * Gogs uses POST /user/orgs instead of Gitea's POST /orgs. + */ + public function createOrganization(string $orgName): string + { + $url = "/user/orgs"; + + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => "token $this->accessToken"], [ + 'username' => $orgName, + ]); + + $responseBody = $response['body'] ?? []; + + return $responseBody['username'] ?? ''; + } + + /** + * Search repositories in organization + * + * When no search query is given, Gogs search API returns empty results, + * so we fall back to listing org repos directly via /orgs/{org}/repos. + * + * @return array + */ + public function searchRepositories(string $owner, int $page, int $per_page, string $search = ''): array + { + if (!empty($search)) { + return parent::searchRepositories($owner, $page, $per_page, $search); + } + + // List all repos for the org directly + $url = "/orgs/{$owner}/repos"; + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + + $responseBody = $response['body'] ?? []; + if (!is_array($responseBody)) { + $responseBody = []; + } + + $total = count($responseBody); + $offset = ($page - 1) * $per_page; + $pagedRepos = array_slice($responseBody, $offset, $per_page); + + return [ + 'items' => $pagedRepos, + 'total' => $total, + ]; + } + + /** + * Get repository tree + * + * Gogs does not support recursive tree listing. For recursive mode, + * we manually traverse subdirectories. + * + * @return array + */ + public function getRepositoryTree(string $owner, string $repositoryName, string $branch, bool $recursive = false): array + { + $url = "/repos/{$owner}/{$repositoryName}/git/trees/" . urlencode($branch); + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode === 404) { + return []; + } + + $responseBody = $response['body'] ?? []; + $entries = $responseBody['tree'] ?? []; + $paths = []; + + foreach ($entries as $entry) { + $paths[] = $entry['path']; + + if ($recursive && ($entry['type'] ?? '') === 'tree') { + $subPaths = $this->getRepositoryTree($owner, $repositoryName, $entry['sha'], true); + foreach ($subPaths as $subPath) { + $paths[] = $entry['path'] . '/' . $subPath; + } + } + } + + return $paths; + } + + /** + * Get repository name by ID + * + * Gogs does not have /repositories/{id}. Searches all repos to find by ID. + */ + public function getRepositoryName(string $repositoryId): string + { + $repo = $this->findRepositoryById((int) $repositoryId); + + return $repo['name']; + } + + /** + * Get owner name by repository ID + * + * Gogs does not have /repositories/{id}. Searches all repos to find by ID. + */ + public function getOwnerName(string $installationId, ?int $repositoryId = null): string + { + if ($repositoryId === null || $repositoryId <= 0) { + throw new Exception("repositoryId is required for this adapter"); + } + + $repo = $this->findRepositoryById($repositoryId); + $owner = $repo['owner'] ?? []; + + if (empty($owner['login'])) { + throw new Exception("Owner login missing or empty in response"); + } + + return $owner['login']; + } + + /** + * Find a repository by its numeric ID using the search API. + * + * @return array Repository data + */ + private function findRepositoryById(int $repositoryId): array + { + $page = 1; + $limit = 50; + + while ($page <= 100) { + $url = "/repos/search?q=_&limit={$limit}&page={$page}"; + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + + $responseBody = $response['body'] ?? []; + $repos = $responseBody['data'] ?? []; + + if (empty($repos)) { + break; + } + + foreach ($repos as $repo) { + if (($repo['id'] ?? 0) === $repositoryId) { + return $repo; + } + } + + if (count($repos) < $limit) { + break; + } + + $page++; + } + + throw new RepositoryNotFound("Repository not found"); + } + + /** + * Get details of a commit + * + * Gogs uses /repos/{owner}/{repo}/commits/{sha} (not /git/commits/{sha} like Gitea). + * + * @return array Details of the commit + */ + public function getCommit(string $owner, string $repositoryName, string $commitHash): array + { + $url = "/repos/{$owner}/{$repositoryName}/commits/{$commitHash}"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Commit not found or inaccessible"); + } + + $responseBody = $response['body'] ?? []; + $commitData = $responseBody['commit'] ?? []; + $commitAuthor = $commitData['author'] ?? []; + $author = $responseBody['author'] ?? []; + + return [ + 'commitAuthor' => $commitAuthor['name'] ?? 'Unknown', + 'commitMessage' => $commitData['message'] ?? 'No message', + 'commitAuthorAvatar' => $author['avatar_url'] ?? '', + 'commitAuthorUrl' => $author['html_url'] ?? '', + 'commitHash' => $responseBody['sha'] ?? '', + 'commitUrl' => $responseBody['html_url'] ?? '', + ]; + } + + /** + * Get latest commit of a branch + * + * Gogs ignores the sha query param, so we validate the branch exists first. + * + * @return array Details of the commit + */ + public function getLatestCommit(string $owner, string $repositoryName, string $branch): array + { + // Gogs ignores sha param — verify branch exists first + $branches = $this->listBranches($owner, $repositoryName); + if (!in_array($branch, $branches, true)) { + throw new Exception("Branch '{$branch}' not found"); + } + + return parent::getLatestCommit($owner, $repositoryName, $branch); + } + + /** + * Create a file in a repository + * + * For the default branch (or when no branch is specified), uses the Gogs + * contents API. For non-default branches, uses git CLI because the Gogs + * API returns 500 when targeting an existing non-default branch. + * + * @return array + */ + public function createFile(string $owner, string $repositoryName, string $filepath, string $content, string $message = 'Add file', string $branch = ''): array + { + if (!empty($branch)) { + // Check if branch is the default branch + $url = "/repos/{$owner}/{$repositoryName}"; + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + $defaultBranch = $response['body']['default_branch'] ?? 'master'; + + if ($branch !== $defaultBranch) { + return $this->createFileViaCli($owner, $repositoryName, $filepath, $content, $message, $branch); + } + } + + $url = "/repos/{$owner}/{$repositoryName}/contents/{$filepath}"; + + $response = $this->call( + self::METHOD_PUT, + $url, + ['Authorization' => "token $this->accessToken"], + [ + 'content' => base64_encode($content), + 'message' => $message, + ] + ); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to create file {$filepath}: HTTP {$responseHeadersStatusCode}"); + } + + return $response['body'] ?? []; + } + + /** + * Create a file on a non-default branch using git CLI. + * + * @return array + */ + private function createFileViaCli(string $owner, string $repositoryName, string $filepath, string $content, string $message, string $branch): array + { + $dir = $this->gitClone($owner, $repositoryName, $branch); + + try { + $fullPath = $dir . '/' . $filepath; + $dirPath = dirname($fullPath); + if (!is_dir($dirPath)) { + mkdir($dirPath, 0777, true); + } + file_put_contents($fullPath, $content); + + $this->exec("git -C {$dir} add " . escapeshellarg($filepath)); + $this->exec("git -C {$dir} commit -m " . escapeshellarg($message)); + $this->exec("git -C {$dir} push origin " . escapeshellarg($branch)); + } finally { + $this->exec("rm -rf {$dir}"); + } + + return ['content' => ['path' => $filepath]]; + } + + /** + * Create a branch + * + * Gogs does not support branch creation via API, so we use git CLI. + * + * @return array + */ + public function createBranch(string $owner, string $repositoryName, string $newBranchName, string $oldBranchName): array + { + $dir = $this->gitClone($owner, $repositoryName, $oldBranchName); + + try { + $this->exec("git -C {$dir} checkout -b " . escapeshellarg($newBranchName)); + $this->exec("git -C {$dir} push origin " . escapeshellarg($newBranchName)); + } finally { + $this->exec("rm -rf {$dir}"); + } + + return ['name' => $newBranchName]; + } + + /** + * Clone a repository into a temporary directory and checkout a branch. + */ + private function gitClone(string $owner, string $repositoryName, string $branch = ''): string + { + $cloneUrl = str_replace('://', "://{$owner}:{$this->accessToken}@", $this->giteaUrl) . "/{$owner}/{$repositoryName}.git"; + + $dir = escapeshellarg(sys_get_temp_dir() . '/gogs-' . uniqid()); + + $branchArg = ''; + if (!empty($branch)) { + $branchArg = ' -b ' . escapeshellarg($branch); + } + + $this->exec("git clone --depth=1{$branchArg} " . escapeshellarg($cloneUrl) . " {$dir}"); + $this->exec("git -C {$dir} config user.email 'gogs@test.local'"); + $this->exec("git -C {$dir} config user.name 'Gogs Test'"); + + return trim($dir, "'\""); + } + + + /** + * Execute a shell command and throw on failure. + */ + private function exec(string $command): string + { + $output = []; + $exitCode = 0; + + \exec($command . ' 2>&1', $output, $exitCode); + + $outputStr = implode("\n", $output); + + if ($exitCode !== 0) { + throw new Exception("Command failed (exit {$exitCode}): {$command}\n{$outputStr}"); + } + + return $outputStr; + } + + /** + * List repository languages + * + * Gogs does not support the languages endpoint. + * + * @return array + */ + public function listRepositoryLanguages(string $owner, string $repositoryName): array + { + throw new Exception("Listing repository languages is not supported by Gogs"); + } + + /** + * Create a tag + * + * Gogs does not support tag creation via API, so we use git CLI. + * + * @return array + */ + public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array + { + $dir = $this->gitClone($owner, $repositoryName); + + try { + $this->exec("git -C {$dir} fetch origin " . escapeshellarg($target)); + if (!empty($message)) { + $this->exec("git -C {$dir} tag -a " . escapeshellarg($tagName) . " " . escapeshellarg($target) . " -m " . escapeshellarg($message)); + } else { + $this->exec("git -C {$dir} tag " . escapeshellarg($tagName) . " " . escapeshellarg($target)); + } + $this->exec("git -C {$dir} push origin " . escapeshellarg($tagName)); + } finally { + $this->exec("rm -rf {$dir}"); + } + + return [ + 'name' => $tagName, + 'commit' => [ + 'sha' => $target, + ], + ]; + } + + /** + * Create a pull request + * + * Gogs does not have a pull request API. + * + * @return array + */ + public function createPullRequest(string $owner, string $repositoryName, string $title, string $head, string $base, string $body = ''): array + { + throw new Exception("Pull request API is not supported by Gogs"); + } + + /** + * Get a pull request + * + * @return array + */ + public function getPullRequest(string $owner, string $repositoryName, int $pullRequestNumber): array + { + throw new Exception("Pull request API is not supported by Gogs"); + } + + /** + * Get pull request from branch + * + * @return array + */ + public function getPullRequestFromBranch(string $owner, string $repositoryName, string $branch): array + { + throw new Exception("Pull request API is not supported by Gogs"); + } + + /** + * Update commit status + * + * Gogs does not support commit statuses API. + */ + public function updateCommitStatus(string $repositoryName, string $commitHash, string $owner, string $state, string $description = '', string $target_url = '', string $context = ''): void + { + throw new Exception("Commit status API is not supported by Gogs"); + } + + /** + * Get commit statuses + * + * Gogs does not support commit statuses API. + * + * @return array + */ + public function getCommitStatuses(string $owner, string $repositoryName, string $commitHash): array + { + throw new Exception("Commit status API is not supported by Gogs"); + } + + /** + * List branches + * + * Gogs supports listing branches but without pagination parameters. + * + * @return array + */ + public function listBranches(string $owner, string $repositoryName): array + { + $url = "/repos/{$owner}/{$repositoryName}/branches"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + + if ($responseHeadersStatusCode === 404) { + return []; + } + + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to list branches: HTTP {$responseHeadersStatusCode}"); + } + + $responseBody = $response['body'] ?? []; + + if (!is_array($responseBody)) { + return []; + } + + $branches = []; + foreach ($responseBody as $branch) { + if (is_array($branch) && array_key_exists('name', $branch)) { + $branches[] = $branch['name']; + } + } + + return $branches; + } +} diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 9bc56dbd..861b9a9b 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -487,14 +487,14 @@ public function testGetPullRequest(): void public function testGetPullRequestFiles(): void { $repositoryName = 'test-get-pull-request-files-' . \uniqid(); - $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test'); - $this->vcsAdapter->createBranch(self::$owner, $repositoryName, 'feature-branch', static::$defaultBranch); - $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'feature.txt', 'feature content', 'Add feature', 'feature-branch'); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'feature-branch', static::$defaultBranch); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'feature.txt', 'feature content', 'Add feature', 'feature-branch'); $pr = $this->vcsAdapter->createPullRequest( - self::$owner, + static::$owner, $repositoryName, 'Test PR Files', 'feature-branch', @@ -504,7 +504,7 @@ public function testGetPullRequestFiles(): void $prNumber = $pr['number'] ?? 0; $this->assertGreaterThan(0, $prNumber); - $result = $this->vcsAdapter->getPullRequestFiles(self::$owner, $repositoryName, $prNumber); + $result = $this->vcsAdapter->getPullRequestFiles(static::$owner, $repositoryName, $prNumber); $this->assertIsArray($result); $this->assertNotEmpty($result); @@ -512,7 +512,7 @@ public function testGetPullRequestFiles(): void $filenames = array_column($result, 'filename'); $this->assertContains('feature.txt', $filenames); - $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } public function testGetPullRequestWithInvalidNumber(): void @@ -861,7 +861,7 @@ public function testGetLatestCommitWithInvalidBranch(): void public function testGetEventPush(): void { $payload = json_encode([ - 'ref' => 'refs/heads/main', + 'ref' => 'refs/heads/' . static::$defaultBranch, 'before' => 'abc123', 'after' => 'def456', 'created' => false, @@ -1376,7 +1376,7 @@ public function testCreateFile(): void public function testCreateFileOnBranch(): void { $repositoryName = 'test-create-file-branch-'.\uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $res = $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); try { $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Main'); diff --git a/tests/VCS/Adapter/GogsTest.php b/tests/VCS/Adapter/GogsTest.php new file mode 100644 index 00000000..d7f04193 --- /dev/null +++ b/tests/VCS/Adapter/GogsTest.php @@ -0,0 +1,133 @@ +setupGogs(); + } + + $adapter = new Gogs(new Cache(new None())); + $gogsUrl = System::getEnv('TESTS_GOGS_URL', 'http://gogs:3000'); + + $adapter->initializeVariables( + installationId: '', + privateKey: '', + appId: '', + accessToken: static::$accessToken, + refreshToken: '' + ); + $adapter->setEndpoint($gogsUrl); + + if (empty(static::$owner)) { + $orgName = 'test-org-' . \uniqid(); + static::$owner = $adapter->createOrganization($orgName); + } + + $this->vcsAdapter = $adapter; + } + + protected function setupGogs(): void + { + $tokenFile = '/gogs-data/gogs/token.txt'; + + if (file_exists($tokenFile)) { + $contents = file_get_contents($tokenFile); + if ($contents !== false) { + static::$accessToken = trim($contents); + } + } + } + + + // --- Skip tests for unsupported Gogs features --- + + // Pull request API + public function testCommentWorkflow(): void + { + $this->markTestSkipped('Gogs does not support pull request API'); + } + public function testGetComment(): void + { + $this->markTestSkipped('Gogs does not support pull request API'); + } + public function testGetPullRequest(): void + { + $this->markTestSkipped('Gogs does not support pull request API'); + } + public function testGetPullRequestWithInvalidNumber(): void + { + $this->markTestSkipped('Gogs does not support pull request API'); + } + public function testGetPullRequestFromBranch(): void + { + $this->markTestSkipped('Gogs does not support pull request API'); + } + public function testGetPullRequestFromBranchNoPR(): void + { + $this->markTestSkipped('Gogs does not support pull request API'); + } + public function testUpdateComment(): void + { + $this->markTestSkipped('Gogs does not support pull request API'); + } + public function testCreateComment(): void + { + $this->markTestSkipped('Gogs does not support pull request API'); + } + public function testWebhookPullRequestEvent(): void + { + $this->markTestSkipped('Gogs does not support pull request API'); + } + + // Commit status + public function testUpdateCommitStatus(): void + { + $this->markTestSkipped('Gogs does not support commit status API'); + } + public function testUpdateCommitStatusWithInvalidCommit(): void + { + $this->markTestSkipped('Gogs does not support commit status API'); + } + public function testUpdateCommitStatusWithNonExistingRepository(): void + { + $this->markTestSkipped('Gogs does not support commit status API'); + } + + // Repository languages + public function testListRepositoryLanguages(): void + { + $this->markTestSkipped('Gogs does not support repository languages endpoint'); + } + public function testListRepositoryLanguagesEmptyRepo(): void + { + $this->markTestSkipped('Gogs does not support repository languages endpoint'); + } + + public function testGetPullRequestFiles(): void + { + $this->markTestSkipped('Gogs does not support pull request files API'); + } +}