diff --git a/src/db.ts b/src/db.ts index 41237e2..f64f369 100644 --- a/src/db.ts +++ b/src/db.ts @@ -127,6 +127,20 @@ function migrateIssuesTable(db: Database): void { } } +function migrateLabelsTable(db: Database): void { + const have = tableColumns(db, "labels"); + if (have.size === 0) return; + if (!have.has("description")) { + db.exec(applyPrefix("ALTER TABLE {{labels}} ADD COLUMN description TEXT NOT NULL DEFAULT ''")); + } + if (!have.has("exclusive")) { + db.exec(applyPrefix("ALTER TABLE {{labels}} ADD COLUMN exclusive INTEGER NOT NULL DEFAULT 0")); + } + if (!have.has("is_archived")) { + db.exec(applyPrefix("ALTER TABLE {{labels}} ADD COLUMN is_archived INTEGER NOT NULL DEFAULT 0")); + } +} + // ---- SqliteDriver: wraps bun:sqlite behind AsyncDatabase ---- class SqliteDriver implements AsyncDatabase { readonly dialect = "sqlite" as const; @@ -145,10 +159,11 @@ class SqliteDriver implements AsyncDatabase { ) ); // Migration must run BEFORE schema.sql (same ordering as the original file). - migrateUsersTable(db); - migratePatTable(db); - migrateProjectsTable(db); - migrateIssuesTable(db); + migrateUsersTable(db); + migratePatTable(db); + migrateProjectsTable(db); + migrateIssuesTable(db); + migrateLabelsTable(db); db.exec(applyPrefix(readFileSync(join(import.meta.dir, "schema.sql"), "utf8"))); return new SqliteDriver(db); } diff --git a/src/index.ts b/src/index.ts index 24a06a7..a283def 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,6 +61,13 @@ import { setProjectModel, listCachedModels, replaceCachedModels, + listLabels, + listLabelsForIssue, + createLabel, + updateLabel, + archiveLabel, + deleteLabel, + setIssueLabels, type ProjectRole, type UserRow, } from "./store"; @@ -91,6 +98,7 @@ import { buildWebhookDeliveriesPage } from "./views/webhookDeliveries"; import { browseRemoteFile, proxyFileSince, RemoteFileError } from "./remote-file"; import { buildProjectMembersPage } from "./views/projectMembers"; import { buildProjectUpstreamsPage, trySetUpstreamUrls } from "./views/projectUpstreams"; +import { buildProjectLabelsPage } from "./views/projectLabels"; import { buildProjectModelPage } from "./views/projectModel"; import { handleGiteaApi } from "./giteaApi"; import { deployRemoteDaemon, deployBatch, type DeployTarget } from "./daemon-deploy"; @@ -331,6 +339,10 @@ const REPO_MEMBER_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/([^/]+)\/ const REPO_MEMBER_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/add$/; const REPO_UPSTREAMS_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstreams$/; const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/; +const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/; +const REPO_LABEL_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/add$/; +const REPO_LABEL_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/(\d+)\/(update|archive|unarchive|delete)$/; +const API_ISSUE_LABELS_RE = /^\/api\/([^/]+)\/([^/]+)\/issues\/(\d+)\/labels$/; const WH_ACTION_RE = /^\/__wh\/(\d+)\/(delete|toggle|test)$/; const SESSIONS_RE = /^\/sessions$/; const SESSION_VIEW_RE = /^\/sessions\/([A-Za-z0-9_-]+)$/; @@ -1663,6 +1675,61 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean } } + const labelAdd = url.pathname.match(REPO_LABEL_ADD_RE); + if (labelAdd) { + const [, owner, repo] = labelAdd; + if (!(owner && repo)) return html(errorPage("bad path", ""), 400); + const project = await getProject(owner, repo); + if (!project) return html(errorPage("项目不存在", ""), 404); + const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/labels`; + if (!(await canAdminProject(project.id, ctx.user))) { + return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303); + } + const form = await req.formData().catch(() => new FormData()); + try { + await createLabel(project.id, { + name: String(form.get("name") ?? ""), + color: String(form.get("color") ?? "#888888"), + description: String(form.get("description") ?? ""), + exclusive: form.get("exclusive") === "1", + }); + return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent("标签已创建")}`, 303); + } catch (e) { + return Response.redirect(`${back}?err=${encodeURIComponent(errMsg(e))}`, 303); + } + } + + const labelAction = url.pathname.match(REPO_LABEL_ACTION_RE); + if (labelAction) { + const [, owner, repo, lidStr, action] = labelAction; + if (!(owner && repo && lidStr && action)) return html(errorPage("bad path", ""), 400); + const project = await getProject(owner, repo); + if (!project) return html(errorPage("项目不存在", ""), 404); + const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/labels`; + if (!(await canAdminProject(project.id, ctx.user))) { + return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303); + } + const lid = Number(lidStr); + const form = await req.formData().catch(() => new FormData()); + try { + if (action === "update") { + await updateLabel(project.id, lid, { + name: String(form.get("name") ?? ""), + color: String(form.get("color") ?? "#888888"), + description: String(form.get("description") ?? ""), + exclusive: form.get("exclusive") === "1", + }); + } else if (action === "archive" || action === "unarchive") { + await archiveLabel(project.id, lid, action === "archive"); + } else if (action === "delete") { + await deleteLabel(project.id, lid); + } + return Response.redirect(`${back}?ok=1`, 303); + } catch (e) { + return Response.redirect(`${back}?err=${encodeURIComponent(errMsg(e))}`, 303); + } + } + return json({ error: "not found" }, 404); } @@ -1696,6 +1763,38 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean } } + const issueLabelsApi = url.pathname.match(API_ISSUE_LABELS_RE); + if (issueLabelsApi) { + const [, owner, repo, numStr] = issueLabelsApi; + if (!(owner && repo && numStr)) return html(errorPage("404", "bad path"), 404); + const project = await getProject(owner, repo); + if (!project) return json({ error: "project not found" }, 404); + const issue = await getIssueWithMeta(project.id, Number(numStr)); + if (!issue) return json({ error: "issue not found" }, 404); + if (req.method === "GET") { + const [current, available] = await Promise.all([ + listLabelsForIssue(issue.id), + listLabels(project.id), + ]); + return json({ current, available }); + } + if (req.method === "POST") { + if (!ctx.user || !(await canWriteProject(project.id, ctx.user))) { + return json({ error: "forbidden: needs writer role on project" }, 403); + } + const body = await req.json().catch(() => ({})); + const labelIds = Array.isArray(body.labelIds) ? body.labelIds.map((n: unknown) => Number(n)).filter((n: number) => Number.isInteger(n) && n > 0) : []; + try { + await setIssueLabels(issue.id, labelIds); + const current = await listLabelsForIssue(issue.id); + return json({ current }); + } catch (e) { + return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500); + } + } + return json({ error: "method not allowed" }, 405); + } + const isNew = url.pathname.match(REPO_NEW_RE); if (isNew) { const [, owner, repo] = isNew; @@ -1779,6 +1878,22 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean return html(buildProjectUpstreamsPage(ctx.user!, project, flash)); } + const labelsPage = url.pathname.match(REPO_LABELS_RE); + if (labelsPage) { + const [, owner, repo] = labelsPage; + if (!(owner && repo)) return html(errorPage("404", "bad path"), 404); + const project = await getProject(owner, repo); + if (!project) return html(errorPage("项目不存在", "项目未创建"), 404); + if (ctx.user!.is_admin === 1) await ensureProjectBootstrapAdmin(project.id, ctx.user!.login); + if (!(await canAdminProject(project.id, ctx.user))) { + return html(errorPage("无权限", "需要该项目 admin 角色才能管理标签"), 403); + } + const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null; + const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? ""); + const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null; + return html(await buildProjectLabelsPage(ctx.user!, project, flash)); + } + const modelPage = url.pathname.match(REPO_MODEL_RE); if (modelPage) { const [, owner, repo] = modelPage; diff --git a/src/render/layout.ts b/src/render/layout.ts index f020ca2..70d20bc 100644 --- a/src/render/layout.ts +++ b/src/render/layout.ts @@ -14,6 +14,8 @@ export interface LayoutProps { upstreamWebUrl?: string | null; translateEnabled?: boolean; ttsEnabled?: boolean; + labels?: { id: number; name: string; color: string }[]; + canEditLabels?: boolean; } export const THEME_CSS = ` @@ -107,6 +109,20 @@ header.topbar .num{opacity:.7} .desc-toggle{margin-top:.4rem;background:none;border:none;color:var(--accent);font-size:13px;cursor:pointer;padding:.2rem 0} .upstream-link{font-size:12px;color:var(--accent);opacity:.85} .upstream-link:hover{opacity:1;text-decoration:underline} +.issue-label{display:inline-flex;align-items:center;font-size:12px;font-weight:500;padding:.05rem .5rem;border:1px solid;border-radius:99px;line-height:1.6;background:color-mix(in srgb,currentColor 8%,transparent)} +.label-edit-btn{background:none;border:1px solid var(--border);border-radius:6px;padding:.05rem .35rem;cursor:pointer;font-size:13px;line-height:1.5;color:var(--text-muted)} +.label-edit-btn:hover{border-color:var(--accent);color:var(--accent)} +#labelDlg{border:1px solid var(--border);border-radius:10px;background:var(--bg-elev);color:var(--text);padding:1.1rem;max-width:380px;width:90vw} +#labelDlg::backdrop{background:rgba(0,0,0,.5)} +#labelDlg h3{margin:0 0 .6rem;font-size:14px} +.lp-list{max-height:300px;overflow-y:auto;display:flex;flex-direction:column;gap:.15rem} +.lp-item{display:flex;align-items:center;gap:.4rem;padding:.3rem .4rem;border-radius:6px;cursor:pointer;font-size:13px} +.lp-item:hover{background:var(--bg-muted)} +.lp-item input{margin:0} +.lp-dot{width:10px;height:10px;border-radius:50%;border:1px solid var(--border);flex-shrink:0} +.lp-name{flex:1;overflow-wrap:anywhere} +.lp-scope{font-size:10px;color:var(--text-muted)} +.lp-empty{color:var(--text-muted);font-size:12px;padding:.6rem 0;text-align:center} `; export function renderLayout(props: LayoutProps, inner: string, initialItems: string): string { @@ -119,6 +135,12 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st const [repoOwner, repoName] = props.repoPath.split("/"); const repoIssuesHref = `/${encodeURIComponent(repoOwner ?? "")}/${encodeURIComponent(repoName ?? "")}/issues`; const op = props.operatorLogin ?? "operator"; + const labelsHtml = (props.labels ?? []).length + ? props.labels!.map((l) => `${escapeHtml(l.name)}`).join("") + : ""; + const labelPickerBtn = props.canEditLabels + ? `` + : ""; return ` @@ -140,6 +162,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st

${escapeHtml(props.issueTitle)}

${stateLabel} + ${labelsHtml}${labelPickerBtn} ${props.upstreamWebUrl ? `🔗 查看上游` : ""}
@@ -168,6 +191,8 @@ ${props.writesEnabled !== false +${props.canEditLabels ? `

标签

+` : ""} `; } diff --git a/src/schema-mysql.sql b/src/schema-mysql.sql index 6e93787..38ba179 100644 --- a/src/schema-mysql.sql +++ b/src/schema-mysql.sql @@ -74,10 +74,13 @@ CREATE INDEX comments_issue_created ON {{comments}} (issue_id, created_at); CREATE INDEX comments_author ON {{comments}} (author); CREATE TABLE IF NOT EXISTS {{labels}} ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - project_id BIGINT NOT NULL, - name VARCHAR(255) NOT NULL, - color VARCHAR(16) NOT NULL DEFAULT '#888888', + id BIGINT AUTO_INCREMENT PRIMARY KEY, + project_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + color VARCHAR(16) NOT NULL DEFAULT '#888888', + description VARCHAR(255) NOT NULL DEFAULT '', + exclusive TINYINT NOT NULL DEFAULT 0, + is_archived TINYINT NOT NULL DEFAULT 0, UNIQUE (project_id, name), CONSTRAINT {{fk_labels_project}} FOREIGN KEY (project_id) REFERENCES {{projects}}(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/src/schema.sql b/src/schema.sql index 3b874d0..8037218 100644 --- a/src/schema.sql +++ b/src/schema.sql @@ -78,10 +78,13 @@ CREATE INDEX IF NOT EXISTS comments_issue_created ON {{comments}} (issue_id, created_at); CREATE TABLE IF NOT EXISTS {{labels}} ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - project_id INTEGER NOT NULL REFERENCES {{projects}}(id) ON DELETE CASCADE, - name TEXT NOT NULL, - color TEXT NOT NULL DEFAULT '#888888', + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES {{projects}}(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#888888', + description TEXT NOT NULL DEFAULT '', + exclusive INTEGER NOT NULL DEFAULT 0, + is_archived INTEGER NOT NULL DEFAULT 0, UNIQUE (project_id, name) ); diff --git a/src/static/label-picker.js b/src/static/label-picker.js new file mode 100644 index 0000000..1ebc5ad --- /dev/null +++ b/src/static/label-picker.js @@ -0,0 +1,106 @@ +(function () { + var btn = document.getElementById("labelEditBtn"); + if (!btn) return; + var dlg = document.getElementById("labelDlg"); + var listEl = document.getElementById("lpList"); + var emptyEl = document.getElementById("lpEmpty"); + if (!dlg || !listEl) return; + + var path = window.location.pathname.replace(/\/+$/, ""); + var m = path.match(/^\/(.+)\/(.+)\/issues\/(\d+)$/); + if (!m) return; + var apiBase = "/api/" + encodeURIComponent(m[1]) + "/" + encodeURIComponent(m[2]) + "/issues/" + m[3] + "/labels"; + + function scopeOf(name) { + var i = name.indexOf("/"); + return i > 0 ? name.slice(0, i) : ""; + } + + var state = { available: [], current: [] }; + + function render() { + if (!state.available.length) { + listEl.classList.add("hidden"); + emptyEl.classList.remove("hidden"); + return; + } + listEl.classList.remove("hidden"); + emptyEl.classList.add("hidden"); + var currentIds = new Set(state.current.map(function (l) { return l.id; })); + listEl.innerHTML = state.available.map(function (l) { + var checked = currentIds.has(l.id); + var sc = scopeOf(l.name); + var scopeHint = l.exclusive === 1 && sc ? '互斥 · ' + esc(sc) + "" : ""; + return '"; + }).join(""); + } + + function renderChips() { + var row = document.querySelector(".meta-status"); + if (!row) return; + var existing = row.querySelectorAll(".issue-label"); + existing.forEach(function (e) { e.remove(); }); + var badge = row.querySelector(".state-badge"); + var html = state.current.map(function (l) { + return '' + esc(l.name) + ""; + }).join(""); + badge.insertAdjacentHTML("afterend", html); + } + + function esc(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + + async function load() { + try { + var res = await fetch(apiBase); + if (!res.ok) return; + var data = await res.json(); + state.available = data.available || []; + state.current = data.current || []; + render(); + } catch (e) {} + } + + async function save(labelIds) { + try { + var res = await fetch(apiBase, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ labelIds: labelIds }), + }); + if (!res.ok) return; + var data = await res.json(); + state.current = data.current || []; + renderChips(); + } catch (e) {} + } + + btn.addEventListener("click", async function () { + await load(); + dlg.showModal(); + }); + + listEl.addEventListener("change", function (e) { + var cb = e.target; + if (cb.tagName !== "INPUT" || cb.type !== "checkbox") return; + if (cb.checked && cb.dataset.exclusive === "1" && cb.dataset.scope) { + var scope = cb.dataset.scope; + listEl.querySelectorAll('input[data-exclusive="1"]').forEach(function (other) { + if (other !== cb && other.dataset.scope === scope) other.checked = false; + }); + } + var ids = Array.from(listEl.querySelectorAll("input:checked")).map(function (c) { + return Number(c.dataset.id); + }); + save(ids); + }); +})(); diff --git a/src/store.ts b/src/store.ts index 81bde75..0a9c6f9 100644 --- a/src/store.ts +++ b/src/store.ts @@ -79,6 +79,17 @@ export interface LabelRow { project_id: number; name: string; color: string; + description: string; + exclusive: number; + is_archived: number; +} + +/** For an exclusive label named "scope/name", the scope is "scope". A label + * with no "/" has no scope and is never exclusive. Issues may carry at most + * one label per scope (Gitea semantics). */ +export function labelScope(name: string): string { + const i = name.indexOf("/"); + return i > 0 ? name.slice(0, i) : ""; } export interface AttachmentRow { @@ -595,8 +606,12 @@ export async function removeReaction(commentId: number, userLogin: string, conte ); } -export async function listLabels(projectId: number): Promise { - return await getDB().all("SELECT * FROM {{labels}} WHERE project_id = ? ORDER BY name", [projectId]); +const LABEL_COLOR_RE = /^#[0-9a-fA-F]{6}$/; +const LABEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 _\-./]{0,62}$/; + +export async function listLabels(projectId: number, includeArchived = false): Promise { + const where = includeArchived ? "project_id = ?" : "project_id = ? AND is_archived = 0"; + return await getDB().all(`SELECT * FROM {{labels}} WHERE ${where} ORDER BY name`, [projectId]); } export async function listLabelsForIssue(issueId: number): Promise { @@ -608,19 +623,98 @@ export async function listLabelsForIssue(issueId: number): Promise { ); } -export async function createLabel(projectId: number, name: string, color: string): Promise { - name = name.trim(); +export async function getLabel(projectId: number, id: number): Promise { + return (await getDB().get("SELECT * FROM {{labels}} WHERE project_id = ? AND id = ?", [projectId, id])) ?? null; +} + +interface LabelInput { + name: string; + color: string; + description?: string; + exclusive?: boolean; +} + +function validateLabel(t: LabelInput): { name: string; color: string; description: string; exclusive: number } { + const name = t.name.trim(); if (!name) throw new StoreError(400, "标签名不能为空"); - if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new StoreError(400, "颜色须为 #RRGGBB"); - const info = await getDB().run("INSERT INTO {{labels}} (project_id, name, color) VALUES (?, ?, ?)", [projectId, name, color]); + if (!LABEL_NAME_RE.test(name)) throw new StoreError(400, "标签名仅允许字母数字、空格、_ - . /,且不能以 / 开头"); + if (!LABEL_COLOR_RE.test(t.color)) throw new StoreError(400, "颜色须为 #RRGGBB"); + return { name, color: t.color, description: (t.description ?? "").trim(), exclusive: t.exclusive ? 1 : 0 }; +} + +export async function createLabel(projectId: number, input: LabelInput): Promise { + const v = validateLabel(input); + const info = await getDB().run( + "INSERT INTO {{labels}} (project_id, name, color, description, exclusive) VALUES (?, ?, ?, ?, ?)", + [projectId, v.name, v.color, v.description, v.exclusive] + ); return (await getDB().get("SELECT * FROM {{labels}} WHERE id = ?", [info.insertId]))!; } +export async function updateLabel(projectId: number, id: number, input: Partial): Promise { + const existing = await getLabel(projectId, id); + if (!existing) throw new StoreError(404, "标签不存在"); + const merged: LabelInput = { + name: input.name ?? existing.name, + color: input.color ?? existing.color, + description: input.description ?? existing.description, + exclusive: input.exclusive ?? (existing.exclusive === 1), + }; + const v = validateLabel(merged); + await getDB().run( + "UPDATE {{labels}} SET name = ?, color = ?, description = ?, exclusive = ? WHERE project_id = ? AND id = ?", + [v.name, v.color, v.description, v.exclusive, projectId, id] + ); + return (await getDB().get("SELECT * FROM {{labels}} WHERE id = ?", [id]))!; +} + +export async function archiveLabel(projectId: number, id: number, archived: boolean): Promise { + const existing = await getLabel(projectId, id); + if (!existing) throw new StoreError(404, "标签不存在"); + await getDB().run("UPDATE {{labels}} SET is_archived = ? WHERE project_id = ? AND id = ?", [archived ? 1 : 0, projectId, id]); + return (await getDB().get("SELECT * FROM {{labels}} WHERE id = ?", [id]))!; +} + +export async function deleteLabel(projectId: number, id: number): Promise { + await getDB().run("DELETE FROM {{labels}} WHERE project_id = ? AND id = ?", [projectId, id]); +} + +/** Attach (`on=true`) or detach a label. When attaching an exclusive label + * (scope/name with exclusive=1), any other already-attached label sharing the + * same scope is removed first, so an issue holds at most one label per scope. */ export async function setIssueLabel(issueId: number, labelId: number, on: boolean): Promise { - if (on) { - await getDB().run("INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)", [issueId, labelId]); - } else { + if (!on) { await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ? AND label_id = ?", [issueId, labelId]); + return; + } + const label = await getDB().get("SELECT * FROM {{labels}} WHERE id = ?", [labelId]); + if (!label) throw new StoreError(404, "标签不存在"); + if (label.exclusive === 1) { + const scope = labelScope(label.name); + if (scope) { + const current = await listLabelsForIssue(issueId); + const siblings = current.filter((l) => l.exclusive === 1 && labelScope(l.name) === scope && l.id !== labelId); + for (const s of siblings) { + await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ? AND label_id = ?", [issueId, s.id]); + } + } + } + const dialect = getDB().dialect; + const insert = dialect === "sqlite" + ? "INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)" + : "INSERT IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)"; + await getDB().run(insert, [issueId, labelId]); +} + +export async function setIssueLabels(issueId: number, labelIds: number[]): Promise { + const dialect = getDB().dialect; + await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ?", [issueId]); + const ids = Array.from(new Set(labelIds)); + const insert = dialect === "sqlite" + ? "INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)" + : "INSERT IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)"; + for (const id of ids) { + await getDB().run(insert, [issueId, id]); } } diff --git a/src/views/issueThread.ts b/src/views/issueThread.ts index f4957e8..bacfa48 100644 --- a/src/views/issueThread.ts +++ b/src/views/issueThread.ts @@ -11,6 +11,7 @@ import { listCommentsPage, listCommentsSince, getDefaultUpstreamUrl, + listLabelsForIssue, type CommentRow, type IssueWithMeta, } from "../store"; @@ -110,6 +111,7 @@ export async function buildIssueThread( if (!clone) return null; return webUrlFromClone(clone); })(); + const labels = await listLabelsForIssue(issue.id); const html = renderLayout( { @@ -126,6 +128,8 @@ export async function buildIssueThread( upstreamWebUrl, translateEnabled: !!cfg.translateUrl && cfg.translateUrl.trim() !== "", ttsEnabled: cfg.ttsBackends.some((b) => b.url && b.url.trim() !== ""), + labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color })), + canEditLabels: cfg.writesEnabled !== false, }, safeJsonEmbed(payload), displayViews.map((v) => renderCommentCard(v, cfg)).join("") diff --git a/src/views/projectLabels.ts b/src/views/projectLabels.ts new file mode 100644 index 0000000..16e18f6 --- /dev/null +++ b/src/views/projectLabels.ts @@ -0,0 +1,190 @@ +import { THEME_CSS, escapeHtml, escapeAttr } from "../render/layout"; +import { projectSettingsTabsHTML } from "./projectUpstreams"; +import { listLabels, type ProjectRow, type UserRow, type LabelRow } from "../store"; + +interface Flash { + kind: "ok" | "err"; + msg: string; +} + +const PALETTE = [ + "#888888", "#e11d48", "#db2777", "#9333ea", "#4f46e5", + "#2563eb", "#0891b2", "#0d9488", "#16a34a", "#ca8a04", + "#d97706", "#92400e", +]; + +function colorDot(c: string): string { + return ``; +} + +function labelRowHtml(l: LabelRow, owner: string, repo: string): string { + const base = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/labels/${l.id}`; + const exclusiveBadge = l.exclusive === 1 ? `互斥` : ""; + const archivedBadge = l.is_archived === 1 ? `已归档` : ""; + return ` + ${colorDot(l.color)}${escapeHtml(l.name)} ${exclusiveBadge}${archivedBadge} + ${escapeHtml(l.description || "—")} + +
+ + + + + +
+
+ +
+
+ +
+ + `; +} + +export async function buildProjectLabelsPage( + _viewer: UserRow, + project: ProjectRow, + flash: Flash | null, +): Promise { + const labels = await listLabels(project.id, true); + const rowsHtml = labels.length + ? ` + +${labels.map((l) => labelRowHtml(l, project.owner, project.name)).join("")} +
标签描述操作
` + : `
该项目还没有标签。在下方创建。
`; + + const flashHtml = flash ? `
${escapeHtml(flash.msg)}
` : ""; + const createAction = `/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/labels/add`; + const paletteHtml = PALETTE.map((c) => ``).join(""); + const labelsJson = JSON.stringify(labels.map((l) => ({ id: l.id, name: l.name, color: l.color, description: l.description, exclusive: l.exclusive, is_archived: l.is_archived }))); + + return ` + + + +标签 · ${escapeHtml(project.owner)}/${escapeHtml(project.name)} + +
🏷️ ${escapeHtml(project.owner)}/${escapeHtml(project.name)} · 标签
+
+

${escapeHtml(project.owner)}/${escapeHtml(project.name)} · 标签

+${projectSettingsTabsHTML(project.owner, project.name, "labels")} +${flashHtml} + +
+

已有标签(${labels.length})

+${rowsHtml} +
+ +
+

新建标签

+
+
+ + +
+
+ + +
+
+
${paletteHtml}
+ + + +
名称含 / 时,/ 前的部分为 scope。同一 scope 下的互斥标签,一个 issue 只能贴一个。
+ +
+
+ + +

编辑标签

+
+
+
+
+
+
+ + +
+ + +
+
+
+ +`; +} diff --git a/src/views/projectUpstreams.ts b/src/views/projectUpstreams.ts index 9c5b7dd..00209a6 100644 --- a/src/views/projectUpstreams.ts +++ b/src/views/projectUpstreams.ts @@ -16,7 +16,7 @@ interface Flash { export function projectSettingsTabsHTML( owner: string, name: string, - active: "webhooks" | "members" | "upstreams" | "model", + active: "webhooks" | "members" | "upstreams" | "model" | "labels", ): string { const base = `/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/settings`; const cls = (which: typeof active) => (active === which ? " active" : ""); @@ -24,6 +24,7 @@ export function projectSettingsTabsHTML( Webhooks 成员 上游 + 🏷️ 标签 🤖 模型 `; } diff --git a/src/webhooks.ts b/src/webhooks.ts index 5995883..9635dd4 100644 --- a/src/webhooks.ts +++ b/src/webhooks.ts @@ -26,6 +26,7 @@ import { getDefaultUpstreamUrl, ensureUser, resolveModel, + listLabelsForIssue, type IssueRow, type ProjectRow, type CommentRow, @@ -355,12 +356,18 @@ function buildRepository(project: ProjectRow, origin: string, model?: string): P return repo; } +async function toPayloadLabels(issueId: number): Promise { + const rows = await listLabelsForIssue(issueId); + return rows.map((l) => ({ id: l.id, name: l.name, color: l.color, description: l.description })); +} + function buildIssue( issue: IssueRow, project: ProjectRow, commentCount: number, origin: string, model?: string, + labels: PayloadLabel[] = [], ): PayloadIssue { const repoUrl = `${origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}`; const issueUrl = `${repoUrl}/issues/${issue.number}`; @@ -371,7 +378,7 @@ function buildIssue( number: issue.number, title: issue.title, body: issue.body ?? "", - labels: [], + labels, milestone: null, assignee: null, assignees: null, @@ -415,10 +422,11 @@ function buildCommentPayload( commentCount: number, origin: string, model?: string, + labels: PayloadLabel[] = [], ): CommentEventPayload { return { action: "created", - issue: buildIssue(issue, project, commentCount, origin, model), + issue: buildIssue(issue, project, commentCount, origin, model, labels), comment: buildComment(issue, comment, project, origin), repository: buildRepository(project, origin, model), sender: buildUser(comment.author, origin), @@ -432,10 +440,11 @@ function buildIssuePayload( action: IssueAction, origin: string, model?: string, + labels: PayloadLabel[] = [], ): IssueEventPayload { return { action, - issue: buildIssue(issue, project, commentCount, origin, model), + issue: buildIssue(issue, project, commentCount, origin, model, labels), repository: buildRepository(project, origin, model), sender: buildUser(issue.author, origin), }; @@ -604,7 +613,8 @@ export async function emitIssueEvent( // comes from the config table via loadConfig() — cheap DB read. const globalDefault = (await loadConfig()).defaultModel; const model = resolveModel(project.model, globalDefault); - const payload = buildIssuePayload(issue, project, commentCount, action, origin, model); + const labels = await toPayloadLabels(issueId); + const payload = buildIssuePayload(issue, project, commentCount, action, origin, model, labels); const rawBody = JSON.stringify(payload); await fanOut(projectId, "issues", rawBody); } catch (e) { @@ -633,7 +643,8 @@ export async function emitCommentEvent( const commentCount = await countCommentsSafe(issueId); const globalDefault = (await loadConfig()).defaultModel; const model = resolveModel(project.model, globalDefault); - const payload = buildCommentPayload(issue, comment, project, commentCount, origin, model); + const labels = await toPayloadLabels(issueId); + const payload = buildCommentPayload(issue, comment, project, commentCount, origin, model, labels); const rawBody = JSON.stringify(payload); await fanOut(projectId, "issue_comment", rawBody); } catch (e) { diff --git a/tests/db-admin.test.ts b/tests/db-admin.test.ts index b612ce2..282dec1 100644 --- a/tests/db-admin.test.ts +++ b/tests/db-admin.test.ts @@ -116,7 +116,7 @@ describe.skipIf(!process.env.WORK_MIGRATE_TEST)("db-admin: sqlite→mysql round- await addReaction(c1.id, "alice", "+1"); await addReaction(c1.id, "bob", "heart"); await addReaction(c2.id, "alice", "+1"); - const lab = await createLabel(p.id, "bug", "#ff0000"); + const lab = await createLabel(p.id, { name: "bug", color: "#ff0000" }); await setIssueLabel(issue.id, lab.id, true); await createAttachment({ uuid: "deadbeef-1234", diff --git a/tests/labels.test.ts b/tests/labels.test.ts new file mode 100644 index 0000000..4a90273 --- /dev/null +++ b/tests/labels.test.ts @@ -0,0 +1,247 @@ +import { beforeAll, beforeEach, describe, expect, test } from "bun:test"; + +import { getDB, initDB } from "../src/db"; +import { + StoreError, + archiveLabel, + createIssue, + createLabel, + createProject, + deleteLabel, + ensureUser, + getLabel, + labelScope, + listLabels, + listLabelsForIssue, + setIssueLabel, + setIssueLabels, + updateLabel, +} from "../src/store"; + +beforeAll(async () => { + await initDB(); +}); + +beforeEach(async () => { + const db = getDB(); + const mysql = db.dialect === "mysql"; + await db.exec(mysql ? "SET FOREIGN_KEY_CHECKS = 0" : "PRAGMA foreign_keys = OFF"); + for (const t of ["issue_labels", "labels", "issues", "projects", "users", "comments"]) { + await db.exec(`DELETE FROM {{${t}}}`); + } + await db.exec(mysql ? "SET FOREIGN_KEY_CHECKS = 1" : "PRAGMA foreign_keys = ON"); + if (mysql) { + for (const t of ["issues", "labels", "projects"]) { + await db.exec(`ALTER TABLE {{${t}}} AUTO_INCREMENT = 1`); + } + } else { + await db.exec("DELETE FROM sqlite_sequence WHERE name IN ('issues','labels','projects')"); + } +}); + +async function setupProject() { + await ensureUser("dog"); + return createProject("dog", "labelrepo", ""); +} + +describe("labelScope", () => { + test("extracts scope before first slash", () => { + expect(labelScope("priority/high")).toBe("priority"); + expect(labelScope("type/bug")).toBe("type"); + }); + test("empty string for no slash", () => { + expect(labelScope("bug")).toBe(""); + expect(labelScope("")).toBe(""); + }); + test("label starting with slash has no scope", () => { + expect(labelScope("/leading")).toBe(""); + }); +}); + +describe("createLabel", () => { + test("creates with all fields", async () => { + const p = await setupProject(); + const l = await createLabel(p.id, { name: "bug", color: "#ff0000", description: "defects", exclusive: false }); + expect(l.name).toBe("bug"); + expect(l.color).toBe("#ff0000"); + expect(l.description).toBe("defects"); + expect(l.exclusive).toBe(0); + expect(l.is_archived).toBe(0); + }); + + test("rejects empty name", async () => { + const p = await setupProject(); + await expect(createLabel(p.id, { name: " ", color: "#000000" })).rejects.toThrow(StoreError); + }); + + test("rejects invalid color", async () => { + const p = await setupProject(); + await expect(createLabel(p.id, { name: "x", color: "red" })).rejects.toThrow(StoreError); + await expect(createLabel(p.id, { name: "x", color: "#fff" })).rejects.toThrow(StoreError); + await expect(createLabel(p.id, { name: "x", color: "#GGGGGG" })).rejects.toThrow(StoreError); + }); + + test("rejects name with illegal chars", async () => { + const p = await setupProject(); + await expect(createLabel(p.id, { name: "a;b", color: "#000000" })).rejects.toThrow(StoreError); + await expect(createLabel(p.id, { name: "a!b", color: "#000000" })).rejects.toThrow(StoreError); + }); + + test("accepts scope/name syntax", async () => { + const p = await setupProject(); + const l = await createLabel(p.id, { name: "priority/high", color: "#00ff00", exclusive: true }); + expect(l.name).toBe("priority/high"); + expect(l.exclusive).toBe(1); + }); + + test("description defaults to empty", async () => { + const p = await setupProject(); + const l = await createLabel(p.id, { name: "x", color: "#000000" }); + expect(l.description).toBe(""); + }); +}); + +describe("updateLabel", () => { + test("partial update preserves other fields", async () => { + const p = await setupProject(); + const l = await createLabel(p.id, { name: "bug", color: "#ff0000", description: "orig", exclusive: true }); + const updated = await updateLabel(p.id, l.id, { color: "#00ff00" }); + expect(updated.color).toBe("#00ff00"); + expect(updated.name).toBe("bug"); + expect(updated.description).toBe("orig"); + expect(updated.exclusive).toBe(1); + }); + + test("full update", async () => { + const p = await setupProject(); + const l = await createLabel(p.id, { name: "old", color: "#111111" }); + const updated = await updateLabel(p.id, l.id, { name: "new", color: "#222222", description: "d", exclusive: true }); + expect(updated.name).toBe("new"); + expect(updated.exclusive).toBe(1); + }); + + test("404 on unknown label", async () => { + const p = await setupProject(); + await expect(updateLabel(p.id, 99999, { name: "x" })).rejects.toThrow(StoreError); + }); +}); + +describe("archiveLabel / deleteLabel", () => { + test("archive sets is_archived", async () => { + const p = await setupProject(); + const l = await createLabel(p.id, { name: "x", color: "#000000" }); + const arch = await archiveLabel(p.id, l.id, true); + expect(arch.is_archived).toBe(1); + const restored = await archiveLabel(p.id, l.id, false); + expect(restored.is_archived).toBe(0); + }); + + test("listLabels hides archived by default", async () => { + const p = await setupProject(); + await createLabel(p.id, { name: "active", color: "#000000" }); + const arch = await createLabel(p.id, { name: "archived", color: "#000000" }); + await archiveLabel(p.id, arch.id, true); + const visible = await listLabels(p.id); + expect(visible).toHaveLength(1); + expect(visible[0]!.name).toBe("active"); + const all = await listLabels(p.id, true); + expect(all).toHaveLength(2); + }); + + test("delete removes label and its issue associations", async () => { + const p = await setupProject(); + const issue = await createIssue(p.id, "t", "b", "dog"); + const l = await createLabel(p.id, { name: "x", color: "#000000" }); + await setIssueLabel(issue.id, l.id, true); + expect(await listLabelsForIssue(issue.id)).toHaveLength(1); + await deleteLabel(p.id, l.id); + expect(await getLabel(p.id, l.id)).toBeNull(); + expect(await listLabelsForIssue(issue.id)).toHaveLength(0); + }); +}); + +describe("setIssueLabel exclusive-scope eviction", () => { + test("attaching exclusive label evicts sibling in same scope", async () => { + const p = await setupProject(); + const issue = await createIssue(p.id, "t", "b", "dog"); + const high = await createLabel(p.id, { name: "priority/high", color: "#ff0000", exclusive: true }); + const low = await createLabel(p.id, { name: "priority/low", color: "#00ff00", exclusive: true }); + await setIssueLabel(issue.id, high.id, true); + expect(await listLabelsForIssue(issue.id)).toHaveLength(1); + await setIssueLabel(issue.id, low.id, true); + const after = await listLabelsForIssue(issue.id); + expect(after).toHaveLength(1); + expect(after[0]!.name).toBe("priority/low"); + }); + + test("non-exclusive labels in same scope do not evict", async () => { + const p = await setupProject(); + const issue = await createIssue(p.id, "t", "b", "dog"); + const a = await createLabel(p.id, { name: "scope/a", color: "#000000", exclusive: false }); + const b = await createLabel(p.id, { name: "scope/b", color: "#111111", exclusive: false }); + await setIssueLabel(issue.id, a.id, true); + await setIssueLabel(issue.id, b.id, true); + expect(await listLabelsForIssue(issue.id)).toHaveLength(2); + }); + + test("different scopes do not evict each other", async () => { + const p = await setupProject(); + const issue = await createIssue(p.id, "t", "b", "dog"); + const ph = await createLabel(p.id, { name: "p/high", color: "#000000", exclusive: true }); + const th = await createLabel(p.id, { name: "t/high", color: "#111111", exclusive: true }); + await setIssueLabel(issue.id, ph.id, true); + await setIssueLabel(issue.id, th.id, true); + expect(await listLabelsForIssue(issue.id)).toHaveLength(2); + }); + + test("detach works", async () => { + const p = await setupProject(); + const issue = await createIssue(p.id, "t", "b", "dog"); + const l = await createLabel(p.id, { name: "x", color: "#000000" }); + await setIssueLabel(issue.id, l.id, true); + await setIssueLabel(issue.id, l.id, false); + expect(await listLabelsForIssue(issue.id)).toHaveLength(0); + }); + + test("re-attaching existing label is idempotent", async () => { + const p = await setupProject(); + const issue = await createIssue(p.id, "t", "b", "dog"); + const l = await createLabel(p.id, { name: "x", color: "#000000" }); + await setIssueLabel(issue.id, l.id, true); + await setIssueLabel(issue.id, l.id, true); + expect(await listLabelsForIssue(issue.id)).toHaveLength(1); + }); +}); + +describe("setIssueLabels (bulk replace)", () => { + test("replaces full set", async () => { + const p = await setupProject(); + const issue = await createIssue(p.id, "t", "b", "dog"); + const a = await createLabel(p.id, { name: "a", color: "#000000" }); + const b = await createLabel(p.id, { name: "b", color: "#111111" }); + const c = await createLabel(p.id, { name: "c", color: "#222222" }); + await setIssueLabels(issue.id, [a.id, b.id]); + expect(await listLabelsForIssue(issue.id)).toHaveLength(2); + await setIssueLabels(issue.id, [c.id]); + const after = await listLabelsForIssue(issue.id); + expect(after).toHaveLength(1); + expect(after[0]!.name).toBe("c"); + }); + + test("empty array clears all", async () => { + const p = await setupProject(); + const issue = await createIssue(p.id, "t", "b", "dog"); + const a = await createLabel(p.id, { name: "a", color: "#000000" }); + await setIssueLabels(issue.id, [a.id]); + await setIssueLabels(issue.id, []); + expect(await listLabelsForIssue(issue.id)).toHaveLength(0); + }); + + test("deduplicates ids", async () => { + const p = await setupProject(); + const issue = await createIssue(p.id, "t", "b", "dog"); + const a = await createLabel(p.id, { name: "a", color: "#000000" }); + await setIssueLabels(issue.id, [a.id, a.id, a.id]); + expect(await listLabelsForIssue(issue.id)).toHaveLength(1); + }); +}); diff --git a/tests/prefix.test.ts b/tests/prefix.test.ts index 1232bfd..f5833e2 100644 --- a/tests/prefix.test.ts +++ b/tests/prefix.test.ts @@ -26,7 +26,7 @@ describe.skipIf(!PREFIX)("storage under WORK_DB_PREFIX", () => { const c = await postComment(issue.id, "first!", "alice"); await addReaction(c.id, "alice", "+1"); await listReactionsFor([c.id]); - const lab = await createLabel(p.id, "bug", "#ff0000"); + const lab = await createLabel(p.id, { name: "bug", color: "#ff0000" }); await setIssueLabel(issue.id, lab.id, true); await listLabelsForIssue(issue.id); await createAttachment({