* * 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/GitHubAdapter.php * BRIEF: GitHub implementation of GitPlatformAdapter */ declare(strict_types=1); namespace MokoEnterprise; use RuntimeException; /** * GitHub implementation of GitPlatformAdapter. * * Wraps ApiClient with GitHub-specific API semantics: * - Base URL: https://api.github.com * - Auth: Bearer {token} * - Pagination: per_page + page params * - File ops: PUT for both create and update (SHA distinguishes) * - Topics: PUT with {"names": [...]} * - Workflow dir: .github/workflows * * @package MokoStandards\Enterprise * @since 04.06.10 * @see GitPlatformAdapter */ class GitHubAdapter implements GitPlatformAdapter { /** @var ApiClient HTTP client for GitHub API calls. */ private ApiClient $apiClient; /** * @param ApiClient $apiClient Configured API client for api.github.com */ public function __construct(ApiClient $apiClient) { $this->apiClient = $apiClient; } // ────────────────────────────────────────────── // Identity // ────────────────────────────────────────────── public function getPlatformName(): string { return 'github'; } public function getBaseUrl(): string { return 'https://api.github.com'; } public function getWorkflowDir(): string { return '.github/workflows'; } public function getMetadataDir(): string { return '.github'; } public function getRepoWebUrl(string $org, string $repo): string { return "https://github.com/{$org}/{$repo}"; } public function getPullRequestWebUrl(string $org, string $repo, int $number): string { return "https://github.com/{$org}/{$repo}/pull/{$number}"; } public function getIssueWebUrl(string $org, string $repo, int $number): string { return "https://github.com/{$org}/{$repo}/issues/{$number}"; } public function getBranchWebUrl(string $org, string $repo, string $branch): string { return "https://github.com/{$org}/{$repo}/tree/{$branch}"; } public function getStepSummaryEnvVar(): string { return 'GITHUB_STEP_SUMMARY'; } // ────────────────────────────────────────────── // Repository CRUD // ────────────────────────────────────────────── public function listOrgRepos(string $org, bool $skipArchived = false): array { $all = $this->paginateAll("/orgs/{$org}/repos", ['type' => 'all']); $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 { return $this->apiClient->patch("/repos/{$org}/{$repo}", [ 'archived' => true, ]); } public function setRepoTopics(string $org, string $repo, array $topics): void { $this->apiClient->put("/repos/{$org}/{$repo}/topics", [ 'names' => $topics, ]); } public function getRepoTopics(string $org, string $repo): array { $response = $this->apiClient->get("/repos/{$org}/{$repo}/topics"); return $response['names'] ?? []; } // ────────────────────────────────────────────── // 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 ($sha !== null) { $data['sha'] = $sha; } if ($branch !== null) { $data['branch'] = $branch; } // GitHub uses PUT for both create and update return $this->apiClient->put("/repos/{$org}/{$repo}/contents/{$path}", $data); } public function deleteFile( string $org, string $repo, string $path, string $sha, string $message, ?string $branch = null ): array { // GitHub's delete endpoint requires a body with sha+message, // but ApiClient::delete() doesn't accept a body. Use the raw approach. $data = [ 'message' => $message, 'sha' => $sha, ]; if ($branch !== null) { $data['branch'] = $branch; } // Work around ApiClient::delete() not accepting a body by using // a direct HTTP call. For now, fall back to the underlying client. 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 { $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 { 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 { // GitHub accepts label names directly return $this->apiClient->post("/repos/{$org}/{$repo}/issues/{$number}/labels", [ 'labels' => $labels, ]); } // ────────────────────────────────────────────── // Branch Protection // ────────────────────────────────────────────── public function setBranchProtection(string $org, string $repo, string $branch, array $rules): array { // GitHub uses rulesets API (newer) or branch protection API (legacy) // Map our generic rules to GitHub's branch protection format $protection = [ 'required_status_checks' => null, 'enforce_admins' => $rules['enforce_admins'] ?? true, 'required_pull_request_reviews' => null, 'restrictions' => null, ]; if (isset($rules['required_reviews']) && $rules['required_reviews'] > 0) { $protection['required_pull_request_reviews'] = [ 'required_approving_review_count' => $rules['required_reviews'], 'dismiss_stale_reviews' => $rules['dismiss_stale'] ?? false, 'require_code_owner_reviews' => $rules['require_code_owner'] ?? false, ]; } return $this->apiClient->put( "/repos/{$org}/{$repo}/branches/{$branch}/protection", $protection ); } public function listBranchProtections(string $org, string $repo): array { // GitHub doesn't have a "list all protections" endpoint; list branches and check each // For rulesets: GET /repos/{owner}/{repo}/rulesets try { return $this->apiClient->get("/repos/{$org}/{$repo}/rulesets"); } catch (\Exception $e) { return []; } } // ────────────────────────────────────────────── // Git Refs // ────────────────────────────────────────────── public function resolveRef(string $org, string $repo, string $ref): string { // Try as a tag first, then as a branch try { $tag = $this->apiClient->get("/repos/{$org}/{$repo}/git/ref/tags/{$ref}"); $object = $tag['object'] ?? []; // Annotated tags have type 'tag' — dereference to the commit if (($object['type'] ?? '') === 'tag') { $tagObj = $this->apiClient->get($object['url'] ?? "/repos/{$org}/{$repo}/git/tags/{$object['sha']}"); return $tagObj['object']['sha'] ?? $object['sha']; } return $object['sha'] ?? ''; } catch (\Exception $e) { // Not a tag — try as a branch $this->apiClient->resetCircuitBreaker(); } $branch = $this->apiClient->get("/repos/{$org}/{$repo}/git/ref/heads/{$ref}"); return $branch['object']['sha'] ?? ''; } public function getTree(string $org, string $repo, string $ref = 'HEAD', bool $recursive = true): array { $params = $recursive ? ['recursive' => '1'] : []; $response = $this->apiClient->get("/repos/{$org}/{$repo}/git/trees/{$ref}", $params); return $response['tree'] ?? []; } // ────────────────────────────────────────────── // Pagination // ────────────────────────────────────────────── public function paginateAll(string $endpoint, array $params = [], int $perPage = 100): array { $all = []; $page = 1; $params['per_page'] = $perPage; while (true) { $params['page'] = $page; $response = $this->apiClient->get($endpoint, $params); if (empty($response)) { break; } $all = array_merge($all, $response); $page++; } return array_values($all); } // ────────────────────────────────────────────── // Migration // ────────────────────────────────────────────── public function migrateRepository(array $options): array { throw new RuntimeException('Repository migration is not supported on GitHub — use Gitea\'s built-in migration'); } // ────────────────────────────────────────────── // Low-level // ────────────────────────────────────────────── public function getApiClient(): ApiClient { return $this->apiClient; } public function listBranches(string $org, string $repo): array { return $this->apiClient->get("/repos/{$org}/{$repo}/branches") ?? []; } public function getCloneUrl(string $repo): string { return "https://github.com/{$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; } }