* * Errors throw GiteaApiException with status code + body. */ class GiteaApiException extends RuntimeException { public int $statusCode; public string $body; public function __construct(int $statusCode, string $body) { $this->statusCode = $statusCode; $this->body = $body; parent::__construct("Gitea API HTTP $statusCode: " . substr($body, 0, 200)); } } class GiteaClient { private string $baseUrl; private string $token; private int $timeout; public function __construct(string $baseUrl, string $token, int $timeout = 20) { $this->baseUrl = rtrim($baseUrl, '/'); $this->token = $token; $this->timeout = $timeout; } /** * Verify the token works against the configured base URL by hitting * /user. Returns the authenticated user dict on success. * Throws GiteaApiException on failure. */ public function testConnection(): array { $resp = $this->request('GET', '/user'); return is_array($resp) ? $resp : []; } /** * Cross-repo issue search. Mirrors Python search_issues(). * @param array $opts Any of: state, labels, milestones, q, type, * since, before, assigned, created, mentioned, * review_requested, reviewed, owner, team, * page, limit */ public function searchIssues(array $opts = []): array { $resp = $this->request('GET', '/repos/issues/search', $opts); return is_array($resp) ? $resp : []; } public function listRepoIssues(string $owner, string $repo, array $opts = []): array { $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues'; $resp = $this->request('GET', $path, $opts); return is_array($resp) ? $resp : []; } public function getIssue(string $owner, string $repo, int $index): array { $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index; $resp = $this->request('GET', $path); return is_array($resp) ? $resp : []; } public function listIssueComments(string $owner, string $repo, int $index, array $opts = []): array { $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index . '/comments'; $resp = $this->request('GET', $path, $opts); return is_array($resp) ? $resp : []; } /** Post a new comment on an issue. Returns the created comment object. */ public function createIssueComment(string $owner, string $repo, int $index, string $body): array { $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index . '/comments'; $resp = $this->request('POST', $path, [], ['body' => $body]); return is_array($resp) ? $resp : []; } /** * Edit an issue. Pass any subset of: title, body, state ('open'|'closed'), * assignees (string[]), milestone (int), due_date (RFC3339), unset_due_date (bool). * Returns the updated issue. */ public function editIssue(string $owner, string $repo, int $index, array $changes): array { $allowed = ['title', 'body', 'state', 'assignees', 'milestone', 'due_date', 'ref', 'unset_due_date']; $payload = []; foreach ($allowed as $k) { if (array_key_exists($k, $changes)) $payload[$k] = $changes[$k]; } if (!$payload) return []; $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index; $resp = $this->request('PATCH', $path, [], $payload); return is_array($resp) ? $resp : []; } /** * Replace the labels of an issue. $labels is an array of label IDs (ints). * Returns the resulting list of labels. */ public function replaceIssueLabels(string $owner, string $repo, int $index, array $labels): array { $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index . '/labels'; $resp = $this->request('PUT', $path, [], ['labels' => array_map('intval', $labels)]); return is_array($resp) ? $resp : []; } /** Fetch the available labels for a repository. */ public function listRepoLabels(string $owner, string $repo, int $page = 1, int $limit = 100): array { $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/labels'; $resp = $this->request('GET', $path, ['page' => $page, 'limit' => $limit]); return is_array($resp) ? $resp : []; } /** * Create a new issue in a repository. Pass any of: title (required), * body, assignees (string[]), labels (int[] of label IDs), milestone (int), * due_date (RFC3339), ref, closed (bool). * Returns the created issue. */ public function createIssue(string $owner, string $repo, array $opts): array { if (empty($opts['title'])) return []; $allowed = ['title', 'body', 'assignees', 'labels', 'milestone', 'due_date', 'ref', 'closed']; $payload = []; foreach ($allowed as $k) { if (array_key_exists($k, $opts) && $opts[$k] !== null) $payload[$k] = $opts[$k]; } $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues'; $resp = $this->request('POST', $path, [], $payload); return is_array($resp) ? $resp : []; } /** * List repositories the authenticated user has access to. * Used to populate the repo picker for "new issue". */ public function listMyRepos(int $page = 1, int $limit = 100): array { $resp = $this->request('GET', '/user/repos', ['page' => $page, 'limit' => $limit]); return is_array($resp) ? $resp : []; } /** List milestones for a repo (state: 'open' | 'closed' | 'all'). */ public function listRepoMilestones(string $owner, string $repo, string $state = 'open', int $limit = 100): array { $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/milestones'; $resp = $this->request('GET', $path, ['state' => $state, 'page' => 1, 'limit' => $limit]); return is_array($resp) ? $resp : []; } /** * List who can be assigned to issues in this repo. This is the correct * endpoint for the "assignee picker": it includes the repo owner, org * members (when the repo is in an org), AND explicit collaborators — * unlike `/collaborators` which only returns explicit ones. */ public function listRepoAssignees(string $owner, string $repo, int $limit = 100): array { $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/assignees'; $resp = $this->request('GET', $path, ['page' => 1, 'limit' => $limit]); return is_array($resp) ? $resp : []; } /** * List labels defined at the org level. These are inherited by repos in * the org but DON'T appear in `/repos/{owner}/{repo}/labels`. * 404 if the owner is a user (not an org). */ public function listOrgLabels(string $org, int $limit = 100): array { $path = '/orgs/' . rawurlencode($org) . '/labels'; $resp = $this->request('GET', $path, ['page' => 1, 'limit' => $limit]); return is_array($resp) ? $resp : []; } // ── Low-level request ───────────────────────────────────────── private function request(string $method, string $path, array $query = [], ?array $payload = null) { $url = $this->baseUrl . '/api/v1' . $path; $cleanQuery = []; foreach ($query as $k => $v) { if ($v === null || $v === '') continue; if (is_bool($v)) $v = $v ? 'true' : 'false'; $cleanQuery[$k] = $v; } if ($cleanQuery) { $url .= '?' . http_build_query($cleanQuery); } $ch = curl_init($url); $headers = [ 'Accept: application/json', 'Authorization: token ' . $this->token, 'User-Agent: hyprcrd-gitea-plugin/1.0', ]; $opts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]; if ($payload !== null) { $headers[] = 'Content-Type: application/json'; $opts[CURLOPT_POSTFIELDS] = json_encode($payload); } $opts[CURLOPT_HTTPHEADER] = $headers; curl_setopt_array($ch, $opts); $body = curl_exec($ch); if ($body === false) { $err = curl_error($ch); curl_close($ch); throw new RuntimeException("Could not reach Gitea: $err"); } $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? ''; curl_close($ch); if ($status >= 400) { throw new GiteaApiException((int)$status, (string)$body); } if ($body === '' || $body === null) return null; if (stripos($contentType, 'application/json') !== false || preg_match('/^\s*[\[\{]/', (string)$body)) { $decoded = json_decode((string)$body, true); return $decoded; } return $body; } }