Gitea plugin: default_repo field per account (stored in DB, pre-fills create issue form but stays editable). Editor toolbar: asset picker flyout with 4-column thumbnail grid and live search filter. Clicking a tile inserts the matching markdown. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
628 lines
28 KiB
PHP
628 lines
28 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Gitea Plugin: backend.
|
|
*
|
|
* Replaces the standalone Python `gitea-issue-client` tool. MVP scope:
|
|
* accounts CRUD + connection test. Issue browsing/extraction comes once
|
|
* the connection layer is verified. See TODO.md for the deferred items.
|
|
*
|
|
* Routes:
|
|
* GET /api/gitea/accounts → list accounts (no tokens)
|
|
* POST /api/gitea/accounts → create account
|
|
* PATCH /api/gitea/accounts/{id} → update (omit token to keep)
|
|
* DELETE /api/gitea/accounts/{id} → remove
|
|
* POST /api/gitea/accounts/{id}/test → verify token via /api/v1/user
|
|
*/
|
|
|
|
require_once __DIR__ . '/GiteaClient.php';
|
|
|
|
// ── Schema migration ──────────────────────────────────────────────
|
|
|
|
(function () {
|
|
$db = Database::connect();
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS gitea_accounts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
base_url TEXT NOT NULL,
|
|
token TEXT NOT NULL,
|
|
default_repo TEXT DEFAULT NULL,
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
");
|
|
// Migration: add default_repo column if missing
|
|
$cols = $db->query("PRAGMA table_info(gitea_accounts)")->fetchAll(PDO::FETCH_COLUMN, 1);
|
|
if (!in_array('default_repo', $cols)) {
|
|
$db->exec("ALTER TABLE gitea_accounts ADD COLUMN default_repo TEXT DEFAULT NULL");
|
|
}
|
|
})();
|
|
|
|
// ── Encryption helpers (mirrors email plugin / CalDavSync) ────────
|
|
|
|
function giteaEncKey(): string {
|
|
$db = Database::connect();
|
|
$vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
|
|
if ($vk) return substr(hash('sha256', $vk), 0, 32);
|
|
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
|
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
|
|
}
|
|
|
|
function giteaEncrypt(string $plain): string {
|
|
$key = giteaEncKey();
|
|
$iv = random_bytes(16);
|
|
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
|
return base64_encode($iv . $cipher);
|
|
}
|
|
|
|
function giteaDecrypt(string $encoded): string {
|
|
$key = giteaEncKey();
|
|
$raw = base64_decode($encoded);
|
|
if (!$raw || strlen($raw) < 17) return '';
|
|
$iv = substr($raw, 0, 16);
|
|
$cipher = substr($raw, 16);
|
|
return openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv) ?: '';
|
|
}
|
|
|
|
function giteaJson($data, int $code = 200): void {
|
|
http_response_code($code);
|
|
header('Content-Type: application/json');
|
|
echo json_encode($data);
|
|
exit;
|
|
}
|
|
|
|
function giteaGetAccount(int $id): ?array {
|
|
$db = Database::connect();
|
|
$stmt = $db->prepare('SELECT * FROM gitea_accounts WHERE id = :id');
|
|
$stmt->execute(['id' => $id]);
|
|
return $stmt->fetch() ?: null;
|
|
}
|
|
|
|
function giteaOpenClient(array $row): GiteaClient {
|
|
return new GiteaClient($row['base_url'], giteaDecrypt($row['token']));
|
|
}
|
|
|
|
// ── Route registration ────────────────────────────────────────────
|
|
|
|
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|
|
|
// ── Accounts CRUD ──
|
|
|
|
if ($route === '/api/gitea/accounts' && $method === 'GET') {
|
|
$db = Database::connect();
|
|
$rows = $db->query('SELECT id, name, base_url, default_repo, position, created_at FROM gitea_accounts ORDER BY position ASC, id ASC')->fetchAll();
|
|
giteaJson($rows);
|
|
}
|
|
|
|
if ($route === '/api/gitea/accounts' && $method === 'POST') {
|
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
$name = trim((string)($input['name'] ?? ''));
|
|
$baseUrl = trim((string)($input['base_url'] ?? ''));
|
|
$token = (string)($input['token'] ?? '');
|
|
if ($name === '' || $baseUrl === '' || $token === '') {
|
|
giteaJson(['error' => 'name, base_url, and token are required'], 400);
|
|
}
|
|
// Normalize base URL: strip trailing slash, accept with/without scheme
|
|
if (!preg_match('#^https?://#i', $baseUrl)) {
|
|
$baseUrl = 'https://' . $baseUrl;
|
|
}
|
|
$baseUrl = rtrim($baseUrl, '/');
|
|
$db = Database::connect();
|
|
$stmt = $db->prepare('
|
|
INSERT INTO gitea_accounts (name, base_url, token)
|
|
VALUES (:n, :u, :t)
|
|
');
|
|
$stmt->execute([
|
|
'n' => $name,
|
|
'u' => $baseUrl,
|
|
't' => giteaEncrypt($token),
|
|
]);
|
|
giteaJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
|
}
|
|
|
|
if (preg_match('#^/api/gitea/accounts/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
|
$id = (int)$m[1];
|
|
$row = giteaGetAccount($id);
|
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
$fields = []; $params = ['id' => $id];
|
|
if (array_key_exists('name', $input)) {
|
|
$fields[] = 'name = :name'; $params['name'] = trim((string)$input['name']);
|
|
}
|
|
if (array_key_exists('base_url', $input)) {
|
|
$u = trim((string)$input['base_url']);
|
|
if (!preg_match('#^https?://#i', $u)) $u = 'https://' . $u;
|
|
$fields[] = 'base_url = :base_url'; $params['base_url'] = rtrim($u, '/');
|
|
}
|
|
if (!empty($input['token'])) {
|
|
$fields[] = 'token = :token'; $params['token'] = giteaEncrypt($input['token']);
|
|
}
|
|
if (array_key_exists('default_repo', $input)) {
|
|
$val = trim((string)($input['default_repo'] ?? ''));
|
|
$fields[] = 'default_repo = :default_repo'; $params['default_repo'] = $val ?: null;
|
|
}
|
|
if (!$fields) giteaJson(['ok' => true]);
|
|
$db = Database::connect();
|
|
$db->prepare('UPDATE gitea_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
|
|
giteaJson(['ok' => true]);
|
|
}
|
|
|
|
if (preg_match('#^/api/gitea/accounts/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
|
$id = (int)$m[1];
|
|
$db = Database::connect();
|
|
$db->prepare('DELETE FROM gitea_accounts WHERE id = :id')->execute(['id' => $id]);
|
|
giteaJson(['ok' => true]);
|
|
}
|
|
|
|
// ── Issues: list ──
|
|
|
|
if ($route === '/api/gitea/issues' && $method === 'GET') {
|
|
$accId = (int)($_GET['account'] ?? 0);
|
|
$row = giteaGetAccount($accId);
|
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
|
|
|
$state = in_array($_GET['state'] ?? 'open', ['open', 'closed', 'all'], true) ? $_GET['state'] : 'open';
|
|
$q = trim((string)($_GET['q'] ?? ''));
|
|
$owner = trim((string)($_GET['owner'] ?? ''));
|
|
$scope = $_GET['scope'] ?? 'all'; // all | assigned | created | mentioned
|
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
|
$limit = max(5, min(100, (int)($_GET['limit'] ?? 30)));
|
|
|
|
try {
|
|
$client = giteaOpenClient($row);
|
|
$issues = $client->searchIssues([
|
|
'state' => $state,
|
|
'q' => $q !== '' ? $q : null,
|
|
'owner' => $owner !== '' ? $owner : null,
|
|
'type' => 'issues', // exclude PRs
|
|
'assigned' => $scope === 'assigned' ? true : null,
|
|
'created' => $scope === 'created' ? true : null,
|
|
'mentioned' => $scope === 'mentioned' ? true : null,
|
|
'page' => $page,
|
|
'limit' => $limit,
|
|
]);
|
|
|
|
// Map to a compact, UI-friendly shape
|
|
$mapped = array_map(function ($i) {
|
|
$repoFull = $i['repository']['full_name'] ?? null;
|
|
if (!$repoFull && isset($i['repository']['owner'], $i['repository']['name'])) {
|
|
$repoFull = $i['repository']['owner'] . '/' . $i['repository']['name'];
|
|
}
|
|
return [
|
|
'id' => $i['id'] ?? null,
|
|
'number' => $i['number'] ?? null,
|
|
'title' => $i['title'] ?? '',
|
|
'state' => $i['state'] ?? 'open',
|
|
'created_at' => $i['created_at'] ?? null,
|
|
'updated_at' => $i['updated_at'] ?? null,
|
|
'comments_count' => $i['comments'] ?? 0,
|
|
'html_url' => $i['html_url'] ?? null,
|
|
'repo' => $repoFull,
|
|
'author' => $i['user']['login'] ?? null,
|
|
'labels' => array_map(fn($l) => [
|
|
'name' => $l['name'] ?? '',
|
|
'color' => $l['color'] ?? '888888',
|
|
], $i['labels'] ?? []),
|
|
'assignees' => array_map(fn($a) => $a['login'] ?? '', $i['assignees'] ?? []),
|
|
];
|
|
}, $issues);
|
|
|
|
giteaJson([
|
|
'ok' => true,
|
|
'issues' => $mapped,
|
|
'page' => $page,
|
|
'limit' => $limit,
|
|
'hasMore' => count($mapped) >= $limit, // heuristic — Gitea doesn't always return total
|
|
]);
|
|
} catch (GiteaApiException $e) {
|
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
|
} catch (\Throwable $e) {
|
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
|
}
|
|
}
|
|
|
|
// ── Issue detail (with comments) ──
|
|
|
|
if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)$#', $route, $m) && $method === 'GET') {
|
|
$accId = (int)$m[1];
|
|
$owner = urldecode($m[2]);
|
|
$repo = urldecode($m[3]);
|
|
$index = (int)$m[4];
|
|
$row = giteaGetAccount($accId);
|
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
|
|
|
try {
|
|
$client = giteaOpenClient($row);
|
|
$issue = $client->getIssue($owner, $repo, $index);
|
|
$comments = $client->listIssueComments($owner, $repo, $index);
|
|
|
|
giteaJson([
|
|
'ok' => true,
|
|
'issue' => [
|
|
'id' => $issue['id'] ?? null,
|
|
'number' => $issue['number'] ?? $index,
|
|
'title' => $issue['title'] ?? '',
|
|
'body' => $issue['body'] ?? '',
|
|
'state' => $issue['state'] ?? 'open',
|
|
'created_at' => $issue['created_at'] ?? null,
|
|
'updated_at' => $issue['updated_at'] ?? null,
|
|
'closed_at' => $issue['closed_at'] ?? null,
|
|
'due_date' => $issue['due_date'] ?? null,
|
|
'html_url' => $issue['html_url'] ?? null,
|
|
'author' => $issue['user']['login'] ?? null,
|
|
'author_avatar' => $issue['user']['avatar_url'] ?? null,
|
|
'repo' => "$owner/$repo",
|
|
'owner' => $owner,
|
|
'repo_name' => $repo,
|
|
'labels' => array_map(fn($l) => [
|
|
'name' => $l['name'] ?? '',
|
|
'color' => $l['color'] ?? '888888',
|
|
], $issue['labels'] ?? []),
|
|
'assignees' => array_map(fn($a) => $a['login'] ?? '', $issue['assignees'] ?? []),
|
|
'milestone' => isset($issue['milestone']) && $issue['milestone'] ? [
|
|
'id' => $issue['milestone']['id'] ?? null,
|
|
'title' => $issue['milestone']['title'] ?? '',
|
|
] : null,
|
|
],
|
|
'comments' => array_map(fn($c) => [
|
|
'id' => $c['id'] ?? null,
|
|
'author' => $c['user']['login'] ?? null,
|
|
'body' => $c['body'] ?? '',
|
|
'created_at' => $c['created_at'] ?? null,
|
|
'updated_at' => $c['updated_at'] ?? null,
|
|
], $comments),
|
|
]);
|
|
} catch (GiteaApiException $e) {
|
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
|
} catch (\Throwable $e) {
|
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
|
}
|
|
}
|
|
|
|
// ── Issue write operations ──
|
|
|
|
if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)/comment$#', $route, $m) && $method === 'POST') {
|
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4];
|
|
$row = giteaGetAccount($accId);
|
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
$body = trim((string)($input['body'] ?? ''));
|
|
if ($body === '') giteaJson(['error' => 'comment body required'], 400);
|
|
try {
|
|
$client = giteaOpenClient($row);
|
|
$created = $client->createIssueComment($owner, $repo, $index, $body);
|
|
giteaJson([
|
|
'ok' => true,
|
|
'comment' => [
|
|
'id' => $created['id'] ?? null,
|
|
'author' => $created['user']['login'] ?? null,
|
|
'body' => $created['body'] ?? '',
|
|
'created_at' => $created['created_at'] ?? null,
|
|
],
|
|
]);
|
|
} catch (GiteaApiException $e) {
|
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
|
} catch (\Throwable $e) {
|
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
|
}
|
|
}
|
|
|
|
if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4];
|
|
$row = giteaGetAccount($accId);
|
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
$changes = [];
|
|
foreach (['title', 'body', 'state', 'assignees', 'milestone', 'due_date', 'unset_due_date'] as $k) {
|
|
if (array_key_exists($k, $input)) $changes[$k] = $input[$k];
|
|
}
|
|
if (!$changes) giteaJson(['error' => 'no changes'], 400);
|
|
if (isset($changes['state']) && !in_array($changes['state'], ['open', 'closed'], true)) {
|
|
giteaJson(['error' => 'invalid state'], 400);
|
|
}
|
|
try {
|
|
$client = giteaOpenClient($row);
|
|
$updated = $client->editIssue($owner, $repo, $index, $changes);
|
|
giteaJson([
|
|
'ok' => true,
|
|
'issue' => [
|
|
'title' => $updated['title'] ?? '',
|
|
'body' => $updated['body'] ?? '',
|
|
'state' => $updated['state'] ?? 'open',
|
|
'updated_at' => $updated['updated_at'] ?? null,
|
|
'closed_at' => $updated['closed_at'] ?? null,
|
|
],
|
|
]);
|
|
} catch (GiteaApiException $e) {
|
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
|
} catch (\Throwable $e) {
|
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
|
}
|
|
}
|
|
|
|
if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)/labels$#', $route, $m) && $method === 'PUT') {
|
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4];
|
|
$row = giteaGetAccount($accId);
|
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
$labels = $input['labels'] ?? [];
|
|
if (!is_array($labels)) giteaJson(['error' => 'labels array required'], 400);
|
|
try {
|
|
$client = giteaOpenClient($row);
|
|
$resulting = $client->replaceIssueLabels($owner, $repo, $index, $labels);
|
|
giteaJson([
|
|
'ok' => true,
|
|
'labels' => array_map(fn($l) => [
|
|
'id' => $l['id'] ?? null,
|
|
'name' => $l['name'] ?? '',
|
|
'color' => $l['color'] ?? '888888',
|
|
], $resulting),
|
|
]);
|
|
} catch (GiteaApiException $e) {
|
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
|
} catch (\Throwable $e) {
|
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
|
}
|
|
}
|
|
|
|
// ── Attachment proxy ──
|
|
// Gitea attachments live under /attachments/<uuid> on the gitea instance
|
|
// and require the auth token (or session cookie). The browser can't fetch
|
|
// them directly (CORS + no token), so hyprcrd proxies them.
|
|
|
|
if (preg_match('#^/api/gitea/attachment/(\d+)/(.+)$#', $route, $m) && $method === 'GET') {
|
|
$accId = (int)$m[1];
|
|
$path = $m[2]; // e.g. "attachments/abc-123-uuid" or "user/repo/raw/branch/file.png"
|
|
$row = giteaGetAccount($accId);
|
|
if (!$row) { http_response_code(404); exit; }
|
|
|
|
// Build target URL on the gitea instance
|
|
$url = rtrim($row['base_url'], '/') . '/' . ltrim($path, '/');
|
|
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_TIMEOUT => 30,
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Authorization: token ' . giteaDecrypt($row['token']),
|
|
'User-Agent: hyprcrd-gitea-plugin/1.0',
|
|
],
|
|
]);
|
|
$body = curl_exec($ch);
|
|
if ($body === false) {
|
|
$err = curl_error($ch);
|
|
curl_close($ch);
|
|
http_response_code(502);
|
|
header('Content-Type: text/plain');
|
|
echo "Could not reach gitea: $err";
|
|
exit;
|
|
}
|
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: 'application/octet-stream';
|
|
curl_close($ch);
|
|
|
|
if ($code >= 400) {
|
|
http_response_code($code);
|
|
header('Content-Type: text/plain');
|
|
echo "Gitea returned HTTP $code";
|
|
exit;
|
|
}
|
|
|
|
// Strip any default headers PHP might emit so we can replace them
|
|
header_remove('Pragma');
|
|
header_remove('Expires');
|
|
header('Content-Type: ' . $contentType);
|
|
header('Cache-Control: private, max-age=3600');
|
|
header('Content-Length: ' . strlen($body));
|
|
echo $body;
|
|
exit;
|
|
}
|
|
|
|
// ── Create issue ──
|
|
|
|
if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/issues$#', $route, $m) && $method === 'POST') {
|
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]);
|
|
$row = giteaGetAccount($accId);
|
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
$title = trim((string)($input['title'] ?? ''));
|
|
if ($title === '') giteaJson(['error' => 'title is required'], 400);
|
|
$opts = ['title' => $title];
|
|
if (array_key_exists('body', $input)) $opts['body'] = (string)$input['body'];
|
|
if (array_key_exists('labels', $input) && is_array($input['labels'])) {
|
|
$opts['labels'] = array_map('intval', $input['labels']);
|
|
}
|
|
if (array_key_exists('assignees', $input) && is_array($input['assignees'])) {
|
|
$opts['assignees'] = array_values(array_filter(array_map('strval', $input['assignees'])));
|
|
}
|
|
if (!empty($input['milestone'])) $opts['milestone'] = (int)$input['milestone'];
|
|
if (!empty($input['due_date'])) $opts['due_date'] = (string)$input['due_date'];
|
|
|
|
try {
|
|
$client = giteaOpenClient($row);
|
|
$created = $client->createIssue($owner, $repo, $opts);
|
|
giteaJson([
|
|
'ok' => true,
|
|
'issue' => [
|
|
'id' => $created['id'] ?? null,
|
|
'number' => $created['number'] ?? null,
|
|
'title' => $created['title'] ?? '',
|
|
'state' => $created['state'] ?? 'open',
|
|
'html_url' => $created['html_url'] ?? null,
|
|
'owner' => $owner,
|
|
'repo_name' => $repo,
|
|
],
|
|
]);
|
|
} catch (GiteaApiException $e) {
|
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
|
} catch (\Throwable $e) {
|
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
|
}
|
|
}
|
|
|
|
// ── User repos (for the repo picker) ──
|
|
|
|
if ($route === '/api/gitea/repos' && $method === 'GET') {
|
|
$accId = (int)($_GET['account'] ?? 0);
|
|
$row = giteaGetAccount($accId);
|
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
|
try {
|
|
$client = giteaOpenClient($row);
|
|
$repos = $client->listMyRepos(1, 100);
|
|
$mapped = array_map(fn($r) => [
|
|
'id' => $r['id'] ?? null,
|
|
'full_name' => $r['full_name'] ?? null,
|
|
'name' => $r['name'] ?? null,
|
|
'owner' => $r['owner']['login'] ?? null,
|
|
'description' => $r['description'] ?? '',
|
|
'private' => $r['private'] ?? false,
|
|
'archived' => $r['archived'] ?? false,
|
|
'permissions' => $r['permissions'] ?? null,
|
|
], $repos);
|
|
// Filter out archived repos and ones without push permission
|
|
$mapped = array_values(array_filter($mapped, function ($r) {
|
|
if ($r['archived']) return false;
|
|
$p = $r['permissions'] ?? null;
|
|
if ($p && isset($p['push'])) return $p['push'] || $p['admin'] ?? false;
|
|
return true; // unknown permissions = include
|
|
}));
|
|
giteaJson(['ok' => true, 'repos' => $mapped]);
|
|
} catch (GiteaApiException $e) {
|
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
|
} catch (\Throwable $e) {
|
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
|
}
|
|
}
|
|
|
|
// ── Repo metadata ──
|
|
|
|
if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/labels$#', $route, $m) && $method === 'GET') {
|
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]);
|
|
$row = giteaGetAccount($accId);
|
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
|
try {
|
|
$client = giteaOpenClient($row);
|
|
// Merge repo-level + org-level labels (org labels are inherited by
|
|
// repos in an org but don't appear in /repos/.../labels)
|
|
$repoLabels = $client->listRepoLabels($owner, $repo);
|
|
$orgLabels = [];
|
|
$warning = null;
|
|
try {
|
|
$orgLabels = $client->listOrgLabels($owner);
|
|
} catch (GiteaApiException $e) {
|
|
if ($e->statusCode === 404) {
|
|
// Owner is a user, not an org — no org labels exist. silent.
|
|
} elseif ($e->statusCode === 403) {
|
|
// Token lacks read:organization scope — surface a warning
|
|
// but don't fail the whole request; repo labels still work.
|
|
$warning = 'Token lacks read:organization scope — org-level labels are not visible. Update your gitea token to include read:organization to see them.';
|
|
} else {
|
|
throw $e;
|
|
}
|
|
}
|
|
// Dedupe by id (in case the same label appears in both)
|
|
$seen = [];
|
|
$merged = [];
|
|
foreach (array_merge($repoLabels, $orgLabels) as $l) {
|
|
$id = $l['id'] ?? null;
|
|
if ($id !== null && !isset($seen[$id])) {
|
|
$seen[$id] = true;
|
|
$merged[] = $l;
|
|
}
|
|
}
|
|
$payload = [
|
|
'ok' => true,
|
|
'labels' => array_map(fn($l) => [
|
|
'id' => $l['id'] ?? null,
|
|
'name' => $l['name'] ?? '',
|
|
'color' => $l['color'] ?? '888888',
|
|
'description' => $l['description'] ?? '',
|
|
], $merged),
|
|
];
|
|
if ($warning) $payload['warning'] = $warning;
|
|
giteaJson($payload);
|
|
} catch (GiteaApiException $e) {
|
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
|
} catch (\Throwable $e) {
|
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
|
}
|
|
}
|
|
|
|
if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/milestones$#', $route, $m) && $method === 'GET') {
|
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]);
|
|
$row = giteaGetAccount($accId);
|
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
|
try {
|
|
$client = giteaOpenClient($row);
|
|
$milestones = $client->listRepoMilestones($owner, $repo, 'open');
|
|
giteaJson([
|
|
'ok' => true,
|
|
'milestones' => array_map(fn($ms) => [
|
|
'id' => $ms['id'] ?? null,
|
|
'title' => $ms['title'] ?? '',
|
|
'description' => $ms['description'] ?? '',
|
|
'due_on' => $ms['due_on'] ?? null,
|
|
'state' => $ms['state'] ?? 'open',
|
|
], $milestones),
|
|
]);
|
|
} catch (GiteaApiException $e) {
|
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
|
} catch (\Throwable $e) {
|
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
|
}
|
|
}
|
|
|
|
if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/collaborators$#', $route, $m) && $method === 'GET') {
|
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]);
|
|
$row = giteaGetAccount($accId);
|
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
|
try {
|
|
$client = giteaOpenClient($row);
|
|
// Use /assignees instead of /collaborators — it returns the full
|
|
// set of users that can be assigned (owner + org members +
|
|
// explicit collaborators), which is what we actually want.
|
|
$assignees = $client->listRepoAssignees($owner, $repo);
|
|
giteaJson([
|
|
'ok' => true,
|
|
'collaborators' => array_map(fn($u) => [
|
|
'login' => $u['login'] ?? '',
|
|
'full_name' => $u['full_name'] ?? '',
|
|
'avatar_url' => $u['avatar_url'] ?? '',
|
|
], $assignees),
|
|
]);
|
|
} catch (GiteaApiException $e) {
|
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
|
} catch (\Throwable $e) {
|
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
|
}
|
|
}
|
|
|
|
if (preg_match('#^/api/gitea/accounts/(\d+)/test$#', $route, $m) && $method === 'POST') {
|
|
$id = (int)$m[1];
|
|
$row = giteaGetAccount($id);
|
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
|
try {
|
|
$client = giteaOpenClient($row);
|
|
$user = $client->testConnection();
|
|
giteaJson([
|
|
'ok' => true,
|
|
'user' => [
|
|
'login' => $user['login'] ?? null,
|
|
'full_name' => $user['full_name'] ?? null,
|
|
'email' => $user['email'] ?? null,
|
|
'avatar_url' => $user['avatar_url'] ?? null,
|
|
],
|
|
'base_url' => $row['base_url'],
|
|
]);
|
|
} catch (GiteaApiException $e) {
|
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
|
} catch (\Throwable $e) {
|
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
|
}
|
|
}
|
|
});
|