5c0cb98082
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>
502 lines
15 KiB
PHP
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;
|
|
}
|
|
}
|