Public: anyone can read this; the crew can change it.
An admin renames or recolours a label from the terminal's Labels screen with `e` #8017
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7868-an-admin-renames-or-recolours-a-label-fr → main 2 commits fork 2 threads · 2 unresolved
3 files
changed +135
−10
docs/APPS.md +1 −1
| @@ -149,7 +149,7 @@ Under the box, four starter sentences are plain links (no script): | ||
| 149 | 149 | one opens the run's log; a run on work you may not see is left out), |
| 150 | 150 | *Tell the crew*, *Report a problem* (one text, an issue |
| 151 | 151 | titled by its first line, through `openIssueThrough`) and *Make it yours* |
| 152 | (for those who cannot write the repository: `Y` on the repository screen, or the palette row, asks for a name, defaulting to the original's, and a visibility, `tab` to switch, then shows the plan and copies only after a confirm, and the copy opens), *Labels* (its labels with their hex; an admin presses `n`, types a name and six hex digits, `bug d73a4a`, the `#` optional, and the list shows the new label at once — `d` on a label asks first, naming it, with Keep the default, then deletes it; a reader is offered neither `n` nor `d`), and *Vision* | |
| 152 | (for those who cannot write the repository: `Y` on the repository screen, or the palette row, asks for a name, defaulting to the original's, and a visibility, `tab` to switch, then shows the plan and copies only after a confirm, and the copy opens), *Labels* (its labels with their hex; an admin presses `n`, types a name and six hex digits, `bug d73a4a`, the `#` optional, and the list shows the new label at once — `e` on a label opens the same form prefilled with its name and colour, and saving renames or recolours it (a refusal shows on the form and keeps what was typed); `d` on a label asks first, naming it, with Keep the default, then deletes it; a reader is offered none of `n`, `e` or `d`), and *Vision* | |
| 153 | 153 | (docs/VISION.md on the default branch: the purpose, each pillar numbered, |
| 154 | 154 | the rules, through the one read MCP `vision` makes; when there is none the |
| 155 | 155 | terminal says so in one line of its own, where MCP answers an empty `order`; |
src/tui/screens/labels.ts +35 −8
| @@ -1,13 +1,13 @@ | ||
| 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. Read-only, no counts. Offered to, and | |
| 4 | * read with `listLabels`, name order. No counts. Offered to, and | |
| 5 | 5 | * read for, only someone who may read the repository. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { LABEL_WORDS } from "../../actions/label_words.ts"; |
| 9 | 9 | import { requireRepoAdmin } from "../../actions/access.ts"; |
| 10 | import { createLabel, deleteLabel } from "../../actions/repos.ts"; | |
| 10 | import { createLabel, deleteLabel, updateLabel } from "../../actions/repos.ts"; | |
| 11 | 11 | import { canRead, type Repo } from "../../core/repos.ts"; |
| 12 | 12 | import { listLabels, type Label } from "../../core/issues.ts"; |
| 13 | 13 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -20,7 +20,7 @@ import type { Session } from "../session.ts"; | ||
| 20 | 20 | |
| 21 | 21 | /** What a viewer who may not read the repository sees: what the page's 404 says. */ |
| 22 | 22 | export const NOT_FOUND_LINE = "No such repository"; |
| 23 | export const NOT_ALLOWED_LINE = "Only a repository admin can make a label."; | |
| 23 | export const NOT_ALLOWED_LINE = "Only a repository admin can change a label."; | |
| 24 | 24 | export const NO_LABELS_LINE = "No labels yet."; |
| 25 | 25 | export const DELETED_LINE = "Deleted that label."; |
| 26 | 26 | export const GONE_LINE = "That label is already gone, or you can no longer delete it."; |
| @@ -34,17 +34,42 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 34 | 34 | const admin = (): boolean => requireRepoAdmin(f.app, f.user, repo).ok; |
| 35 | 35 | |
| 36 | 36 | /** "name hex": the last word is the colour; a leading `#` is allowed. */ |
| 37 | const make = (entry: string): string | undefined => { | |
| 37 | const parse = (entry: string): { name: string; hex: string } | undefined => { | |
| 38 | 38 | const words = entry.trim().split(/\s+/); |
| 39 | 39 | const hex = (words.pop() ?? "").replace(/^#/, ""); |
| 40 | 40 | const name = words.join(" "); |
| 41 | if (name === "" || !/^[0-9a-fA-F]{6}$/.test(hex)) return LABEL_WORDS.shape; | |
| 42 | const made = createLabel(f.app, f.user, repo, name, hex.toLowerCase()); | |
| 43 | if (!made.ok) return made.error === "taken" ? LABEL_WORDS.taken : made.error === "invalid" ? LABEL_WORDS.shape : NOT_ALLOWED_LINE; | |
| 41 | return name === "" || !/^[0-9a-fA-F]{6}$/.test(hex) ? undefined : { name, hex: hex.toLowerCase() }; | |
| 42 | }; | |
| 43 | const refusal = (error: string): string => | |
| 44 | error === "taken" ? LABEL_WORDS.taken : error === "invalid" ? LABEL_WORDS.shape : NOT_ALLOWED_LINE; | |
| 45 | const make = (entry: string): string | undefined => { | |
| 46 | const got = parse(entry); | |
| 47 | if (got === undefined) return LABEL_WORDS.shape; | |
| 48 | const made = createLabel(f.app, f.user, repo, got.name, got.hex); | |
| 49 | if (!made.ok) return refusal(made.error); | |
| 44 | 50 | labels = listLabels(f.db, repo.id); |
| 45 | 51 | ctx.invalidate(); |
| 46 | 52 | return undefined; |
| 47 | 53 | }; |
| 54 | /** The same form as `n`, prefilled with the label's name and colour. */ | |
| 55 | const rename = (picked: Label): void => { | |
| 56 | ctx.push( | |
| 57 | promptScreen(ctx, screen, { | |
| 58 | title: "Edit label", | |
| 59 | label: "name and six hex digits (bug d73a4a)", | |
| 60 | value: `${picked.name} ${picked.color.replace(/^#/, "")}`, | |
| 61 | onSubmit: (entry) => { | |
| 62 | const got = parse(entry); | |
| 63 | if (got === undefined) return LABEL_WORDS.shape; | |
| 64 | const done = updateLabel(f.app, f.user, repo, picked.id, got.name, got.hex); | |
| 65 | if (!done.ok) return done.error === "not_found" && admin() ? LABEL_WORDS.gone : refusal(done.error); | |
| 66 | labels = listLabels(f.db, repo.id); | |
| 67 | ctx.invalidate(); | |
| 68 | return undefined; | |
outdated
The |
||
| 69 | }, | |
| 70 | }), | |
| 71 | ); | |
| 72 | }; | |
| 48 | 73 | const ask = (picked: Label): void => { |
| 49 | 74 | ctx.push( |
| 50 | 75 | confirmScreen(ctx, screen, { |
| @@ -65,7 +90,7 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 65 | 90 | title: "Labels", |
| 66 | 91 | // Read each time: a demoted admin stops being offered `n`. |
| 67 | 92 | get keys() { |
| 68 | return [binding(["j", "k"], "move"), ...(admin() ? [binding(["n"], "new label"), binding(["d"], "delete label")] : []), binding(["esc", "q"], "back")]; | |
| 93 | return [binding(["j", "k"], "move"), ...(admin() ? [binding(["n"], "new label"), binding(["e"], "edit label"), binding(["d"], "delete label")] : []), binding(["esc", "q"], "back")]; | |
| 69 | 94 | }, |
| 70 | 95 | async load() { |
| 71 | 96 | // Gated here as well as on the command: the screen is exported. |
| @@ -103,6 +128,8 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 103 | 128 | else if (e.key === "esc" || e.key === "q") ctx.pop(); |
| 104 | 129 | else if (e.key === "d" && !e.ctrl && !e.alt && !hidden && admin() && labels?.[state.index] !== undefined) { |
| 105 | 130 | ask(labels[state.index]!); |
| 131 | } else if (e.key === "e" && !e.ctrl && !e.alt && !hidden && admin() && labels?.[state.index] !== undefined) { | |
| 132 | rename(labels[state.index]!); | |
| 106 | 133 | } else if (e.key === "n" && !e.ctrl && !e.alt && !hidden && admin()) { |
| 107 | 134 | ctx.push( |
| 108 | 135 | promptScreen(ctx, screen, { |
test/tui-labels.test.ts +99 −1
| @@ -6,7 +6,7 @@ import { join } from "node:path"; | ||
| 6 | 6 | import { createRepo, deleteLabel } from "../src/actions/repos.ts"; |
| 7 | 7 | import { createApp, type App } from "../src/app.ts"; |
| 8 | 8 | import { LABEL_WORDS } from "../src/actions/label_words.ts"; |
| 9 | import { createLabel, listLabels } from "../src/core/issues.ts"; | |
| 9 | import { createIssue, labelsFor, setIssueLabels, createLabel, listLabels } from "../src/core/issues.ts"; | |
| 10 | 10 | import type { Repo } from "../src/core/repos.ts"; |
| 11 | 11 | import { createUser, type User } from "../src/core/users.ts"; |
| 12 | 12 | import { createForgeApp, Session } from "../src/tui/forge.ts"; |
| @@ -280,3 +280,101 @@ test("a reader is not offered d and d deletes nothing", async () => { | ||
| 280 | 280 | expect(screen.key!({ type: "key", key: "d", ctrl: false, alt: false, shift: false } as never)).toBe(false); |
| 281 | 281 | expect(labelsScreen(new Session(app, { ...guest, user: alice }), ctx, open).keys.some((k) => k.keys.includes("d"))).toBe(true); |
| 282 | 282 | }); |
| 283 | ||
| 284 | async function editing(repo: Repo, user: User | undefined, keys: string[]) { | |
| 285 | const terminal = headlessTerminal(100, 30); | |
| 286 | const { app: tui, session } = createForgeApp(app, user ? { ...guest, user } : guest, { terminal, exitHooks: false }); | |
| 287 | tui.start(); | |
| 288 | tui.ctx.push(labelsScreen(session, tui.ctx, repo)); | |
| 289 | await settleApp(tui); | |
| 290 | for (const k of keys) tui.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 291 | await settleApp(tui); | |
| 292 | const text = tui.screen.toText(); | |
| 293 | tui.stop?.(); | |
| 294 | return text; | |
| 295 | } | |
| 296 | ||
| 297 | const BACK = Array(20).fill("backspace"); | |
| 298 | ||
| 299 | test("e opens the form prefilled; an admin renames and recolours, and the issue still carries it", async () => { | |
| 300 | const r = await createRepo(app, alice, { name: "editing", visibility: "public" }); | |
| 301 | if (!r.ok) throw new Error("repo"); | |
| 302 | const label = createLabel(app.db, r.value.id, "zeta", "123456"); | |
| 303 | if (!label.ok) throw new Error("label"); | |
| 304 | const issue = createIssue(app.db, { repoId: r.value.id, authorId: alice.id, title: "carries it" }); | |
| 305 | if (!issue.ok) throw new Error("issue"); | |
| 306 | setIssueLabels(app.db, issue.value.id, [label.value.id]); | |
| 307 | const mid = await editing(r.value, alice, ["e"]); | |
| 308 | expect(mid).toContain("zeta 123456"); | |
| 309 | const text = await editing(r.value, alice, ["e", ...BACK, ..."alpha #00ff00", "enter"]); | |
| 310 | expect(text).toContain("alpha"); | |
| 311 | expect(text).toContain("#00ff00"); | |
| 312 | expect(text).not.toContain("zeta"); | |
| 313 | const after = listLabels(app.db, r.value.id); | |
| 314 | expect(after.map((l) => [l.id, l.name, l.color])).toEqual([[label.value.id, "alpha", "00ff00"]]); | |
| 315 | expect(labelsFor(app.db, issue.value.id).map((l) => l.name)).toEqual(["alpha"]); | |
| 316 | }); | |
| 317 | ||
| 318 | test("a taken name is refused in place and keeps what was typed", async () => { | |
| 319 | const text = await editing(open, alice, ["e", ...BACK, ..."docs 111111", "enter"]); | |
| 320 | expect(text).toContain(LABEL_WORDS.taken); | |
| 321 | expect(text).toContain("docs 111111"); | |
| 322 | expect(listLabels(app.db, open.id).find((l) => l.name === "bug")?.color).toBe("d73a4a"); | |
| 323 | }); | |
| 324 | ||
| 325 | test("a reader is not offered e and e opens nothing", async () => { | |
| 326 | const stranger = new Session(app, guest); | |
| 327 | const ctx = { invalidate() {}, push() { throw new Error("pushed"); } } as unknown as Ctx; | |
| 328 | const screen = labelsScreen(stranger, ctx, open); | |
| 329 | expect(screen.keys.some((k) => k.keys.includes("e"))).toBe(false); | |
| 330 | await screen.load!(new AbortController().signal); | |
| 331 | expect(screen.key!({ type: "key", key: "e", ctrl: false, alt: false, shift: false } as never)).toBe(false); | |
| 332 | expect(labelsScreen(new Session(app, { ...guest, user: alice }), ctx, open).keys.some((k) => k.keys.includes("e"))).toBe(true); | |
| 333 | }); | |
| 334 | ||
| 335 | test("a label deleted under the open form says it is gone and changes nothing", async () => { | |
| 336 | const r = await createRepo(app, alice, { name: "vanish", visibility: "public" }); | |
| 337 | if (!r.ok) throw new Error("repo"); | |
| 338 | const label = createLabel(app.db, r.value.id, "ghost", "123456"); | |
| 339 | if (!label.ok) throw new Error("label"); | |
| 340 | const terminal = headlessTerminal(100, 30); | |
| 341 | const { app: tui, session } = createForgeApp(app, { ...guest, user: alice }, { terminal, exitHooks: false }); | |
| 342 | tui.start(); | |
| 343 | tui.ctx.push(labelsScreen(session, tui.ctx, r.value)); | |
| 344 | await settleApp(tui); | |
| 345 | const press = (key: string) => tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 346 | press("e"); | |
| 347 | press("backspace"); | |
| 348 | press("9"); | |
| 349 | deleteLabel(app, alice, r.value, label.value.id); | |
| 350 | press("enter"); | |
| 351 | await settleApp(tui); | |
| 352 | const text = tui.screen.toText(); | |
| 353 | tui.stop?.(); | |
| 354 | expect(text).toContain(LABEL_WORDS.gone); | |
| 355 | expect(text).not.toContain(NOT_ALLOWED_LINE); | |
| 356 | expect(listLabels(app.db, r.value.id)).toEqual([]); | |
| 357 | }); | |
| 358 | ||
| 359 | test("an admin demoted under the open form is told so and the label is unchanged", async () => { | |
| 360 | const r = await createRepo(app, alice, { name: "demoted", visibility: "public" }); | |
| 361 | if (!r.ok) throw new Error("repo"); | |
| 362 | createLabel(app.db, r.value.id, "keep", "123456"); | |
| 363 | const terminal = headlessTerminal(100, 30); | |
| 364 | const { app: tui, session } = createForgeApp(app, { ...guest, user: alice }, { terminal, exitHooks: false }); | |
| 365 | tui.start(); | |
| 366 | tui.ctx.push(labelsScreen(session, tui.ctx, r.value)); | |
| 367 | await settleApp(tui); | |
| 368 | const press = (key: string) => tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 369 | press("e"); | |
| 370 | press("backspace"); | |
| 371 | press("9"); | |
| 372 | (session as { user: User | undefined }).user = undefined; | |
| 373 | press("enter"); | |
| 374 | await settleApp(tui); | |
| 375 | const text = tui.screen.toText(); | |
| 376 | tui.stop?.(); | |
| 377 | expect(text).toContain(NOT_ALLOWED_LINE); | |
| 378 | expect(text).not.toContain(LABEL_WORDS.gone); | |
| 379 | expect(listLabels(app.db, r.value.id).map((l) => l.name)).toEqual(["keep"]); | |
| 380 | }); |
new above
offline
renamealso recolours;editmatches the key and the title. Optional.