Files
moko-platform/lib/Enterprise/GiteaAdapter.php
T
Jonathan Miller 5c0cb98082 fix: resolve label names to IDs in GiteaAdapter::createIssue
Gitea API expects label IDs (int64) not names. When string labels are
passed, resolve them via listLabels() before posting. Fixes 422
Unprocessable Entity errors that were causing tracking issue creation
to fail and repos to be marked as skipped during bulk sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 11:53:38 -05:00

502 lines
15 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/MokoStandards-API
* PATH: /lib/Enterprise/GiteaAdapter.php
* VERSION: 04.06.10
* 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: .gitea/workflows
*
* @package MokoStandards\Enterprise
* @version 04.06.10
*/
class GiteaAdapter implements GitPlatformAdapter
{
private ApiClient $apiClient;
private string $baseUrl;
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 '.gitea/workflows';
}
public function getMetadataDir(): string
{
return '.gitea';
}
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 $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;
}
}