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

An admin makes a label from the terminal's Labels screen with `n` #7942

Merged opened this

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

Merged

scout/forge:scout/7842-an-admin-makes-a-label-from-the-terminal → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/7842-an-admin-makes-a-label-from-the-terminal was merged into main as 7242cd2.

Review

Sign in to review this change.
3 files changed +123 −6
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* (a read-only list of its labels with their hex), 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 — a reader is not offered `n`), 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 −3
@@ -5,27 +5,49 @@
55 * read for, only someone who may read the repository.
66 */
77
8 import { LABEL_WORDS } from "../../actions/label_words.ts";
9 import { requireRepoAdmin } from "../../actions/access.ts";
10 import { createLabel } from "../../actions/repos.ts";
811 import { canRead, type Repo } from "../../core/repos.ts";
912 import { listLabels, type Label } from "../../core/issues.ts";
1013 import type { Ctx, Screen } from "../app/app.ts";
1114 import { binding } from "../app/keys.ts";
1215 import type { Command } from "../app/palette.ts";
1316 import { style } from "../term/style.ts";
14 import { ListState } from "./common.ts";
17 import { ListState, promptScreen } from "./common.ts";
1518 import { labelChip } from "../ui/widgets.ts";
1619 import type { Session } from "../session.ts";
1720
1821 /** What a viewer who may not read the repository sees: what the page's 404 says. */
1922 export const NOT_FOUND_LINE = "No such repository";
23 export const NOT_ALLOWED_LINE = "Only a repository admin can make a label.";
2024 export const NO_LABELS_LINE = "No labels yet.";
2125
2226 export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
2327 let labels: Label[] | undefined;
2428 let hidden = false;
2529 const state = new ListState();
26 return {
30 // Only whoever `requireRepoAdmin` admits is offered `n`.
31 const admin = (): boolean => requireRepoAdmin(f.app, f.user, repo).ok;
outdated

Admin is evaluated once here; the key bar goes stale if the role changes. Recompute on load/render.

32
33 /** "name hex": the last word is the colour; a leading `#` is allowed. */
34 const make = (entry: string): string | undefined => {
35 const words = entry.trim().split(/\s+/);
36 const hex = (words.pop() ?? "").replace(/^#/, "");
37 const name = words.join(" ");
38 if (name === "" || !/^[0-9a-fA-F]{6}$/.test(hex)) return LABEL_WORDS.shape;

Every error other than taken becomes the 'needs a name and hex colour' text; not_found/denied is misreported as bad input. Map each error to its own message.

39 const made = createLabel(f.app, f.user, repo, name, hex.toLowerCase());
40 if (!made.ok) return made.error === "taken" ? LABEL_WORDS.taken : made.error === "invalid" ? LABEL_WORDS.shape : NOT_ALLOWED_LINE;
41 labels = listLabels(f.db, repo.id);
42 ctx.invalidate();
43 return undefined;

Non-blocking: the long nested ternary on one line is hard to read. A small map from error to words would read better.

44 };
45 const screen: Screen = {
2746 title: "Labels",
28 keys: [binding(["j", "k"], "move"), binding(["esc", "q"], "back")],
47 // Read each time: a demoted admin stops being offered `n`.
48 get keys() {
49 return [binding(["j", "k"], "move"), ...(admin() ? [binding(["n"], "new label")] : []), binding(["esc", "q"], "back")];
50 },
2951 async load() {
3052 // Gated here as well as on the command: the screen is exported.
3153 hidden = !canRead(f.db, repo, f.user);
@@ -53,11 +75,21 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
5375 if (e.key === "j" || e.key === "down") state.move(1, labels?.length ?? 0);
5476 else if (e.key === "k" || e.key === "up") state.move(-1, labels?.length ?? 0);
5577 else if (e.key === "esc" || e.key === "q") ctx.pop();
78 else if (e.key === "n" && !e.ctrl && !e.alt && !hidden && admin()) {
79 ctx.push(
80 promptScreen(ctx, screen, {
81 title: "New label",
82 label: "name and six hex digits (bug d73a4a)",
83 onSubmit: make,
84 }),
85 );
86 }
5687 else return false;
5788 ctx.invalidate();
5889 return true;
5990 },
6091 };
92 return screen;
6193 }
6294
6395 /** The palette's door on a repository screen, for whoever may read it. */
test/tui-labels.test.ts +87 −2
@@ -5,12 +5,13 @@ import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { createRepo } from "../src/actions/repos.ts";
77 import { createApp, type App } from "../src/app.ts";
8 import { createLabel } from "../src/core/issues.ts";
8 import { LABEL_WORDS } from "../src/actions/label_words.ts";
9 import { createLabel, listLabels } from "../src/core/issues.ts";
910 import type { Repo } from "../src/core/repos.ts";
1011 import { createUser, type User } from "../src/core/users.ts";
1112 import { createForgeApp, Session } from "../src/tui/forge.ts";
1213 import type { Ctx } from "../src/tui/app/app.ts";
13 import { labelsCommands, labelsScreen, NOT_FOUND_LINE, NO_LABELS_LINE } from "../src/tui/screens/labels.ts";
14 import { labelsCommands, labelsScreen, NOT_ALLOWED_LINE, NOT_FOUND_LINE, NO_LABELS_LINE } from "../src/tui/screens/labels.ts";
1415 import { repoScreen } from "../src/tui/screens/repo.ts";
1516 import { local } from "../src/tui/screens/where.ts";
1617 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -135,3 +136,87 @@ test("j moves a visible > marker and scrolls later labels into view", async () =
135136 expect(text).not.toContain("l00");
136137 expect(text.split("\n").find((l) => l.includes("l39"))).toContain(">");
137138 });
139
140 /** label.created events on the bus for a repository: how web viewers hear of the write. */
141 const created = (repo: Repo): number =>
142 app.db
143 .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM event WHERE repo_id = ?1 AND kind = 'label.created'")
144 .get(repo.id)!.n;
145
146 async function typed(repo: Repo, user: User | undefined, entry: string): Promise<string> {
147 const terminal = headlessTerminal(100, 30);
148 const { app: tui, session } = createForgeApp(app, user ? { ...guest, user } : guest, { terminal, exitHooks: false });
149 tui.start();
150 tui.ctx.push(labelsScreen(session, tui.ctx, repo));
151 await settleApp(tui);
152 const press = (key: string) => tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
153 press("n");
154 for (const ch of entry) press(ch);
155 press("enter");
156 await settleApp(tui);
157 const text = tui.screen.toText();
158 tui.stop?.();
159 return text;
160 }
161
162 test("an admin makes a label with n and the list shows it at once", async () => {
163 const before = created(bare);
164 const text = await typed(bare, alice, "bug d73a4a");
165 expect(created(bare)).toBe(before + 1);
166 expect(text).toContain("#d73a4a");
167 expect(text).not.toContain(NO_LABELS_LINE);
168 expect(listLabels(app.db, bare.id).map((l) => l.name)).toEqual(["bug"]);
169 });
170
171 test("a bad colour shows the LABEL_WORDS text and makes nothing", async () => {
172 const text = await typed(open, alice, "wontfix zzz");
173 expect(text).toContain(LABEL_WORDS.shape);
174 expect(listLabels(app.db, open.id).map((l) => l.name)).not.toContain("wontfix");
175 });
176
177 test("a taken name says so and makes nothing", async () => {
178 const text = await typed(open, alice, "bug 111111");
179 expect(text).toContain(LABEL_WORDS.taken);
180 expect(listLabels(app.db, open.id).filter((l) => l.name === "bug")).toHaveLength(1);
181 });
182
183 test("a reader is not offered n and n makes nothing", async () => {
184 const stranger = new Session(app, guest);
185 const ctx = { invalidate() {}, push() { throw new Error("pushed"); } } as unknown as Ctx;
186 const screen = labelsScreen(stranger, ctx, open);
187 expect(screen.keys.some((k) => k.keys.includes("n"))).toBe(false);
188 await screen.load!(new AbortController().signal);
189 expect(screen.key!({ type: "key", key: "n", ctrl: false, alt: false, shift: false } as never)).toBe(false);
190 const admin = labelsScreen(new Session(app, { ...guest, user: alice }), ctx, open);
191 expect(admin.keys.some((k) => k.keys.includes("n"))).toBe(true);
192 });
193
194 test("a write by someone no longer admin is refused with its own words, not the shape text", async () => {
195 const terminal = headlessTerminal(100, 30);
196 const { app: tui, session } = createForgeApp(app, { ...guest, user: alice }, { terminal, exitHooks: false });
197 tui.start();
198 tui.ctx.push(labelsScreen(session, tui.ctx, many));
199 await settleApp(tui);
200 const before = created(many);
201 const press = (key: string) => tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
202 press("n");
203 for (const ch of "sneaky 123456") press(ch);
204 (session as { user: User | undefined }).user = undefined; // demoted mid-prompt
205 press("enter");
206 await settleApp(tui);
207 const text = tui.screen.toText();
208 tui.stop?.();
209 expect(text).toContain(NOT_ALLOWED_LINE);
210 expect(text).not.toContain(LABEL_WORDS.shape);
211 expect(listLabels(app.db, many.id).map((l) => l.name)).not.toContain("sneaky");
212 expect(created(many)).toBe(before);
213 // and the key bar follows the role
214 expect(labelsScreen(session, tui.ctx, many).keys.some((k) => k.keys.includes("n"))).toBe(false);
215 });
216
217 test("a # before the colour and a name of several words are accepted", async () => {
218 const text = await typed(bare, alice, "good first issue #7057ff");
219 expect(text).toContain("good first issue");
220 expect(text).toContain("#7057ff");
221 expect(listLabels(app.db, bare.id).map((l) => l.name)).toContain("good first issue");
222 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs