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

Home's "N waiting on you" counts exactly the rows /review lists, and asks older than 30 days fold into "N stale, archive?" #6462

Merged opened this

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

Merged

scout/forge:scout/5800-home-s-n-waiting-on-you-counts-exactly-t → main 7 commits fork 1 thread · 1 unresolved

  • Merged. scout/5800-home-s-n-waiting-on-you-counts-exactly-t was merged into main as c8ab1c6.

Review

Sign in to review this change.
11 files changed +337 −16
docs/TOUR.md +2

Diff not shown — a line 2,062 characters wide (limit 2,000). +2

View file
src/flow/kpi_words.ts +5
@@ -127,6 +127,11 @@ export function waitingWords(count: number, oldestAt: string | null, now: Date):
127127 return `${count} waiting on you, oldest ${h < 1 ? "under 1" : h} h`;
128128 }
129129
130 /** The fold for asks older than 30 days, on Home and on /review. */
131 export function staleWords(count: number): string {
132 return `${count} stale, archive?`;
133 }
134
130135 /**
131136 * The terminal's row: the same figures in the same words, as plain
132137 * lines. Forge-wide like the web's, so the caller shows them to an admin only.
src/tui/screens/dashboard.ts +58 −3
@@ -39,9 +39,13 @@ import { putAsideWaves } from "../../web/studio/asks.ts";
3939 import type { Proposal } from "../../core/proposals.ts";
4040 import {
4141 applyAsksBulk,
42 BULK_MAX,
4243 latestUndoable,
44 previewAsksBulk,
4345 undoAsksBulk,
4446 } from "../../actions/asks_bulk.ts";
47 import { reviewSetOf, type ReviewSet } from "../../web/studio/review_set.ts";
48 import { staleWords } from "../../flow/kpi_words.ts";
4549 import { mutedRepos, removeMutes } from "../../core/interactions.ts";
4650 import { chipsOf, KIND_CHIPS, setChip } from "../../core/feed_chips.ts";
4751 import type { Operation } from "../../core/fleet_ops.ts";
@@ -161,6 +165,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
161165 /** The rows marked for one verb, by issue id. */
162166 const marked = new Set<string>();
163167 let lastSet: Operation | undefined;
168 /** What Home and /review count: live asks waiting on the viewer, and the stale ones folded apart. */
169 let waitingSet: ReviewSet | undefined;
164170 /** The repositories marked for archiving, by id; only ones the viewer administers. */
165171 const markedRepos = new Set<string>();
166172 /** The receipt of the last archive made here, which `u` reverses once. */
@@ -236,6 +242,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
236242 pinned = f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>();
237243 all = listReposVisibleTo(f.db, f.user, { limit: 200 });
238244 onYou = assignedToMe();
245 waitingSet = f.user === undefined ? undefined : reviewSetOf(f.db, f.user, new Date());
239246 waves = f.user === undefined ? [] : wavesFor();
240247 asideWaves = f.user === undefined ? [] : putAsideWaves(f.db, f.user);
241248 for (const id of [...marked])
@@ -548,6 +555,48 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
548555 );
549556 };
550557
558 /** The asks older than 30 days, dismissed in one step: the bulk preview first, nothing before the choice. */
559 const archiveStale = (): void => {
560 const stale = (waitingSet?.stale ?? []).slice(0, BULK_MAX);
561 if (stale.length === 0 || f.user === undefined) return;
562 const items = stale.map((a) => a.id);
563 const plan = previewAsksBulk(f.app, f.user, "dismiss", items);
564 if (!plan.ok) {
565 ctx.toast(plan.error.message, "danger");
566 return;
567 }
568 if (plan.value.counts.change === 0) {
569 ctx.toast("nothing to archive", "attn");
570 return;
571 }
572 ctx.push(
573 confirmScreen(ctx, screen, {
574 title: `Archive ${plural(plan.value.counts.change, "stale ask")}?`,
575 body: [
576 ...plan.value.rows.map((r) => r.label),
577 ...((waitingSet?.stale.length ?? 0) > BULK_MAX ? [`First ${BULK_MAX} of ${waitingSet?.stale.length}; run it again for the rest.`] : []),
578 ],
579 actions: [
580 { label: "Archive", variant: "danger" },
581 { label: "Cancel", variant: "ghost" },
582 ],
583 onChoose: (choice) => {
584 if (choice !== 0) return;
585 runAsync(ctx, async () => {
586 const done = await applyAsksBulk(f.app, f.user, "dismiss", items);
587 if (!done.ok) {
588 ctx.toast(done.error.message, "danger");
589 return;
590 }
591 const n = done.value.rows.filter((r) => r.outcome === "ok").length;
592 reload();
593 ctx.toast(`archived ${n}${n > 0 ? " — u undoes it" : ""}`, n > 0 ? "success" : "attn");
594 });
595 },
596 }),
597 );
598 };
599
551600 const undoArchive = (): void => {
552601 const id = lastArchive;
553602 if (id === undefined) return;
@@ -789,6 +838,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
789838 binding(["S"], "ship"),
790839 ]
791840 : []),
841 ...(focus === "onyou" && (waitingSet?.stale.length ?? 0) > 0 ? [binding(["Z"], "archive stale")] : []),
792842 ...(lastSet !== undefined || lastArchive !== undefined ? [binding(["u"], "undo")] : []),
793843 ...(silenced.size > 0 ? [binding(["L"], "listen again")] : []),
794844 binding(["d"], "unpin", {
@@ -951,6 +1001,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
9511001 return true;
9521002 }
9531003 if (e.key === "x") return closeSet(), true;
1004 if (e.key === "Z") return archiveStale(), true;
9541005 if (e.key === "m") return clear("mute"), true;
9551006 if (e.key === "S") return shipSet(), true;
9561007 if (e.key === "D" && item !== undefined) return handToDecider(), true;
@@ -1095,6 +1146,13 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
10951146 /** What the card says about mutes, marks and the day's undo. */
10961147 function onYouFooter(): string | undefined {
10971148 const parts: string[] = [];
1149 // The same figures as Home's line and /review: live asks counted, stale ones folded apart.
1150 if (waitingSet !== undefined && waitingSet.count > 0) parts.push(`${waitingSet.count} waiting on you`);
1151 if (waitingSet !== undefined && waitingSet.stale.length > 0) parts.push(`${staleWords(waitingSet.stale.length)} Z previews`);
1152 // The decider keys come first: the card clips its footer from the right.
1153 if (lastHand !== undefined) parts.push("U undo decider");
1154 if (onYou[at("onyou").index]?.hand === true) parts.push("D let the decider");
1155 if (onYou[at("onyou").index]?.over === true) parts.push("b give back to the crew");
10981156 if (f.user !== undefined) {
10991157 const chips = chipsOf(f.db, f.user.id);
11001158 const off: string[] = KIND_CHIPS.filter((k) => chips.kinds.has(k));
@@ -1111,9 +1169,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
11111169 `${silenced.size} ${silenced.size === 1 ? "repository" : "repositories"} muted · L listen again`,
11121170 );
11131171 if (lastSet !== undefined) parts.push(`u undo ${lastSet.verb}`);
1114 if (lastHand !== undefined) parts.push("U undo decider");
1115 if (onYou[at("onyou").index]?.hand === true) parts.push("D let the decider");
1116 if (onYou[at("onyou").index]?.over === true) parts.push("b give back to the crew");
11171172 if (asideWaves.length > 0) parts.push(`${asideWaves.length} ${asideWaves.length === 1 ? "wave" : "waves"} put aside · B bring back`);
11181173 return parts.length > 0 ? parts.join(" · ") : undefined;
11191174 }
src/web/studio/asks.ts +4
@@ -9,6 +9,7 @@
99
1010 import type { Database } from "bun:sqlite";
1111 import { forgeVitals, needLine } from "../../actions/health.ts";
12 import { reviewSet } from "./review_set.ts";
1213 import type { Degraded } from "../../kernel/vitals.ts";
1314 import { previewEnv } from "../../apps/data.ts";
1415 import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts";
@@ -1450,7 +1451,10 @@ export function askSentence(count: number): string {
14501451 */
14511452 export function asksAnswer(ctx: Ctx, user: User, chips?: Chips) {
14521453 const since = new Date(Date.now() - 7 * 86_400_000).toISOString();
1454 const set = reviewSet(ctx, user);
14531455 return {
1456 // The very figures Home's line and /review show: `count` is live asks plus the forge's needs; `stale` are older than 30 days.

Comment is wrong: set.count is the live asks only (review_set.ts says the forge needs are not in it). Reword, so the MCP reader is not told to expect them.

1457 waiting: { count: set.count, stale: set.stale.length },
14541458 rows: askLinesFor(ctx.app.db, user, chips),
14551459 forge: forgeNeedsFor(ctx, user).map((d) => needLine(d)),
14561460 measure: { days: 7, ...askMeasure(ctx.app.db, user.id, since) },
src/web/studio/home_cache.ts +2 −1
@@ -21,10 +21,11 @@ const panelCaches = new WeakMap<Database, Stamped<Answer>>();
2121 const vitalsCaches = new WeakMap<Database, Stamped<Vitals>>();
2222 const waitingCaches = new WeakMap<Database, Stamped<Waiting>>();
2323
24 /** What the waiting line keeps of the viewer's asks: how many, and the oldest's time. */
24 /** What the waiting line keeps of the viewer's asks: how many are live, the oldest's time, and the stale ones' ids. */
2525 export interface Waiting {
2626 readonly count: number;
2727 readonly oldest: string | null;
28 readonly stale: readonly { readonly id: string }[];
2829 }
2930
3031 function cacheOf<V>(into: WeakMap<Database, Stamped<V>>, db: Database, maxEntries: number): Stamped<V> {
src/web/studio/kpi_row.ts +13 −9
@@ -16,9 +16,9 @@ import { escapeHtml, html, raw, type Html } from "../html.ts";
1616 import { BOOTED } from "../../release/gate.ts";
1717 import { running } from "../../kernel/version.ts";
1818 import { sha, stat, time } from "../ui.ts";
19 import { listReposVisibleTo } from "../../core/repos.ts";
2019 import type { User } from "../../core/users.ts";
21 import { waitingOn } from "./asks.ts";
20 import { reviewSetOf } from "./review_set.ts";
21 import { staleArchive } from "./review_stale.ts";
2222
2323 const W = 96;
2424 const H = 24;
@@ -170,19 +170,23 @@ export function kpiRow(db: Database, opts: { ceilingUsd?: number; dataDir?: stri
170170
171171 /**
172172 * One `.small .muted` line over `/review`: how many asks wait on the viewer
173 * (the very rows /review lists) and how long the oldest has. Empty at zero.
173 * (the very set /review draws, stale ones left out) and how long the oldest has. Asks older than 30 days follow as
174 * `N stale, archive?`, which opens the bulk-dismiss preview: nothing is
175 * dismissed without a look. Empty when there is nothing to say.
174176 */
175177 export function waitingLine(db: Database, user: User, now: Date = new Date()): Html {
176 const { count, oldest } = cachedWaiting(db, user.id, () => {
177 const repos = listReposVisibleTo(db, user, { limit: 200, withoutArchived: true });
178 const asks = waitingOn(db, user, repos);
178 const { count, oldest, stale } = cachedWaiting(db, user.id, () => {
179 const set = reviewSetOf(db, user, now);
179180 return {
180 count: asks.length,
181 oldest: asks.reduce<string | null>((o, a) => (o === null || Date.parse(a.since) < Date.parse(o) ? a.since : o), null),
181 count: set.count,
182 oldest: set.live.reduce<string | null>((o, a) => (o === null || Date.parse(a.since) < Date.parse(o) ? a.since : o), null),
183 stale: set.stale.map((a) => ({ id: a.id })),
182184 };
183185 });
184186 const words = waitingWords(count, oldest, now);
185 return words === null ? html`` : html`<p class="small muted"><a href="/review">${words}</a></p>`;
187 if (words === null && stale.length === 0) return html``;
188 // The fold is a form, which cannot sit in the paragraph: it follows it.
189 return html`${words === null ? null : html`<p class="small muted"><a href="/review">${words}</a></p>`}${stale.length === 0 ? null : staleArchive(stale)}`;
186190 }
187191
188192 /** Home's flow vitals for an admin (#5935): one muted line of counts; the open count links to the boards. */
src/web/studio/review_page.ts +8 −3
@@ -18,7 +18,9 @@ import { html, type Html } from "../html.ts";
1818 import { page } from "../layout.ts";
1919 import { badge, empty, listCard, row, runStateBadge, time } from "../ui.ts";
2020 import { lookThumb, lookThumbPlain, thumbLook } from "../pages/looks.ts";
21 import { actRow, orderedAsks, rowId } from "./review_act.ts";
21 import { actRow, rowId } from "./review_act.ts";
22 import { reviewSet } from "./review_set.ts";
23 import { staleArchive } from "./review_stale.ts";
2224 import { diffStatFor, sizeLine } from "./review_evidence.ts";
2325
2426 /** The check on a pull's head, as the pull page wears it; only a run of this very head counts. */
@@ -67,7 +69,8 @@ export function reviewPage(ctx: Ctx): Response {
6769 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
6870 const user = ctx.auth.user;
6971 if (!user) return signInFirst(ctx);
70 const asks = timePhase(ctx.request, "asks", () => orderedAsks(ctx.app.db, user));
72 const set = timePhase(ctx.request, "asks", () => reviewSet(ctx, user));
73 const asks = set.live;
7174 const rendering = performance.now();
7275 const advice = recommendationsFor(
7376 ctx.app.db,
@@ -140,8 +143,10 @@ export function reviewPage(ctx: Ctx): Response {
140143 ],
141144 });
142145 });
146 // Asks older than 30 days fold under one row, whose button opens the bulk-dismiss preview.
147 if (set.stale.length > 0) rows.push(html`<li>${staleArchive(set.stale)}<span class="small muted">Older than 30 days; nothing is dismissed without a look</span></li>`);
143148 const body = html`<h1>Review</h1>
144 <p class="muted">${asks.length === 0 ? "All fine" : `${asks.length} ${asks.length === 1 ? "ask" : "asks"}, newest first`}</p>
149 <p class="muted">${set.count === 0 ? "All fine" : `${set.count} ${set.count === 1 ? "ask" : "asks"}, newest first`}</p>
145150 ${rows.length === 0
146151 ? empty({
147152 icon: "issue",
src/web/studio/review_set.ts added +46
@@ -0,0 +1,46 @@
1 /**
2 * The set /review draws and Home's "N waiting on you" counts: the asks
3 * waiting on the viewer, split at 30 days into live and stale, and
4 * nothing else: the forge's own needs stay where Home already shows them. One read, so the page, the line and the
5 * MCP's `asks` cannot disagree.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { listReposVisibleTo } from "../../core/repos.ts";
10 import type { User } from "../../core/users.ts";
11 import type { Ctx } from "../../http/context.ts";
12 import { waitingOn } from "./asks.ts";
13
14 /** An ask older than this many days is stale: out of the count, folded under one row. */
15 export const STALE_DAYS = 30;
16
17 type Ask = ReturnType<typeof waitingOn>[number];
18
19 export interface ReviewSet {
20 /** Newest first. */
21 readonly live: Ask[];
22 /** Newest first. */
23 readonly stale: Ask[];
24 /** What the line says: the live asks. */
25 readonly count: number;
26 }
27
28 /** Splits `asks` at `STALE_DAYS` before `now`, each side newest first. */
29 export function splitStale<T extends { since: string }>(asks: readonly T[], now: Date): { live: T[]; stale: T[] } {
30 const cut = now.getTime() - STALE_DAYS * 86_400_000;
31 const sorted = [...asks].sort((a, b) => Date.parse(b.since) - Date.parse(a.since));
32 return {
33 live: sorted.filter((a) => !(Date.parse(a.since) < cut)),
34 stale: sorted.filter((a) => Date.parse(a.since) < cut),
35 };
36 }
37
38 export function reviewSet(ctx: Ctx, user: User, now: Date = new Date()): ReviewSet {
39 return reviewSetOf(ctx.app.db, user, now);
40 }
41
42 export function reviewSetOf(db: Database, user: User, now: Date): ReviewSet {
43 const repos = listReposVisibleTo(db, user, { limit: 200, withoutArchived: true });
44 const { live, stale } = splitStale(waitingOn(db, user, repos), now);
45 return { live, stale, count: live.length };
46 }
src/web/studio/review_stale.ts added +22
@@ -0,0 +1,22 @@
1 /**
2 * The `N stale, archive?` control: a form posting the stale asks to the
3 * existing bulk-dismiss preview (`POST /asks/bulk`), which lists what would
4 * change and asks again before anything is dismissed. One bulk step takes
5 * `BULK_MAX` asks, so past that the label says it is the first of them.
6 */
7
8 import { staleWords } from "../../flow/kpi_words.ts";
9 import { BULK_MAX } from "../../actions/asks_bulk.ts";
10 import { html, type Html } from "../html.ts";
11 import { btn } from "../ui.ts";
12
13 export function staleLabel(count: number): string {
14 return count > BULK_MAX ? `First ${BULK_MAX} of ${staleWords(count)}` : staleWords(count);
15 }
16
17 export function staleArchive(stale: readonly { id: string }[]): Html {
18 return html`<form method="post" action="/asks/bulk">
19 <input type="hidden" name="action" value="dismiss" />${stale.slice(0, BULK_MAX).map((a) => html`<input type="hidden" name="item" value="${a.id}" />`)}
20 ${btn(staleLabel(stale.length), { size: "sm", variant: "ghost" })}
21 </form>`;
22 }
test/review-stale.test.ts added +96
@@ -0,0 +1,96 @@
1 /**
2 * Home's "N waiting on you", /review and the MCP's `asks` count one set; asks
3 * older than 30 days fold into "N stale, archive?" on all three.
4 */
5 import { describe, expect, test } from "bun:test";
6 import { mkdtempSync, rmSync } from "node:fs";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp } from "../src/app.ts";
10 import { createRepo as createRepoRow } from "../src/core/repos.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { staleWords } from "../src/flow/kpi_words.ts";
14 import { serve } from "../src/http/server.ts";
15 import { id } from "../src/kernel/id.ts";
16 import { BULK_MAX } from "../src/actions/asks_bulk.ts";
17 import { splitStale, STALE_DAYS } from "../src/web/studio/review_set.ts";
18 import { staleArchive, staleLabel } from "../src/web/studio/review_stale.ts";
19
20 describe("splitStale", () => {
21 test("30 days is the line; each side is newest first", () => {
22 const now = new Date("2026-10-25T12:00:00.000Z");
23 const at = (d: number) => new Date(now.getTime() - d * 86_400_000).toISOString();
24 const { live, stale } = splitStale([{ since: at(29) }, { since: at(STALE_DAYS + 1) }, { since: at(2) }, { since: at(120) }], now);
25 expect(live.map((a) => a.since)).toEqual([at(2), at(29)]);
26 expect(stale.map((a) => a.since)).toEqual([at(31), at(120)]);
27 });
28 test("the label says when one step cannot take them all", () => {
29 expect(staleLabel(3)).toBe("3 stale, archive?");
30 expect(staleLabel(BULK_MAX + 1)).toBe(`First ${BULK_MAX} of ${BULK_MAX + 1} stale, archive?`);
31 const form = staleArchive(Array.from({ length: BULK_MAX + 5 }, (_, i) => ({ id: `waiting:${i}` }))).toString();
32 expect(form.match(/name="item"/g)?.length).toBe(BULK_MAX);
33 });
34 test("words", () => expect(staleWords(4)).toBe("4 stale, archive?"));
35 });
36
37 describe("one count on Home, /review and the MCP", () => {
38 test("recent asks are counted, old ones fold, and the three agree", async () => {
39 const root = mkdtempSync(join(tmpdir(), "forge-review-stale-"));
40 const app = createApp({ dataDir: root, port: 0 });
41 const servers = await serve(app);
42 try {
43 const made = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true });
44 if (!made.ok) throw new Error(made.error);
45 const repo = await createRepoRow(app.db, app.reposRoot, { ownerId: made.value.id, name: "wait", visibility: "public", description: "wait" });
46 if (!repo.ok) throw new Error(repo.error);
47 [30, 40, 24 * 40, 24 * 120].forEach((h, i) => {
48 const issueId = id();
49 const number = app.db.query<{ issue_seq: number }, [string]>("UPDATE repo SET issue_seq = issue_seq + 1 WHERE id = ?1 RETURNING issue_seq").get(repo.value.id)!.issue_seq;
50 app.db
51 .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, 'open', ?6)`)
52 .run(issueId, repo.value.id, number, `Ask ${i}`, made.value.id, new Date(Date.now() - h * 3_600_000).toISOString());
53 app.db
54 .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) VALUES (?1, 'main', ?2, ?3, ?3, ?4)`)
55 .run(issueId, `b/${i}`, "0".repeat(40), repo.value.id);
56 });
57 const signin = await fetch(`${servers.url}/signin`, {
58 method: "POST",
59 headers: { origin: servers.url },
60 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
61 redirect: "manual",
62 });
63 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
64 const get = async (path: string) => await (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
65
66 const home = await get("/");
67 const said = Number(/(\d+) waiting on you/.exec(home)?.[1]);
68 expect(home).toContain("2 stale, archive?");
69 expect(home).toContain('action="/asks/bulk"');
70 expect(home).toContain('name="action" value="dismiss"');
71
72 const review = await get("/review");
73 const asks = (review.match(/<li[^>]* data-ask/g) ?? []).length;
74 expect(review).toContain("2 stale, archive?");
75 expect(review).toContain(">Review</h1>");
76 // The admin's forge needs are not /review's rows, and the fold is not a list inside a list item.
77 expect(review).not.toContain("The forge</span>");
78 expect(review).not.toMatch(/<li[^>]*>(?:(?!<\/li>)[\s\S])*<ul class="row-children">(?:(?!<\/ul>)[\s\S])*<form method="post" action="\/asks\/bulk"/);
79
80 const res = await fetch(`${servers.url}/mcp`, {
81 method: "POST",
82 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${createToken(app.db, made.value.id, "r", "ro").token}` },
83 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "asks", arguments: {} } }),
84 });
85 const mcp = JSON.parse(((await res.json()) as any).result.content[0].text);
86
87 expect(said).toBe(2);
88 expect(asks).toBe(said);
89 expect(mcp.waiting).toEqual({ count: said, stale: 2 });
90 } finally {
91 await servers.stop();
92 app.close();
93 rmSync(root, { recursive: true, force: true });
94 }
95 }, 30_000);
96 });
test/tui-stale.test.ts added +81
@@ -0,0 +1,81 @@
1 /**
2 * The terminal's On you says the same count as Home and /review, folds asks
3 * older than 30 days into `N stale, archive?`, and `Z` previews before it
4 * dismisses anything.
5 */
6
7 import { afterAll, beforeAll, 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 { createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App as Forge } from "../src/app.ts";
13 import { id } from "../src/kernel/id.ts";
14 import type { User } from "../src/core/users.ts";
15 import { reviewSetOf } from "../src/web/studio/review_set.ts";
16 import { createForgeApp } from "../src/tui/forge.ts";
17 import { headlessTerminal } from "../src/tui/term/terminal.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { settleApp } from "./helpers/tui.ts";
20 import { seedAdmin } from "./helpers/admin.ts";
21
22 quietLogs();
23
24 let dir: string;
25 let forge: Forge;
26 let alice: User;
27
28 beforeAll(async () => {
29 dir = await mkdtemp(join(tmpdir(), "forge-tui-stale-"));
30 forge = createApp({ dataDir: dir, port: 0 });
31 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
32 if (!a.ok) throw new Error("signup failed");
33 alice = a.value;
34 const repo = await createRepo(forge, alice, { name: "wait", visibility: "public" });
35 if (!repo.ok) throw new Error("repo failed");
36 [30, 40, 24 * 40, 24 * 120].forEach((h, i) => {
37 const issueId = id();
38 const number = forge.db.query<{ issue_seq: number }, [string]>("UPDATE repo SET issue_seq = issue_seq + 1 WHERE id = ?1 RETURNING issue_seq").get(repo.value.id)!.issue_seq;
39 forge.db
40 .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, 'open', ?6)`)
41 .run(issueId, repo.value.id, number, `Ask ${i}`, alice.id, new Date(Date.now() - h * 3_600_000).toISOString());
42 forge.db
43 .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) VALUES (?1, 'main', ?2, ?3, ?3, ?4)`)
44 .run(issueId, `b/${i}`, "0".repeat(40), repo.value.id);
45 });
46 });
47
48 afterAll(async () => {
49 forge.close();
50 await rm(dir, { recursive: true, force: true });
51 });
52
53 test("the card counts what Home counts, folds the stale, and Z archives after a look", async () => {
54 const terminal = headlessTerminal(200, 32);
55 const { app } = createForgeApp(
56 forge,
57 { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: alice },
58 { terminal, exitHooks: false },
59 );
60 app.start();
61 const press = async (...keys: string[]) => {
62 for (const key of keys) {
63 app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
64 await settleApp(app);
65 }
66 };
67 await settleApp(app);
68 await press("tab");
69 const set = reviewSetOf(forge.db, alice, new Date());
70 expect([set.count, set.stale.length]).toEqual([2, 2]);
71 expect(app.screen.toText()).toContain(`${set.count} waiting on you`);
72 expect(app.screen.toText()).toContain("2 stale, archive?");
73 await press("Z");
74 expect(app.screen.toText()).toContain("Archive 2 stale asks?");
75 expect(reviewSetOf(forge.db, alice, new Date()).stale.length).toBe(2);
76 await press("enter");
77 const after = reviewSetOf(forge.db, alice, new Date());
78 expect([after.count, after.stale.length]).toEqual([2, 0]);
79 expect(app.screen.toText()).not.toContain("stale, archive?");
80 app.stop();
81 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs