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

Today's card says what the decider did: closed N, deferred N, asked you about N #1983

Merged opened this

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

Merged

scout/forge:scout/1606-today-s-card-says-what-the-decider-did-c → main 6 commits fork

  • Merged. scout/1606-today-s-card-says-what-the-decider-did-c was merged into main as 5b8f1c9.

Review

Sign in to review this change.
10 files changed +590 −3
README.md +9
@@ -306,6 +306,15 @@ muted"). On the On you card: `space` marks a row, `x` closes the marked rows
306306 again to every muted repository. Each goes through `applyAsksBulk`, so the
307307 per-row access checks and refusals are the web's, shown in the toast.
308308
309 **What the decider did today.** Today's card on the board carries one line,
310 "The decider closed N, deferred N, asked you about N": each count links to
311 its ledger rows (`/board?ledger=closed|deferred|asked`; any other value is
312 ignored, `?apps=` is kept). A deferral is the decider's own `stop`, never the
313 rule's. The ledger view lists up to 200 rows and says when it left some out.
314 In the terminal the same line sits above the dashboard, `C` (or the palette's
315 Decider) opens the rows, and `1`, `2`, `3` switch pile. Both are for the
316 repositories you own only: a reader sees nothing.
317
309318 **Let the decider** answers a hand-over card (an issue the crew parked) when
310319 you would rather not: the third tap on the card, `POST /api/v1/asks/decide`
311320 (`{"item": "parked:<id>"}`), the MCP tool `asks_decide`, or `D` on the On you
scripts/gate.ts +2
@@ -64,6 +64,7 @@ export const GATE: readonly string[] = [
6464 "test/tui-onyou.test.ts",
6565 "test/tui-deploys.test.ts",
6666 "test/tui-access.test.ts",
67 "test/tui-decider.test.ts",
6768 "test/claim-link.test.ts",
6869 "test/og-card.test.ts",
6970 "test/og-route.test.ts",
@@ -231,6 +232,7 @@ export const GATE: readonly string[] = [
231232 "test/credentials.test.ts",
232233 "test/standing.test.ts",
233234 "test/board.test.ts",
235 "test/decider-day.test.ts",
234236 "test/spawn-plan.test.ts",
235237 "test/spawn-anon.test.ts",
236238 "test/spawn-host.test.ts",
src/core/decisions.ts +87
@@ -357,3 +357,90 @@ export function decisionsSince(
357357 .all(kind, since, Math.min(500, Math.max(1, limit)))
358358 .map(toDecision);
359359 }
360
361 /**
362 * What the decider did with a person's cards, in three piles: `closed`
363 * (a hand-over set aside), `deferred` (a pull the decider held back with a
364 * `stop`, until its check passes) and `asked` (a hand-over it left with the
365 * owner). A rule's `stop` is the crew's own, not the decider's.
366 */
367 export const DECIDER_PILES = ["closed", "deferred", "asked"] as const;
368 export type DeciderPile = (typeof DECIDER_PILES)[number];
369
370 const PILE_WHERE: Record<DeciderPile, string> = {
371 closed: "kind = 'hand' AND answer = 'set_aside' AND outcome = 'set_aside'",
372 deferred: "kind = 'retry' AND answer = 'stop' AND by != 'rule'",
373 asked: "kind = 'hand' AND answer = 'ask_owner'",
374 };
375
376 export function isDeciderPile(s: unknown): s is DeciderPile {
377 return DECIDER_PILES.includes(s as DeciderPile);
378 }
379
380 const inRepos = (n: number, from: number) =>
381 Array.from({ length: n }, (_, i) => `?${i + from}`).join(",");
382
383 /** Repository ids, once each, in batches small enough for one query — no owner's count is cut short. */
384 function repoBatches(repoIds: readonly string[], size = 400): string[][] {
385 const ids = [...new Set(repoIds)];
386 const out: string[][] = [];
387 for (let i = 0; i < ids.length; i += size) out.push(ids.slice(i, i + size));
388 return out;
389 }
390
391 /** How many decisions fall in each pile since a moment, across these repositories. */
392 export function deciderCounts(
393 db: Database,
394 repoIds: readonly string[],
395 since: string,
396 ): Record<DeciderPile, number> {
397 const out = { closed: 0, deferred: 0, asked: 0 };
398 for (const ids of repoBatches(repoIds))
399 for (const pile of DECIDER_PILES)
400 out[pile] +=
401 db
402 .query<{ n: number }, string[]>(
403 `SELECT COUNT(*) AS n FROM decision
404 WHERE created_at >= ?1 AND (${PILE_WHERE[pile]}) AND repo_id IN (${inRepos(ids.length, 2)})`,
405 )
406 .get(since, ...ids)?.n ?? 0;
407 return out;
408 }
409
410 /** The sentence for the counts, in plain words: "closed 4, deferred 2, asked you about 1". */
411 export function deciderWords(n: Record<DeciderPile, number>): string {
412 return `closed ${n.closed}, deferred ${n.deferred}, asked you about ${n.asked}`;
413 }
414
415 /** What an empty pile says, for a person. */
416 export const PILE_EMPTY: Record<DeciderPile, string> = {
417 closed: "The decider has closed nothing today.",
418 deferred: "The decider has held nothing back today.",
419 asked: "The decider has not asked you about anything today.",
420 };
421
422 /** The ledger rows behind one pile's count, newest first. */
423 export function listDeciderPile(
424 db: Database,
425 repoIds: readonly string[],
426 pile: DeciderPile,
427 since: string,
428 limit = 200,
429 ): Decision[] {
430 const cap = Math.min(500, Math.max(1, limit));
431 const rows: Decision[] = [];
432 for (const ids of repoBatches(repoIds))
433 rows.push(
434 ...db
435 .query<Row, (string | number)[]>(
436 `SELECT * FROM decision
437 WHERE created_at >= ?1 AND (${PILE_WHERE[pile]}) AND repo_id IN (${inRepos(ids.length, 3)})
438 ORDER BY created_at DESC, id DESC LIMIT ?2`,
439 )
440 .all(since, cap, ...ids)
441 .map(toDecision),
442 );
443 return rows
444 .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : a.id < b.id ? 1 : -1))
445 .slice(0, cap);
446 }
src/tui/forge.ts +8
@@ -30,6 +30,7 @@ import {
3030 } from "./screens/common.ts";
3131 import { boardsScreen } from "./screens/boards.ts";
3232 import { askedScreen } from "./screens/asked.ts";
33 import { deciderScreen } from "./screens/decider.ts";
3334 import { familyScreen } from "./screens/family.ts";
3435 import { familyLines, familyView } from "../spawn/family.ts";
3536 import { issueScreen } from "./screens/issue.ts";
@@ -179,6 +180,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
179180 hint: "your thoughts and what became of them",
180181 run: () => ctx.push(askedScreen(session, ctx)),
181182 });
183 if (session.signedIn)
184 commands.push({
185 id: "decider",
186 title: "Decider",
187 hint: "what it closed, deferred and asked you about today",
188 run: () => ctx.push(deciderScreen(session, ctx)),
189 });
182190 if (session.signedIn)
183191 commands.push(
184192 {
src/tui/screens/dashboard.ts +17
@@ -73,6 +73,7 @@ import {
7373 promptScreen,
7474 plural,
7575 } from "./common.ts";
76 import { deciderLine, deciderScreen } from "./decider.ts";
7677 import { issueScreen } from "./issue.ts";
7778 import { newRepoScreen } from "./newrepo.ts";
7879 import { pullScreen } from "./pull.ts";
@@ -126,6 +127,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
126127 let clientLines: string[] = [];
127128 /** The check lane, as the board's strip says it; null when nothing is queued. */
128129 let lane: string | null = null;
130 /** What the decider did today, the board's Today line; null on a quiet day or for a reader. */
131 let decider: string | null = null;
129132 const filter = new Filter();
130133 /** One cursor per pane: moving in one must never move another. */
131134 const cursors: Record<PaneId, ListState> = {
@@ -168,6 +171,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
168171 );
169172 forge = forgeNeeds(f.app, f.user);
170173 lane = forgeLane(f.app, f.user);
174 decider = deciderLine(f.app, f.user);
171175 // "Yours" is ownership plus the orgs you are on a team of; typing in the
172176 // filter reaches every repository you can see.
173177 const orgs = f.user
@@ -541,6 +545,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
541545 binding(["d"], "unpin", {
542546 disabled: focus !== "machine" || pins.length === 0,
543547 }),
548 binding(["C"], "decider", { disabled: !f.user }),
544549 binding(["n"], "new repo", { disabled: !f.signedIn }),
545550 binding(["A"], "account", { disabled: !f.signedIn }),
546551 binding(["s"], "sign in", { disabled: f.signedIn }),
@@ -574,6 +579,17 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
574579 );
575580 body = rest!;
576581 }
582 if (decider !== null) {
583 const [strip, rest] = split(body, "v", [len(1), fill(1)]);
584 s.text(
585 strip!.x,
586 strip!.y,
587 truncate(`${decider} C lists`, strip!.w),
588 style(c.mutedFg, c.bg),
589 strip!.x + strip!.w,
590 );
591 body = rest!;
592 }
577593 if (forge !== undefined) {
578594 const lines = forge.length === 0 ? ["All fine"] : forge.slice(0, 3);
579595 if (lane !== null) lines.push(`Lane: ${lane}`);
@@ -666,6 +682,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
666682 if (e.key === "u" && f.user) return undoSet(), true;
667683 if (e.key === "U" && f.user) return undoHand(), true;
668684 if (e.key === "L" && f.user) return listenAgain(), true;
685 if (e.key === "C" && f.user) return ctx.push(deciderScreen(f, ctx)), true;
669686 if (e.key === "P" && f.user) return showPeople(), true;
670687 if (f.user && e.key >= "1" && e.key <= String(KIND_CHIPS.length))
671688 return toggleChip(Number(e.key) - 1), true;
src/tui/screens/decider.ts added +122
@@ -0,0 +1,122 @@
1 /**
2 * What the decider did today, in the terminal: the board's Today line and the
3 * rows behind each count, from the same `deciderCounts` and `listDeciderPile`
4 * reads. Only repositories this person holds owner facts for are counted —
5 * the board's own gate — so a guest or a reader sees nothing.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { returnsRepos } from "../../actions/returns.ts";
10 import { startOfTodayUtc } from "../../crew/autonomy.ts";
11 import {
12 DECIDER_PILES,
13 PILE_EMPTY,
14 deciderCounts,
15 deciderWords,
16 listDeciderPile,
17 type DeciderPile,
18 } from "../../core/decisions.ts";
19 import { getIssuesByIds } from "../../core/issues.ts";
20 import { getReposByIds } from "../../core/repos.ts";
21 import type { User } from "../../core/users.ts";
22 import type { App } from "../../app.ts";
23 import type { Ctx, Screen } from "../app/app.ts";
24 import { binding } from "../app/keys.ts";
25 import type { Session } from "../session.ts";
26 import { fill, len, split } from "../ui/layout.ts";
27 import { emptyState } from "../ui/widgets.ts";
28 import { style } from "../term/style.ts";
29 import { truncate } from "../term/width.ts";
30 import { ListState, drawList, hitList, metaOf, openOnWeb, relTime, type ListRow } from "./common.ts";
31
32 const ROWS = 200;
33
34 export interface DeciderRow extends ListRow {
35 /** The web path `enter` opens. */
36 readonly path: string;
37 }
38
39 /** The repositories whose decisions this viewer may count: the board's ownerFacts. */
40 const owned = (app: App, viewer: User | undefined): string[] =>
41 viewer === undefined ? [] : returnsRepos(app, viewer).map((r) => r.id);
42
43 /** "The decider closed 4, deferred 2, asked you about 1"; null on a quiet day. */
44 export function deciderLine(app: App, viewer: User | undefined, now = new Date()): string | null {
45 const n = deciderCounts(app.db, owned(app, viewer), startOfTodayUtc(now).toISOString());
46 return n.closed + n.deferred + n.asked === 0 ? null : `The decider ${deciderWords(n)}.`;
47 }
48
49 /** The ledger rows behind one pile's count today, newest first. */
50 export function deciderRows(app: App, viewer: User | undefined, pile: DeciderPile, now = new Date()): DeciderRow[] {
51 const db: Database = app.db;
52 const ids = owned(app, viewer);
53 const decisions = listDeciderPile(db, ids, pile, startOfTodayUtc(now).toISOString(), ROWS);
54 const about = getIssuesByIds(db, decisions.flatMap((d) => (d.issueId === null ? [] : [d.issueId])));
55 const repos = getReposByIds(db, [...new Set(decisions.map((d) => d.repoId))]);
56 return decisions.flatMap((d) => {
57 const repo = repos.get(d.repoId);
58 if (repo === undefined) return [];
59 const issue = d.issueId === null ? undefined : about.get(d.issueId);
60 const base = `/${repo.ownerName}/${repo.name}`;
61 return [
62 {
63 title: (issue?.title ?? repo.name).replace(/[\u0000-\u001f\u007f-\u009f]+/g, " "),
64 secondary: `${repo.ownerName}/${repo.name}${issue ? ` #${issue.number}` : ""} · ${d.by === "rule" ? "by the rule" : `by ${d.by}`}`,
65 meta: metaOf(d.outcome ?? "", relTime(d.createdAt)),
66 path: issue === undefined ? base : `${base}/${issue.kind === "pull" ? "pulls" : "issues"}/${issue.number}`,
67 },
68 ];
69 });
70 }
71
72 export function deciderScreen(f: Session, ctx: Ctx, first: DeciderPile = "closed"): Screen {
73 const state = new ListState();
74 let pile: DeciderPile = first;
75 let rows: DeciderRow[] = [];
76 let line: string | null = null;
77 const read = (): void => {
78 line = deciderLine(f.app, f.user);
79 rows = deciderRows(f.app, f.user, pile);
80 state.clamp(rows.length);
81 };
82 const pick = (next: DeciderPile): void => {
83 pile = next;
84 state.index = 0;
85 read();
86 ctx.invalidate();
87 };
88 const open = (index: number): void => {
89 const row = rows[index];
90 if (row !== undefined) openOnWeb(f, ctx, row.path, "That card");
91 };
92 return {
93 title: "Decider",
94 keys: [
95 binding(["1", "2", "3"], "closed · deferred · asked"),
96 binding(["j", "k"], "move"),
97 binding(["enter"], "open"),
98 binding(["r"], "refresh"),
99 ],
100 load: async () => read(),
101 render(area, s) {
102 const c = ctx.theme.c;
103 const [head, body] = split(area, "v", [len(1), fill(1)]);
104 const sentence = `${line ?? "The decider has done nothing today."} [${pile}]`;
105 s.text(head!.x, head!.y, truncate(sentence, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
106 if (rows.length === 0) {
107 emptyState(s, body!, c, { title: PILE_EMPTY[pile], body: "Press 1, 2 or 3 for another pile." });
108 return;
109 }
110 drawList(s, body!, c, state, rows);
111 hitList(ctx, body!, state, rows.length, { open });
112 },
113 key(e) {
114 const i = Number(e.key) - 1;
115 if (Number.isInteger(i) && i >= 0 && i < DECIDER_PILES.length) return pick(DECIDER_PILES[i]!), true;
116 if (state.key(e, rows.length)) return true;
117 if (e.key === "enter") return open(state.index), true;
118 if (e.key === "r") return read(), ctx.invalidate(), true;
119 return false;
120 },
121 };
122 }
src/web/pages/board.ts +55 −2
@@ -22,7 +22,12 @@ import { tokenReturns } from "../../core/returns.ts";
2222 import { needLine } from "../../actions/health.ts";
2323 import { returnsRepos } from "../../actions/returns.ts";
2424 import {
25 deciderCounts,
26 PILE_EMPTY,
27 isDeciderPile,
2528 listDecisionsForRepos,
29 listDeciderPile,
30 type DeciderPile,
2631 type Decision,
2732 type DecisionKind,
2833 } from "../../core/decisions.ts";
@@ -848,6 +853,15 @@ function handContextLine(d: Decision): Html {
848853 </p>`;
849854 }
850855
856 /** The most rows a ledger view lists; past it the view says how many it left out. */
857 const LEDGER_ROWS = 200;
858
859 const PILE_LEAD: Record<DeciderPile, string> = {
860 closed: "What the decider closed today.",
861 deferred: "What the decider held back today.",
862 asked: "What the decider asked you about today.",
863 };
864
851865 function decided(ctx: Ctx, scope: BoardScope): Html {
852866 const db = ctx.app.db;
853867 // The decision ledger is the owner's own record of what the crew chose —
@@ -855,7 +869,15 @@ function decided(ctx: Ctx, scope: BoardScope): Html {
855869 const ids = scope.repos
856870 .filter((r) => scope.ownerFacts.has(r.id))
857871 .map((r) => r.id);
858 const decisions = listDecisionsForRepos(db, ids, 12);
872 // `?ledger=closed|deferred|asked` is the rows behind a count on Today's line.
873 const asked = ctx.url.searchParams.get("ledger");
874 const pile = isDeciderPile(asked) ? asked : null;
875 const since = startOfTodayUtc(new Date()).toISOString();
876 const decisions =
877 pile === null
878 ? listDecisionsForRepos(db, ids, 12)
879 : listDeciderPile(db, ids, pile, since, LEDGER_ROWS);
880 const total = pile === null ? 0 : deciderCounts(db, ids, since)[pile];
859881 const about = getIssuesByIds(
860882 db,
861883 decisions.map((d) => d.issueId).filter((id): id is string => id !== null),
@@ -910,12 +932,42 @@ function decided(ctx: Ctx, scope: BoardScope): Html {
910932 "Decided",
911933 "run.*,pull.*",
912934 rows,
913 "Nothing decided yet.",
935 pile === null ? "Nothing decided yet." : PILE_EMPTY[pile],
936 pile === null
937 ? null
938 : html`<p class="muted small board-context">
939 ${PILE_LEAD[pile]}${total > decisions.length
940 ? ` Showing the newest ${decisions.length} of ${total}.`
941 : ""}
942 <a href="${boardHref(ctx, null)}#decided">All decisions</a>
943 </p>`,
914944 );
915945 }
916946
917947 // ---- today ----
918948
949 /** The board's own address, keeping `?apps=`, with a ledger pile (or none) in `?ledger=`. */
950 function boardHref(ctx: Ctx, pile: DeciderPile | null): string {
951 const q = new URLSearchParams();
952 const apps = ctx.url.searchParams.get("apps");
953 if (apps) q.set("apps", apps);
954 if (pile !== null) q.set("ledger", pile);
955 const s = q.toString();
956 return s === "" ? "/board" : `/board?${s}`;
957 }
958
959 /** "The decider closed 4, deferred 2, asked you about 1", each count a link to its ledger rows; null on a quiet day. */
960 function deciderLine(ctx: Ctx, scope: BoardScope, since: string): Html | null {
961 const n = deciderCounts(ctx.app.db, [...scope.ownerFacts], since);
962 if (n.closed + n.deferred + n.asked === 0) return null;
963 const link = (pile: DeciderPile) =>
964 html`<a href="${boardHref(ctx, pile)}#decided">${n[pile]}</a>`;
965 return html`<p class="board-context muted small" id="decider-day">
966 The decider closed ${link("closed")}, deferred ${link("deferred")}, asked
967 you about ${link("asked")}.
968 </p>`;
969 }
970
919971 function today(ctx: Ctx, scope: BoardScope): Html {
920972 const db = ctx.app.db;
921973 const now = new Date();
@@ -1003,6 +1055,7 @@ function today(ctx: Ctx, scope: BoardScope): Html {
10031055 scope.repos.length === 0
10041056 ? "No apps to watch."
10051057 : "No app runs on its own yet.",
1058 deciderLine(ctx, scope, since),
10061059 );
10071060 }
10081061
test/decider-day.test.ts added +165
@@ -0,0 +1,165 @@
1 /**
2 * Today's card on the board carries one line — "The decider closed N,
3 * deferred N, asked you about N" — each count linking to the ledger rows
4 * behind it. A fixture day: a yesterday's row never counts, a rule's stop is
5 * not the decider's, and the link shows exactly the counted rows.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { openIssue } from "../src/actions/issues.ts";
13 import { enableApp } from "../src/actions/apps.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import {
16 deciderCounts,
17 recordDecision,
18 setOutcome,
19 type DecisionKind,
20 } from "../src/core/decisions.ts";
21 import type { Repo } from "../src/core/repos.ts";
22 import { createUser, type User } from "../src/core/users.ts";
23 import { serve, type Servers } from "../src/http/server.ts";
24
25 describe("the decider's day on Today's card", () => {
26 let dataDir: string;
27 let app: App;
28 let servers: Servers;
29 let alice: User;
30 let repo: Repo;
31 let cookie: string;
32 let cards = 0;
33
34 const get = (path: string) =>
35 fetch(`${servers.url}${path}`, { headers: { cookie } }).then((r) => r.text());
36
37 function row(
38 kind: DecisionKind,
39 answer: string,
40 by: string,
41 outcome?: string,
42 ): string {
43 const issue = openIssue(app, alice, repo, { title: `Card ${++cards}` });
44 if (!issue.ok) throw new Error(String(issue.error));
45 const d = recordDecision(app.db, {
46 kind,
47 repoId: repo.id,
48 issueId: issue.value.id,
49 state: {},
50 answer,
51 p: 0.9,
52 by,
53 ms: 1,
54 });
55 if (outcome !== undefined) setOutcome(app.db, d.id, outcome);
56 return d.id;
57 }
58
59 beforeAll(async () => {
60 dataDir = await mkdtemp(join(tmpdir(), "forge-decider-day-"));
61 app = createApp({ dataDir, port: 0 });
62 servers = await serve(app);
63 const a = await createUser(app.db, {
64 trusted: true,
65 username: "alice",
66 email: "[email protected]",
67 password: "a-good-passphrase",
68 adminIfFirst: true,
69 });
70 if (!a.ok) throw new Error(a.error);
71 alice = a.value;
72 cookie = (
73 (
74 await fetch(`${servers.url}/signin`, {
75 method: "POST",
76 body: new URLSearchParams({
77 username: "alice",
78 password: "a-good-passphrase",
79 }),
80 redirect: "manual",
81 })
82 ).headers.get("set-cookie") ?? ""
83 ).split(";")[0]!;
84 const made = await createRepo(app, alice, { name: "notes", visibility: "private" });
85 if (!made.ok) throw new Error(String(made.error));
86 repo = made.value;
87 const seeded = await commitToBranch(app, alice, repo, {
88 message: "Start",
89 files: [
90 {
91 path: "package.json",
92 content: '{"name":"notes","scripts":{"start":"bun run server.ts"}}\n',
93 },
94 {
95 path: "server.ts",
96 content:
97 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
98 },
99 ],
100 });
101 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
102 const enabled = await enableApp(app, alice, repo, { slug: "notes" });
103 if (!enabled.ok) throw new Error(String(enabled.error));
104 }, 30_000);
105
106 afterAll(async () => {
107 servers.stop();
108 app.close();
109 await rm(dataDir, { recursive: true, force: true });
110 });
111
112 test("a quiet day says nothing", async () => {
113 expect(await get("/board")).not.toContain('id="decider-day"');
114 });
115
116 test("counts the day's closed, deferred and asked, and links each to its rows", async () => {
117 const closed = [1, 2, 3].map(() => row("hand", "set_aside", "typesafe", "set_aside"));
118 row("hand", "set_aside", "typesafe", "undone"); // taken back: not closed
119 row("retry", "stop", "typesafe");
120 row("retry", "stop", "typesafe");
121 row("retry", "stop", "rule"); // the rule's own stop is not the decider's
122 row("retry", "retry", "typesafe");
123 row("hand", "ask_owner", "typesafe", "asked");
124 // Yesterday does not count.
125 app.db
126 .query("UPDATE decision SET created_at = '2020-01-01T00:00:00.000Z' WHERE id = ?1")
127 .run(closed[0]!);
128
129 expect(deciderCounts(app.db, [repo.id], "2021-01-01T00:00:00.000Z")).toEqual({
130 closed: 2,
131 deferred: 2,
132 asked: 1,
133 });
134
135 const page = await get("/board");
136 const line = page.slice(page.indexOf('id="decider-day"'));
137 const text = line
138 .slice(0, line.indexOf("</p>"))
139 .replace(/<[^>]+>/g, "")
140 .replace(/\s+/g, " ");
141 expect(text).toContain("The decider closed 2, deferred 2, asked you about 1.");
142 expect(line).toContain('href="/board?ledger=closed#decided"');
143 expect(line).toContain('href="/board?ledger=deferred#decided"');
144 expect(line).toContain('href="/board?ledger=asked#decided"');
145
146 const rowsOf = async (pile: string) => {
147 const html = await get(`/board?ledger=${pile}`);
148 const decided = html.slice(
149 html.indexOf('id="decided"'),
150 html.indexOf('id="today"'),
151 );
152 return (decided.match(/class="board-card board-decision"/g) ?? []).length;
153 };
154 expect(await rowsOf("closed")).toBe(2);
155 expect(await rowsOf("deferred")).toBe(2);
156 expect(await rowsOf("asked")).toBe(1);
157 });
158
159 test("an unknown pile is ignored, and ?apps= is kept in the links", async () => {
160 const plain = await get("/board?ledger=nonsense");
161 expect(plain).not.toContain("What the decider nonsense");
162 const scoped = await get("/board?apps=notes");
163 expect(scoped).toContain('href="/board?apps=notes&amp;ledger=closed#decided"');
164 });
165 });
test/tui-decider.test.ts added +124
@@ -0,0 +1,124 @@
1 /**
2 * The decider's day in the terminal: the board's Today line and the rows
3 * behind each pile, gated by owner facts — a reader of the repository, or a
4 * guest, sees nothing of it.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { openIssue } from "../src/actions/issues.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { deciderCounts, recordDecision, setOutcome, type DecisionKind } from "../src/core/decisions.ts";
14 import type { Repo } from "../src/core/repos.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { createForgeApp } from "../src/tui/forge.ts";
17 import { deciderLine, deciderRows, deciderScreen } from "../src/tui/screens/decider.ts";
18 import { headlessTerminal } from "../src/tui/term/terminal.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20 import { settleApp } from "./helpers/tui.ts";
21
22 quietLogs();
23
24 let dir: string;
25 let app: App;
26 let alice: User;
27 let bob: User;
28 let repo: Repo;
29 let cards = 0;
30
31 const identity = (user?: User) => ({
32 sshUsername: user?.username ?? "git",
33 remoteAddress: "127.0.0.1",
34 fingerprint: "SHA256:test",
35 user,
36 });
37
38 function row(kind: DecisionKind, answer: string, by: string, outcome?: string): void {
39 const issue = openIssue(app, alice, repo, { title: `Card ${++cards}` });
40 if (!issue.ok) throw new Error(String(issue.error));
41 const d = recordDecision(app.db, {
42 kind, repoId: repo.id, issueId: issue.value.id, state: {}, answer, p: 0.9, by, ms: 1,
43 });
44 if (outcome !== undefined) setOutcome(app.db, d.id, outcome);
45 }
46
47 async function screenText(user: User | undefined, keys = ""): Promise<string> {
48 const terminal = headlessTerminal(120, 30);
49 const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false });
50 tui.start();
51 tui.ctx.push(deciderScreen(session, tui.ctx));
52 await settleApp(tui);
53 if (keys !== "") {
54 terminal.feed(keys);
55 await settleApp(tui);
56 }
57 const text = tui.screen.toText();
58 tui.stop?.();
59 return text;
60 }
61
62 beforeAll(async () => {
63 dir = await mkdtemp(join(tmpdir(), "forge-tui-decider-"));
64 app = createApp({ dataDir: dir, port: 0 });
65 const mk = async (username: string, first = false) => {
66 const r = await createUser(app.db, {
67 trusted: true, username, email: `${username}@forge.local`,
68 password: "a-good-passphrase", adminIfFirst: first,
69 });
70 if (!r.ok) throw new Error(r.error);
71 return r.value;
72 };
73 alice = await mk("alice", true);
74 bob = await mk("bob");
75 const made = await createRepo(app, alice, { name: "notes", visibility: "public" });
76 if (!made.ok) throw new Error(String(made.error));
77 repo = made.value;
78 row("hand", "set_aside", "typesafe", "set_aside");
79 row("hand", "set_aside", "typesafe", "set_aside");
80 row("retry", "stop", "typesafe");
81 row("retry", "stop", "rule"); // the rule's stop is the crew's, not the decider's
82 row("hand", "ask_owner", "typesafe", "asked");
83 });
84 afterAll(async () => {
85 app.close();
86 await rm(dir, { recursive: true, force: true });
87 });
88
89 describe("the decider in the terminal", () => {
90 test("the owner gets the board's sentence", () => {
91 expect(deciderLine(app, alice)).toBe("The decider closed 2, deferred 1, asked you about 1.");
92 });
93
94 test("a reader of the repository and a guest get nothing", () => {
95 expect(deciderLine(app, bob)).toBeNull();
96 expect(deciderLine(app, undefined)).toBeNull();
97 expect(deciderRows(app, bob, "closed")).toEqual([]);
98 });
99
100 test("each pile lists the rows behind its count, with the card's address", () => {
101 expect(deciderRows(app, alice, "closed")).toHaveLength(2);
102 expect(deciderRows(app, alice, "deferred")).toHaveLength(1);
103 const asked = deciderRows(app, alice, "asked");
104 expect(asked).toHaveLength(1);
105 expect(asked[0]!.path).toMatch(/^\/alice\/notes\/issues\/\d+$/);
106 });
107
108 test("the screen shows the sentence and switches pile with 1, 2, 3", async () => {
109 const closed = await screenText(alice);
110 expect(closed).toContain("The decider closed 2, deferred 1, asked you about 1.");
111 expect(closed).toContain("Card 1");
112 expect(closed).not.toContain("Card 5");
113 expect(await screenText(alice, "3")).toContain("Card 5");
114 });
115
116 test("a reader's screen says the pile is empty in words", async () => {
117 expect(await screenText(bob)).toContain("The decider has closed nothing today.");
118 });
119
120 test("a large owner is counted whole, past the batch", () => {
121 const ids = [...Array.from({ length: 450 }, (_, i) => `x${i}`), repo.id];
122 expect(deciderCounts(app.db, ids, "2000-01-01T00:00:00.000Z").closed).toBe(2);
123 });
124 });
test/tui-tell.test.ts +1 −1
@@ -31,7 +31,7 @@ let bob: User;
3131 let repo: Repo;
3232
3333 async function palette(user: User, keys: string[]): Promise<string> {
34 const terminal = headlessTerminal(120, 30);
34 const terminal = headlessTerminal(120, 40);
3535 const { app: tui, session } = createForgeApp(
3636 app,
3737 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs