From a1a1cd86de0feb54a16a7cda05eb6a2ce4e53c08 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Tue, 28 Jul 2026 00:07:42 +0800 Subject: [PATCH 1/6] feat(labels): add description/exclusive/is_archived columns Adds Gitea-compatible columns to the labels table for both SQLite and MySQL schemas. Includes a SQLite boot-time ALTER migration (migrateLabelsTable) so existing DBs gain the columns idempotently. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/db.ts | 23 +++++++++++++++++++---- src/schema-mysql.sql | 11 +++++++---- src/schema.sql | 11 +++++++---- 3 files changed, 33 insertions(+), 12 deletions(-) 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/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) ); From 4043f7accb92c887491621fd3d10891227c8c67f Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Tue, 28 Jul 2026 00:07:47 +0800 Subject: [PATCH 2/6] feat(labels): implement label CRUD with exclusive-scope semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the label store functions (previously dead code with zero callers) into a full CRUD: create/update/archive/delete + list/get. Adds exclusive-scope support (Gitea scope/name syntax) — attaching an exclusive label evicts sibling labels sharing the same scope so an issue holds at most one label per scope. setIssueLabels replaces the full label set of an issue. Includes input validation (name charset, #RRGGBB color). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/store.ts | 112 +++++++++++++++++++++++++++++++++++++---- tests/db-admin.test.ts | 2 +- tests/prefix.test.ts | 2 +- 3 files changed, 105 insertions(+), 11 deletions(-) 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/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/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({ From 7d59a367cc929cf54279f05768828183a8069dca Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Tue, 28 Jul 2026 00:07:51 +0800 Subject: [PATCH 3/6] feat(labels): add label management + issue-label API routes Adds form-POST management routes (add/update/archive/delete) at /:o/:r/settings/labels/* mirroring the members/upstreams pattern, plus a JSON API at /api/:o/:r/issues/:n/labels (GET returns current+available, POST replaces the label set). Enforces admin role for management, writer role for issue-label edits. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/index.ts | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) 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; From 0c149ac0867bf6c2e4b35e6d029a4b1a30d03146 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Tue, 28 Jul 2026 00:07:57 +0800 Subject: [PATCH 4/6] fix(webhooks): populate labels in outbound issue payloads buildIssue previously hardcoded labels: [] in every emitted Gitea-shaped payload (issues + issue_comment events). Now fetches the issue's labels via listLabelsForIssue and maps them to the PayloadLabel shape, threaded through buildIssuePayload/buildCommentPayload. Fixes the latent bug where downstream consumers never received label data. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/webhooks.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) 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) { From ffc05ce4ec231fd26328f9f5a21561960a75e2cf Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Tue, 28 Jul 2026 00:08:05 +0800 Subject: [PATCH 5/6] feat(labels): add management page + issue label chips/picker UI Adds a project settings tab (labels) with a full management page: create/edit/archive/delete labels with a color palette and exclusive-scope hint, plus an edit dialog. On the issue detail page, label chips render in the meta-status row next to the state badge, with a picker dialog (label-picker.js) that toggles labels via the JSON API and reflects exclusive-scope mutual exclusion in the UI. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/render/layout.ts | 25 +++++ src/static/label-picker.js | 106 +++++++++++++++++++ src/views/issueThread.ts | 4 + src/views/projectLabels.ts | 190 ++++++++++++++++++++++++++++++++++ src/views/projectUpstreams.ts | 3 +- 5 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 src/static/label-picker.js create mode 100644 src/views/projectLabels.ts 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/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/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 成员 上游 + 🏷️ 标签 🤖 模型 `; } From 91cd48da5d473862b98b0459500d216039363bb9 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Tue, 28 Jul 2026 00:58:43 +0800 Subject: [PATCH 6/6] test(labels): unit tests for label CRUD + exclusive-scope eviction + bulk replace 23 tests covering: labelScope parsing, createLabel validation (empty name, invalid color, illegal chars, scope/name syntax), partial/full updateLabel, archive visibility filtering, deleteLabel cascade, and the exclusive-scope mutual-exclusion semantics (same-scope eviction, different-scope coexistence, non-exclusive coexistence, detach, idempotent re-attach, bulk replace with dedup). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- tests/labels.test.ts | 247 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 tests/labels.test.ts 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); + }); +});