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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7866-each-label-on-settings-labels-says-how-m → main 7 commits fork 1 thread · 1 unresolved
13 files
changed +232
−28
docs/MCP.md +1 −1
| @@ -145,7 +145,7 @@ for a tunnel and a forge that sleeps. | ||
| 145 | 145 | | `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 | | |
| 146 | 146 | | `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 | | |
| 147 | 147 | | `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 | | |
| 149 | 149 | | `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 | |
| 150 | 150 | | `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 | |
| 151 | 151 | | `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 | ||
| 25 | 25 | - `.preview` — the label chip at the head of a label's edit row on a repository's Labels settings, kept from |
| 26 | 26 | shrinking. Written by `labelEditor()` in `src/web/pages/labels.ts`; styled in `src/web/style/components.ts` as |
| 27 | 27 | `.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[] { | ||
| 860 | 860 | .all(repoId); |
| 861 | 861 | } |
| 862 | 862 | |
| 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 | ||
| 863 | 898 | export function setIssueLabels( |
| 864 | 899 | db: Database, |
| 865 | 900 | issueId: string, |
src/http/api/labels.ts +6 −2
| @@ -6,8 +6,9 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { createLabel, deleteLabel, updateLabel } from "../../actions/repos.ts"; |
| 8 | 8 | import { LABEL_WORDS } from "../../actions/label_words.ts"; |
| 9 | import { listLabels } from "../../core/issues.ts"; | |
| 9 | import { labelsWithOpen, listLabels } from "../../core/issues.ts"; | |
| 10 | 10 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 11 | import { canWrite } from "../../core/repos.ts"; | |
| 11 | 12 | import { loadRepo, pageOf, pageParams } from "../api.ts"; |
| 12 | 13 | import type { Handler } from "../context.ts"; |
| 13 | 14 | import { body, json, notFound, requireScope, respondAccess, str } from "../respond.ts"; |
| @@ -20,7 +21,10 @@ export function labelsRoutes(router: Router<Handler>): void { | ||
| 20 | 21 | if (!repo) return notFound(); |
| 21 | 22 | const page = pageParams(ctx); |
| 22 | 23 | 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 | ); | |
| 24 | 28 | return timePhase(ctx.request, "render", () => pageOf(rows, page)); |
| 25 | 29 | }); |
| 26 | 30 |
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"; | |
| 2 | 3 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 3 | 4 | import { createLabel, deleteLabel, updateLabel } from "../../../actions/repos.ts"; |
| 4 | 5 | import { LABEL_WORDS } from "../../../actions/label_words.ts"; |
| @@ -7,7 +8,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 7 | 8 | |
| 8 | 9 | export const tool: Tool = { |
| 9 | 10 | 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", | |
| 11 | 12 | description: |
| 12 | 13 | "Repo labels: list (default), create, update, remove (admin token).", |
| 13 | 14 | inputSchema: { |
| @@ -30,7 +31,11 @@ export const tool: Tool = { | ||
| 30 | 31 | const repo = repoArg(ctx, user, args); |
| 31 | 32 | if (isAnswer(repo)) return repo; |
| 32 | 33 | 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 | } | |
| 34 | 39 | if (action !== "create" && action !== "update" && action !== "remove") |
| 35 | 40 | return bad("invalid", "action is list, create, update or remove"); |
| 36 | 41 | // The API asks for an admin token on both routes; so does this. |
src/tui/screens/labels.ts +25 −9
| @@ -1,15 +1,17 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The Labels command, a repository's (#7841): each label the repository |
| 3 | 3 | * 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. | |
| 6 | 8 | */ |
| 7 | 9 | |
| 8 | 10 | import { LABEL_WORDS } from "../../actions/label_words.ts"; |
| 9 | 11 | import { requireRepoAdmin } from "../../actions/access.ts"; |
| 10 | 12 | import { createLabel, deleteLabel, updateLabel } from "../../actions/repos.ts"; |
| 11 | 13 | 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"; | |
| 13 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| 14 | 16 | import { binding } from "../app/keys.ts"; |
| 15 | 17 | 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 | ||
| 28 | 30 | export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { |
| 29 | 31 | let labels: Label[] | undefined; |
| 30 | 32 | 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>(); | |
| 31 | 35 | const state = new ListState(); |
| 32 | 36 | let note = ""; |
| 33 | 37 | // Only whoever `requireRepoAdmin` admits is offered `n`. |
| 34 | 38 | const admin = (): boolean => requireRepoAdmin(f.app, f.user, repo).ok; |
| 35 | 39 | |
| 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 | ||
| 36 | 46 | /** "name hex": the last word is the colour; a leading `#` is allowed. */ |
| 37 | 47 | const parse = (entry: string): { name: string; hex: string } | undefined => { |
| 38 | 48 | const words = entry.trim().split(/\s+/); |
| @@ -47,7 +57,7 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 47 | 57 | if (got === undefined) return LABEL_WORDS.shape; |
| 48 | 58 | const made = createLabel(f.app, f.user, repo, got.name, got.hex); |
| 49 | 59 | if (!made.ok) return refusal(made.error); |
| 50 | labels = listLabels(f.db, repo.id); | |
| 60 | reload(); | |
| 51 | 61 | ctx.invalidate(); |
| 52 | 62 | return undefined; |
| 53 | 63 | }; |
| @@ -63,7 +73,7 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 63 | 73 | if (got === undefined) return LABEL_WORDS.shape; |
| 64 | 74 | const done = updateLabel(f.app, f.user, repo, picked.id, got.name, got.hex); |
| 65 | 75 | if (!done.ok) return done.error === "not_found" && admin() ? LABEL_WORDS.gone : refusal(done.error); |
| 66 | labels = listLabels(f.db, repo.id); | |
| 76 | reload(); | |
| 67 | 77 | ctx.invalidate(); |
| 68 | 78 | return undefined; |
| 69 | 79 | }, |
| @@ -74,13 +84,17 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 74 | 84 | ctx.push( |
| 75 | 85 | confirmScreen(ctx, screen, { |
| 76 | 86 | 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 | ], | |
| 78 | 92 | actions: [{ label: "Keep", variant: "ghost" }, { label: "Delete", variant: "danger" }], |
| 79 | 93 | onChoose: (i) => { |
| 80 | 94 | if (i < 1) return; |
| 81 | 95 | 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); | |
| 84 | 98 | ctx.invalidate(); |
| 85 | 99 | }, |
| 86 | 100 | }), |
| @@ -95,7 +109,8 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 95 | 109 | async load() { |
| 96 | 110 | // Gated here as well as on the command: the screen is exported. |
| 97 | 111 | hidden = !canRead(f.db, repo, f.user); |
| 98 | labels = hidden ? [] : listLabels(f.db, repo.id); | |
| 112 | if (hidden) labels = []; | |
| 113 | else reload(); | |
| 99 | 114 | ctx.invalidate(); |
| 100 | 115 | }, |
| 101 | 116 | render(area, s) { |
| @@ -119,6 +134,7 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 119 | 134 | s.text(area.x, area.y + i, picked ? ">" : " ", style(c.accent, c.bg), right); |
| 120 | 135 | const x = labelChip(s, area.x + 1, area.y + i, c, { text: l.name, color: l.color }, right); |
| 121 | 136 | 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); | |
| 122 | 138 | } |
| 123 | 139 | }, |
| 124 | 140 | key(e) { |
src/web/pages/labels.ts +12 −3
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { LABEL_WORDS } from "../../actions/label_words.ts"; |
| 9 | 9 | 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"; | |
| 11 | 11 | import { notePhase, timePhase } from "../../core/route_timing.ts"; |
| 12 | 12 | import type { Ctx } from "../../http/context.ts"; |
| 13 | 13 | import { repoBase, repoNotFound } from "../chrome.ts"; |
| @@ -39,7 +39,14 @@ function colorField(value: string, label: string): Html { | ||
| 39 | 39 | >`; |
| 40 | 40 | } |
| 41 | 41 | |
| 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 { | |
| 43 | 50 | return html`<li> |
| 44 | 51 | <form |
| 45 | 52 | class="edit-row" |
| @@ -47,6 +54,7 @@ function labelEditor(base: string, label: Label): Html { | ||
| 47 | 54 | action="${base}/settings/labels/${label.id}" |
| 48 | 55 | > |
| 49 | 56 | <span class="preview">${labelChip(label)}</span> |
| 57 | ${usage(base, label, open)} | |
| 50 | 58 | <input |
| 51 | 59 | class="input" |
| 52 | 60 | type="text" |
| @@ -70,6 +78,7 @@ export function labelsPage(ctx: Ctx): Response { | ||
| 70 | 78 | const repo = timedAdminRepo(ctx); |
| 71 | 79 | if (!repo) return repoNotFound(ctx); |
| 72 | 80 | 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)); | |
| 73 | 82 | const rendering = performance.now(); |
| 74 | 83 | const base = repoBase(repo); |
| 75 | 84 | |
| @@ -82,7 +91,7 @@ export function labelsPage(ctx: Ctx): Response { | ||
| 82 | 91 | }) |
| 83 | 92 | : html`<div class="card"> |
| 84 | 93 | <ul class="list"> |
| 85 | ${labels.map((l) => labelEditor(base, l))} | |
| 94 | ${labels.map((l) => labelEditor(base, l, counts.get(l.id) ?? 0))} | |
| 86 | 95 | </ul> |
| 87 | 96 | </div>`; |
| 88 | 97 |
src/web/pages/search.ts +17 −6
| @@ -14,6 +14,7 @@ import { strangerCodeSearch } from "../../http/strangers.ts"; | ||
| 14 | 14 | import { search } from "../../actions/search.ts"; |
| 15 | 15 | import { envsFor } from "../../apps/runtime.ts"; |
| 16 | 16 | import type { AppRecord } from "../../core/apps.ts"; |
| 17 | import { openLabelCounts } from "../../core/issues.ts"; | |
| 17 | 18 | import { getPull } from "../../core/pulls.ts"; |
| 18 | 19 | import { canAdmin, canWrite, enterableApps, getRepo } from "../../core/repos.ts"; |
| 19 | 20 | import { getMirrorTarget } from "../../core/migrate_mirror.ts"; |
| @@ -639,12 +640,22 @@ const DESTRUCTIVE: { | ||
| 639 | 640 | }, |
| 640 | 641 | { |
| 641 | 642 | 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 | }, | |
| 648 | 659 | }, |
| 649 | 660 | { |
| 650 | 661 | pattern: /^\/boards\/([^/]+)\/delete$/, |
src/web/style/components.ts +3
| @@ -473,6 +473,8 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 473 | 473 | .edit-row > .preview { flex: none; } |
| 474 | 474 | .edit-row > .input { flex: 1 1 160px; width: auto; min-width: 0; } |
| 475 | 475 | .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); } | |
| 476 | 478 | /* A swatch and its hex, as one control-shaped thing. */ |
| 477 | 479 | .color-field { |
| 478 | 480 | display: inline-flex; align-items: center; gap: 8px; flex: none; |
| @@ -494,6 +496,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 494 | 496 | .edit-row { align-items: stretch; } |
| 495 | 497 | .edit-row > .input { flex-basis: 100%; } |
| 496 | 498 | .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; } | |
| 497 | 500 | } |
| 498 | 501 | |
| 499 | 502 | /* ---- 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", () => { | ||
| 78 | 78 | says: "Label “defect” updated.", |
| 79 | 79 | label: { id, name: "defect", color: "0e8a16" }, |
| 80 | 80 | }); |
| 81 | expect(await listed()).toEqual([{ id, name: "defect", color: "0e8a16" }]); | |
| 81 | expect(await listed()).toEqual([{ id, open: 0, name: "defect", color: "0e8a16" }]); | |
| 82 | 82 | // Either field alone keeps the other's value. |
| 83 | 83 | 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" }]); | |
| 85 | 85 | 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" }]); | |
| 87 | 87 | }); |
| 88 | 88 | |
| 89 | 89 | 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", () => { | ||
| 100 | 100 | expect(c.error).toContain("not_found"); |
| 101 | 101 | const r = await call(bob, { repo: R, action: "remove", id: before[0].id }); |
| 102 | 102 | 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)); | |
| 104 | 105 | expect((await call(adminRw, { repo: R, action: "create", name: "no" })).error).toContain("admin token"); |
| 105 | 106 | expect((await call(admin, { repo: R, action: "remove", id: "nope" })).error).toContain("not_found"); |
| 106 | 107 | }); |
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 | ||
| 378 | 378 | expect(text).not.toContain(LABEL_WORDS.gone); |
| 379 | 379 | expect(listLabels(app.db, r.value.id).map((l) => l.name)).toEqual(["keep"]); |
| 380 | 380 | }); |
| 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 | }); |
new above
offline
The terminal labels screen (src/tui/screens/labels.ts) needs the same counts; reuse openLabelCounts there. Both doors are required.