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

The terminal's crew hand-overs screen opens a parked-twice issue with Enter, and `r` reads the list again #7420

Merged opened this

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

Merged

scout/forge:scout/7415-the-terminal-s-crew-hand-overs-screen-op → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/7415-the-terminal-s-crew-hand-overs-screen-op was merged into main as b501895.

Review

Sign in to review this change.
3 files changed +165 −8
README.md +3 −3
@@ -36,9 +36,9 @@ terminal's palette as "Crew scorecard per agent"; both are a forge admin's only.
3636 Settings → Crew also shows, each only when it has something, a "Handed to the
3737 planner" card (issues the crew parked twice: repo, issue link, when last
3838 parked, why) and a "Restore drill" card (the last drill's pass or fail and
39 when). The terminal's palette has the same as "Crew hand-overs", a read-only
40 list of the same rows (repo#number, title, reasons; it does not open the
41 issue — use the link on the page). Both are a forge admin's only.
39 when). The terminal's palette has the same as "Crew hand-overs", a list of the
40 same rows (repo#number, title, reasons): Enter opens a parked row's issue and
41 `r` reads the list again. Both are a forge admin's only.
4242
4343 `/boards` carries a Held files card for anyone who administers a repository (a forge admin sees every repository's): the holds of the repositories you administer, a form to hold another path, Release on each row, and "Showing 200 of N" when the list is cut. The user menu links Boards to such a person. In the terminal, a bare line on the Held files prompt lists the same holds.
4444
src/tui/screens/crewhandover.ts +34 −5
@@ -4,6 +4,7 @@
44 * (`crewHandover`) — each section only when it has something to say.
55 */
66
7 import { getRepo } from "../../core/repos.ts";
78 import { crewHandover } from "../../crew/handed.ts";
89 import type { Ctx, Screen } from "../app/app.ts";
910 import { binding } from "../app/keys.ts";
@@ -11,14 +12,21 @@ import { style } from "../term/style.ts";
1112 import { truncate } from "../term/width.ts";
1213 import type { Session } from "../session.ts";
1314 import { fill, len, split } from "../ui/layout.ts";
15 import { issueScreen } from "./issue.ts";
16 import { local } from "./where.ts";
1417 import { ListState, drawList, type ListRow } from "./common.ts";
1518
1619 export function crewHandoverScreen(f: Session, ctx: Ctx): Screen {
1720 const state = new ListState();
1821 const allowed = f.user?.isAdmin === true;
19 // Read once when the screen opens, not per frame.
20 const { parked, drill } = allowed ? crewHandover(f.db) : { parked: [], drill: undefined };
21 const rows: ListRow[] = [
22 let parked: ReturnType<typeof crewHandover>["parked"] = [];

Unrequested behaviour change with no test that fails without it. Revert it, or test it with an admin agent session.

23 let drill: ReturnType<typeof crewHandover>["drill"];
24 const read = (): void => {
25 ({ parked, drill } = allowed ? crewHandover(f.db) : { parked: [], drill: undefined });
26 };
27 // Read when the screen opens and on `r`, not per frame.
28 read();
29 const build = (): ListRow[] => [
2230 ...parked.map((r) => ({
2331 title: `Handed to the planner: ${r.repo}#${r.number} ${r.title}`,
2432 secondary: `parked ${r.at.slice(0, 16).replace("T", " ")} UTC`,
@@ -34,9 +42,17 @@ export function crewHandoverScreen(f: Session, ctx: Ctx): Screen {
3442 },
3543 ]),
3644 ];
45 let rows = build();
46 const open = (): void => {
47 const p = parked[state.index];
48 if (!allowed || p === undefined) return;
49 const [owner, name] = p.repo.split("/");
50 const repo = owner === undefined || name === undefined ? undefined : getRepo(f.db, owner, name);
51 if (repo !== undefined) ctx.push(issueScreen(f, ctx, local(f, repo), p.number));
52 };
3753 return {
3854 title: "Crew hand-overs",
39 keys: [binding(["j", "k"], "move cursor")],
55 keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "open"), binding(["r"], "refresh")],
4056 render(area, s) {
4157 const c = ctx.theme.c;
4258 const [head, list] = split(area, "v", [len(1), fill(1)]);
@@ -48,6 +64,19 @@ export function crewHandoverScreen(f: Session, ctx: Ctx): Screen {
4864 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
4965 drawList(s, list!, c, state, rows);
5066 },
51 key: (e) => state.key(e, rows.length),
67 key(e) {
68 if (e.ctrl || e.alt) return state.key(e, rows.length);
69 if (e.key === "enter") {
70 open();
71 return true;
72 }
73 if (e.key === "r") {
74 read();
75 rows = build();
76 state.clamp(rows.length);
77 return true;
78 }
79 return state.key(e, rows.length);
80 },
5281 };
5382 }
test/tui-crewhandover-open.test.ts added +128
@@ -0,0 +1,128 @@
1 /** The crew hand-overs screen opens a parked-twice issue with Enter and reads again on `r`. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { createUser, getUserByName } from "../src/core/users.ts";
4 import type { Ctx } from "../src/tui/app/app.ts";
5 import { crewHandoverScreen } from "../src/tui/screens/crewhandover.ts";
6 import type { Session } from "../src/tui/session.ts";
7 import { createForgeApp } from "../src/tui/forge.ts";
8 import { headlessTerminal } from "../src/tui/term/terminal.ts";
9 import { engine, tick, type Engine } from "./helpers/parks-engine.ts";
10 import { quietLogs } from "./helpers/globals.ts";
11 import { settleApp } from "./helpers/tui.ts";
12
13 quietLogs();
14
15 let e: Engine;
16
17 function boot(username: string) {
18 const user = getUserByName(e.app.db, username)!;
19 const { app } = createForgeApp(
20 e.app,
21 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
22 { terminal: headlessTerminal(140, 32), exitHooks: false },
23 );
24 app.start();
25 const key = async (k: string, ctrl = false) => {
26 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
27 await settleApp(app);
28 };
29 return { app, key, text: () => app.screen.toText() };
30 }
31
32 async function crewScreen(username: string) {
33 const t = boot(username);
34 await t.key("k", true);
35 for (const ch of "Crew hand-overs") await t.key(ch);
36 await t.key("enter");
37 return t;
38 }
39
40 beforeAll(async () => {
41 e = await engine("forge-handover-open-");
42 const made = await createUser(e.app.db, {
43 origin: "seed",
44 trusted: true,
45 username: "bob",
46 email: "[email protected]",
47 password: "a-good-passphrase",
48 adminIfFirst: false,
49 });
50 if (!made.ok) throw new Error(made.error);
51 });
52 afterAll(() => e.close());
53
54 test("Enter opens the parked issue, r reads again, a non-admin gets nothing", async () => {
55 const t = await crewScreen("alice");
56 expect(t.text()).toContain("Nothing handed to the planner");
57 await t.key("enter");
58 expect(t.app.screen.toText()).toContain("Crew hand-overs");
59
60 const work = e.issue("Scene graph");
61 expect(e.ended(work, "no changes pushed")).toBe(true);
62 await tick();
63 expect(e.ended(work, "nothing to propose")).toBe(true);
64 await tick();
65 expect(t.text()).not.toContain("Handed to the planner: ");
66 await t.key("r");
67 expect(t.text()).toContain("Handed to the planner: alice/engine#" + work.number);
68 expect(t.text()).toMatch(/enter\s+open/);
69 expect(t.text()).toMatch(/r\s+refresh/);
70
71 await t.key("enter");
72 expect(t.text()).toContain("Scene graph");
73 expect(t.text()).not.toContain("Handed to the planner: ");
74 t.app.stop();
75
76 // Bob has no palette command for it; built directly, the screen has no rows to open.
77 const b = await crewScreen("bob");
78 expect(b.text()).not.toContain("Crew hand-overs");
79 b.app.stop();
80 const pushed: unknown[] = [];
81 const screen = crewHandoverScreen(
82 { db: e.app.db, app: e.app, user: getUserByName(e.app.db, "bob")! } as unknown as Session,
83 { push: (s: unknown) => pushed.push(s) } as unknown as Ctx,
84 );
85 screen.key({ type: "key", key: "enter", ctrl: false, alt: false, shift: false });
86 expect(pushed).toEqual([]);
87 });

Add cases for Enter on the drill row (pushes nothing) and for r clamping the cursor after the list shrinks.

88
89 test("Enter on the drill row opens nothing, and r clamps the cursor when the list shrinks", async () => {
90 const { setIssueState } = await import("../src/actions/issues.ts");
91 const { recordDrill } = await import("../src/snapshot/drills.ts");
92 const keep = e.issue("Keeper");
93 const gone = e.issue("Goner");
94 for (const w of [keep, gone]) {
95 expect(e.ended(w, "a")).toBe(true);
96 await tick();
97 expect(e.ended(w, "b")).toBe(true);
98 await tick();
99 }
100 recordDrill(e.app.db, {
101 at: new Date().toISOString(),
102 snapshotId: "s1",
103 ms: 10,
104 counts: { users: 1, repositories: 1, issues: 1, pulls: 0, apps: 0 },
105 health: "ok",
106 verdict: "green",
107 failedStep: null,
108 phases: [],
109 budgetMs: 0,
110 reason: "x",
111 });
112 const t = await crewScreen("alice");
113 const rows = (t.text().match(/Handed to the planner: /g) ?? []).length;
114 expect(rows).toBeGreaterThanOrEqual(2);
115 // Cursor to the last row, the drill: Enter stays on this screen.
116 await t.key("G");
117 await t.key("enter");
118 expect(t.text()).toContain("Restore drill: passed");
119 expect(t.text()).toContain("Handed to the planner: ");
120 // Close the issue the cursor row stands past, then r: the cursor lands on a row that exists.
121 setIssueState(e.app, e.alice, e.fresh(), gone, "closed");
122 await t.key("r");
123 expect((t.text().match(/Handed to the planner: /g) ?? []).length).toBe(rows - 1);
124 await t.key("k");
125 await t.key("enter");
126 expect(t.text()).not.toContain("Restore drill: passed");
127 t.app.stop();
128 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs