* * 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 : []; } // ── 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; } }