Public: anyone can read this; the crew can change it.
A pull's /review row shows files changed and +/−, cached per base and head, and the page never waits on git #4874
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4790-a-pull-s-review-row-shows-files-changed → main 3 commits fork 6 threads · 6 unresolved
README.md +1 −1
| @@ -449,7 +449,7 @@ only, shows a guest nothing, and names nothing of a placement in a repository | ||
| 449 | 449 | you cannot read. |
| 450 | 450 | |
| 451 | 451 | Review: `/review` lists the asks waiting on you, one row each (title, repository, |
| 452 | age, why, and the crew's advice or that it has not advised yet), newest first. A pull's row also carries the check verdict for its head (the pull page's badge: passed, failed, running, cancelled, or none) in the row's trail, and, when its repository is an app, an "Open the preview" link under it (shown for any app, even before a preview is deployed); both come from the database, never git. It only reads; the buttons stay on `/asks`, which links | |
| 452 | age, why, and the crew's advice or that it has not advised yet), newest first. A pull's row also carries the check verdict for its head (the pull page's badge: passed, failed, running, cancelled, or none) in the row's trail, and, when its repository is an app, an "Open the preview" link under it (shown for any app, even before a preview is deployed); both come from the database, never git. The row's trail also says the pull's size (`3 files changed, +40 −7`), kept per base and head commit (`src/web/studio/review_evidence.ts`); a request never waits on git: until the stat is kept the row says "Diff loading — reload for the size" while the app computes it in the background (at most 4 at once, stopped by closing the app), and a failed stat says "Diff size unavailable" and is not asked again for a minute, so a bad pull cannot spawn git on every visit. Visits while a stat is running share it. It only reads; the buttons stay on `/asks`, which links | |
| 453 | 453 | to it, and signed out it sends you to sign-in. The terminal has no page of its |
| 454 | 454 | own for it: the same asks are the dashboard's On you card. |
| 455 | 455 |
src/web/studio/review_evidence.ts added +152
| @@ -0,0 +1,152 @@ | ||
| 1 | /** | |
| 2 | * A pull's diff stat for the `/review` row: files changed, additions and | |
| 3 | * deletions, kept per (base oid, head oid). A request only reads the cache; | |
| 4 | * a miss is computed in the background, at most `LIMIT` at once, by the app | |
| 5 | * that asked, so `app.close()` (its `closing` signal) kills the git processes | |
| 6 | * and starts no more. Only a successful stat is kept: a failure is asked | |
| 7 | * again, but not before `RETRY_MS` has passed, so a pull whose diff always | |
| 8 | * fails cannot turn each visit into a git process. | |
| 9 | */ | |
| 10 | ||
| 11 | import { pullDir, type Pull } from "../../core/pulls.ts"; | |
| 12 | import type { App } from "../../app.ts"; | |
| 13 | import { runGit } from "../../git/env.ts"; | |
| 14 | import { isSafeOid } from "../../git/guard.ts"; | |
| 15 | ||
| 16 | export interface DiffStat { | |
| 17 | readonly files: number; | |
| 18 | readonly additions: number; | |
| 19 | readonly deletions: number; | |
| 20 | } | |
| 21 | ||
| 22 | const LIMIT = 4; | |
| 23 | const KEPT_MAX = 500; | |
| 24 | const RETRY_MS = 60_000; | |
| 25 | ||
| 26 | interface Work { | |
| 27 | readonly kept: Map<string, DiffStat>; | |
| 28 | readonly queued: Map<string, () => Promise<void>>; | |
| 29 | readonly running: Set<Promise<void>>; | |
| 30 | /** Keys queued or running: one git per pair, however many visits. */ | |
| 31 | readonly inflight: Set<string>; | |
| 32 | /** Keys whose stat failed, until when. */ | |
| 33 | readonly failed: Map<string, number>; | |
| 34 | } | |
| 35 | ||
| 36 | // Per app, never per module: nothing exists until a request asks, and a | |
| 37 | // closed app takes its work with it. | |
| 38 | const works = new WeakMap<App, Work>(); | |
| 39 | ||
| 40 | /** `git diff --numstat` lines to totals; a binary file counts as a file with no lines. */ | |
| 41 | export function parseNumstat(text: string): DiffStat { | |
| 42 | let files = 0; | |
| 43 | let additions = 0; | |
| 44 | let deletions = 0; | |
| 45 | for (const line of text.split("\n")) { | |
| 46 | const m = /^(\d+|-)\t(\d+|-)\t/.exec(line); | |
| 47 | if (m === null) continue; | |
| 48 | files++; | |
| 49 | additions += m[1] === "-" ? 0 : Number(m[1]); | |
| 50 | deletions += m[2] === "-" ? 0 : Number(m[2]); | |
| 51 | } | |
| 52 | return { files, additions, deletions }; | |
| 53 | } | |
| 54 | ||
| 55 | function workOf(app: App): Work { | |
| 56 | let w = works.get(app); | |
| 57 | if (w === undefined) works.set(app, (w = { kept: new Map(), queued: new Map(), running: new Set(), inflight: new Set(), failed: new Map() })); | |
| 58 | return w; | |
| 59 | } | |
| 60 | ||
| 61 | function pump(app: App, w: Work): void { | |
| 62 | while (!app.closing.signal.aborted && w.running.size < LIMIT) { | |
| 63 | const next = w.queued.entries().next(); | |
| 64 | if (next.done) return; | |
| 65 | const [key, job] = next.value; | |
| 66 | w.queued.delete(key); | |
| 67 | const p: Promise<void> = job() | |
| 68 | .catch(() => {}) | |
| 69 | .finally(() => { | |
| 70 | w.running.delete(p); | |
| 71 | pump(app, w); | |
| 72 | }); | |
| 73 | w.running.add(p); | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * The stat if it is kept; "failed" while a recent attempt failed; otherwise | |
| 79 | * "loading", with the computation started in the background (once per pair). | |
| 80 | * Never waits on git. | |
| 81 | */ | |
| 82 | export function diffStatFor( | |
| 83 | app: App, | |
| 84 | repo: { ownerName: string; name: string }, | |
| 85 | pull: Pick<Pull, "baseOid" | "headOid" | "sealed" | "crossRepo" | "headRepoId" | "baseBranch">, | |
| 86 | ): DiffStat | "loading" | "failed" { | |
| 87 | const w = workOf(app); | |
| 88 | const key = `${pull.baseOid}:${pull.headOid}`; | |
| 89 | const kept = w.kept.get(key); | |
| 90 | if (kept !== undefined) return kept; | |
| 91 | if (!isSafeOid(pull.baseOid) || !isSafeOid(pull.headOid)) return "failed"; | |
outdated
A failure is forgotten at once, so a pull whose diff always fails or times out spawns git on every visit. Keep a short negative entry. |
||
| 92 | const failedUntil = w.failed.get(key); | |
| 93 | if (failedUntil !== undefined) { | |
| 94 | if (failedUntil > Date.now()) return "failed"; | |
| 95 | w.failed.delete(key); | |
| 96 | } | |
| 97 | if (app.closing.signal.aborted || w.inflight.has(key)) return "loading"; | |
| 98 | w.inflight.add(key); | |
| 99 | w.queued.set(key, async () => { | |
| 100 | try { | |
| 101 | await compute(app, w, key, repo, pull); | |
| 102 | } catch { | |
| 103 | if (!app.closing.signal.aborted) w.failed.set(key, Date.now() + RETRY_MS); | |
| 104 | } finally { | |
| 105 | w.inflight.delete(key); | |
| 106 | } | |
| 107 | }); | |
| 108 | pump(app, w); | |
| 109 | return "loading"; | |
| 110 | } | |
| 111 | ||
| 112 | async function compute( | |
Bare block with no purpose; remove it and dedent. |
||
| 113 | app: App, | |
| 114 | w: Work, | |
| 115 | key: string, | |
| 116 | repo: { ownerName: string; name: string }, | |
| 117 | pull: Pick<Pull, "baseOid" | "headOid" | "sealed" | "crossRepo" | "headRepoId" | "baseBranch">, | |
| 118 | ): Promise<void> { | |
| 119 | const dir = await pullDir(app.db, app.reposRoot, repo, pull); | |
| 120 | const out = await runGit(["diff", "--numstat", "--no-color", "--no-renames", `${pull.baseOid}...${pull.headOid}`, "--"], { | |
| 121 | cwd: dir, | |
| 122 | timeoutMs: 30_000, | |
| 123 | signal: app.closing.signal, | |
| 124 | }); | |
| 125 | if (app.closing.signal.aborted) return; | |
| 126 | if (!out.ok) { | |
| 127 | w.failed.set(key, Date.now() + RETRY_MS); | |
| 128 | return; | |
| 129 | } | |
| 130 | keep(w, key, parseNumstat(out.value)); | |
| 131 | } | |
| 132 | ||
| 133 | function keep(w: Work, key: string, stat: DiffStat): void { | |
| 134 | if (w.kept.size >= KEPT_MAX) w.kept.delete(w.kept.keys().next().value!); | |
| 135 | w.kept.set(key, stat); | |
| 136 | } | |
| 137 | ||
| 138 | /** Seed the cache for a (base, head) pair: for tests that need a known size without a repository. */ | |
| 139 | export function rememberStat(app: App, baseOid: string, headOid: string, stat: DiffStat): void { | |
| 140 | keep(workOf(app), `${baseOid}:${headOid}`, stat); | |
| 141 | } | |
| 142 | ||
| 143 | /** `3 files changed, +40 −7`. */ | |
| 144 | export function sizeLine(s: DiffStat): string { | |
| 145 | return `${s.files} ${s.files === 1 ? "file" : "files"} changed, +${s.additions} −${s.deletions}`; | |
| 146 | } | |
| 147 | ||
| 148 | /** Resolves when the app's background stats have all settled: for tests. */ | |
| 149 | export async function settled(app: App): Promise<void> { | |
| 150 | const w = works.get(app); | |
| 151 | while (w !== undefined && w.running.size > 0) await Promise.allSettled([...w.running]); | |
| 152 | } |
src/web/studio/review_page.ts +15 −3
| @@ -17,6 +17,7 @@ import { html, type Html } from "../html.ts"; | ||
| 17 | 17 | import { page } from "../layout.ts"; |
| 18 | 18 | import { badge, empty, listCard, row, runStateBadge, time } from "../ui.ts"; |
| 19 | 19 | import { waitingOn } from "./asks.ts"; |
| 20 | import { diffStatFor, sizeLine } from "./review_evidence.ts"; | |
| 20 | 21 | |
| 21 | 22 | /** The check on a pull's head, as the pull page wears it; only a run of this very head counts. */ |
| 22 | 23 | function verdict(run: Run | undefined, headOid: string): Html { |
| @@ -60,14 +61,25 @@ export function reviewPage(ctx: Ctx): Response { | ||
| 60 | 61 | rec === undefined |
| 61 | 62 | ? "The crew has not advised yet" |
| 62 | 63 | : html`The crew advises: ${rec.verdict}${rec.reason === "" ? "" : html` — ${rec.reason}`}`; |
| 64 | const stat = a.pull === undefined ? undefined : diffStatFor(ctx.app, a.repo, a.pull); | |
| 65 | const pending = | |
| 66 | stat === "loading" | |
| 67 | ? " · Diff loading — reload for the size" | |
| 68 | : stat === "failed" | |
outdated
A failed stat and a pending one both read "Diff loading", and nothing says to reload. Word the failed state differently and tell the reader how to get the number. |
||
| 69 | ? " · Diff size unavailable — open the pull to see it" | |
| 70 | : ""; | |
| 63 | 71 | return row({ |
| 64 | 72 | href, |
| 65 | 73 | nav: true, |
outdated
Size is the last item of an ellipsised meta line, so it is dropped first on 360px. Move it to the trail or its own line. |
||
| 66 | 74 | title: a.pull?.title ?? a.issue?.title ?? a.proposal?.title ?? `${a.repo.name} is waiting on you`, |
| 67 | 75 | titleAttr: `${a.repo.ownerName}/${a.repo.name}: ${a.why ?? a.consequence.why}`, |
| 68 | meta: html`${a.repo.ownerName}/${a.repo.name} · ${time(a.since)} · ${a.why ?? a.consequence.why} · ${advised}`, | |
| 69 | // The trail survives the meta line's ellipsis: the verdict is the fact this row gives. | |
| 70 | ...(a.pull === undefined ? {} : { trail: verdict(checks.get(a.pull.id), a.pull.headOid) }), | |
| 76 | meta: html`${a.repo.ownerName}/${a.repo.name} · ${time(a.since)} · ${a.why ?? a.consequence.why} · ${advised}${pending}`, | |
| 77 | // The trail survives the meta line's ellipsis: the verdict and the size are the facts this row gives. | |
| 78 | ...(a.pull === undefined | |
| 79 | ? {} | |
| 80 | : { | |
| 81 | trail: html`${typeof stat === "object" ? html`<span class="small muted">${sizeLine(stat)}</span> ` : ""}${verdict(checks.get(a.pull.id), a.pull.headOid)}`, | |
| 82 | }), | |
| 71 | 83 | // A sibling of the row's link, never inside it: a link in a link is not a link. |
| 72 | 84 | ...(a.pull !== undefined && apps.has(a.repo.id) |
| 73 | 85 | ? { children: [row({ href: `${base}/app/${previewEnv(a.pull.number)}/open`, title: "Open the preview", nav: true })] } |
test/review-evidence.test.ts added +120
| @@ -0,0 +1,120 @@ | ||
| 1 | /** `/review`'s diff stat: cached per base and head, never waited on, stopped with the app. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import type { App } from "../src/app.ts"; | |
| 7 | import { diffStatFor, parseNumstat, settled } from "../src/web/studio/review_evidence.ts"; | |
| 8 | ||
| 9 | let root = ""; | |
| 10 | let base = ""; | |
| 11 | let head = ""; | |
| 12 | const repo = { ownerName: "o", name: "r" }; | |
| 13 | const pullOf = (b: string, h: string) => | |
| 14 | ({ baseOid: b, headOid: h, sealed: false, crossRepo: false, headRepoId: "", baseBranch: "main" }) as const; | |
| 15 | const fakeApp = () => ({ closing: new AbortController(), reposRoot: root, db: null }) as unknown as App; | |
| 16 | ||
| 17 | const git = (cwd: string, ...args: string[]) => { | |
| 18 | const r = Bun.spawnSync(["git", ...args], { cwd, env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } }); | |
| 19 | return r.stdout.toString().trim(); | |
| 20 | }; | |
| 21 | ||
| 22 | beforeAll(() => { | |
| 23 | root = mkdtempSync(join(tmpdir(), "forge-evidence-")); | |
| 24 | const dir = join(root, "o", "r.git"); | |
| 25 | mkdirSync(dir, { recursive: true }); | |
| 26 | git(dir, "init", "--bare", "-b", "main"); | |
| 27 | const work = join(root, "work"); | |
| 28 | mkdirSync(work); | |
| 29 | git(work, "init", "-b", "main"); | |
| 30 | writeFileSync(join(work, "a.txt"), "one\ntwo\n"); | |
| 31 | git(work, "add", "."); | |
| 32 | git(work, "commit", "-m", "base"); | |
| 33 | base = git(work, "rev-parse", "HEAD"); | |
| 34 | writeFileSync(join(work, "a.txt"), "one\n"); | |
| 35 | writeFileSync(join(work, "b.txt"), "x\ny\nz\n"); | |
| 36 | git(work, "add", "."); | |
| 37 | git(work, "commit", "-m", "head"); | |
| 38 | head = git(work, "rev-parse", "HEAD"); | |
| 39 | git(work, "push", dir, "main"); | |
| 40 | }); | |
| 41 | afterAll(() => rmSync(root, { recursive: true, force: true })); | |
| 42 | ||
| 43 | function countSpawns<T>(f: () => Promise<T>): Promise<{ value: T; spawns: number }> { | |
| 44 | const real = Bun.spawn; | |
| 45 | let spawns = 0; | |
| 46 | (Bun as { spawn: unknown }).spawn = ((...a: unknown[]) => (spawns++, (real as (...x: unknown[]) => unknown)(...a))) as unknown; | |
| 47 | return f().then((value) => ({ value, spawns })).finally(() => ((Bun as { spawn: unknown }).spawn = real)); | |
| 48 | } | |
| 49 | ||
| 50 | test("numstat totals count files, lines and binary files", () => { | |
| 51 | expect(parseNumstat("3\t1\ta\n-\t-\tb.png\n")).toEqual({ files: 2, additions: 3, deletions: 1 }); | |
| 52 | }); | |
| 53 | ||
| 54 | test("a cold request is loading and computes in the background; a warm one makes no git reads", async () => { | |
| 55 | const app = fakeApp(); | |
| 56 | expect(diffStatFor(app, repo, pullOf(base, head))).toBe("loading"); | |
| 57 | await settled(app); | |
| 58 | const warm = await countSpawns(async () => diffStatFor(app, repo, pullOf(base, head))); | |
| 59 | expect(warm.value).toEqual({ files: 2, additions: 3, deletions: 1 }); | |
| 60 | expect(warm.spawns).toBe(0); | |
| 61 | }); | |
| 62 | ||
| 63 | test("calls while the first is running share it: git is spawned once", async () => { | |
| 64 | const app = fakeApp(); | |
| 65 | const r = await countSpawns(async () => { | |
| 66 | const seen = [1, 2, 3].map(() => diffStatFor(app, repo, pullOf(base, head))); | |
| 67 | await settled(app); | |
| 68 | seen.push(diffStatFor(app, repo, pullOf(base, head))); | |
| 69 | return seen; | |
| 70 | }); | |
| 71 | expect(r.spawns).toBe(1); | |
| 72 | expect(r.value.slice(0, 3)).toEqual(["loading", "loading", "loading"]); | |
| 73 | expect(r.value[3]).toEqual({ files: 2, additions: 3, deletions: 1 }); | |
| 74 | }); | |
| 75 | ||
| 76 | test("a failed stat says failed and is not asked again within the retry window", async () => { | |
| 77 | const app = fakeApp(); | |
| 78 | const missing = "1".repeat(40); | |
| 79 | const r = await countSpawns(async () => { | |
| 80 | expect(diffStatFor(app, repo, pullOf(base, missing))).toBe("loading"); | |
| 81 | await settled(app); | |
| 82 | return [diffStatFor(app, repo, pullOf(base, missing)), diffStatFor(app, repo, pullOf(base, missing))]; | |
| 83 | }); | |
| 84 | expect(r.value).toEqual(["failed", "failed"]); | |
| 85 | expect(r.spawns).toBe(1); | |
| 86 | }); | |
| 87 | ||
| 88 | test("an oid git would not take is failed without a process", async () => { | |
| 89 | const app = fakeApp(); | |
| 90 | const r = await countSpawns(async () => diffStatFor(app, repo, pullOf(base, "--output=x"))); | |
| 91 | expect(r.value).toBe("failed"); | |
| 92 | expect(r.spawns).toBe(0); | |
| 93 | }); | |
| 94 | ||
| 95 | test("at most four run at once, and closing the app stops the work and starts none", async () => { | |
| 96 | const app = fakeApp(); | |
| 97 | const real = Bun.spawn; | |
| 98 | let live = 0; | |
| 99 | let peak = 0; | |
| 100 | (Bun as { spawn: unknown }).spawn = ((...a: unknown[]) => { | |
| 101 | const p = (real as (...x: unknown[]) => { exited: Promise<number> })(...a); | |
| 102 | peak = Math.max(peak, ++live); | |
| 103 | void p.exited.then(() => live--); | |
| 104 | return p; | |
| 105 | }) as unknown; | |
| 106 | try { | |
| 107 | for (let i = 0; i < 10; i++) diffStatFor(app, repo, pullOf(base, i.toString(16).repeat(40).slice(0, 40))); | |
| 108 | app.closing.abort(); | |
| 109 | await settled(app); | |
| 110 | expect(peak).toBeLessThanOrEqual(4); | |
| 111 | expect(live).toBe(0); | |
| 112 | const before = live; | |
| 113 | diffStatFor(app, repo, pullOf(base, head)); | |
| 114 | await settled(app); | |
| 115 | expect(live).toBe(before); | |
| 116 | expect(diffStatFor(app, repo, pullOf(base, head))).toBe("loading"); | |
| 117 | } finally { | |
| 118 | (Bun as { spawn: unknown }).spawn = real; | |
| 119 | } | |
| 120 | }); |
test/review-page.test.ts +34
| @@ -14,6 +14,7 @@ import { createUser } from "../src/core/users.ts"; | ||
| 14 | 14 | import { BREAKER_NOTE } from "../src/crew/breaker.ts"; |
| 15 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 16 | 16 | import { id } from "../src/kernel/id.ts"; |
| 17 | import { rememberStat, settled } from "../src/web/studio/review_evidence.ts"; | |
| 17 | 18 | import { quietLogs } from "./helpers/globals.ts"; |
| 18 | 19 | |
| 19 | 20 | quietLogs(); |
| @@ -172,3 +173,36 @@ test("a pull of an app links its preview beside the row, not inside it; an issue | ||
| 172 | 173 | expect(issue).not.toContain("badge"); |
| 173 | 174 | expect(issue).not.toContain("No check"); |
| 174 | 175 | }); |
| 176 | ||
| 177 | test("a pull's row says its diff is loading or unavailable, and unavailable once git failed; an issue row says neither", async () => { | |
| 178 | const rowsOf = async () => | |
| 179 | (await (await fetch(`${servers.url}/review`, { headers: { cookie } })).text()) | |
| 180 | .split("<li") | |
| 181 | .filter((r) => r.includes('class="row"')); | |
| 182 | const first = await rowsOf(); | |
| 183 | // Earlier tests in this file may already have asked git; either way the page answered at once. | |
| 184 | expect(first.find((r) => r.includes("Stale pull"))!).toMatch(/Diff (loading — reload for the size|size unavailable)/); | |
| 185 | await settled(app); | |
| 186 | const second = await rowsOf(); | |
| 187 | // The fixture pulls point at the zero oid, which git cannot diff. | |
| 188 | expect(second.find((r) => r.includes("Stale pull"))!).toContain("Diff size unavailable"); | |
| 189 | expect(second.find((r) => r.includes("Stale pull"))!).not.toContain("Diff loading"); | |
| 190 | expect(second.find((r) => r.includes("Crew asked about this"))!).not.toContain("Diff"); | |
| 191 | }); | |
Only the loading and unavailable states are covered. Add a case that renders |
||
| 192 | ||
| 193 | test("a kept stat is the sentence in the row's trail, singular for one file", async () => { | |
| 194 | const rowsOf = async () => | |
| 195 | (await (await fetch(`${servers.url}/review`, { headers: { cookie } })).text()) | |
| 196 | .split("<li") | |
| 197 | .filter((r) => r.includes('class="row"')); | |
| 198 | rememberStat(app, ZERO, ZERO, { files: 3, additions: 40, deletions: 7 }); | |
| 199 | const many = (await rowsOf()).find((r) => r.includes("Stale pull"))!; | |
| 200 | expect(many).toContain("3 files changed, +40 −7"); | |
| 201 | expect(many.slice(many.indexOf("row-trail"))).toContain("3 files changed, +40 −7"); | |
| 202 | expect(many).not.toContain("Diff size unavailable"); | |
| 203 | rememberStat(app, ZERO, ZERO, { files: 1, additions: 2, deletions: 0 }); | |
| 204 | const one = (await rowsOf()).find((r) => r.includes("Stale pull"))!; | |
| 205 | expect(one).toContain("1 file changed, +2 −0"); | |
| 206 | expect(one).not.toContain("1 files"); | |
| 207 | expect((await rowsOf()).find((r) => r.includes("Crew asked about this"))!).not.toContain("changed"); | |
| 208 | }); |
Only
queuedis checked. Once pump() has moved the job to running, the same key is queued again by the next request. Check an in-flight set as well.