Public: anyone can read this; the crew can change it.

Each label on Settings › Labels says how many open issues and pulls carry it, as a link to the issues list narrowed to it #8053

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7866-each-label-on-settings-labels-says-how-m → main 7 commits fork 1 thread · 1 unresolved

  • Merged. scout/7866-each-label-on-settings-labels-says-how-m was merged into main as 0ea9e7d.

Review

Sign in to review this change.
13 files changed +232 −28
docs/MCP.md +1 −1
@@ -145,7 +145,7 @@ for a tunnel and a forge that sleeps.
145145 | `release_keeper` | `{}`: what the release keeper (#7557) did: its releases newest first (version, stage — rolling, verifying, verified or rolled_back —, the known-good version it would roll back to, roll probes and non-200s, probes and pages under load, worst ms, seconds dark, reason) and its holds (kind failed or owner, version, reason, probe numbers, the pull that said `release hold N`, lifted); `blocked` says what stops the next cut; for whoever may read /releases | |
146146 | `releases` | `{shipped: true}`: what went live, for anyone: `version`, `commit`, `live` (newest first: `repo`, `number`, `title`, `at`) and `onItsWay` (landed, not yet in the running commit), the list /changelog shows. `{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page | |
147147 | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | |
148 | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; a read-only token may), create (`create`, `name`, `color` six hex digits), rename or recolour (`update`, `id`, `name` and/or `color`, as the labels page) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `PATCH`/`DELETE …/labels/:id`; create, update and remove need an admin token and a repository admin | yes |
148 | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`, each label with `open`, its open issues and pulls, for a writer; a read-only token may), create (`create`, `name`, `color` six hex digits), rename or recolour (`update`, `id`, `name` and/or `color`, as the labels page) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `PATCH`/`DELETE …/labels/:id`; create, update and remove need an admin token and a repository admin | yes |
149149 | `repo_settings` | (admin scope) `{ repo, description?, visibility? }` (admin): change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }` (with none to change it only reads); a visibility other than public or private is `invalid` | yes |
150150 | `report` | `{ repo, title, body?, page?, agent? }`: report a problem in a public repository, the one tool open without a token; it still stores the report, which a person reads first; plain text. With a token the report carries your account; `agent` without one is shown as a claim | yes |
151151 | `reports` | `{ repo, id?, action? }`: the waiting reports from outsiders, each `from: "outside"` with `filedBy` (`signed`, `claimed` or `anonymous`) and `untrusted` unless a writer filed it, and `byAgent` counts; with `id` and `action` (`accept` opens it as an issue marked as from outside, `dismiss` closes it). A person who writes the repository only; needs an `rw` token | yes |
docs/design/areas/settings.md +5
@@ -25,3 +25,8 @@ the text wrap and gives the control a place that is always visible. Use `.row-ac
2525 - `.preview` — the label chip at the head of a label's edit row on a repository's Labels settings, kept from
2626 shrinking. Written by `labelEditor()` in `src/web/pages/labels.ts`; styled in `src/web/style/components.ts` as
2727 `.edit-row > .preview`.
28
29 - `.label-open` — a label's `N open` link beside its chip in `labelEditor()` (`unused` stays a `.muted` span). Underlined so it
30 reads as a link; at the phone breakpoint a 44px tap target. Styled under `.edit-row` in `src/web/style/components.ts`.
31
32 The terminal's Labels screen (`src/tui/screens/labels.ts`) shows the same counts to a repository admin: `N open` or `unused` after each hex, and its delete confirm says how many open issues and pulls carry the label. A reader sees neither.
src/core/issues.ts +35
@@ -860,6 +860,41 @@ export function listLabels(db: Database, repoId: string): Label[] {
860860 .all(repoId);
861861 }
862862
863 /**
864 * How many open issues and pulls carry each label of a repository, in one
865 * grouped query. A label with none has no entry. Sealed items are counted:
866 * the caller is the admin-only labels page (and the delete confirm, gated on
867 * write), and a writer sees sealed issues under `sealedScope` anyway.
868 */
869 export function openLabelCounts(
870 db: Database,
871 repoId: string,
872 ): Map<string, number> {
873 const rows = db
874 .query<{ label_id: string; n: number }, [string]>(
875 `SELECT il.label_id, COUNT(*) AS n
876 FROM label l
877 JOIN issue_label il ON il.label_id = l.id
878 JOIN issue i ON i.id = il.issue_id AND i.state = 'open'
879 WHERE l.repo_id = ?1
880 GROUP BY il.label_id`,
881 )
882 .all(repoId);
883 return new Map(rows.map((r) => [r.label_id, r.n]));
884 }
885
886 /** The repository's labels, each with `open` (its open count) when `withCounts`. */
887 export function labelsWithOpen(
888 db: Database,
889 repoId: string,
890 withCounts: boolean,
891 ): Array<Label & { open?: number }> {
892 const rows = listLabels(db, repoId);
893 if (!withCounts) return rows;
894 const counts = openLabelCounts(db, repoId);
895 return rows.map((l) => ({ ...l, open: counts.get(l.id) ?? 0 }));
896 }
897
863898 export function setIssueLabels(
864899 db: Database,
865900 issueId: string,
src/http/api/labels.ts +6 −2
@@ -6,8 +6,9 @@
66
77 import { createLabel, deleteLabel, updateLabel } from "../../actions/repos.ts";
88 import { LABEL_WORDS } from "../../actions/label_words.ts";
9 import { listLabels } from "../../core/issues.ts";
9 import { labelsWithOpen, listLabels } from "../../core/issues.ts";
1010 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
11 import { canWrite } from "../../core/repos.ts";
1112 import { loadRepo, pageOf, pageParams } from "../api.ts";
1213 import type { Handler } from "../context.ts";
1314 import { body, json, notFound, requireScope, respondAccess, str } from "../respond.ts";
@@ -20,7 +21,10 @@ export function labelsRoutes(router: Router<Handler>): void {
2021 if (!repo) return notFound();
2122 const page = pageParams(ctx);
2223 if (page instanceof Response) return page;
23 const rows = timePhase(ctx.request, "labels", () => listLabels(ctx.app.db, repo.id));
24 // Sealed items are counted, so only a writer is told how many are open.
25 const rows = timePhase(ctx.request, "labels", () =>
26 labelsWithOpen(ctx.app.db, repo.id, canWrite(ctx.app.db, repo, ctx.auth.user)),
27 );
2428 return timePhase(ctx.request, "render", () => pageOf(rows, page));
2529 });
2630
src/http/mcp/tools/repo_labels.ts +8 −3
@@ -1,4 +1,5 @@
1 import { listLabels } from "../../../core/issues.ts";
1 import { labelsWithOpen, listLabels } from "../../../core/issues.ts";
2 import { canWrite } from "../../../core/repos.ts";
23 import { scopeAllows } from "../../../core/tokens.ts";
34 import { createLabel, deleteLabel, updateLabel } from "../../../actions/repos.ts";
45 import { LABEL_WORDS } from "../../../actions/label_words.ts";
@@ -7,7 +8,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"
78
89 export const tool: Tool = {
910 name: "repo_labels",
10 doc: "`{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; a read-only token may), create (`create`, `name`, `color` six hex digits), rename or recolour (`update`, `id`, `name` and/or `color`, as the labels page) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `PATCH`/`DELETE …/labels/:id`; create, update and remove need an admin token and a repository admin",
11 doc: "`{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`, each label with `open`, its open issues and pulls, for a writer; a read-only token may), create (`create`, `name`, `color` six hex digits), rename or recolour (`update`, `id`, `name` and/or `color`, as the labels page) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `PATCH`/`DELETE …/labels/:id`; create, update and remove need an admin token and a repository admin",
1112 description:
1213 "Repo labels: list (default), create, update, remove (admin token).",
1314 inputSchema: {
@@ -30,7 +31,11 @@ export const tool: Tool = {
3031 const repo = repoArg(ctx, user, args);
3132 if (isAnswer(repo)) return repo;
3233 const action = args["action"] ?? "list";
33 if (action === "list") return pageRows(listLabels(ctx.app.db, repo.id), (l) => l.id, args);
34 if (action === "list") {
35 // `open` is each label's open count, told to a writer only (sealed items are counted).
36 const rows = labelsWithOpen(ctx.app.db, repo.id, canWrite(ctx.app.db, repo, user));
37 return pageRows(rows, (l) => l.id, args);
38 }
3439 if (action !== "create" && action !== "update" && action !== "remove")
3540 return bad("invalid", "action is list, create, update or remove");
3641 // The API asks for an admin token on both routes; so does this.
src/tui/screens/labels.ts +25 −9
@@ -1,15 +1,17 @@
11 /**
22 * The Labels command, a repository's (#7841): each label the repository
33 * defines, as the issue screen draws it — the colour chip, then its hex —
4 * read with `listLabels`, name order. No counts. Offered to, and
5 * read for, only someone who may read the repository.
4 * read with `listLabels`, name order. A repository admin also sees how many
5 * open issues and pulls carry each (`openLabelCounts`: `N open` / `unused`),
6 * and the delete confirm names that count. Offered to, and read for, only
7 * someone who may read the repository.
68 */
79
810 import { LABEL_WORDS } from "../../actions/label_words.ts";
911 import { requireRepoAdmin } from "../../actions/access.ts";
1012 import { createLabel, deleteLabel, updateLabel } from "../../actions/repos.ts";
1113 import { canRead, type Repo } from "../../core/repos.ts";
12 import { listLabels, type Label } from "../../core/issues.ts";
14 import { listLabels, openLabelCounts, type Label } from "../../core/issues.ts";
1315 import type { Ctx, Screen } from "../app/app.ts";
1416 import { binding } from "../app/keys.ts";
1517 import type { Command } from "../app/palette.ts";
@@ -28,11 +30,19 @@ export const GONE_LINE = "That label is already gone, or you can no longer delet
2830 export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
2931 let labels: Label[] | undefined;
3032 let hidden = false;
33 /** Open items per label id; empty for a viewer who is not an admin. */
34 let counts = new Map<string, number>();
3135 const state = new ListState();
3236 let note = "";
3337 // Only whoever `requireRepoAdmin` admits is offered `n`.
3438 const admin = (): boolean => requireRepoAdmin(f.app, f.user, repo).ok;
3539
40 const reload = (): void => {
41 labels = listLabels(f.db, repo.id);
42 counts = admin() ? openLabelCounts(f.db, repo.id) : new Map();
43 };
44 const usage = (l: Label): string => ((counts.get(l.id) ?? 0) > 0 ? `${counts.get(l.id)} open` : "unused");
45
3646 /** "name hex": the last word is the colour; a leading `#` is allowed. */
3747 const parse = (entry: string): { name: string; hex: string } | undefined => {
3848 const words = entry.trim().split(/\s+/);
@@ -47,7 +57,7 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4757 if (got === undefined) return LABEL_WORDS.shape;
4858 const made = createLabel(f.app, f.user, repo, got.name, got.hex);
4959 if (!made.ok) return refusal(made.error);
50 labels = listLabels(f.db, repo.id);
60 reload();
5161 ctx.invalidate();
5262 return undefined;
5363 };
@@ -63,7 +73,7 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
6373 if (got === undefined) return LABEL_WORDS.shape;
6474 const done = updateLabel(f.app, f.user, repo, picked.id, got.name, got.hex);
6575 if (!done.ok) return done.error === "not_found" && admin() ? LABEL_WORDS.gone : refusal(done.error);
66 labels = listLabels(f.db, repo.id);
76 reload();
6777 ctx.invalidate();
6878 return undefined;
6979 },
@@ -74,13 +84,17 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
7484 ctx.push(
7585 confirmScreen(ctx, screen, {
7686 title: "Delete a label",
77 body: [`Delete the label ${picked.name}? Issues carrying it lose it.`],
87 body: [
88 admin() && (counts.get(picked.id) ?? 0) > 0
89 ? `Delete the label ${picked.name}? ${counts.get(picked.id)} open carry it.`
90 : `Delete the label ${picked.name}? Issues carrying it lose it.`,
91 ],
7892 actions: [{ label: "Keep", variant: "ghost" }, { label: "Delete", variant: "danger" }],
7993 onChoose: (i) => {
8094 if (i < 1) return;
8195 note = deleteLabel(f.app, f.user, repo, picked.id).ok ? DELETED_LINE : GONE_LINE;
82 labels = listLabels(f.db, repo.id);
83 state.clamp(labels.length);
96 reload();
97 state.clamp(labels?.length ?? 0);
8498 ctx.invalidate();
8599 },
86100 }),
@@ -95,7 +109,8 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
95109 async load() {
96110 // Gated here as well as on the command: the screen is exported.
97111 hidden = !canRead(f.db, repo, f.user);
98 labels = hidden ? [] : listLabels(f.db, repo.id);
112 if (hidden) labels = [];
113 else reload();
99114 ctx.invalidate();
100115 },
101116 render(area, s) {
@@ -119,6 +134,7 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
119134 s.text(area.x, area.y + i, picked ? ">" : " ", style(c.accent, c.bg), right);
120135 const x = labelChip(s, area.x + 1, area.y + i, c, { text: l.name, color: l.color }, right);
121136 s.text(x + 2, area.y + i, `#${l.color.replace(/^#/, "")}`, style(picked ? c.fg : c.muted, c.bg), right);
137 if (admin()) s.text(x + 10, area.y + i, usage(l), style(c.muted, c.bg), right);
122138 }
123139 },
124140 key(e) {
src/web/pages/labels.ts +12 −3
@@ -7,7 +7,7 @@
77
88 import { LABEL_WORDS } from "../../actions/label_words.ts";
99 import { createLabel, deleteLabel, updateLabel } from "../../actions/repos.ts";
10 import { listLabels, type Label } from "../../core/issues.ts";
10 import { listLabels, openLabelCounts, type Label } from "../../core/issues.ts";
1111 import { notePhase, timePhase } from "../../core/route_timing.ts";
1212 import type { Ctx } from "../../http/context.ts";
1313 import { repoBase, repoNotFound } from "../chrome.ts";
@@ -39,7 +39,14 @@ function colorField(value: string, label: string): Html {
3939 >`;
4040 }
4141
42 function labelEditor(base: string, label: Label): Html {
42 /** `N open`, a link to the issues list narrowed to the label; `unused` at 0. */
43 function usage(base: string, label: Label, open: number): Html {
44 return open > 0
45 ? html`<a class="label-open" href="${base}/issues?label=${encodeURIComponent(label.name)}">${open} open</a>`
46 : html`<span class="muted">unused</span>`;
47 }
48
49 function labelEditor(base: string, label: Label, open: number): Html {
4350 return html`<li>
4451 <form
4552 class="edit-row"
@@ -47,6 +54,7 @@ function labelEditor(base: string, label: Label): Html {
4754 action="${base}/settings/labels/${label.id}"
4855 >
4956 <span class="preview">${labelChip(label)}</span>
57 ${usage(base, label, open)}
5058 <input
5159 class="input"
5260 type="text"
@@ -70,6 +78,7 @@ export function labelsPage(ctx: Ctx): Response {
7078 const repo = timedAdminRepo(ctx);
7179 if (!repo) return repoNotFound(ctx);
7280 const labels = timePhase(ctx.request, "labels", () => listLabels(ctx.app.db, repo.id));
81 const counts = timePhase(ctx.request, "counts", () => openLabelCounts(ctx.app.db, repo.id));

The terminal labels screen (src/tui/screens/labels.ts) needs the same counts; reuse openLabelCounts there. Both doors are required.

7382 const rendering = performance.now();
7483 const base = repoBase(repo);
7584
@@ -82,7 +91,7 @@ export function labelsPage(ctx: Ctx): Response {
8291 })
8392 : html`<div class="card">
8493 <ul class="list">
85 ${labels.map((l) => labelEditor(base, l))}
94 ${labels.map((l) => labelEditor(base, l, counts.get(l.id) ?? 0))}
8695 </ul>
8796 </div>`;
8897
src/web/pages/search.ts +17 −6
@@ -14,6 +14,7 @@ import { strangerCodeSearch } from "../../http/strangers.ts";
1414 import { search } from "../../actions/search.ts";
1515 import { envsFor } from "../../apps/runtime.ts";
1616 import type { AppRecord } from "../../core/apps.ts";
17 import { openLabelCounts } from "../../core/issues.ts";
1718 import { getPull } from "../../core/pulls.ts";
1819 import { canAdmin, canWrite, enterableApps, getRepo } from "../../core/repos.ts";
1920 import { getMirrorTarget } from "../../core/migrate_mirror.ts";
@@ -639,12 +640,22 @@ const DESTRUCTIVE: {
639640 },
640641 {
641642 pattern: /^\/([^/]+)\/([^/]+)\/settings\/labels\/([^/]+)\/delete$/,
642 describe: (m) => ({
643 verb: "Delete label",
644 title: "Delete this label?",
645 detail: `It is removed from every issue and pull request in ${m[1]}/${m[2]} that carries it. The issues themselves are untouched.`,
646 phrase: "",
647 }),
643 describe: (m, ctx) => {
644 // Only a writer is told the count, so this page reads no private repository's work.
645 const repo = getRepo(ctx.app.db, m[1]!, m[2]!);
646 const n =
647 repo && canWrite(ctx.app.db, repo, ctx.auth.user)
648 ? (openLabelCounts(ctx.app.db, repo.id).get(m[3]!) ?? 0)
649 : 0;
650 const carried = n === 1 ? "item carries" : "items carry";
651 const count = n > 0 ? `${n} open ${carried} it. ` : "";
652 return {
653 verb: "Delete label",
654 title: "Delete this label?",
655 detail: `${count}It is removed from every issue and pull request in ${m[1]}/${m[2]} that carries it. The issues themselves are untouched.`,
656 phrase: "",
657 };
658 },
648659 },
649660 {
650661 pattern: /^\/boards\/([^/]+)\/delete$/,
src/web/style/components.ts +3
@@ -473,6 +473,8 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); }
473473 .edit-row > .preview { flex: none; }
474474 .edit-row > .input { flex: 1 1 160px; width: auto; min-width: 0; }
475475 .edit-row .btn { flex: none; }
476 .edit-row .label-open { color: var(--muted-fg); font-size: 13px; text-decoration: underline; text-underline-offset: 2px; }
477 .edit-row .label-open:hover { color: var(--fg); }
476478 /* A swatch and its hex, as one control-shaped thing. */
477479 .color-field {
478480 display: inline-flex; align-items: center; gap: 8px; flex: none;
@@ -494,6 +496,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); }
494496 .edit-row { align-items: stretch; }
495497 .edit-row > .input { flex-basis: 100%; }
496498 .edit-row .color-field, .edit-row .btn { flex: 1 1 auto; justify-content: center; }
499 .edit-row .label-open { display: inline-flex; align-items: center; min-height: 44px; padding: 0 8px; }
497500 }
498501
499502 /* ---- table ---- */
test/labels-page-counts.test.ts added +96
@@ -0,0 +1,96 @@
1 /**
2 * Settings › Labels says how many open issues carry each label, links to the
3 * list narrowed to it, and the delete confirm names the count.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { createIssue, createLabel, openLabelCounts, setIssueLabels, setIssueState } from "../src/core/issues.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { mcpTool } from "./helpers/mcp.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { adminCookie } from "./helpers/admin.ts";
15
16 describe("labels page counts", () => {
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 let cookie = "";
21 let repoId = "";
22 let bugId = "";
23 let idleId = "";
24
25 beforeAll(async () => {
26 dataDir = await mkdtemp(join(tmpdir(), "forge-label-counts-"));
27 app = createApp({ dataDir, port: 0 });
28 servers = await serve(app);
29 cookie = await adminCookie(app, { username: "olive", email: "[email protected]", password: "label-counts-password" });
30 await fetch(`${servers.url}/new`, {
31 method: "POST",
32 headers: { cookie },
33 body: new URLSearchParams({ name: "proj", visibility: "private", description: "" }),
34 redirect: "manual",
35 });
36 const repo = app.db.query<{ id: string; author: string }, []>("SELECT id, owner_id AS author FROM repo").get()!;
37 repoId = repo.id;
38 const bug = createLabel(app.db, repoId, "bug fix", "d73a4a");
39 const idle = createLabel(app.db, repoId, "idle", "6b7280");
40 if (!bug.ok || !idle.ok) throw new Error("labels");
41 bugId = bug.value.id;
42 idleId = idle.value.id;
43 for (const title of ["a", "b", "c"]) {
44 const made = createIssue(app.db, { repoId, authorId: repo.author, title });
45 if (!made.ok) throw new Error(made.error);
46 setIssueLabels(app.db, made.value.id, [bugId]);
47 if (title === "c") setIssueState(app.db, made.value.id, "closed");
48 }
49 }, 30_000);
50
51 afterAll(async () => {
52 servers.stop();
53 app.close();
54 await rm(dataDir, { recursive: true, force: true });
55 });
56
57 test("openLabelCounts counts open items only, one entry per used label", () => {
58 const counts = openLabelCounts(app.db, repoId);
59 expect(counts.get(bugId)).toBe(2);
60 expect(counts.has(idleId)).toBe(false);
61 });
62
63 test("the page says 2 open as a link, and unused for the other", async () => {
64 const res = await fetch(`${servers.url}/olive/proj/settings/labels`, { headers: { cookie } });
65 const text = await res.text();
66 expect(text).toContain("2 open");
67 expect(text).toContain('href="/olive/proj/issues?label=bug%20fix"');
68 expect(text).toContain("unused");
69 expect(res.headers.get("server-timing")).toContain("counts");
70 });
71
72 test("the delete confirm names the count, and says nothing for an unused label", async () => {
73 const confirm = async (id: string) =>
74 (
75 await fetch(
76 `${servers.url}/-/confirm?to=${encodeURIComponent(`/olive/proj/settings/labels/${id}/delete`)}`,
77 { headers: { cookie } },
78 )
79 ).text();
80 expect(await confirm(bugId)).toContain("2 open items carry it.");
81 expect(await confirm(idleId)).not.toContain("carry it.");
82 });
83
84 test("both doors list each label with its open count, for a writer", async () => {
85 const owner = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'olive'").get()!;
86 const token = createToken(app.db, owner.id, "t", "ro").token;
87 const res = await fetch(`${servers.url}/api/v1/repos/olive/proj/labels`, {
88 headers: { authorization: `Bearer ${token}` },
89 });
90 const api = ((await res.json()) as any).items as Array<{ id: string; open: number }>;
91 expect(api.find((l) => l.id === bugId)?.open).toBe(2);
92 expect(api.find((l) => l.id === idleId)?.open).toBe(0);
93 const mcp = await mcpTool(servers.url, token, "repo_labels", { repo: "olive/proj" });
94 expect(mcp.value.items.find((l: any) => l.id === bugId).open).toBe(2);
95 });
96 });
test/mcp-labels-update.test.ts +3 −3
@@ -78,12 +78,12 @@ describe("MCP repo_labels update", () => {
7878 says: "Label “defect” updated.",
7979 label: { id, name: "defect", color: "0e8a16" },
8080 });
81 expect(await listed()).toEqual([{ id, name: "defect", color: "0e8a16" }]);
81 expect(await listed()).toEqual([{ id, open: 0, name: "defect", color: "0e8a16" }]);
8282 // Either field alone keeps the other's value.
8383 await call(admin, { repo: R, action: "update", id, name: "Defect" });
84 expect(await listed()).toEqual([{ id, name: "Defect", color: "0e8a16" }]);
84 expect(await listed()).toEqual([{ id, open: 0, name: "Defect", color: "0e8a16" }]);
8585 await call(admin, { repo: R, action: "update", id, color: "aabbcc" });
86 expect(await listed()).toEqual([{ id, name: "Defect", color: "aabbcc" }]);
86 expect(await listed()).toEqual([{ id, open: 0, name: "Defect", color: "aabbcc" }]);
8787 });
8888
8989 test("a taken name, a bad colour and a missing label are refused in the page's words", async () => {
test/mcp-repo-labels.test.ts +2 −1
@@ -100,7 +100,8 @@ describe("MCP repo_labels", () => {
100100 expect(c.error).toContain("not_found");
101101 const r = await call(bob, { repo: R, action: "remove", id: before[0].id });
102102 expect(r.error).toContain("not_found");
103 expect((await call(bob, { repo: R })).value.items).toEqual(before);
103 // A reader sees the same labels, without the writer-only `open` count.
104 expect((await call(bob, { repo: R })).value.items).toEqual(before.map(({ open: _open, ...l }: any) => l));
104105 expect((await call(adminRw, { repo: R, action: "create", name: "no" })).error).toContain("admin token");
105106 expect((await call(admin, { repo: R, action: "remove", id: "nope" })).error).toContain("not_found");
106107 });
test/tui-labels.test.ts +19
@@ -378,3 +378,22 @@ test("an admin demoted under the open form is told so and the label is unchanged
378378 expect(text).not.toContain(LABEL_WORDS.gone);
379379 expect(listLabels(app.db, r.value.id).map((l) => l.name)).toEqual(["keep"]);
380380 });
381
382 test("an admin sees N open / unused per label and the delete confirm names the count; a reader sees neither", async () => {
383 const r = await createRepo(app, alice, { name: "counted", visibility: "public" });
384 if (!r.ok) throw new Error("repo");
385 const repo = r.value;
386 const used = createLabel(app.db, repo.id, "used", "112233");
387 createLabel(app.db, repo.id, "idle", "445566");
388 const made = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: "t" });
389 if (!used.ok || !made.ok) throw new Error("setup");
390 setIssueLabels(app.db, made.value.id, [used.value.id]);
391 const admin = await screenText(repo, alice);
392 expect(admin).toContain("1 open");
393 expect(admin).toContain("unused");
394 const asked = await screenText(repo, alice, ["j", "d"]);
395 expect(asked).toContain("1 open carry it");
396 const reader = await screenText(repo);
397 expect(reader).not.toContain("1 open");
398 expect(reader).not.toContain("unused");
399 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs