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

Merged opened this

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

Merged

torque/forge:torque/7868-an-admin-renames-or-recolours-a-label-fr → main 2 commits fork 2 threads · 2 unresolved

  • Merged. torque/7868-an-admin-renames-or-recolours-a-label-fr was merged into main as 0832c27.

Review

Sign in to review this change.
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):
149149 one opens the run's log; a run on work you may not see is left out),
150150 *Tell the crew*, *Report a problem* (one text, an issue
151151 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*
153153 (docs/VISION.md on the default branch: the purpose, each pillar numbered,
154154 the rules, through the one read MCP `vision` makes; when there is none the
155155 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 @@
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. Read-only, no counts. Offered to, and
4 * read with `listLabels`, name order. No counts. Offered to, and
55 * read for, only someone who may read the repository.
66 */
77
88 import { LABEL_WORDS } from "../../actions/label_words.ts";
99 import { requireRepoAdmin } from "../../actions/access.ts";
10 import { createLabel, deleteLabel } from "../../actions/repos.ts";
10 import { createLabel, deleteLabel, updateLabel } from "../../actions/repos.ts";
1111 import { canRead, type Repo } from "../../core/repos.ts";
1212 import { listLabels, type Label } from "../../core/issues.ts";
1313 import type { Ctx, Screen } from "../app/app.ts";
@@ -20,7 +20,7 @@ import type { Session } from "../session.ts";
2020
2121 /** What a viewer who may not read the repository sees: what the page's 404 says. */
2222 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.";
2424 export const NO_LABELS_LINE = "No labels yet.";
2525 export const DELETED_LINE = "Deleted that label.";
2626 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 {
3434 const admin = (): boolean => requireRepoAdmin(f.app, f.user, repo).ok;
3535
3636 /** "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 => {
3838 const words = entry.trim().split(/\s+/);
3939 const hex = (words.pop() ?? "").replace(/^#/, "");
4040 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);
4450 labels = listLabels(f.db, repo.id);
4551 ctx.invalidate();
4652 return undefined;
4753 };
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",

rename also recolours; edit matches the key and the title. Optional.

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 not_found && admin() and demoted paths are untested, and the not-allowed line says "make" for an edit. Add tests for both and fix the wording.

69 },
70 }),
71 );
72 };
4873 const ask = (picked: Label): void => {
4974 ctx.push(
5075 confirmScreen(ctx, screen, {
@@ -65,7 +90,7 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
6590 title: "Labels",
6691 // Read each time: a demoted admin stops being offered `n`.
6792 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")];
6994 },
7095 async load() {
7196 // 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 {
103128 else if (e.key === "esc" || e.key === "q") ctx.pop();
104129 else if (e.key === "d" && !e.ctrl && !e.alt && !hidden && admin() && labels?.[state.index] !== undefined) {
105130 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]!);
106133 } else if (e.key === "n" && !e.ctrl && !e.alt && !hidden && admin()) {
107134 ctx.push(
108135 promptScreen(ctx, screen, {
test/tui-labels.test.ts +99 −1
@@ -6,7 +6,7 @@ import { join } from "node:path";
66 import { createRepo, deleteLabel } from "../src/actions/repos.ts";
77 import { createApp, type App } from "../src/app.ts";
88 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";
1010 import type { Repo } from "../src/core/repos.ts";
1111 import { createUser, type User } from "../src/core/users.ts";
1212 import { createForgeApp, Session } from "../src/tui/forge.ts";
@@ -280,3 +280,101 @@ test("a reader is not offered d and d deletes nothing", async () => {
280280 expect(screen.key!({ type: "key", key: "d", ctrl: false, alt: false, shift: false } as never)).toBe(false);
281281 expect(labelsScreen(new Session(app, { ...guest, user: alice }), ctx, open).keys.some((k) => k.keys.includes("d"))).toBe(true);
282282 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs