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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5800-home-s-n-waiting-on-you-counts-exactly-t → main 7 commits fork 1 thread · 1 unresolved
11 files
changed +337
−16
docs/TOUR.md +2
Diff not shown — a line 2,062 characters wide (limit 2,000). +2
View filesrc/flow/kpi_words.ts +5
| @@ -127,6 +127,11 @@ export function waitingWords(count: number, oldestAt: string | null, now: Date): | ||
| 127 | 127 | return `${count} waiting on you, oldest ${h < 1 ? "under 1" : h} h`; |
| 128 | 128 | } |
| 129 | 129 | |
| 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 | ||
| 130 | 135 | /** |
| 131 | 136 | * The terminal's row: the same figures in the same words, as plain |
| 132 | 137 | * 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"; | ||
| 39 | 39 | import type { Proposal } from "../../core/proposals.ts"; |
| 40 | 40 | import { |
| 41 | 41 | applyAsksBulk, |
| 42 | BULK_MAX, | |
| 42 | 43 | latestUndoable, |
| 44 | previewAsksBulk, | |
| 43 | 45 | undoAsksBulk, |
| 44 | 46 | } from "../../actions/asks_bulk.ts"; |
| 47 | import { reviewSetOf, type ReviewSet } from "../../web/studio/review_set.ts"; | |
| 48 | import { staleWords } from "../../flow/kpi_words.ts"; | |
| 45 | 49 | import { mutedRepos, removeMutes } from "../../core/interactions.ts"; |
| 46 | 50 | import { chipsOf, KIND_CHIPS, setChip } from "../../core/feed_chips.ts"; |
| 47 | 51 | import type { Operation } from "../../core/fleet_ops.ts"; |
| @@ -161,6 +165,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 161 | 165 | /** The rows marked for one verb, by issue id. */ |
| 162 | 166 | const marked = new Set<string>(); |
| 163 | 167 | 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; | |
| 164 | 170 | /** The repositories marked for archiving, by id; only ones the viewer administers. */ |
| 165 | 171 | const markedRepos = new Set<string>(); |
| 166 | 172 | /** The receipt of the last archive made here, which `u` reverses once. */ |
| @@ -236,6 +242,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 236 | 242 | pinned = f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>(); |
| 237 | 243 | all = listReposVisibleTo(f.db, f.user, { limit: 200 }); |
| 238 | 244 | onYou = assignedToMe(); |
| 245 | waitingSet = f.user === undefined ? undefined : reviewSetOf(f.db, f.user, new Date()); | |
| 239 | 246 | waves = f.user === undefined ? [] : wavesFor(); |
| 240 | 247 | asideWaves = f.user === undefined ? [] : putAsideWaves(f.db, f.user); |
| 241 | 248 | for (const id of [...marked]) |
| @@ -548,6 +555,48 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 548 | 555 | ); |
| 549 | 556 | }; |
| 550 | 557 | |
| 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 | ||
| 551 | 600 | const undoArchive = (): void => { |
| 552 | 601 | const id = lastArchive; |
| 553 | 602 | if (id === undefined) return; |
| @@ -789,6 +838,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 789 | 838 | binding(["S"], "ship"), |
| 790 | 839 | ] |
| 791 | 840 | : []), |
| 841 | ...(focus === "onyou" && (waitingSet?.stale.length ?? 0) > 0 ? [binding(["Z"], "archive stale")] : []), | |
| 792 | 842 | ...(lastSet !== undefined || lastArchive !== undefined ? [binding(["u"], "undo")] : []), |
| 793 | 843 | ...(silenced.size > 0 ? [binding(["L"], "listen again")] : []), |
| 794 | 844 | binding(["d"], "unpin", { |
| @@ -951,6 +1001,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 951 | 1001 | return true; |
| 952 | 1002 | } |
| 953 | 1003 | if (e.key === "x") return closeSet(), true; |
| 1004 | if (e.key === "Z") return archiveStale(), true; | |
| 954 | 1005 | if (e.key === "m") return clear("mute"), true; |
| 955 | 1006 | if (e.key === "S") return shipSet(), true; |
| 956 | 1007 | if (e.key === "D" && item !== undefined) return handToDecider(), true; |
| @@ -1095,6 +1146,13 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1095 | 1146 | /** What the card says about mutes, marks and the day's undo. */ |
| 1096 | 1147 | function onYouFooter(): string | undefined { |
| 1097 | 1148 | 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"); | |
| 1098 | 1156 | if (f.user !== undefined) { |
| 1099 | 1157 | const chips = chipsOf(f.db, f.user.id); |
| 1100 | 1158 | const off: string[] = KIND_CHIPS.filter((k) => chips.kinds.has(k)); |
| @@ -1111,9 +1169,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1111 | 1169 | `${silenced.size} ${silenced.size === 1 ? "repository" : "repositories"} muted · L listen again`, |
| 1112 | 1170 | ); |
| 1113 | 1171 | 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"); | |
| 1117 | 1172 | if (asideWaves.length > 0) parts.push(`${asideWaves.length} ${asideWaves.length === 1 ? "wave" : "waves"} put aside · B bring back`); |
| 1118 | 1173 | return parts.length > 0 ? parts.join(" · ") : undefined; |
| 1119 | 1174 | } |
src/web/studio/asks.ts +4
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | |
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| 11 | 11 | import { forgeVitals, needLine } from "../../actions/health.ts"; |
| 12 | import { reviewSet } from "./review_set.ts"; | |
| 12 | 13 | import type { Degraded } from "../../kernel/vitals.ts"; |
| 13 | 14 | import { previewEnv } from "../../apps/data.ts"; |
| 14 | 15 | import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts"; |
| @@ -1450,7 +1451,10 @@ export function askSentence(count: number): string { | ||
| 1450 | 1451 | */ |
| 1451 | 1452 | export function asksAnswer(ctx: Ctx, user: User, chips?: Chips) { |
| 1452 | 1453 | const since = new Date(Date.now() - 7 * 86_400_000).toISOString(); |
| 1454 | const set = reviewSet(ctx, user); | |
| 1453 | 1455 | 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. | |
| 1457 | waiting: { count: set.count, stale: set.stale.length }, | |
| 1454 | 1458 | rows: askLinesFor(ctx.app.db, user, chips), |
| 1455 | 1459 | forge: forgeNeedsFor(ctx, user).map((d) => needLine(d)), |
| 1456 | 1460 | 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>>(); | ||
| 21 | 21 | const vitalsCaches = new WeakMap<Database, Stamped<Vitals>>(); |
| 22 | 22 | const waitingCaches = new WeakMap<Database, Stamped<Waiting>>(); |
| 23 | 23 | |
| 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. */ | |
| 25 | 25 | export interface Waiting { |
| 26 | 26 | readonly count: number; |
| 27 | 27 | readonly oldest: string | null; |
| 28 | readonly stale: readonly { readonly id: string }[]; | |
| 28 | 29 | } |
| 29 | 30 | |
| 30 | 31 | 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"; | ||
| 16 | 16 | import { BOOTED } from "../../release/gate.ts"; |
| 17 | 17 | import { running } from "../../kernel/version.ts"; |
| 18 | 18 | import { sha, stat, time } from "../ui.ts"; |
| 19 | import { listReposVisibleTo } from "../../core/repos.ts"; | |
| 20 | 19 | 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"; | |
| 22 | 22 | |
| 23 | 23 | const W = 96; |
| 24 | 24 | const H = 24; |
| @@ -170,19 +170,23 @@ export function kpiRow(db: Database, opts: { ceilingUsd?: number; dataDir?: stri | ||
| 170 | 170 | |
| 171 | 171 | /** |
| 172 | 172 | * 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. | |
| 174 | 176 | */ |
| 175 | 177 | 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); | |
| 179 | 180 | 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 })), | |
| 182 | 184 | }; |
| 183 | 185 | }); |
| 184 | 186 | 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)}`; | |
| 186 | 190 | } |
| 187 | 191 | |
| 188 | 192 | /** 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"; | ||
| 18 | 18 | import { page } from "../layout.ts"; |
| 19 | 19 | import { badge, empty, listCard, row, runStateBadge, time } from "../ui.ts"; |
| 20 | 20 | 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"; | |
| 22 | 24 | import { diffStatFor, sizeLine } from "./review_evidence.ts"; |
| 23 | 25 | |
| 24 | 26 | /** 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 { | ||
| 67 | 69 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 68 | 70 | const user = ctx.auth.user; |
| 69 | 71 | 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; | |
| 71 | 74 | const rendering = performance.now(); |
| 72 | 75 | const advice = recommendationsFor( |
| 73 | 76 | ctx.app.db, |
| @@ -140,8 +143,10 @@ export function reviewPage(ctx: Ctx): Response { | ||
| 140 | 143 | ], |
| 141 | 144 | }); |
| 142 | 145 | }); |
| 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>`); | |
| 143 | 148 | 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> | |
| 145 | 150 | ${rows.length === 0 |
| 146 | 151 | ? empty({ |
| 147 | 152 | 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 | }); |
new above
offline
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.