Files
Jonathan Miller cbfa23c4c4
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Repository health (push) Successful in 12s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 45s
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Universal: PR Check / Build RC Package (pull_request) Successful in 2s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 44s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 48s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 48s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 48s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 50s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 12s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 1m13s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 5s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 42s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Failing after 45s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 44s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Failing after 47s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Failing after 49s
fix: PHPStan level 0 → 2 — fix 67 type errors across 18 files
Real bugs found and fixed:
- bulk_joomla_template: $org undefined in heredoc (missing parameter)
- RepositorySynchronizer: $root undefined (should be $repoRoot), duplicate array key
- RepositoryHealthChecker: wrong class name (UnifiedValidation → UnifiedValidator)
- scan_drift: missing $adapter property declaration
- auto_detect_platform: wrong method name (detectProjectType → detect)
- EnterpriseReadinessValidator: void return used as value
- check_client_theme: extra parameter to printSummary()
- ApiClient: unused constructor parameter now stored
- GitPlatformAdapter: added listBranches/getCloneUrl/cloneRepo to interface
- MokoGiteaAdapter/GitHubAdapter: implemented new interface methods

3 legacy CLIApp scripts excluded (need migration to CliFramework):
  repo_cleanup.php, push_files.php, joomla_release.php

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 19:29:52 -05:00

453 lines
16 KiB
PHP

<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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
* @version 04.06.10
*/
class GitHubAdapter implements GitPlatformAdapter
{
private ApiClient $apiClient;
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 $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;
}
}