gitea brauchbar
This commit is contained in:
1 parent
dd1afbbeb2
commit
7219bae655
6 files changed
+606
-60
No files matched your search
@@ -159,6 +159,39 @@ class GiteaClient
|
|||||||
return is_array($resp) ? $resp : [];
|
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 ─────────────────────────────────────────
|
// ── Low-level request ─────────────────────────────────────────
|
||||||
|
|
||||||
private function request(string $method, string $path, array $query = [], ?array $payload = null)
|
private function request(string $method, string $path, array $query = [], ?array $payload = null)
|
||||||
|
|||||||
@@ -55,11 +55,20 @@
|
|||||||
|
|
||||||
## Phase 3.5 — remaining metadata
|
## Phase 3.5 — remaining metadata
|
||||||
|
|
||||||
- [ ] `GET /api/gitea/repo/{accId}/{owner}/{repo}/milestones`
|
- [x] `GET /api/gitea/repo/{accId}/{owner}/{repo}/milestones` (added with create form)
|
||||||
- [ ] `GET /api/gitea/repo/{accId}/{owner}/{repo}/collaborators`
|
- [x] `GET /api/gitea/repo/{accId}/{owner}/{repo}/collaborators` (added with create form)
|
||||||
- [ ] Milestone dropdown in detail view
|
- [x] Milestone dropdown — currently in **create** form only
|
||||||
- [ ] Assignee multi-select with typeahead
|
- [x] Assignee multi-select — currently in **create** form only
|
||||||
- [ ] Due date input + clear button
|
- [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
|
## Phase 4 — create issues
|
||||||
|
|
||||||
|
|||||||
@@ -250,7 +250,10 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
'color' => $l['color'] ?? '888888',
|
'color' => $l['color'] ?? '888888',
|
||||||
], $issue['labels'] ?? []),
|
], $issue['labels'] ?? []),
|
||||||
'assignees' => array_map(fn($a) => $a['login'] ?? '', $issue['assignees'] ?? []),
|
'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) => [
|
'comments' => array_map(fn($c) => [
|
||||||
'id' => $c['id'] ?? null,
|
'id' => $c['id'] ?? null,
|
||||||
@@ -493,15 +496,93 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
try {
|
try {
|
||||||
$client = giteaOpenClient($row);
|
$client = giteaOpenClient($row);
|
||||||
$labels = $client->listRepoLabels($owner, $repo);
|
// Merge repo-level + org-level labels (org labels are inherited by
|
||||||
giteaJson([
|
// 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,
|
'ok' => true,
|
||||||
'labels' => array_map(fn($l) => [
|
'labels' => array_map(fn($l) => [
|
||||||
'id' => $l['id'] ?? null,
|
'id' => $l['id'] ?? null,
|
||||||
'name' => $l['name'] ?? '',
|
'name' => $l['name'] ?? '',
|
||||||
'color' => $l['color'] ?? '888888',
|
'color' => $l['color'] ?? '888888',
|
||||||
'description' => $l['description'] ?? '',
|
'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) {
|
} catch (GiteaApiException $e) {
|
||||||
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||||
|
|||||||
@@ -147,6 +147,32 @@
|
|||||||
/* Title input matches the h3 sizing so swap is seamless */
|
/* Title input matches the h3 sizing so swap is seamless */
|
||||||
#gitea-title-input { font-size: 16px; font-weight: bold; line-height: 1.3; }
|
#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 */
|
/* Override Chrome/Safari autofill on the gitea token field */
|
||||||
#gitea-acc-token:-webkit-autofill,
|
#gitea-acc-token:-webkit-autofill,
|
||||||
#gitea-acc-token:-webkit-autofill:hover,
|
#gitea-acc-token:-webkit-autofill:hover,
|
||||||
|
|||||||
@@ -86,10 +86,10 @@
|
|||||||
<button id="gitea-create-cancel" class="toolbar-btn" title="Cancel"><i class="ph ph-x text-sm"></i></button>
|
<button id="gitea-create-cancel" class="toolbar-btn" title="Cancel"><i class="ph ph-x text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="block">
|
<label class="block relative">
|
||||||
<span class="font-pixel text-[9px] text-t-dim uppercase">repository <span id="gitea-create-repo-hint" class="normal-case text-t-muted text-[8px]"></span></span>
|
<span class="font-pixel text-[9px] text-t-dim uppercase">repository <span id="gitea-create-repo-hint" class="normal-case text-t-muted text-[8px]"></span></span>
|
||||||
<input id="gitea-create-repo" type="text" list="gitea-create-repo-list" placeholder="owner/repo" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
<input id="gitea-create-repo" type="text" placeholder="owner/repo" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||||
<datalist id="gitea-create-repo-list"></datalist>
|
<div id="gitea-create-repo-dropdown" class="hidden absolute left-0 right-0 top-full z-30 mt-0.5 max-h-56 overflow-y-auto bg-t-surface border border-t-accent shadow-lg"></div>
|
||||||
</label>
|
</label>
|
||||||
<div id="gitea-create-repo-error" class="hidden px-2 py-1.5 text-[10px] font-mono break-words border border-amber-500 text-amber-400 bg-t-bg"></div>
|
<div id="gitea-create-repo-error" class="hidden px-2 py-1.5 text-[10px] font-mono break-words border border-amber-500 text-amber-400 bg-t-bg"></div>
|
||||||
<label class="block">
|
<label class="block">
|
||||||
@@ -103,11 +103,27 @@
|
|||||||
<span class="font-pixel text-[9px] text-t-dim uppercase">body (markdown)</span>
|
<span class="font-pixel text-[9px] text-t-dim uppercase">body (markdown)</span>
|
||||||
<textarea id="gitea-create-body" rows="14" placeholder="describe the issue..." class="w-full bg-t-bg border border-t-border text-t-text px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono leading-relaxed"></textarea>
|
<textarea id="gitea-create-body" rows="14" placeholder="describe the issue..." class="w-full bg-t-bg border border-t-border text-t-text px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono leading-relaxed"></textarea>
|
||||||
</label>
|
</label>
|
||||||
<div class="mt-3">
|
<div id="gitea-create-labels-warning" class="hidden mt-3 px-2 py-1.5 text-[10px] font-mono break-words border border-amber-500 text-amber-400 bg-t-bg"></div>
|
||||||
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1">labels (optional)</span>
|
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||||
<div id="gitea-create-labels" class="border border-t-border bg-t-surface p-2 max-h-40 overflow-y-auto">
|
<div>
|
||||||
<div class="text-[10px] text-t-dim italic">pick a repo first</div>
|
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1">labels (optional)</span>
|
||||||
|
<div id="gitea-create-labels" class="border border-t-border bg-t-surface p-2 max-h-40 overflow-y-auto">
|
||||||
|
<div class="text-[10px] text-t-dim italic">pick a repo first</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1">assignees (optional)</span>
|
||||||
|
<div id="gitea-create-assignees" class="border border-t-border bg-t-surface p-2 max-h-40 overflow-y-auto">
|
||||||
|
<div class="text-[10px] text-t-dim italic">pick a repo first</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1">milestone (optional)</span>
|
||||||
|
<select id="gitea-create-milestone" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||||
|
<option value="">— no milestone —</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-[9px] text-t-dim mt-1 italic">project: not supported by gitea api on issue create — set it manually on gitea after creation</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex items-center gap-2">
|
<div class="mt-4 flex items-center gap-2">
|
||||||
<button id="gitea-create-submit" class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-paper-plane-tilt text-sm"></i><span class="font-pixel text-[10px]">create issue</span></button>
|
<button id="gitea-create-submit" class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-paper-plane-tilt text-sm"></i><span class="font-pixel text-[10px]">create issue</span></button>
|
||||||
@@ -144,13 +160,48 @@
|
|||||||
<div id="gitea-detail-labels" class="flex flex-wrap gap-1 flex-1"></div>
|
<div id="gitea-detail-labels" class="flex flex-wrap gap-1 flex-1"></div>
|
||||||
<button id="gitea-labels-edit-btn" class="toolbar-btn shrink-0" title="Edit labels"><i class="ph ph-tag text-sm"></i></button>
|
<button id="gitea-labels-edit-btn" class="toolbar-btn shrink-0" title="Edit labels"><i class="ph ph-tag text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="gitea-labels-editor" class="hidden border border-t-border bg-t-surface p-2 mb-2 max-h-48 overflow-y-auto">
|
<div id="gitea-labels-editor" class="hidden border border-t-border bg-t-surface p-2 mb-2">
|
||||||
<div id="gitea-labels-checkboxes" class="space-y-1"></div>
|
<div id="gitea-labels-warning" class="hidden mb-2 px-2 py-1 text-[10px] font-mono break-words border border-amber-500 text-amber-400 bg-t-bg"></div>
|
||||||
|
<div id="gitea-labels-checkboxes" class="space-y-1 max-h-48 overflow-y-auto"></div>
|
||||||
<div class="flex items-center gap-2 mt-2 pt-2 border-t border-t-border">
|
<div class="flex items-center gap-2 mt-2 pt-2 border-t border-t-border">
|
||||||
<button id="gitea-labels-save" class="flex items-center gap-1.5 px-2 py-0.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
|
<button id="gitea-labels-save" class="flex items-center gap-1.5 px-2 py-0.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
|
||||||
<button id="gitea-labels-cancel" class="flex items-center gap-1.5 px-2 py-0.5 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">cancel</span></button>
|
<button id="gitea-labels-cancel" class="flex items-center gap-1.5 px-2 py-0.5 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">cancel</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Metadata row: assignees + milestone + due date with inline editor -->
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<div id="gitea-detail-meta" class="flex flex-wrap gap-x-3 gap-y-1 flex-1 text-[10px] font-pixel text-t-dim"></div>
|
||||||
|
<button id="gitea-meta-edit-btn" class="toolbar-btn shrink-0" title="Edit assignees, milestone, due date"><i class="ph ph-sliders-horizontal text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="gitea-meta-editor" class="hidden border border-t-border bg-t-surface p-3 mb-2">
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1"><i class="ph ph-users text-xs"></i> assignees</span>
|
||||||
|
<div id="gitea-meta-assignees" class="border border-t-border bg-t-bg p-2 max-h-40 overflow-y-auto">
|
||||||
|
<div class="text-[10px] text-t-dim italic">loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1"><i class="ph ph-flag text-xs"></i> milestone</span>
|
||||||
|
<select id="gitea-meta-milestone" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||||
|
<option value="">— no milestone —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1"><i class="ph ph-calendar text-xs"></i> due date</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<input id="gitea-meta-due" type="date" class="flex-1 bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||||
|
<button id="gitea-meta-due-clear" class="toolbar-btn" title="Clear due date"><i class="ph ph-x text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mt-3 pt-2 border-t border-t-border">
|
||||||
|
<button id="gitea-meta-save" class="flex items-center gap-1.5 px-2 py-0.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
|
||||||
|
<button id="gitea-meta-cancel" class="flex items-center gap-1.5 px-2 py-0.5 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">cancel</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mt-3 flex items-center gap-2 flex-wrap">
|
<div class="mt-3 flex items-center gap-2 flex-wrap">
|
||||||
<button id="gitea-extract-btn" class="flex items-center gap-1.5 px-3 py-1 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-scissors text-sm"></i><span class="font-pixel text-[10px]">extract as card</span></button>
|
<button id="gitea-extract-btn" class="flex items-center gap-1.5 px-3 py-1 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-scissors text-sm"></i><span class="font-pixel text-[10px]">extract as card</span></button>
|
||||||
<button id="gitea-state-toggle" class="flex items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[10px]">close</span></button>
|
<button id="gitea-state-toggle" class="flex items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[10px]">close</span></button>
|
||||||
|
|||||||
+388
-42
@@ -488,6 +488,9 @@ function renderDetail() {
|
|||||||
link.style.display = 'none';
|
link.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Metadata row (assignees / milestone / due date)
|
||||||
|
renderMetaRow();
|
||||||
|
|
||||||
// Comments — delegated to its own function so we can re-render after posting
|
// Comments — delegated to its own function so we can re-render after posting
|
||||||
renderCommentsSection();
|
renderCommentsSection();
|
||||||
|
|
||||||
@@ -498,17 +501,184 @@ function renderDetail() {
|
|||||||
cancelTitleEdit();
|
cancelTitleEdit();
|
||||||
cancelBodyEdit();
|
cancelBodyEdit();
|
||||||
closeLabelsEditor();
|
closeLabelsEditor();
|
||||||
|
closeMetaEditor();
|
||||||
setExtractStatus('');
|
setExtractStatus('');
|
||||||
$('gitea-comment-input').value = '';
|
$('gitea-comment-input').value = '';
|
||||||
$('gitea-comment-status').textContent = '';
|
$('gitea-comment-status').textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Metadata row + editor (assignees, milestone, due date) ──────
|
||||||
|
|
||||||
|
function renderMetaRow() {
|
||||||
|
if (!currentIssue) return;
|
||||||
|
const i = currentIssue;
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
// Assignees
|
||||||
|
if (i.assignees && i.assignees.length) {
|
||||||
|
parts.push(`<span><i class="ph ph-users text-[10px]"></i> ${i.assignees.map(escapeHtml).join(', ')}</span>`);
|
||||||
|
} else {
|
||||||
|
parts.push(`<span class="text-t-muted"><i class="ph ph-users text-[10px]"></i> unassigned</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Milestone
|
||||||
|
if (i.milestone && i.milestone.title) {
|
||||||
|
parts.push(`<span><i class="ph ph-flag text-[10px]"></i> ${escapeHtml(i.milestone.title)}</span>`);
|
||||||
|
} else {
|
||||||
|
parts.push(`<span class="text-t-muted"><i class="ph ph-flag text-[10px]"></i> no milestone</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Due date
|
||||||
|
if (i.due_date) {
|
||||||
|
const ds = i.due_date.substring(0, 10);
|
||||||
|
parts.push(`<span><i class="ph ph-calendar text-[10px]"></i> ${escapeHtml(ds)}</span>`);
|
||||||
|
} else {
|
||||||
|
parts.push(`<span class="text-t-muted"><i class="ph ph-calendar text-[10px]"></i> no due date</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
$('gitea-detail-meta').innerHTML = parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
let metaSelectedAssignees = new Set();
|
||||||
|
|
||||||
|
async function openMetaEditor() {
|
||||||
|
if (!currentIssue) return;
|
||||||
|
const editor = $('gitea-meta-editor');
|
||||||
|
editor.classList.remove('hidden');
|
||||||
|
closeLabelsEditor(); // don't show both at once
|
||||||
|
|
||||||
|
const i = currentIssue;
|
||||||
|
metaSelectedAssignees = new Set(i.assignees || []);
|
||||||
|
|
||||||
|
// Pre-fill due date input
|
||||||
|
$('gitea-meta-due').value = i.due_date ? i.due_date.substring(0, 10) : '';
|
||||||
|
|
||||||
|
// Loading placeholders
|
||||||
|
$('gitea-meta-assignees').innerHTML = '<div class="text-[10px] text-t-dim italic">loading...</div>';
|
||||||
|
$('gitea-meta-milestone').innerHTML = '<option value="">loading...</option>';
|
||||||
|
|
||||||
|
const ownerEnc = encodeURIComponent(i.owner);
|
||||||
|
const repoEnc = encodeURIComponent(i.repo_name);
|
||||||
|
const base = `api/gitea/repo/${currentAccountId}/${ownerEnc}/${repoEnc}`;
|
||||||
|
|
||||||
|
const [collabsRes, milestonesRes] = await Promise.allSettled([
|
||||||
|
fetch(`${base}/collaborators`).then(r => r.json()),
|
||||||
|
fetch(`${base}/milestones`).then(r => r.json()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Collaborators (assignees) ──
|
||||||
|
const assigneesEl = $('gitea-meta-assignees');
|
||||||
|
if (collabsRes.status === 'fulfilled' && collabsRes.value.ok) {
|
||||||
|
const collabs = collabsRes.value.collaborators || [];
|
||||||
|
if (!collabs.length) {
|
||||||
|
assigneesEl.innerHTML = '<div class="text-[10px] text-t-dim italic">no collaborators</div>';
|
||||||
|
} else {
|
||||||
|
// Make sure currently-assigned users are present even if missing
|
||||||
|
// from the collaborator list (e.g. they were assigned before being
|
||||||
|
// removed as a collaborator)
|
||||||
|
const knownLogins = new Set(collabs.map(u => u.login));
|
||||||
|
const extras = (i.assignees || []).filter(login => !knownLogins.has(login)).map(login => ({ login, full_name: '' }));
|
||||||
|
const all = [...collabs, ...extras];
|
||||||
|
assigneesEl.innerHTML = all.map(u => `
|
||||||
|
<label class="gitea-label-checkbox" data-login="${escapeHtml(u.login)}">
|
||||||
|
<input type="checkbox" ${metaSelectedAssignees.has(u.login) ? 'checked' : ''}>
|
||||||
|
<i class="ph ph-user text-sm text-t-dim"></i>
|
||||||
|
<span class="text-[11px] text-t-text">${escapeHtml(u.login)}</span>
|
||||||
|
${u.full_name ? `<span class="text-[9px] text-t-dim ml-1">${escapeHtml(u.full_name)}</span>` : ''}
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
assigneesEl.querySelectorAll('.gitea-label-checkbox input').forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
const login = cb.closest('.gitea-label-checkbox').dataset.login;
|
||||||
|
if (cb.checked) metaSelectedAssignees.add(login);
|
||||||
|
else metaSelectedAssignees.delete(login);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const err = collabsRes.status === 'fulfilled' ? collabsRes.value.error : collabsRes.reason?.message;
|
||||||
|
assigneesEl.innerHTML = `<div class="text-[10px] text-t-dim italic break-words">cannot list collaborators: ${escapeHtml(err || 'failed')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Milestones ──
|
||||||
|
const milestoneSel = $('gitea-meta-milestone');
|
||||||
|
if (milestonesRes.status === 'fulfilled' && milestonesRes.value.ok) {
|
||||||
|
const milestones = milestonesRes.value.milestones || [];
|
||||||
|
const currentId = i.milestone?.id;
|
||||||
|
let html = '<option value="">— no milestone —</option>';
|
||||||
|
if (!milestones.length && !currentId) {
|
||||||
|
html = '<option value="">— no open milestones —</option>';
|
||||||
|
}
|
||||||
|
// If current milestone exists but isn't in the open list (e.g. closed),
|
||||||
|
// include it explicitly so we don't lose the selection
|
||||||
|
if (currentId && !milestones.find(m => m.id === currentId)) {
|
||||||
|
html += `<option value="${currentId}" selected>${escapeHtml(i.milestone.title)} (closed)</option>`;
|
||||||
|
}
|
||||||
|
html += milestones.map(ms =>
|
||||||
|
`<option value="${ms.id}" ${ms.id === currentId ? 'selected' : ''}>${escapeHtml(ms.title)}${ms.due_on ? ' · due ' + escapeHtml(ms.due_on.substring(0, 10)) : ''}</option>`
|
||||||
|
).join('');
|
||||||
|
milestoneSel.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
milestoneSel.innerHTML = '<option value="">— could not load —</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMetaEditor() {
|
||||||
|
$('gitea-meta-editor').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMetaDue() {
|
||||||
|
$('gitea-meta-due').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMetaEditor() {
|
||||||
|
if (!currentIssue) return;
|
||||||
|
setExtractStatus('saving metadata...');
|
||||||
|
|
||||||
|
const payload = {};
|
||||||
|
payload.assignees = [...metaSelectedAssignees];
|
||||||
|
|
||||||
|
const milestoneVal = $('gitea-meta-milestone').value;
|
||||||
|
payload.milestone = milestoneVal ? parseInt(milestoneVal) : 0; // 0 = clear in gitea
|
||||||
|
|
||||||
|
const dueVal = $('gitea-meta-due').value;
|
||||||
|
if (dueVal) {
|
||||||
|
// Gitea expects RFC3339; append T00:00:00Z to make it valid
|
||||||
|
payload.due_date = dueVal + 'T00:00:00Z';
|
||||||
|
} else {
|
||||||
|
payload.unset_due_date = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await patchIssue(payload);
|
||||||
|
if (!res.ok) {
|
||||||
|
setExtractStatus('error: ' + (res.error || 'failed'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Refresh local state from the latest detail call (PATCH only returns
|
||||||
|
// a subset, so re-fetch for accuracy)
|
||||||
|
const detailRes = await fetch(`api/gitea/issue/${currentAccountId}/${encodeURIComponent(currentIssue.owner)}/${encodeURIComponent(currentIssue.repo_name)}/${currentIssue.number}`);
|
||||||
|
const detailData = await detailRes.json();
|
||||||
|
if (detailData.ok) {
|
||||||
|
currentIssue = { ...detailData.issue, comments: currentIssue.comments };
|
||||||
|
renderMetaRow();
|
||||||
|
}
|
||||||
|
closeMetaEditor();
|
||||||
|
setExtractStatus('metadata updated', 'success');
|
||||||
|
updateListRow();
|
||||||
|
} catch (err) {
|
||||||
|
setExtractStatus('error: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Create issue ──────────────────────────────────────────────────
|
// ── Create issue ──────────────────────────────────────────────────
|
||||||
|
|
||||||
let cachedRepos = null; // per-account cache
|
let cachedRepos = null; // per-account cache
|
||||||
let cachedReposForAccount = null;
|
let cachedReposForAccount = null;
|
||||||
let cachedReposError = null; // last error message for repo fetch (so it shows on reopen)
|
let cachedReposError = null; // last error message for repo fetch (so it shows on reopen)
|
||||||
let createSelectedLabels = new Set();
|
let createSelectedLabels = new Set();
|
||||||
|
let createSelectedAssignees = new Set();
|
||||||
|
let createLastLoadedRepo = null; // dedupe: don't reload metadata if value hasn't changed
|
||||||
|
|
||||||
function setCreateStatus(text, kind = 'info') {
|
function setCreateStatus(text, kind = 'info') {
|
||||||
const el = $('gitea-create-status');
|
const el = $('gitea-create-status');
|
||||||
@@ -549,27 +719,29 @@ async function openCreateForm() {
|
|||||||
$('gitea-create-body').value = '';
|
$('gitea-create-body').value = '';
|
||||||
setCreateStatus('');
|
setCreateStatus('');
|
||||||
createSelectedLabels.clear();
|
createSelectedLabels.clear();
|
||||||
|
createSelectedAssignees.clear();
|
||||||
|
createLastLoadedRepo = null;
|
||||||
$('gitea-create-labels').innerHTML = '<div class="text-[10px] text-t-dim italic">pick a repo first</div>';
|
$('gitea-create-labels').innerHTML = '<div class="text-[10px] text-t-dim italic">pick a repo first</div>';
|
||||||
|
$('gitea-create-assignees').innerHTML = '<div class="text-[10px] text-t-dim italic">pick a repo first</div>';
|
||||||
|
$('gitea-create-milestone').innerHTML = '<option value="">— no milestone —</option>';
|
||||||
|
|
||||||
// Try to populate the datalist autocomplete with the user's repos.
|
// Try to load the user's repos for the custom autocomplete dropdown.
|
||||||
// If the token lacks read:repository scope this will 403 — the input is
|
// If the token lacks read:repository scope this will 403 — the input is
|
||||||
// still usable manually, but we surface the error so the user knows why
|
// still usable manually, but we surface the error so the user knows why
|
||||||
// autocomplete isn't working and can update the token if they want.
|
// autocomplete isn't working and can update the token if they want.
|
||||||
const datalist = $('gitea-create-repo-list');
|
|
||||||
const hint = $('gitea-create-repo-hint');
|
const hint = $('gitea-create-repo-hint');
|
||||||
const errBox = $('gitea-create-repo-error');
|
const errBox = $('gitea-create-repo-error');
|
||||||
errBox.classList.add('hidden');
|
errBox.classList.add('hidden');
|
||||||
errBox.textContent = '';
|
errBox.textContent = '';
|
||||||
|
hideRepoDropdown();
|
||||||
|
|
||||||
if (cachedReposForAccount === currentAccountId && cachedRepos) {
|
if (cachedReposForAccount === currentAccountId && cachedRepos) {
|
||||||
renderRepoDatalist(cachedRepos);
|
updateRepoHint();
|
||||||
// If the cached fetch had failed, also restore the error message
|
|
||||||
if (cachedReposError) {
|
if (cachedReposError) {
|
||||||
errBox.textContent = cachedReposError;
|
errBox.textContent = cachedReposError;
|
||||||
errBox.classList.remove('hidden');
|
errBox.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
datalist.innerHTML = '';
|
|
||||||
hint.textContent = '(loading suggestions...)';
|
hint.textContent = '(loading suggestions...)';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`api/gitea/repos?account=${currentAccountId}`);
|
const res = await fetch(`api/gitea/repos?account=${currentAccountId}`);
|
||||||
@@ -586,7 +758,7 @@ async function openCreateForm() {
|
|||||||
cachedRepos = data.repos || [];
|
cachedRepos = data.repos || [];
|
||||||
cachedReposForAccount = currentAccountId;
|
cachedReposForAccount = currentAccountId;
|
||||||
cachedReposError = null;
|
cachedReposError = null;
|
||||||
renderRepoDatalist(cachedRepos);
|
updateRepoHint();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
hint.textContent = '(type owner/repo manually)';
|
hint.textContent = '(type owner/repo manually)';
|
||||||
cachedReposError = 'Network error loading repos: ' + err.message;
|
cachedReposError = 'Network error loading repos: ' + err.message;
|
||||||
@@ -596,27 +768,108 @@ async function openCreateForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRepoDatalist(repos) {
|
function updateRepoHint() {
|
||||||
const datalist = $('gitea-create-repo-list');
|
|
||||||
const hint = $('gitea-create-repo-hint');
|
const hint = $('gitea-create-repo-hint');
|
||||||
if (!repos.length) {
|
const n = (cachedRepos || []).length;
|
||||||
datalist.innerHTML = '';
|
hint.textContent = n > 0 ? `(${n} suggestion${n === 1 ? '' : 's'})` : '(type owner/repo manually)';
|
||||||
hint.textContent = '(type owner/repo manually)';
|
}
|
||||||
|
|
||||||
|
// ── Custom repo autocomplete dropdown ─────────────────────────────
|
||||||
|
|
||||||
|
let repoDropdownActive = -1;
|
||||||
|
|
||||||
|
function showRepoDropdown() {
|
||||||
|
const dropdown = $('gitea-create-repo-dropdown');
|
||||||
|
const input = $('gitea-create-repo');
|
||||||
|
if (!dropdown || !input) return;
|
||||||
|
if (!cachedRepos || !cachedRepos.length) return;
|
||||||
|
|
||||||
|
const query = input.value.trim().toLowerCase();
|
||||||
|
const matches = query
|
||||||
|
? cachedRepos.filter(r => (r.full_name || '').toLowerCase().includes(query))
|
||||||
|
: cachedRepos.slice();
|
||||||
|
|
||||||
|
if (!matches.length) {
|
||||||
|
hideRepoDropdown();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
datalist.innerHTML = repos.map(r =>
|
|
||||||
`<option value="${escapeHtml(r.full_name)}">${r.private ? ' (private)' : ''}</option>`
|
// Cap to first 50 to keep DOM cheap
|
||||||
).join('');
|
const display = matches.slice(0, 50);
|
||||||
hint.textContent = `(${repos.length} suggestion${repos.length === 1 ? '' : 's'})`;
|
dropdown.innerHTML = display.map((r, idx) => `
|
||||||
|
<div class="gitea-repo-option" data-value="${escapeHtml(r.full_name)}" data-idx="${idx}">
|
||||||
|
<i class="ph ph-folder text-sm text-t-dim"></i>
|
||||||
|
<span>${escapeHtml(r.full_name)}</span>
|
||||||
|
${r.private ? '<span class="gitea-repo-tag">private</span>' : ''}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
dropdown.classList.remove('hidden');
|
||||||
|
repoDropdownActive = -1;
|
||||||
|
|
||||||
|
dropdown.querySelectorAll('.gitea-repo-option').forEach(opt => {
|
||||||
|
// Use mousedown so the click registers BEFORE input blur fires
|
||||||
|
opt.addEventListener('mousedown', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
input.value = opt.dataset.value;
|
||||||
|
hideRepoDropdown();
|
||||||
|
loadCreateRepoLabels();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideRepoDropdown() {
|
||||||
|
const dropdown = $('gitea-create-repo-dropdown');
|
||||||
|
if (!dropdown) return;
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
repoDropdownActive = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveRepoDropdownActive(delta) {
|
||||||
|
const dropdown = $('gitea-create-repo-dropdown');
|
||||||
|
if (!dropdown || dropdown.classList.contains('hidden')) return;
|
||||||
|
const opts = dropdown.querySelectorAll('.gitea-repo-option');
|
||||||
|
if (!opts.length) return;
|
||||||
|
repoDropdownActive = (repoDropdownActive + delta + opts.length) % opts.length;
|
||||||
|
opts.forEach((o, i) => o.classList.toggle('active', i === repoDropdownActive));
|
||||||
|
opts[repoDropdownActive].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitRepoDropdownActive() {
|
||||||
|
const dropdown = $('gitea-create-repo-dropdown');
|
||||||
|
if (!dropdown || dropdown.classList.contains('hidden')) return false;
|
||||||
|
const opts = dropdown.querySelectorAll('.gitea-repo-option');
|
||||||
|
if (repoDropdownActive < 0 || repoDropdownActive >= opts.length) return false;
|
||||||
|
const opt = opts[repoDropdownActive];
|
||||||
|
$('gitea-create-repo').value = opt.dataset.value;
|
||||||
|
hideRepoDropdown();
|
||||||
|
loadCreateRepoLabels();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCreateRepoLabels() {
|
async function loadCreateRepoLabels() {
|
||||||
|
// Loads labels + milestones + collaborators in parallel for the picked
|
||||||
|
// repo. Deduped: if the same repo value is passed again (e.g. blur fires
|
||||||
|
// change after a dropdown click already triggered a load), we skip —
|
||||||
|
// otherwise we'd wipe the user's already-checked labels/assignees.
|
||||||
const input = $('gitea-create-repo');
|
const input = $('gitea-create-repo');
|
||||||
const fullName = input.value.trim();
|
const fullName = input.value.trim();
|
||||||
|
|
||||||
|
if (fullName === createLastLoadedRepo) return; // already loaded → don't reset
|
||||||
|
|
||||||
const labelsEl = $('gitea-create-labels');
|
const labelsEl = $('gitea-create-labels');
|
||||||
|
const assigneesEl = $('gitea-create-assignees');
|
||||||
|
const milestoneSel = $('gitea-create-milestone');
|
||||||
|
|
||||||
|
// Now we know we're loading a different repo — safe to clear selections
|
||||||
createSelectedLabels.clear();
|
createSelectedLabels.clear();
|
||||||
|
createSelectedAssignees.clear();
|
||||||
|
createLastLoadedRepo = fullName;
|
||||||
|
|
||||||
if (!fullName || !fullName.includes('/')) {
|
if (!fullName || !fullName.includes('/')) {
|
||||||
labelsEl.innerHTML = '<div class="text-[10px] text-t-dim italic">pick a repo first (owner/repo)</div>';
|
labelsEl.innerHTML = '<div class="text-[10px] text-t-dim italic">pick a repo first (owner/repo)</div>';
|
||||||
|
assigneesEl.innerHTML = '<div class="text-[10px] text-t-dim italic">pick a repo first</div>';
|
||||||
|
milestoneSel.innerHTML = '<option value="">— no milestone —</option>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [owner, repo] = fullName.split('/');
|
const [owner, repo] = fullName.split('/');
|
||||||
@@ -624,34 +877,95 @@ async function loadCreateRepoLabels() {
|
|||||||
labelsEl.innerHTML = '<div class="text-[10px] text-t-dim italic">format: owner/repo</div>';
|
labelsEl.innerHTML = '<div class="text-[10px] text-t-dim italic">format: owner/repo</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
labelsEl.innerHTML = '<div class="text-[10px] text-t-dim italic">loading labels...</div>';
|
|
||||||
try {
|
labelsEl.innerHTML = '<div class="text-[10px] text-t-dim italic">loading...</div>';
|
||||||
const res = await fetch(`api/gitea/repo/${currentAccountId}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`);
|
assigneesEl.innerHTML = '<div class="text-[10px] text-t-dim italic">loading...</div>';
|
||||||
const data = await res.json();
|
milestoneSel.innerHTML = '<option value="">loading...</option>';
|
||||||
if (!data.ok) {
|
|
||||||
labelsEl.innerHTML = `<div class="text-[10px] text-t-danger break-words">${escapeHtml(data.error || 'failed')}</div>`;
|
const ownerEnc = encodeURIComponent(owner);
|
||||||
return;
|
const repoEnc = encodeURIComponent(repo);
|
||||||
|
const base = `api/gitea/repo/${currentAccountId}/${ownerEnc}/${repoEnc}`;
|
||||||
|
|
||||||
|
// Fetch all three in parallel — each one may independently fail (e.g.
|
||||||
|
// collaborators 403 if the token can't see them)
|
||||||
|
const [labelsRes, milestonesRes, collabsRes] = await Promise.allSettled([
|
||||||
|
fetch(`${base}/labels`).then(r => r.json()),
|
||||||
|
fetch(`${base}/milestones`).then(r => r.json()),
|
||||||
|
fetch(`${base}/collaborators`).then(r => r.json()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Labels ──
|
||||||
|
const warningEl = $('gitea-create-labels-warning');
|
||||||
|
warningEl.classList.add('hidden');
|
||||||
|
warningEl.textContent = '';
|
||||||
|
if (labelsRes.status === 'fulfilled' && labelsRes.value.ok) {
|
||||||
|
const labels = labelsRes.value.labels || [];
|
||||||
|
// Surface backend warning (e.g. missing read:organization scope)
|
||||||
|
if (labelsRes.value.warning) {
|
||||||
|
warningEl.textContent = '⚠ ' + labelsRes.value.warning;
|
||||||
|
warningEl.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
const labels = data.labels || [];
|
|
||||||
if (!labels.length) {
|
if (!labels.length) {
|
||||||
labelsEl.innerHTML = '<div class="text-[10px] text-t-dim italic">repo has no labels</div>';
|
labelsEl.innerHTML = '<div class="text-[10px] text-t-dim italic">no labels</div>';
|
||||||
return;
|
} else {
|
||||||
}
|
labelsEl.innerHTML = labels.map(l => `
|
||||||
labelsEl.innerHTML = labels.map(l => `
|
<label class="gitea-label-checkbox" data-id="${l.id}">
|
||||||
<label class="gitea-label-checkbox" data-id="${l.id}">
|
<input type="checkbox">
|
||||||
<input type="checkbox">
|
<span class="gitea-label" style="background:#${escapeHtml(l.color)};color:${labelTextColor(l.color)}">${escapeHtml(l.name)}</span>
|
||||||
<span class="gitea-label" style="background:#${escapeHtml(l.color)};color:${labelTextColor(l.color)}">${escapeHtml(l.name)}</span>
|
</label>
|
||||||
</label>
|
`).join('');
|
||||||
`).join('');
|
labelsEl.querySelectorAll('.gitea-label-checkbox input').forEach(cb => {
|
||||||
labelsEl.querySelectorAll('.gitea-label-checkbox input').forEach(cb => {
|
cb.addEventListener('change', () => {
|
||||||
cb.addEventListener('change', () => {
|
const id = parseInt(cb.closest('.gitea-label-checkbox').dataset.id);
|
||||||
const id = parseInt(cb.closest('.gitea-label-checkbox').dataset.id);
|
if (cb.checked) createSelectedLabels.add(id);
|
||||||
if (cb.checked) createSelectedLabels.add(id);
|
else createSelectedLabels.delete(id);
|
||||||
else createSelectedLabels.delete(id);
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
} catch (err) {
|
} else {
|
||||||
labelsEl.innerHTML = `<div class="text-[10px] text-t-danger">${escapeHtml(err.message)}</div>`;
|
const err = labelsRes.status === 'fulfilled' ? labelsRes.value.error : labelsRes.reason?.message;
|
||||||
|
labelsEl.innerHTML = `<div class="text-[10px] text-t-danger break-words">${escapeHtml(err || 'failed')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Milestones ──
|
||||||
|
if (milestonesRes.status === 'fulfilled' && milestonesRes.value.ok) {
|
||||||
|
const milestones = milestonesRes.value.milestones || [];
|
||||||
|
if (!milestones.length) {
|
||||||
|
milestoneSel.innerHTML = '<option value="">— no milestones available —</option>';
|
||||||
|
} else {
|
||||||
|
milestoneSel.innerHTML = '<option value="">— no milestone —</option>' + milestones.map(ms =>
|
||||||
|
`<option value="${ms.id}">${escapeHtml(ms.title)}${ms.due_on ? ' · due ' + escapeHtml(ms.due_on.substring(0, 10)) : ''}</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
milestoneSel.innerHTML = '<option value="">— could not load —</option>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collaborators (assignees) ──
|
||||||
|
if (collabsRes.status === 'fulfilled' && collabsRes.value.ok) {
|
||||||
|
const collabs = collabsRes.value.collaborators || [];
|
||||||
|
if (!collabs.length) {
|
||||||
|
assigneesEl.innerHTML = '<div class="text-[10px] text-t-dim italic">no collaborators</div>';
|
||||||
|
} else {
|
||||||
|
assigneesEl.innerHTML = collabs.map(u => `
|
||||||
|
<label class="gitea-label-checkbox" data-login="${escapeHtml(u.login)}">
|
||||||
|
<input type="checkbox">
|
||||||
|
<i class="ph ph-user text-sm text-t-dim"></i>
|
||||||
|
<span class="text-[11px] text-t-text">${escapeHtml(u.login)}</span>
|
||||||
|
${u.full_name ? `<span class="text-[9px] text-t-dim ml-1">${escapeHtml(u.full_name)}</span>` : ''}
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
assigneesEl.querySelectorAll('.gitea-label-checkbox input').forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
const login = cb.closest('.gitea-label-checkbox').dataset.login;
|
||||||
|
if (cb.checked) createSelectedAssignees.add(login);
|
||||||
|
else createSelectedAssignees.delete(login);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const err = collabsRes.status === 'fulfilled' ? collabsRes.value.error : collabsRes.reason?.message;
|
||||||
|
assigneesEl.innerHTML = `<div class="text-[10px] text-t-dim italic break-words">cannot list collaborators: ${escapeHtml(err || 'failed')}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,6 +997,9 @@ async function submitCreateIssue() {
|
|||||||
try {
|
try {
|
||||||
const payload = { title, body };
|
const payload = { title, body };
|
||||||
if (createSelectedLabels.size > 0) payload.labels = [...createSelectedLabels];
|
if (createSelectedLabels.size > 0) payload.labels = [...createSelectedLabels];
|
||||||
|
if (createSelectedAssignees.size > 0) payload.assignees = [...createSelectedAssignees];
|
||||||
|
const milestoneId = parseInt($('gitea-create-milestone').value);
|
||||||
|
if (milestoneId) payload.milestone = milestoneId;
|
||||||
const res = await hyprcrd.api(`api/gitea/repo/${currentAccountId}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, {
|
const res = await hyprcrd.api(`api/gitea/repo/${currentAccountId}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: payload,
|
body: payload,
|
||||||
@@ -867,6 +1184,9 @@ async function openLabelsEditor() {
|
|||||||
const editor = $('gitea-labels-editor');
|
const editor = $('gitea-labels-editor');
|
||||||
editor.classList.remove('hidden');
|
editor.classList.remove('hidden');
|
||||||
const list = $('gitea-labels-checkboxes');
|
const list = $('gitea-labels-checkboxes');
|
||||||
|
const warning = $('gitea-labels-warning');
|
||||||
|
warning.classList.add('hidden');
|
||||||
|
warning.textContent = '';
|
||||||
list.innerHTML = '<div class="text-[10px] text-t-dim italic">loading labels...</div>';
|
list.innerHTML = '<div class="text-[10px] text-t-dim italic">loading labels...</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -876,6 +1196,10 @@ async function openLabelsEditor() {
|
|||||||
list.innerHTML = `<div class="text-[10px] text-t-danger break-words">${escapeHtml(data.error || 'failed to load')}</div>`;
|
list.innerHTML = `<div class="text-[10px] text-t-danger break-words">${escapeHtml(data.error || 'failed to load')}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (data.warning) {
|
||||||
|
warning.textContent = '⚠ ' + data.warning;
|
||||||
|
warning.classList.remove('hidden');
|
||||||
|
}
|
||||||
availableRepoLabels = data.labels || [];
|
availableRepoLabels = data.labels || [];
|
||||||
// Match current selections by name (since the issue's labels list contains
|
// Match current selections by name (since the issue's labels list contains
|
||||||
// {name, color} not ids)
|
// {name, color} not ids)
|
||||||
@@ -1193,8 +1517,24 @@ function init() {
|
|||||||
$('gitea-create-cancel').addEventListener('click', closeCreateForm);
|
$('gitea-create-cancel').addEventListener('click', closeCreateForm);
|
||||||
$('gitea-create-cancel-2').addEventListener('click', closeCreateForm);
|
$('gitea-create-cancel-2').addEventListener('click', closeCreateForm);
|
||||||
$('gitea-create-submit').addEventListener('click', submitCreateIssue);
|
$('gitea-create-submit').addEventListener('click', submitCreateIssue);
|
||||||
// Auto-load labels after the user picks/types a repo (change fires on blur)
|
// Repo input — custom autocomplete dropdown
|
||||||
$('gitea-create-repo').addEventListener('change', loadCreateRepoLabels);
|
const repoInput = $('gitea-create-repo');
|
||||||
|
repoInput.addEventListener('focus', showRepoDropdown);
|
||||||
|
repoInput.addEventListener('input', showRepoDropdown);
|
||||||
|
repoInput.addEventListener('blur', () => {
|
||||||
|
// Delay hide so option mousedown can register
|
||||||
|
setTimeout(hideRepoDropdown, 150);
|
||||||
|
});
|
||||||
|
repoInput.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); moveRepoDropdownActive(1); }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); moveRepoDropdownActive(-1); }
|
||||||
|
else if (e.key === 'Enter') {
|
||||||
|
if (commitRepoDropdownActive()) { e.preventDefault(); }
|
||||||
|
}
|
||||||
|
else if (e.key === 'Escape') { hideRepoDropdown(); }
|
||||||
|
});
|
||||||
|
// Auto-load labels when the user leaves the field (no dropdown commit)
|
||||||
|
repoInput.addEventListener('change', loadCreateRepoLabels);
|
||||||
|
|
||||||
// Detail buttons
|
// Detail buttons
|
||||||
$('gitea-extract-btn').addEventListener('click', extractIssue);
|
$('gitea-extract-btn').addEventListener('click', extractIssue);
|
||||||
@@ -1223,6 +1563,12 @@ function init() {
|
|||||||
$('gitea-labels-save').addEventListener('click', saveLabels);
|
$('gitea-labels-save').addEventListener('click', saveLabels);
|
||||||
$('gitea-labels-cancel').addEventListener('click', closeLabelsEditor);
|
$('gitea-labels-cancel').addEventListener('click', closeLabelsEditor);
|
||||||
|
|
||||||
|
// Metadata editor (assignees + milestone + due date)
|
||||||
|
$('gitea-meta-edit-btn').addEventListener('click', openMetaEditor);
|
||||||
|
$('gitea-meta-save').addEventListener('click', saveMetaEditor);
|
||||||
|
$('gitea-meta-cancel').addEventListener('click', closeMetaEditor);
|
||||||
|
$('gitea-meta-due-clear').addEventListener('click', clearMetaDue);
|
||||||
|
|
||||||
// Comment composer
|
// Comment composer
|
||||||
$('gitea-comment-post').addEventListener('click', postComment);
|
$('gitea-comment-post').addEventListener('click', postComment);
|
||||||
$('gitea-comment-input').addEventListener('keydown', e => {
|
$('gitea-comment-input').addEventListener('keydown', e => {
|
||||||
|
|||||||
Reference in new issue
Block a user