* * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Enterprise.Platform * INGROUP: MokoStandards.Enterprise * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /lib/Enterprise/MokoGiteaAdapter.php * BRIEF: Gitea implementation of GitPlatformAdapter */ declare(strict_types=1); namespace MokoEnterprise; use Exception; use RuntimeException; /** * Gitea implementation of GitPlatformAdapter. * * Wraps ApiClient with Gitea-specific API semantics: * - Base URL: https://git.mokoconsulting.tech/api/v1 * - Auth: token {token} * - Pagination: limit + page params * - File ops: POST for create, PUT for update (check existence first) * - Topics: PUT with {"topics": [...]} * - Branch protection: flat API (not rulesets) * - Workflow dir: .mokogitea/workflows * * @package MokoStandards\Enterprise * @since 04.06.10 * @see GitPlatformAdapter */ class MokoGiteaAdapter implements GitPlatformAdapter { /** @var ApiClient HTTP client for Gitea API calls. */ private ApiClient $apiClient; /** @var string Base URL for Gitea API (e.g. https://git.mokoconsulting.tech/api/v1). */ private string $baseUrl; /** * @param ApiClient $apiClient Configured API client * @param string $baseUrl Gitea API base URL */ public function __construct(ApiClient $apiClient, string $baseUrl = 'https://git.mokoconsulting.tech/api/v1') { $this->apiClient = $apiClient; $this->baseUrl = rtrim($baseUrl, '/'); } // ────────────────────────────────────────────── // Identity // ────────────────────────────────────────────── public function getPlatformName(): string { return 'gitea'; } public function getBaseUrl(): string { return $this->baseUrl; } public function getWorkflowDir(): string { return '.mokogitea/workflows'; } public function getMetadataDir(): string { return '.mokogitea'; } public function getRepoWebUrl(string $org, string $repo): string { // Derive web URL from API base URL by stripping '/api/v1' $webBase = preg_replace('#/api/v1$#', '', $this->baseUrl); return "{$webBase}/{$org}/{$repo}"; } public function getPullRequestWebUrl(string $org, string $repo, int $number): string { // Gitea uses /pulls/ (not /pull/) for web UI $webBase = preg_replace('#/api/v1$#', '', $this->baseUrl); return "{$webBase}/{$org}/{$repo}/pulls/{$number}"; } public function getIssueWebUrl(string $org, string $repo, int $number): string { $webBase = preg_replace('#/api/v1$#', '', $this->baseUrl); return "{$webBase}/{$org}/{$repo}/issues/{$number}"; } public function listBranches(string $org, string $repo): array { return $this->paginateAll("/repos/{$org}/{$repo}/branches"); } public function getBranchWebUrl(string $org, string $repo, string $branch): string { // Gitea uses /src/branch/ (not /tree/) for web UI $webBase = preg_replace('#/api/v1$#', '', $this->baseUrl); return "{$webBase}/{$org}/{$repo}/src/branch/{$branch}"; } public function getStepSummaryEnvVar(): string { return 'GITEA_STEP_SUMMARY'; } // ────────────────────────────────────────────── // Repository CRUD // ────────────────────────────────────────────── public function listOrgRepos(string $org, bool $skipArchived = false): array { $all = $this->paginateAll("/orgs/{$org}/repos"); $repos = []; foreach ($all as $repo) { if ($skipArchived && ($repo['archived'] ?? false)) { continue; } $repos[] = [ 'name' => $repo['name'], 'full_name' => $repo['full_name'], 'archived' => $repo['archived'] ?? false, 'private' => $repo['private'] ?? false, ]; } return $repos; } public function getRepo(string $org, string $repo): array { return $this->apiClient->get("/repos/{$org}/{$repo}"); } public function createOrgRepo(string $org, string $name, array $options = []): array { $data = array_merge([ 'name' => $name, 'auto_init' => true, ], $options); return $this->apiClient->post("/orgs/{$org}/repos", $data); } public function archiveRepo(string $org, string $repo): array { // Gitea uses PATCH with archived flag, same as GitHub return $this->apiClient->patch("/repos/{$org}/{$repo}", [ 'archived' => true, ]); } public function setRepoTopics(string $org, string $repo, array $topics): void { // Gitea uses {"topics": [...]} not {"names": [...]} $this->apiClient->put("/repos/{$org}/{$repo}/topics", [ 'topics' => $topics, ]); } public function getRepoTopics(string $org, string $repo): array { $response = $this->apiClient->get("/repos/{$org}/{$repo}/topics"); return $response['topics'] ?? []; } // ────────────────────────────────────────────── // File Contents // ────────────────────────────────────────────── public function getFileContents(string $org, string $repo, string $path, ?string $ref = null): array { $params = []; if ($ref !== null) { $params['ref'] = $ref; } return $this->apiClient->get("/repos/{$org}/{$repo}/contents/{$path}", $params); } public function createOrUpdateFile( string $org, string $repo, string $path, string $content, string $message, ?string $sha = null, ?string $branch = null ): array { $data = [ 'message' => $message, 'content' => base64_encode($content), ]; if ($branch !== null) { $data['branch'] = $branch; } if ($sha !== null) { // Update existing file — Gitea uses PUT with SHA $data['sha'] = $sha; return $this->apiClient->put("/repos/{$org}/{$repo}/contents/{$path}", $data); } // Create new file — Gitea uses POST return $this->apiClient->post("/repos/{$org}/{$repo}/contents/{$path}", $data); } public function deleteFile( string $org, string $repo, string $path, string $sha, string $message, ?string $branch = null ): array { // Gitea's delete uses the same endpoint but with DELETE method // ApiClient::delete() doesn't support a body, so we use the raw approach // For now, this matches GitHubAdapter's limitation return $this->apiClient->delete("/repos/{$org}/{$repo}/contents/{$path}"); } // ────────────────────────────────────────────── // Pull Requests // ────────────────────────────────────────────── public function listPullRequests(string $org, string $repo, array $filters = []): array { return $this->apiClient->get("/repos/{$org}/{$repo}/pulls", $filters); } public function createPullRequest( string $org, string $repo, string $title, string $head, string $base, string $body = '', array $options = [] ): array { $data = array_merge([ 'title' => $title, 'head' => $head, 'base' => $base, 'body' => $body, ], $options); return $this->apiClient->post("/repos/{$org}/{$repo}/pulls", $data); } public function updatePullRequest(string $org, string $repo, int $number, array $data): array { return $this->apiClient->patch("/repos/{$org}/{$repo}/pulls/{$number}", $data); } // ────────────────────────────────────────────── // Issues // ────────────────────────────────────────────── public function listIssues(string $org, string $repo, array $filters = []): array { return $this->apiClient->get("/repos/{$org}/{$repo}/issues", $filters); } public function createIssue( string $org, string $repo, string $title, string $body = '', array $options = [] ): array { // Gitea expects label IDs (int64), not names. Resolve if needed. if (!empty($options['labels']) && is_string($options['labels'][0] ?? null)) { $labelNames = $options['labels']; $existing = $this->listLabels($org, $repo); $nameToId = []; foreach ($existing as $label) { $nameToId[$label['name']] = $label['id']; } $options['labels'] = []; foreach ($labelNames as $name) { if (isset($nameToId[$name])) { $options['labels'][] = $nameToId[$name]; } } } $data = array_merge([ 'title' => $title, 'body' => $body, ], $options); return $this->apiClient->post("/repos/{$org}/{$repo}/issues", $data); } public function addIssueComment(string $org, string $repo, int $number, string $body): array { return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/comments", [ 'body' => $body, ]); } public function closeIssue(string $org, string $repo, int $number): array { return $this->apiClient->patch("/repos/{$org}/{$repo}/issues/{$number}", [ 'state' => 'closed', ]); } // ────────────────────────────────────────────── // Labels // ────────────────────────────────────────────── public function listLabels(string $org, string $repo): array { return $this->paginateAll("/repos/{$org}/{$repo}/labels"); } public function createLabel(string $org, string $repo, string $name, string $color, string $description = ''): array { // Gitea expects color with # prefix $color = ltrim($color, '#'); return $this->apiClient->post("/repos/{$org}/{$repo}/labels", [ 'name' => $name, 'color' => '#' . $color, 'description' => $description, ]); } public function addIssueLabels(string $org, string $repo, int $number, array $labels): array { // Gitea requires label IDs, not names. Resolve names to IDs first. $allLabels = $this->listLabels($org, $repo); $labelMap = []; foreach ($allLabels as $label) { $labelMap[$label['name']] = $label['id']; } $labelIds = []; foreach ($labels as $label) { if (is_int($label)) { $labelIds[] = $label; } elseif (isset($labelMap[$label])) { $labelIds[] = $labelMap[$label]; } } if (empty($labelIds)) { return []; } return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/labels", [ 'labels' => $labelIds, ]); } // ────────────────────────────────────────────── // Branch Protection // ────────────────────────────────────────────── public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array { // Gitea uses a flat branch protection API $protection = [ 'branch_name' => $branch, 'enable_push' => true, 'enable_push_whitelist' => false, 'enable_merge_whitelist' => false, 'enable_status_check' => $rules['required_status_checks'] ?? false, 'enable_approvals_whitelist' => false, 'required_approvals' => $rules['required_reviews'] ?? 0, 'dismiss_stale_approvals' => $rules['dismiss_stale'] ?? false, 'block_on_rejected_reviews' => $rules['block_on_rejected'] ?? true, 'block_on_outdated_branch' => $rules['block_on_outdated'] ?? false, 'block_on_official_review_requests' => false, ]; // Check if protection already exists for this branch try { $existing = $this->apiClient->get("/repos/{$org}/{$repo}/branch_protections/{$branch}"); if (!empty($existing)) { return $this->apiClient->patch("/repos/{$org}/{$repo}/branch_protections/{$branch}", $protection); } } catch (Exception $e) { $this->apiClient->resetCircuitBreaker(); } return $this->apiClient->post("/repos/{$org}/{$repo}/branch_protections", $protection); } public function listBranchProtections(string $org, string $repo): array { try { return $this->apiClient->get("/repos/{$org}/{$repo}/branch_protections"); } catch (Exception $e) { return []; } } // ────────────────────────────────────────────── // Git Refs // ────────────────────────────────────────────── public function resolveRef(string $org, string $repo, string $ref): string { // Try as a tag first try { $tag = $this->apiClient->get("/repos/{$org}/{$repo}/git/tags/{$ref}"); // Gitea tag objects have a 'commit' field with the SHA if (isset($tag['commit']['sha'])) { return $tag['commit']['sha']; } return $tag['id'] ?? $tag['sha'] ?? ''; } catch (Exception $e) { $this->apiClient->resetCircuitBreaker(); } // Try as a branch try { $branch = $this->apiClient->get("/repos/{$org}/{$repo}/branches/{$ref}"); return $branch['commit']['id'] ?? ''; } catch (Exception $e) { $this->apiClient->resetCircuitBreaker(); } // Last resort: try git/refs endpoint $refData = $this->apiClient->get("/repos/{$org}/{$repo}/git/refs/tags/{$ref}"); return $refData['object']['sha'] ?? ''; } public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array { $params = $recursive ? ['recursive' => 'true'] : []; $response = $this->apiClient->get("/repos/{$org}/{$repo}/git/trees/{$ref}", $params); return $response['tree'] ?? []; } // ────────────────────────────────────────────── // Pagination // ────────────────────────────────────────────── public function paginateAll(string $endpoint, array $params = [], int $perPage = 50): array { $all = []; $page = 1; // Gitea uses 'limit' instead of 'per_page' $params['limit'] = $perPage; while (true) { $params['page'] = $page; $response = $this->apiClient->get($endpoint, $params); if (empty($response)) { break; } $all = array_merge($all, $response); // If we got fewer results than the limit, we've reached the end if (count($response) < $perPage) { break; } $page++; } return array_values($all); } // ────────────────────────────────────────────── // Migration // ────────────────────────────────────────────── public function migrateRepository(array $options): array { // Gitea's built-in migration endpoint $data = array_merge([ 'service' => 'github', 'issues' => true, 'labels' => true, 'milestones' => true, 'releases' => true, 'wiki' => false, ], $options); return $this->apiClient->post('/repos/migrate', $data); } // ────────────────────────────────────────────── // Low-level // ────────────────────────────────────────────── public function getApiClient(): ApiClient { return $this->apiClient; } public function getCloneUrl(string $repo): string { $base = str_replace('/api/v1', '', $this->baseUrl); return "{$base}/{$repo}.git"; } public function cloneRepo(string $repo, string $path, array $options = []): bool { $url = $this->getCloneUrl($repo); $depth = $options['depth'] ?? 0; $depthFlag = $depth > 0 ? " --depth {$depth}" : ''; $result = 0; passthru( 'git clone' . $depthFlag . ' --quiet ' . escapeshellarg($url) . ' ' . escapeshellarg($path), $result ); return $result === 0; } }