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

The flows view shows every line in and out: dependencies with advisories, origins, followed sources, forks, followers, mirrors and forges that follow #9603

Merged opened this

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

Merged

scout/forge:scout/9590-the-flows-view-shows-every-line-in-and-o → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/9590-the-flows-view-shows-every-line-in-and-o was merged into main as 122cf35.

Review

Sign in to review this change.
9 files changed +216 −27
docs/features/flows.md +5 −1
@@ -14,4 +14,8 @@
1414 - **Terminal.** The Flows screen shows the same lines with health tones and the same gates: `c` compares heads (admin), `p` pulls in what's new (writer, `takeFromOrigin`), `o` names the page that offers a pull to the parent. A guest sees only what they may read.
1515 - **Actions.** A writer of a mirror with news sees *Pull in what's new* (the existing `sync/take`, which refuses a diverged mirror); a writer of a fork sees *Offer what's useful* (the existing pull-to-parent page).
1616
17 Left out: the CHANGELOG sections between pin and newest (dropped until a lookup can read one within a byte bound); a schedule and the issue the crew files from a report; advisories and code scanning (and the release block on a new high finding); per-line buttons (actions are listed under the columns); counting a fork's commits ahead of its parent; GitHub release tags; the all-repositories admin view; "last moved" for forks. Each is its own pull on top of `dep_latest` and `readFlows`.
17 - **Advisories.** The same *Read upstream releases now* also asks the npm registry's bulk advisory endpoint about each exact pin and stores a one-line answer (severity, title, `(+N more)` when there are several) in `dep_latest.advisory`. A package with one shows an *advisory* badge and the text on the page and in the terminal (`depState`, `src/flows/radar.ts`, words both). They refresh only on that button: the page never calls the registry. The advisory ask is a second request per package inside the same 30 s run deadline.
18 - **Forks.** Each fork out reads *fork, takes updates* or *fork, told of updates* (its `fork_follow` mode), and its last-moved time is its newest push (its creation until it has one). A fork following its parent is the only "follower" the forge records; there is no table of people following a repository.
19 - **Forges that follow.** A child forge holding a proposal token for the repository is a *forge follows* line (its origin without userinfo or query; last use of the token, else when it was minted; *token ended* once expired). Only people who can write to the repository see these lines, page and terminal alike.
20
21 Left out: the CHANGELOG sections between pin and newest (dropped until a lookup can read one within a byte bound); a schedule and the issue the crew files from a report; code scanning (and the release block on a new high finding); per-line buttons (actions are listed under the columns); counting a fork's commits ahead of its parent; GitHub release tags; the all-repositories admin view. Each is its own pull on top of `dep_latest` and `readFlows`.
src/core/migration-pins/20261108160000-dep-advisory.pin added +1
@@ -0,0 +1,1 @@
1 7eed33d02cd876fd1a5ce737a30bf8284931f122025d142fca415416a5c9b0cf
src/core/migrations/20261108160000-dep-advisory.ts added +6
@@ -0,0 +1,6 @@
1 // A known security advisory for the version a repository pins (#9590): the
2 // radar's last read keeps the advisory's one-line summary beside the newest
3 // release, "" when none is known. Additive.
4 export default `
5 ALTER TABLE dep_latest ADD COLUMN advisory TEXT NOT NULL DEFAULT '';
6 `;
src/flows/radar.ts +46 −12
@@ -12,10 +12,12 @@ import { gapOf, parseSemver, type Gap } from "./semver.ts";
1212
1313 export interface Upstream {
1414 readonly latest: string;
15 /** One line about a known advisory for the pinned version, or absent. */
16 readonly advisory?: string;
1517 }
1618
1719 /** Asks upstream about one package: its newest version, or why it could not say. */
18 export type Lookup = (name: string) => Promise<Upstream | string>;
20 export type Lookup = (name: string, version: string) => Promise<Upstream | string>;
1921
2022 export interface DepLine {
2123 readonly name: string;
@@ -24,6 +26,7 @@ export interface DepLine {
2426 readonly latest: string;
2527 readonly gap: Gap;
2628 readonly dev: boolean;
29 readonly advisory: string;
2730 readonly error: string;
2831 readonly checkedAt: string | null;
2932 }
@@ -34,7 +37,7 @@ export const CONCURRENCY = 4;
3437 export const RUN_DEADLINE_MS = 30_000;
3538
3639 /** The npm registry's answer for one package; the name is validated before it is put in a URL. */
37 export const npmLookup: Lookup = async (name) => {
40 export const npmLookup: Lookup = async (name, version) => {
3841 if (!NPM_NAME.test(name)) return "not a package name";
3942 try {
4043 const res = await fetch(`https://registry.npmjs.org/${name.replace("/", "%2F")}/latest`, {
@@ -43,13 +46,36 @@ export const npmLookup: Lookup = async (name) => {
4346 });
4447 if (!res.ok) return `registry answered ${res.status}`;
4548 const body: unknown = await res.json();
46 const version = typeof body === "object" && body !== null ? (body as { version?: unknown }).version : undefined;
47 return typeof version === "string" && parseSemver(version) ? { latest: version } : "no version in the answer";
49 const latest = typeof body === "object" && body !== null ? (body as { version?: unknown }).version : undefined;
50 if (typeof latest !== "string" || !parseSemver(latest)) return "no version in the answer";
51 return { latest, advisory: await npmAdvisory(name, version) };
4852 } catch {
4953 return "registry not reachable";
5054 }
5155 };
5256
57 /** The registry's advisory for one package at one version: its first title, or "" (none, no exact version, or not reachable). */
58 async function npmAdvisory(name: string, version: string): Promise<string> {
59 if (version === "" || !NPM_NAME.test(name)) return "";
60 try {
61 const res = await fetch("https://registry.npmjs.org/-/npm/v1/security/advisories/bulk", {
62 method: "POST",
63 signal: AbortSignal.timeout(5000),
64 headers: { accept: "application/json", "content-type": "application/json" },
65 body: JSON.stringify({ [name]: [version] }),
66 });
67 if (!res.ok) return "";
68 const body: unknown = await res.json();
69 const list = typeof body === "object" && body !== null ? (body as Record<string, unknown>)[name] : undefined;
70 const first = Array.isArray(list) ? (list[0] as { title?: unknown; severity?: unknown } | undefined) : undefined;
71 if (typeof first?.title !== "string") return "";
72 const more = (list as unknown[]).length - 1;
73 return `${typeof first.severity === "string" ? `${first.severity.slice(0, 20)}: ` : ""}${first.title.slice(0, 130)}${more > 0 ? ` (+${more} more)` : ""}`;
74 } catch {
75 return "";
76 }
77 }
78
5379 /**
5480 * Read upstream for every exact pin and store the answers, a few at a time and
5581 * within a total deadline; a pin not reached keeps its last row. Returns how
@@ -64,9 +90,9 @@ export async function refreshRadar(
6490 deadlineMs = RUN_DEADLINE_MS,
6591 ): Promise<number> {
6692 const put = db.query(
67 `INSERT INTO dep_latest (repo_id, name, latest, error, checked_at) VALUES (?1, ?2, ?3, ?4, ?5)
93 `INSERT INTO dep_latest (repo_id, name, latest, error, checked_at, advisory) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
6894 ON CONFLICT (repo_id, name) DO UPDATE SET latest = excluded.latest,
69 error = excluded.error, checked_at = excluded.checked_at`,
95 error = excluded.error, checked_at = excluded.checked_at, advisory = excluded.advisory`,
7096 );
7197 const todo = pins.slice(0, MAX_PINS).filter((p) => p.version !== "");
7298 const stop = Date.now() + deadlineMs;
@@ -75,10 +101,10 @@ export async function refreshRadar(
75101 const worker = async (): Promise<void> => {
76102 while (next < todo.length && Date.now() < stop) {
77103 const pin = todo[next++]!;
78 const answer = await lookup(pin.name);
104 const answer = await lookup(pin.name, pin.version);
79105 const at = now.toISOString();
80 if (typeof answer === "string") put.run(repoId, pin.name, "", answer.slice(0, 200), at);
81 else put.run(repoId, pin.name, answer.latest, "", at);
106 if (typeof answer === "string") put.run(repoId, pin.name, "", answer.slice(0, 200), at, "");
107 else put.run(repoId, pin.name, answer.latest, "", at, (answer.advisory ?? "").slice(0, 200));
82108 written++;
83109 }
84110 };
@@ -86,12 +112,12 @@ export async function refreshRadar(
86112 return written;
87113 }
88114
89 interface Row { name: string; latest: string; error: string; checked_at: string }
115 interface Row { name: string; latest: string; error: string; checked_at: string; advisory: string }
90116
91117 /** Each pin with what the radar last read, outdated first. */
92118 export function readRadar(db: Database, repoId: string, pins: readonly Pin[]): DepLine[] {
93119 const rows = new Map(
94 db.query<Row, [string]>("SELECT name, latest, error, checked_at FROM dep_latest WHERE repo_id = ?1").all(repoId).map((r) => [r.name, r]),
120 db.query<Row, [string]>("SELECT name, latest, error, checked_at, advisory FROM dep_latest WHERE repo_id = ?1").all(repoId).map((r) => [r.name, r]),
95121 );
96122 const rank: Record<Gap, number> = { major: 0, minor: 1, patch: 2, none: 3 };
97123 return pins
@@ -101,8 +127,16 @@ export function readRadar(db: Database, repoId: string, pins: readonly Pin[]): D
101127 const b = parseSemver(r?.latest ?? "");
102128 return {
103129 name: p.name, range: p.range, pinned: p.version, latest: r?.latest ?? "", dev: p.dev,
104 gap: a && b ? gapOf(a, b) : "none", error: r?.error ?? "", checkedAt: r?.checked_at ?? null,
130 gap: a && b ? gapOf(a, b) : "none", advisory: r?.advisory ?? "", error: r?.error ?? "", checkedAt: r?.checked_at ?? null,
105131 };
106132 })
107133 .sort((x, y) => rank[x.gap] - rank[y.gap] || x.name.localeCompare(y.name));
108134 }
135
136 /** How a package stands, worded once for the page and the terminal: an advisory outranks a read error, which outranks the gap. */
137 export function depState(d: DepLine): { label: string; tone: "danger" | "attn" | "default" } {
138 if (d.advisory !== "") return { label: "advisory", tone: "danger" };
139 if (d.error !== "") return { label: "not read", tone: "danger" };
140 if (d.gap === "none") return { label: d.checkedAt === null ? "not read yet" : "current", tone: "default" };
141 return { label: `${d.gap} update`, tone: d.gap === "major" ? "danger" : "attn" };
142 }
src/flows/view.ts +27 −4
@@ -7,7 +7,8 @@
77
88 import type { Database } from "bun:sqlite";
99 import { getCursor } from "../core/origins.ts";
10 import { canRead, canWrite, getRepoById, visibleForks, type Repo } from "../core/repos.ts";
10 import { forkFollowMode } from "../core/fork_follow.ts";
11 import { canRead, canWrite, getRepoById, repoDatesFor, visibleForks, type Repo } from "../core/repos.ts";
1112 import type { User } from "../core/users.ts";
1213 import { aheadWords, parseAhead } from "../origins/ahead.ts";
1314
@@ -25,7 +26,7 @@ export interface FlowAction {
2526 }
2627
2728 export interface FlowLine {
28 readonly kind: "origin" | "source" | "upstream" | "fork" | "mirror";
29 readonly kind: "origin" | "source" | "upstream" | "fork" | "mirror" | "forge";
2930 readonly label: string;
3031 /** In-forge link, when the line is a repository here. */
3132 readonly href: string;
@@ -123,8 +124,15 @@ export function readFlows(db: Database, repo: Repo, viewer: User | undefined, no
123124 ...(writer && health === "behind" ? { action: { label: "Pull in what's new", href: `${base(repo)}/sync/take`, post: true } } : {}),
124125 });
125126 }
126 for (const f of visibleForks(db, repo.id, viewer)) {
127 outbound.push({ kind: "fork", label: `${f.ownerName}/${f.name}`, href: base(f), state: "fork", at: null, problem: false, health: "none" });
127 const forks = visibleForks(db, repo.id, viewer);
128 const dates = repoDatesFor(db, forks.map((f) => f.id));
129 for (const f of forks) {
130 const d = dates.get(f.id);
131 const mode = forkFollowMode(db, f.id);
132 outbound.push({
133 kind: "fork", label: `${f.ownerName}/${f.name}`, href: base(f), at: d?.pushedAt ?? d?.createdAt ?? null, problem: false, health: "none",
134 state: mode === "take-updates" ? "fork, takes updates" : "fork, told of updates",
135 });
128136 }
129137 const mirror = db
130138 .query<{ full_name: string; pushed_at: string | null; last_error: string }, [string]>(
@@ -138,5 +146,20 @@ export function readFlows(db: Database, repo: Repo, viewer: User | undefined, no
138146 health: mirror.last_error !== "" ? "unread" : "ok",
139147 });
140148 }
149 // Forges that follow this one: a child forge holds a proposal token for it. Their names are for those who write here.
150 if (writer) {
151 for (const c of db
152 .query<{ child: string; origin: string; created_at: string; last_used_at: string | null; expires_at: string | null }, [string]>(
153 `SELECT p.child, p.origin, t.created_at, t.last_used_at, t.expires_at
154 FROM proposal_token p JOIN token t ON t.id = p.token_id WHERE p.repo_id = ?1 ORDER BY t.created_at LIMIT 50`,
155 )
156 .all(repo.id)) {
157 const ended = c.expires_at !== null && Date.parse(c.expires_at) <= now;
158 outbound.push({
159 kind: "forge", label: `${c.child} (${plainUrl(c.origin)})`, href: "", at: c.last_used_at ?? c.created_at, problem: false,
160 health: ended ? "stale" : "none", state: ended ? "forge follows, token ended" : "forge follows",
161 });
162 }
163 }
141164 return { inbound, outbound };
142165 }
src/tui/screens/flows.ts +4 −4
@@ -7,7 +7,7 @@
77
88 import { takeFromOrigin } from "../../actions/origins.ts";
99 import { canRead, type Repo } from "../../core/repos.ts";
10 import { readRadar, type DepLine } from "../../flows/radar.ts";
10 import { depState, readRadar, type DepLine } from "../../flows/radar.ts";
1111 import { pinsOf } from "../../flows/run.ts";
1212 import { probeLinks } from "../../flows/probe.ts";
1313 import { mayStartRadar, startRadar } from "../../flows/start.ts";
@@ -28,9 +28,9 @@ const flowRow = (side: string) => (l: FlowLine): ListRow => ({
2828
2929 const depRow = (d: DepLine): ListRow => ({
3030 title: `pkg ${d.name}`,
31 secondary: d.error !== "" ? "not read" : d.gap === "none" ? (d.checkedAt === null ? "not read yet" : "current") : `${d.gap} update`,
32 ...(d.error !== "" || d.gap === "major" ? { tone: "danger" as const } : {}),
33 meta: `${d.pinned === "" ? d.range : `pinned ${d.pinned}`}${d.latest === "" ? "" : ` · newest ${d.latest}`}`,
31 secondary: depState(d).label,
32 ...(depState(d).tone === "default" ? {} : { tone: depState(d).tone }),
33 meta: `${d.pinned === "" ? d.range : `pinned ${d.pinned}`}${d.latest === "" ? "" : ` · newest ${d.latest}`}${d.advisory === "" ? "" : ` · advisory: ${d.advisory}`}`,
3434 });
3535
3636 export function flowsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
src/web/pages/flows.ts +4 −6
@@ -8,7 +8,7 @@
88 */
99
1010 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
11 import { readRadar } from "../../flows/radar.ts";
11 import { depState, readRadar } from "../../flows/radar.ts";
1212 import { pinsOf } from "../../flows/run.ts";
1313 import { probeLinks } from "../../flows/probe.ts";
1414 import { mayStartRadar, startRadar } from "../../flows/start.ts";
@@ -55,10 +55,8 @@ export async function flowsPage(ctx: Ctx): Promise<Response> {
5555 const depRows = deps.map((d) =>
5656 row({
5757 title: html`${d.name}`,
58 meta: html`${d.pinned === "" ? d.range : `pinned ${d.pinned}`}${d.latest === "" ? "" : ` · newest ${d.latest}`}${d.checkedAt === null ? "" : html` · read ${time(d.checkedAt)}`}`,
59 trail: badge(d.error !== "" ? "not read" : d.gap === "none" ? (d.checkedAt === null ? "not read yet" : "current") : `${d.gap} update`, {
60 tone: d.error !== "" || d.gap === "major" ? "danger" : d.gap === "none" ? "default" : "attn",
61 }),
58 meta: html`${d.pinned === "" ? d.range : `pinned ${d.pinned}`}${d.latest === "" ? "" : ` · newest ${d.latest}`}${d.checkedAt === null ? "" : html` · read ${time(d.checkedAt)}`}${d.advisory === "" ? "" : html` · advisory: ${d.advisory}`}`,

The terminal screen has no counterpart for this advisory text and badge. Add it to src/tui/screens/flows.ts.

59 trail: badge(depState(d).label, { tone: depState(d).tone }),
6260 }),
6361 );
6462 const body = html`${repoHeader(ctx, repo, "flows")}
@@ -69,7 +67,7 @@ export async function flowsPage(ctx: Ctx): Promise<Response> {
6967 ${actions}
7068 ${side("Packages", depRows, "No pinned packages.")}
7169 </div>
72 <div>${side("Flows out", flows.outbound.map(lineRow), "Nothing flows out: no forks and no mirror.")}</div>
70 <div>${side("Flows out", flows.outbound.map(lineRow), "Nothing flows out: no forks, no mirror and no forge follows.")}</div>
7371 </div>
7472 ${admin
7573 ? html`<form method="post" action="${repoBase(repo)}/flows/probe" class="mt">${btn("Compare heads now", { variant: "secondary" })}</form>
test/flows-lines.test.ts added +95
@@ -0,0 +1,95 @@
1 /** Flows (#9590): one line of every kind in and out is on the page, read from stored state, fast. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { createHmac } from "node:crypto";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { getRepo } from "../src/core/repos.ts";
10 import { getUserById } from "../src/core/users.ts";
11 import { setForkFollowMode } from "../src/core/fork_follow.ts";
12 import { commitFiles } from "../src/git/write.ts";
13 import { repoDir } from "../src/git/repos.ts";
14 import { refreshRadar, readRadar } from "../src/flows/radar.ts";
15 import { readPins } from "../src/flows/pins.ts";
16 import { readFlows } from "../src/flows/view.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { PREVIEW_ADMIN, PREVIEW_REPO } from "../src/spawn/preview-looks.ts";
19 import { expectWork, requestStatements } from "./helpers/work.ts";
20 import { seedPreview } from "../src/spawn/preview-seed.ts";
21
22 const KEY = "flows-lines-key";
23 let dir: string;
24 let app: App;
25 let servers: Servers;
26 const headers = () => {
27 const said = `u1|trevato|admin|${Date.now() + 60_000}`;
28 return { "x-forge-identity": said, "x-forge-signature": createHmac("sha256", KEY).update(said).digest("base64url") };
29 };
30 const url = () => `${servers.url}/${PREVIEW_ADMIN}/${PREVIEW_REPO}/flows`;
31
32 beforeAll(async () => {
33 dir = await mkdtemp(join(tmpdir(), "forge-flows-lines-"));
34 app = createApp({ dataDir: dir, port: 0, previewIdentityKey: KEY });
35 const seeded = await seedPreview(app, { FORGE_APP_ENV: "preview" });
36 if (!seeded.ok) throw new Error(seeded.error);
37 servers = await serve(app);
38 const repo = getRepo(app.db, PREVIEW_ADMIN, PREVIEW_REPO)!;
39 const pkg = JSON.stringify({ dependencies: { leftish: "1.0.0" } });
40 const c = await commitFiles(repoDir(app.reposRoot, repo.ownerName, repo.name), {
41 branch: repo.defaultBranch, message: "pins", files: [{ path: "package.json", content: pkg }], author: { name: "T", email: "[email protected]" },
42 });
43 if (!c.ok) throw new Error(JSON.stringify(c.error));
44 await refreshRadar(app.db, repo.id, readPins(pkg), async () => ({ latest: "1.4.0", advisory: "high: prototype <script>pollution" }));
45 app.db.query("INSERT INTO repo_source (id, repo_id, url, branch, by_id, created_at, checked_at) VALUES ('s1', ?1, 'https://src.example/x.git', 'main', 'u1', '2026-11-01T00:00:00Z', '2026-11-02T00:00:00Z')").run(repo.id);
46 app.db.exec("PRAGMA foreign_keys = OFF");
47 app.db.query("INSERT INTO migrate_mirror (repo_id, origin_id, full_name, clone_url, pushed_at) VALUES (?1, 'o1', 'octo/mirrored-on-github', 'https://github.com/octo/m.git', '2026-11-03T00:00:00Z')").run(repo.id);
48 const admin = getUserById(app.db, app.db.query<{ id: string }, [string]>("SELECT id FROM user WHERE username = ?").get(PREVIEW_ADMIN)!.id)!;
49 const kid = await createRepo(app, admin, { name: "kid-fork", owner: PREVIEW_ADMIN, visibility: "public", description: "" });
50 if (!kid.ok) throw new Error(JSON.stringify(kid.error));
51 app.db.query("UPDATE repo SET fork_parent_id = ?1 WHERE id = ?2").run(repo.id, kid.value.id);
52 setForkFollowMode(app.db, kid.value.id, "take-updates");
53 app.db.query("INSERT INTO token (id, token_hash, user_id, name, scope, created_at) VALUES ('t1', 'h1', ?1, 'proposal:child-forge', 'rw', '2026-11-04T00:00:00Z')").run(admin.id);
54 app.db.query("INSERT INTO proposal_token (token_id, repo_id, child, origin) VALUES ('t1', ?1, 'child-forge', 'https://child.example')").run(repo.id);
55 app.db.exec("PRAGMA foreign_keys = ON");
56 }, 30_000);
57 afterAll(async () => {
58 servers.stop();
59 app.close();
60 await rm(dir, { recursive: true, force: true });
61 });
62
63 describe("every line in and out", () => {
64 test("the radar keeps an advisory with the newest release", () => {
65 const repo = getRepo(app.db, PREVIEW_ADMIN, PREVIEW_REPO)!;
66 const [dep] = readRadar(app.db, repo.id, readPins(JSON.stringify({ dependencies: { leftish: "1.0.0" } })));
67 expect(dep).toMatchObject({ latest: "1.4.0", advisory: "high: prototype <script>pollution" });
68 });
69
70 test("the view names a forge that follows to a writer only, with when it last moved", () => {
71 const repo = getRepo(app.db, PREVIEW_ADMIN, PREVIEW_REPO)!;
72 const admin = getUserById(app.db, app.db.query<{ id: string }, [string]>("SELECT id FROM user WHERE username = ?").get(PREVIEW_ADMIN)!.id)!;
73 const mine = readFlows(app.db, repo, admin).outbound.find((l) => l.kind === "forge")!;
74 expect(mine).toMatchObject({ label: "child-forge (https://child.example/)", at: "2026-11-04T00:00:00Z" });
75 expect(readFlows(app.db, repo, undefined).outbound.some((l) => l.kind === "forge")).toBe(false);
76 const fork = readFlows(app.db, repo, undefined).outbound.find((l) => l.label.endsWith("/kid-fork"))!;
77 expect(fork.state).toBe("fork, takes updates");
78 expect(fork.at).not.toBeNull();
79 });
80
81 test("the page shows one of each kind, escaped, and costs a bounded number of reads", async () => {
82 await (await fetch(url(), { headers: headers() })).text();
83 const res = await fetch(url(), { headers: headers() });
84 const text = await res.text();
85 const cost = await requestStatements(app.db, url(), { headers: headers() });
86 expect(res.status).toBe(200);
87 for (const want of [
88 "leftish", "newest 1.4.0", "advisory: high: prototype", "src.example/x.git", "octo/mirrored-on-github",
89 `${PREVIEW_ADMIN}/kid-fork`, "fork, takes updates", "child-forge", "forge follows",
90 ]) expect(text).toContain(want);
91 expect(text).not.toContain("<script>pollution");
92 // Stored state only: the page chrome plus a few reads (50 measured), no per-line query, no network.
93 expectWork(cost, { max: 55 });
94 });
95 });
test/flows-tui.test.ts +28
@@ -5,6 +5,10 @@ import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { createRepo } from "../src/actions/repos.ts";
77 import { createApp, type App as Forge } from "../src/app.ts";
8 import { commitFiles } from "../src/git/write.ts";
9 import { repoDir } from "../src/git/repos.ts";
10 import { refreshRadar } from "../src/flows/radar.ts";
11 import { readPins } from "../src/flows/pins.ts";
812 import type { Repo } from "../src/core/repos.ts";
913 import { createUser, type User } from "../src/core/users.ts";
1014 import { createForgeApp } from "../src/tui/forge.ts";
@@ -58,6 +62,10 @@ describe("flows in the terminal", () => {
5862 if (!made.ok) throw new Error(JSON.stringify(made.error));
5963 repo = made.value;
6064 forge.db.query("INSERT INTO repo_source (id, repo_id, url, branch, by_id, created_at) VALUES ('s1', ?1, 'https://u:[email protected]/x.git', 'main', ?2, '2026-11-01T00:00:00Z')").run(repo.id, ada.id);
65 const pkg = JSON.stringify({ dependencies: { leftish: "1.0.0" } });
66 const c = await commitFiles(repoDir(forge.reposRoot, "ada", "arm"), { branch: repo.defaultBranch, message: "pins", files: [{ path: "package.json", content: pkg }], author: { name: "T", email: "[email protected]" } });
67 if (!c.ok) throw new Error(JSON.stringify(c.error));
68 await refreshRadar(forge.db, repo.id, readPins(pkg), async () => ({ latest: "1.4.0", advisory: "high: prototype pollution (+1 more)" }));
6169 }, 60_000);
6270 afterAll(async () => {
6371 forge?.close();
@@ -78,6 +86,26 @@ describe("flows in the terminal", () => {
7886 reader.stop();
7987 });
8088
89 test("a package with an advisory says so, with its text; forks and following forges read as on the page", async () => {
90 const kid = await createRepo(forge, ada, { name: "kid", owner: "ada", visibility: "public", description: "" });
91 if (!kid.ok) throw new Error(JSON.stringify(kid.error));
92 forge.db.query("UPDATE repo SET fork_parent_id = ?1 WHERE id = ?2").run(repo.id, kid.value.id);
93 forge.db.exec("PRAGMA foreign_keys = OFF");
94 forge.db.query("INSERT INTO token (id, token_hash, user_id, name, scope, created_at) VALUES ('t1', 'h1', ?1, 'proposal:child-forge', 'rw', '2026-11-04T00:00:00Z')").run(ada.id);
95 forge.db.query("INSERT INTO proposal_token (token_id, repo_id, child, origin) VALUES ('t1', ?1, 'child-forge', 'https://child.example')").run(repo.id);
96 forge.db.exec("PRAGMA foreign_keys = ON");
97 const admin = boot(ada);
98 await admin.open();
99 for (const want of ["leftish", "advisory", "high: prototype pollution (+1 more)", "ada/kid", "fork, told of updates", "child-forge", "forge follows"])
100 expect(admin.text()).toContain(want);
101 admin.stop();
102 const reader = boot(eve);
103 await reader.open();
104 expect(reader.text()).toContain("advisory");
105 expect(reader.text()).not.toContain("child-forge");
106 reader.stop();
107 });
108
81109 test("compare heads is offered to an admin and not to a reader or a guest", async () => {
82110 const admin = boot(ada);
83111 await admin.open();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs