diff --git a/public/plugins/gitea/GiteaClient.php b/public/plugins/gitea/GiteaClient.php index fbc6949..9ca437b 100644 --- a/public/plugins/gitea/GiteaClient.php +++ b/public/plugins/gitea/GiteaClient.php @@ -159,6 +159,39 @@ class GiteaClient 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) diff --git a/public/plugins/gitea/TODO.md b/public/plugins/gitea/TODO.md index b7ddd24..f2e6eb5 100644 --- a/public/plugins/gitea/TODO.md +++ b/public/plugins/gitea/TODO.md @@ -55,11 +55,20 @@ ## Phase 3.5 — remaining metadata -- [ ] `GET /api/gitea/repo/{accId}/{owner}/{repo}/milestones` -- [ ] `GET /api/gitea/repo/{accId}/{owner}/{repo}/collaborators` -- [ ] Milestone dropdown in detail view -- [ ] Assignee multi-select with typeahead -- [ ] Due date input + clear button +- [x] `GET /api/gitea/repo/{accId}/{owner}/{repo}/milestones` (added with create form) +- [x] `GET /api/gitea/repo/{accId}/{owner}/{repo}/collaborators` (added with create form) +- [x] Milestone dropdown — currently in **create** form only +- [x] Assignee multi-select — currently in **create** form only +- [x] Same metadata editors in the **detail** view: combined inline editor + that lets you change assignees, milestone, and due date all at once. + Single PATCH call handles all three. Re-fetches the issue after save + to keep state in sync. +- [x] Due date input + clear button (create skipped due_date but edit has it) +- [ ] Due date in the **create** form too (currently only on edit) +- [ ] **Project assignment** — gitea's API does NOT include `project` in + `CreateIssueOption` or `EditIssueOption`. Issues are added to projects + via separate board endpoints which differ across gitea versions. Punt + until there's a clear API surface to target. ## Phase 4 — create issues diff --git a/public/plugins/gitea/backend.php b/public/plugins/gitea/backend.php index 07472d2..b1fcde3 100644 --- a/public/plugins/gitea/backend.php +++ b/public/plugins/gitea/backend.php @@ -250,7 +250,10 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { 'color' => $l['color'] ?? '888888', ], $issue['labels'] ?? []), 'assignees' => array_map(fn($a) => $a['login'] ?? '', $issue['assignees'] ?? []), - 'milestone' => $issue['milestone']['title'] ?? null, + '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, @@ -493,15 +496,93 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { if (!$row) giteaJson(['error' => 'account not found'], 404); try { $client = giteaOpenClient($row); - $labels = $client->listRepoLabels($owner, $repo); - giteaJson([ + // 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'] ?? '', - ], $labels), + ], $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); diff --git a/public/plugins/gitea/gitea.css b/public/plugins/gitea/gitea.css index 97ecdfb..3b33835 100644 --- a/public/plugins/gitea/gitea.css +++ b/public/plugins/gitea/gitea.css @@ -147,6 +147,32 @@ /* Title input matches the h3 sizing so swap is seamless */ #gitea-title-input { font-size: 16px; font-weight: bold; line-height: 1.3; } +/* Custom repo autocomplete dropdown */ +.gitea-repo-option { + padding: 6px 10px; + font-family: monospace; + font-size: 11px; + color: var(--t-text); + cursor: pointer; + border-bottom: 1px solid var(--t-border); + display: flex; + align-items: center; + gap: 6px; +} +.gitea-repo-option:last-child { border-bottom: none; } +.gitea-repo-option:hover, +.gitea-repo-option.active { + background: var(--t-raised); + color: var(--t-bright); +} +.gitea-repo-option .gitea-repo-tag { + font-family: var(--t-font-pixel); + font-size: 8px; + color: var(--t-muted); + margin-left: auto; + text-transform: uppercase; +} + /* Override Chrome/Safari autofill on the gitea token field */ #gitea-acc-token:-webkit-autofill, #gitea-acc-token:-webkit-autofill:hover, diff --git a/public/plugins/gitea/gitea.html b/public/plugins/gitea/gitea.html index fe5f813..4f2869c 100644 --- a/public/plugins/gitea/gitea.html +++ b/public/plugins/gitea/gitea.html @@ -86,10 +86,10 @@
-