3f3b1f79a0
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 42s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 45s
Added @since, @param, @see tags to: - CliFramework: class-level @since, 2 undocumented methods - GitHubAdapter: class @since/@see, constructor @param, property docs - MokoGiteaAdapter: class @since/@see, constructor @param, property docs - ApiClient: class @since Wiki: created Coding-Standards page with full PHPDoc standard, PHPCS exclusion rationale, and file structure patterns. Partial progress on #137 Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
530 lines
18 KiB
PHP
530 lines
18 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/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;
|
|
}
|
|
}
|