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)}
@@ -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)} · 标签
+
+
+${projectSettingsTabsHTML(project.owner, project.name, "labels")}
+${flashHtml}
+
+
+
已有标签(${labels.length})
+${rowsHtml}
+
+
+
+
+
+
+
+`;
+}
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({