220 lines
8.2 KiB
PHP
220 lines
8.2 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Minimal Gitea v1 REST client for the hyprcrd gitea plugin.
|
|
*
|
|
* Mirrors the Python `gitea_client.py` shape but only implements the
|
|
* subset needed for MVP: connection test, search/list/get issues, list
|
|
* comments. Write operations (create issue, comment, edit) are deferred
|
|
* to a later iteration — see TODO.md.
|
|
*
|
|
* Authentication is via the standard Gitea token header:
|
|
* Authorization: token <TOKEN>
|
|
*
|
|
* 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 : [];
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
}
|