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

/flow shows hand-overs to a person in the last day, pulls one review round away from one, and the five pulls to rebase first #11468

opened this

Check · Review · Land · Live — It conflicts with its base. Next: builder, it conflicts with its base.

Conflicts with main

scout/forge:scout/11456-flow-shows-hand-overs-to-a-person-in-the → main 8 commits fork 5 threads · 5 unresolved

Review

Sign in to review this change.
13 files changed +392 −15
docs/features/flow-handover.md added +19
@@ -0,0 +1,19 @@
1 # Flow: where the crew gives up (#11456)
2
3 `/flow` (owner only) shows one section under the pipes, "Where the crew gives up": plain rows, one intro line, and only the lists that have rows. Nothing is drawn when all three are empty. The reads are `src/flow/handover_read.ts` (open pulls only; two queries for review verdicts however many pulls are open); the markup is `src/web/pages/flow-handover.ts`.
4
5 | List | Pure rule | Reads |
6 | --- | --- | --- |
7 | Handed to a person in the last 24 h | `src/flow/handovers.ts` (`inWindow`) | the newest "Handed to a person by the crew:" note on an open pull, written under the assignee's name |
8 | One review round from a hand-over | `src/flow/review_rounds.ts` | open pulls whose trailing run of changes-requested reviews is two |
9 | Bring up to date first | `src/flow/rebase_first.ts` | at most five open pulls behind main (the drifts `/boards` reads), approved first, held never |
10
11 Both doors: the terminal Flow dialog (`src/tui/screens/flow.ts`) shows the same lists for a site admin who is a person; the digits 1-5 bring the numbered rebase pull up to date through `askUpdate` (`src/tui/screens/drift.ts`), which makes the same checks as `updateSubmit` (visible, writer, open). On the page each rebase row has the existing `POST /:owner/:name/pulls/:number/update` form; `updateSubmit` checks same-origin and takes `back` only when it is a local path.
12
13 `readDrifts` skips a repository whose name is unsafe for a directory; every other error surfaces.
14
15 Cost: `/flow` now awaits `readDrifts`: one `rev-parse` per open pull's base branch (shared per repository) plus a drift read per pull, both cached per head and tip, so a warm load is a map lookup and a cold one is git-bound.
16
17 No API route carries these lists yet; left out on purpose.
18
19 Tests: `test/flow-handover-page.test.ts`.
plugins/claude-code/.claude-plugin/plugin.json +1 −1
@@ -1,7 +1,7 @@
11 {
22 "name": "forge",
33 "version": "0.2.0",
4 "description": "Your forge inside Claude Code: its MCP server and a /forge command (new, tell, status, decide, archive, undo, plan, preview, pulls, runs, log, asked).",
4 "description": "Your forge inside Claude Code: its MCP server and a /forge command (new, tell, status, decide, archive, undo, plan, preview, pulls, runs, log, asked, waves, held, forks).",
55 "mcpServers": {
66 "forge": {
77 "type": "http",
scripts/unreached-allow.json −3
@@ -5,9 +5,6 @@
55 "src/flow/approved_wait.ts",
66 "src/flow/clog_words.ts",
77 "src/flow/conflict_pairs.ts",
8 "src/flow/handovers.ts",
9 "src/flow/rebase_first.ts",
10 "src/flow/review_rounds.ts",
118 "src/flow/shared_red.ts",
129 "src/flow/stale_fold.ts",
1310 "src/perf/page_budget.ts",
src/flow/alarm_drift.ts +4 −2
@@ -10,7 +10,7 @@ import { existsSync } from "node:fs";
1010
1111 import type { App } from "../app.ts";
1212 import { runGit } from "../git/env.ts";
13 import { isSafeOid, isSafeRef } from "../git/guard.ts";
13 import { isSafeName, isSafeOid, isSafeRef } from "../git/guard.ts";
1414 import { repoDir } from "../git/repos.ts";
1515 import { span } from "./pipeline.ts";
1616
@@ -127,7 +127,9 @@ export async function readDrifts(
127127 WHERE i.kind = 'pull' AND i.state = 'open' AND p.merged_at IS NULL
128128 AND (?1 = 0 OR r.visibility = 'public')`,
129129 )
130 .all(publicOnly ? 1 : 0);
130 .all(publicOnly ? 1 : 0)
131 // A repository whose name cannot name a directory has no git to read: skip it, never fail the others.
132 .filter((r) => isSafeName(r.owner) && isSafeName(r.name));
131133 const tips = new Map<string, Promise<string | null>>();
132134 const tipOf = (dir: string, branch: string) => {
133135 const k = `${dir}\0${branch}`;
src/flow/handover_read.ts added +94
@@ -0,0 +1,94 @@
1 /**
2 * The three lists /flow and its terminal screen share (#11456): pulls handed
3 * to a person in the last 24 h, pulls one changes-requested round from that,
4 * and the five open pulls to bring up to date first. Plain SQL, one query per
5 * list however many pulls are open; the drifts are passed in (the callers
6 * already read them). Open pulls only: a closed or merged pull needs no help.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { HANDOVER_NOTE } from "../crew/guard.ts";
11 import type { RepoDrift } from "./alarm_drift.ts";
12 import { inWindow } from "./handovers.ts";
13 import { rebaseFirst, type RebaseDrift } from "./rebase_first.ts";
14 import { nearHandover, type Verdict } from "./review_rounds.ts";
15
16 /** A pull named by repository and number. */
17 export interface Named {
18 readonly repo: string;
19 readonly number: number;
20 }
21
22 export interface HandoverLists {
23 readonly handed: readonly Named[];
24 readonly near: readonly Named[];
25 readonly rebase: readonly Named[];
26 }
27
28 const key = (n: Named): string => `${n.repo}#${String(n.number)}`;
29
30 const OPEN = `i.kind = 'pull' AND i.state = 'open' AND p.merged_at IS NULL`;
31 const FROM = `issue i JOIN pull p ON p.issue_id = i.id JOIN repo r ON r.id = i.repo_id JOIN user o ON o.id = r.owner_id`;
32
33 /** Pulls handed to a person in the last 24 h, newest first: the newest hand-over note by the assignee's own name on each. */
34 export function readHandovers(db: Database, now: number): Named[] {
35 return db
36 .query<{ repo: string; number: number; at: string }, [string]>(
37 `SELECT o.username || '/' || r.name AS repo, i.number AS number, MAX(c.created_at) AS at
38 FROM comment c JOIN issue i ON i.id = c.issue_id AND c.author_id = i.assignee_id
39 JOIN pull p ON p.issue_id = i.id JOIN repo r ON r.id = i.repo_id JOIN user o ON o.id = r.owner_id
40 WHERE ${OPEN} AND substr(c.body, 1, ${String(HANDOVER_NOTE.length)}) = ?1
41 GROUP BY i.id`,
42 )
43 .all(HANDOVER_NOTE)
44 .filter((r) => inWindow(r.at, now))
45 .sort((x, y) => Date.parse(y.at) - Date.parse(x.at) || x.repo.localeCompare(y.repo) || y.number - x.number)
46 .map((r) => ({ repo: r.repo, number: r.number }));
47 }
48
49 interface Open {
50 readonly id: string;
51 readonly named: Named;
52 readonly verdicts: Verdict[];
53 }
54
55 /** Every open pull with its approved / changes-requested verdicts in order: two queries in all. */
56 function openWithVerdicts(db: Database): Open[] {
57 const open = new Map<string, Open>();
58 for (const r of db
59 .query<{ id: string; repo: string; number: number }, []>(`SELECT i.id AS id, o.username || '/' || r.name AS repo, i.number AS number FROM ${FROM} WHERE ${OPEN} ORDER BY i.number`)
60 .all())
61 open.set(r.id, { id: r.id, named: { repo: r.repo, number: r.number }, verdicts: [] });
62 for (const v of db
63 .query<{ issue_id: string; state: Verdict }, []>(
64 `SELECT v.issue_id, v.state FROM review v JOIN issue i ON i.id = v.issue_id JOIN pull p ON p.issue_id = i.id
65 WHERE ${OPEN} AND v.state IN ('approved','changes_requested') ORDER BY v.issue_id, v.created_at, v.id`,
66 )
67 .all())
68 open.get(v.issue_id)?.verdicts.push(v.state);
69 return [...open.values()];
70 }
71
72 /** Open pulls whose trailing run of changes-requested reviews is two, the most recent first. */
73 export function readReviewRounds(db: Database): Named[] {
74 return nearHandover(openWithVerdicts(db).map((p) => ({ pull: p.named, verdicts: p.verdicts })));
75 }
76
77 /** The pulls to bring up to date first, from the drifts the caller already read. */
78 export function readRebaseFirst(db: Database, drifts: readonly RepoDrift[], now: number): Named[] {
79 const open = new Map(openWithVerdicts(db).map((p) => [key(p.named), p]));
80 const held = new Set(db.query<{ issue_id: string }, []>("SELECT issue_id FROM pull_hold WHERE released_at IS NULL").all().map((h) => h.issue_id));
81 const behind = drifts.filter((d) => d.landings > 0 && open.has(key(d)));
82 const picked = rebaseFirst(
83 behind.map((d, i): RebaseDrift => {
84 const p = open.get(key(d))!;
85 return { number: i, behind: d.landings, baseAt: d.since ?? now, approved: p.verdicts.at(-1) === "approved", held: held.has(p.id) };
86 }),
87 now,
88 );
89 return picked.map((i) => ({ repo: behind[i]!.repo, number: behind[i]!.number }));
90 }
91
92 export function readHandoverLists(db: Database, drifts: readonly RepoDrift[], now: number): HandoverLists {
93 return { handed: readHandovers(db, now), near: readReviewRounds(db), rebase: readRebaseFirst(db, drifts, now) };
94 }
src/flow/handovers.ts +7 −1
@@ -20,11 +20,17 @@ export interface Handovers {
2020
2121 const DAY_MS = 24 * 60 * 60 * 1000;
2222
23 /** Whether a note at `at` is inside the 24 h window ending at `now`. */
24 export function inWindow(at: string, now: number): boolean {
25 const t = Date.parse(at);
26 return Number.isFinite(t) && t <= now && now - t <= DAY_MS;
27 }
28
2329 export function handovers(rows: readonly Handover[], now: number): Handovers {
2430 const latest = new Map<number, number>();
2531 for (const r of rows) {
32 if (!inWindow(r.at, now)) continue;
2633 const t = Date.parse(r.at);
27 if (!Number.isFinite(t) || t > now || now - t > DAY_MS) continue;
2834 if (t > (latest.get(r.pull) ?? -Infinity)) latest.set(r.pull, t);
2935 }
3036 const newest = [...latest]
src/tui/forge.ts +7 −1
@@ -77,6 +77,9 @@ import { capacityScreen, mayCapacity } from "./screens/capacity.ts";
7777 import { crawlersScreen } from "./screens/crawlers.ts";
7878 import { forgeCeiling } from "../flow/ceiling.ts";
7979 import { flowLines, flowScreen } from "./screens/flow.ts";
80 import { askUpdate, mayDrift } from "./screens/drift.ts";
81 import { readHandoverLists } from "../flow/handover_read.ts";
82 import { readDrifts } from "../flow/alarm_drift.ts";
8083 import { readHeld } from "../flow/held.ts";
8184 import { answerKeeper, readKeeper } from "../flow/keeper.ts";
8285 import { readVerified } from "../flow/verified.ts";
@@ -497,7 +500,10 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
497500 readVerified(session.app, flowReader.kind === "owner"),
498501 keeper,
499502 session.app.config.dataDir,
500 ), keeper.ask && user ? { ask: keeper.ask, answer: () => answerKeeper(session.app, keeper.ask!.id, user.id) } : undefined));
503 ), keeper.ask && user ? { ask: keeper.ask, answer: () => answerKeeper(session.app, keeper.ask!.id, user.id) } : undefined,
504 mayDrift(session)
505 ? { read: async () => readHandoverLists(session.db, await readDrifts(session.app), Date.now()), update: (n) => askUpdate(session, n) }
506 : undefined));
501507 },
502508 });
503509 // The forge's capacity and guards, as MCP `capacity` gates them: a forge admin who is a person.
src/tui/screens/drift.ts +3 −2
@@ -10,6 +10,7 @@
1010 import { bringUpToDate } from "../../crew/autonomy.ts";
1111 import { driftingPulls, readDrifts, type DriftingPull } from "../../flow/alarm_drift.ts";
1212 import { getPull } from "../../core/pulls.ts";
13 import { mayView } from "../../core/sealed.ts";
1314 import { canWrite, getRepo } from "../../core/repos.ts";
1415 import type { Ctx, Screen } from "../app/app.ts";
1516 import { binding } from "../app/keys.ts";
@@ -25,11 +26,11 @@ export function driftRows(pulls: readonly DriftingPull[]): ListRow[] {
2526 }
2627
2728 /** Ask the pull's agent, as the route does; the words the toast says. */
28 export function askUpdate(f: Session, p: DriftingPull): { ok: boolean; says: string } {
29 export function askUpdate(f: Session, p: { readonly repo: string; readonly number: number }): { ok: boolean; says: string } {
2930 const [owner = "", name = ""] = p.repo.split("/");
3031 const repo = getRepo(f.db, owner, name);
3132 const pull = repo ? getPull(f.db, repo.id, p.number) : undefined;
32 if (!repo || !pull) return { ok: false, says: "That pull is gone." };
33 if (!repo || !pull || !mayView(f.db, repo, pull, f.user)) return { ok: false, says: "That pull is gone." };
3334 if (!canWrite(f.db, repo, f.user)) return { ok: false, says: "Only someone who can write here brings a pull up to date." };
3435 const who = pull.state === "open" && pull.mergedAt === null ? bringUpToDate(f.app, repo, pull, []) : undefined;
3536 return who === undefined
src/tui/screens/flow.ts +34 −3
@@ -58,6 +58,19 @@ import { dialog } from "../ui/widgets.ts";
5858 import { live } from "../../system/live.ts";
5959 import { confirmScreen } from "./common.ts";
6060 import { duplicateLines } from "./system.ts";
61 import type { HandoverLists, Named } from "../../flow/handover_read.ts";
62
63 /** The hand-over lists as dialog lines: only lists with rows; the rebase rows are numbered, and the number is the key that updates one. */
64 export function handoverLines(l: HandoverLists, inner: number): string[] {
65 const list = (title: string, rows: readonly Named[], num = false): string[] =>
66 rows.length === 0 ? [] : ["", title, ...rows.map((n, i) => truncate(` ${num ? `${String(i + 1)} ` : ""}${n.repo} #${String(n.number)}`, inner))];
67 const out = [
68 ...list("Handed to a person in the last 24 h:", l.handed),
69 ...list("One review round from a hand-over:", l.near),
70 ...list("Bring up to date first (press the number):", l.rebase, true),
71 ];
72 return out.length === 0 ? [] : ["", "Where the crew gives up", ...out.slice(1)];
73 }
6174
6275 const WIDTH = 64;
6376
@@ -288,14 +301,26 @@ export function flowScreen(
288301 lines: readonly string[],
289302 /** The keeper's open question (a site admin's); `answer` presses its one button and says what happened. */
290303 keeper?: { ask: KeeperAsk; answer: () => { done: boolean; message: string } },
304 /** The hand-over lists (a person-admin's): read when the screen opens; a digit presses that rebase row's update, as `updateSubmit` does. */
305 handover?: { read: () => Promise<HandoverLists>; update: (n: Named) => { ok: boolean; says: string } },
291306 ): Screen {
307 let lists: HandoverLists | undefined;
292308 const self: Screen = {
293309 title: "Flow",
294 keys: [binding(["enter", "esc", "q"], "close"), ...(keeper?.ask ? [binding(["l"], keeper.ask.button.toLowerCase())] : [])],
310 keys: [
311 binding(["enter", "esc", "q"], "close"),
312 ...(keeper?.ask ? [binding(["l"], keeper.ask.button.toLowerCase())] : []),
313 ...(handover ? [binding(["1", "2", "3", "4", "5"], "bring a pull up to date")] : []),
314 ],
315 async load() {
316 if (handover === undefined) return;
317 lists = await handover.read();
318 ctx.invalidate();
319 },
295320 render(area, s) {
296321 const buttons = dialog(s, area, ctx.theme.c, {
297322 title: "Flow",
298 body: lines,
323 body: [...lines, ...(lists ? handoverLines(lists, WIDTH - 8) : [])],
299324 actions: [{ label: "Close" }],
300325 selected: 0,
301326 width: WIDTH,
@@ -304,7 +329,13 @@ export function flowScreen(
304329 },
305330 key(e) {
306331 if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop();
307 else if (e.key === "l" && keeper)
332 else if (handover && lists && /^[1-5]$/.test(e.key)) {
333 const n = lists.rebase[Number(e.key) - 1];
334 if (n !== undefined) {
335 const r = handover.update(n);
336 ctx.toast(r.says, r.ok ? "success" : "danger");
337 }
338 } else if (e.key === "l" && keeper)
308339 ctx.push(
309340 confirmScreen(ctx, self, {
310341 title: keeper.ask.button,
src/web/pages/flow-handover.ts added +49
@@ -0,0 +1,49 @@
1 /**
2 * /flow: where the crew is about to give up and where a rebase would unstick
3 * it, as plain rows under the pipes. The three lists come from
4 * `flow/handover_read.ts` (the terminal screen reads the same); each shows
5 * only when it has rows, and each rebase row carries the existing update form
6 * (`POST …/pulls/N/update`, same-origin checked and `back` validated as a
7 * local path in `updateSubmit`).
8 */
9
10 import { readHandoverLists, type HandoverLists, type Named } from "../../flow/handover_read.ts";
11 import type { Database } from "bun:sqlite";
12 import type { RepoDrift } from "../../flow/alarm_drift.ts";
13 import { html, type Html } from "../html.ts";
14 import { btn } from "../ui.ts";
15
16 const href = (n: Named): string => `/${n.repo}/pulls/${String(n.number)}`;
17 const link = (n: Named): Html => html`<a href="${href(n)}">${n.repo} #${String(n.number)}</a>`;
18
19 /** The section for lists already read: nothing when all three are empty. */
20 export function handoverSection(lists: HandoverLists): Html | null {
21 const { handed, near, rebase } = lists;
22 if (handed.length + near.length + rebase.length === 0) return null;
23 return html`<section id="handover">
24 <h2>Where the crew gives up</h2>
25 <p class="muted small">Handed over after three changes-requested rounds; one round from that; and the five pulls to bring up to date first.</p>
26 ${handed.length === 0
27 ? ""
28 : html`<h3>Handed to a person in the last 24 h</h3>
29 <ul class="list" data-handovers="${String(handed.length)}">${handed.map((n) => html`<li>${link(n)}</li>`)}</ul>`}
30 ${near.length === 0
31 ? ""
32 : html`<h3>One review round from a hand-over</h3>
33 <ul class="list" data-near="${String(near.length)}">${near.map((n) => html`<li>${link(n)}</li>`)}</ul>`}
34 ${rebase.length === 0
35 ? ""
36 : html`<h3>Bring up to date first</h3>
37 <ul class="list" data-rebase="${String(rebase.length)}">${rebase.map(
38 (n) => html`<li>${link(n)}
39 <form method="post" action="${href(n)}/update" class="bar">
40 <input type="hidden" name="back" value="/flow" />
41 ${btn("Bring up to date", { size: "sm", label: `Bring ${n.repo} #${String(n.number)} up to date` })}
42 </form></li>`,
43 )}</ul>`}
44 </section>`;
45 }
46
47 export function flowHandoverSection(db: Database, drifts: readonly RepoDrift[], now: number): Html | null {
48 return handoverSection(readHandoverLists(db, drifts, now));
49 }
src/web/pages/flow.ts +4 −1
@@ -20,6 +20,8 @@ import { field, readForm } from "../form.ts";
2020 import { html, when } from "../html.ts";
2121 import { flashTo, page } from "../layout.ts";
2222 import { btn, icon, row, time } from "../ui.ts";
23 import { flowHandoverSection } from "./flow-handover.ts";
24 import { readDrifts } from "../../flow/alarm_drift.ts";
2325
2426 const money = (usd: number): string => `$${usd.toFixed(2)}`;
2527 const plural = (n: number, one: string, many = `${one}s`): string => `${String(n)} ${n === 1 ? one : many}`;
@@ -87,7 +89,7 @@ export function backupRow(alarm: BackupAlarm) {
8789 }
8890
8991 // GET /flow
90 export function flowPage(ctx: Ctx): Response {
92 export async function flowPage(ctx: Ctx): Promise<Response> {
9193 if (ctx.url.search !== "") return new Response(null, { status: 301, headers: { location: `/boards${ctx.url.search}` } });
9294 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
9395 const user = ctx.auth.user;
@@ -99,6 +101,7 @@ export function flowPage(ctx: Ctx): Response {
99101 ${backupRow(alarmBackup(ctx.app.config.dataDir, new Date()))}
100102 ${pipesSvg(p)}
outdated

The .catch(() => []) on readDrifts hides every error. Handle the unsafe repo name where it arises and let others surface. Both doors: the terminal flow screen needs these lists and the update action too.

101103 <p class="muted small">${money(p.usd)} spent in the last 7 days; ${plural(p.shipped, "pull")} shipped.</p>
104 ${flowHandoverSection(ctx.app.db, await readDrifts(ctx.app), Date.now())}
102105 ${p.valves.length === 0
103106 ? html`<p class="muted">No repository has spent, queued or shipped anything this week, so there is no pipe to turn yet.</p>`
104107 : html`<h2>Valves</h2>
test/flow-handover-page.test.ts added +169
@@ -0,0 +1,169 @@
1 /** /flow names hand-overs, pulls one review round away, and the pulls to rebase first (#11456). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtempSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/core/repos.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { HANDOVER_NOTE } from "../src/crew/guard.ts";
10 import type { RepoDrift } from "../src/flow/alarm_drift.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { id } from "../src/kernel/id.ts";
13 import { readDrifts } from "../src/flow/alarm_drift.ts";
14 import { readHandovers } from "../src/flow/handover_read.ts";
15 import { handoverLines } from "../src/tui/screens/flow.ts";
16 import { flowHandoverSection } from "../src/web/pages/flow-handover.ts";
17
18 const NOW = Date.parse("2026-11-11T12:00:00Z");
19 const ago = (h: number) => new Date(NOW - h * 3_600_000).toISOString();
20
21 let app: App;
22 let servers: Servers;
23 let alice: string;
24 let repoId: string;
25 let n = 0;
26
27 function pull(): { n: number; id: string } {
28 const issue = id();
29 n++;
30 app.db.query("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', 'P', '', ?4, 'open', ?5)").run(issue, repoId, n, alice, ago(1));
31 app.db.query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) VALUES (?1, 'main', ?2, '', '', ?3)").run(issue, `b/${n}`, repoId);
32 return { n, id: issue };
33 }
34 const review = (issue: string, state: string, h: number) =>
35 app.db.query("INSERT INTO review (id, issue_id, author_id, state, commit_oid, created_at) VALUES (?1, ?2, ?3, ?4, 'h', ?5)").run(id(), issue, alice, state, ago(h));
36 const drift = (number: number, landings: number): RepoDrift => ({ repo: "alice/r", number, landings, since: NOW - landings * 3_600_000 });
37
38 beforeAll(async () => {
39 app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "flow-handover-")), port: 0 });
40 const u = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true });
41 if (!u.ok) throw new Error(u.error);
42 alice = u.value.id;
43 const r = await createRepo(app.db, app.reposRoot, { ownerId: alice, name: "r", visibility: "public", description: "r" });
44 if (!r.ok) throw new Error(r.error);
45 repoId = r.value.id;
46 servers = await serve(app);
47 }, 30_000);
48 afterAll(() => {
49 servers?.stop?.();
50 app.close?.();
51 });
52
53 test("empty: no section", () => {
54 expect(flowHandoverSection(app.db, [], NOW)).toBeNull();
55 });
56
57 test("the three lists, on the page and on /flow", async () => {
58 const handed = pull();
59 app.db.query("UPDATE issue SET assignee_id = ?1 WHERE id = ?2").run(alice, handed.id);
60 app.db.query("INSERT INTO comment (id, issue_id, author_id, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)").run(id(), handed.id, alice, `${HANDOVER_NOTE} three rounds`, ago(2));
61 const old = pull();
62 app.db.query("UPDATE issue SET assignee_id = ?1 WHERE id = ?2").run(alice, old.id);
63 app.db.query("INSERT INTO comment (id, issue_id, author_id, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)").run(id(), old.id, alice, `${HANDOVER_NOTE} long ago`, ago(30));
64 const near = pull();
65 review(near.id, "changes_requested", 5);
66 review(near.id, "changes_requested", 4);
67 const behind = pull();
68 const held = pull();
69 app.db.query("INSERT INTO pull_hold (id, issue_id, held_by, why) VALUES (?1, ?2, ?3, ?4)").run(id(), held.id, alice, "wait");
70
71 const out = String(flowHandoverSection(app.db, [drift(behind.n, 3), drift(held.n, 9)], NOW));
72 expect(out).toContain(`data-handovers="1"`);
73 expect(out).toContain(`href="/alice/r/pulls/${handed.n}"`);
74 expect(out).not.toContain(`href="/alice/r/pulls/${old.n}"`);
75 expect(out).toContain(`data-near="1"`);
76 expect(out).toContain(`href="/alice/r/pulls/${near.n}"`);
77 expect(out).toContain(`data-rebase="1"`);
78 expect(out).toContain(`action="/alice/r/pulls/${behind.n}/update"`);
79 expect(out).not.toContain(`/pulls/${held.n}/update`);
80
81 // Wired into /flow: the page shows the lists for the owner.
82 const res = await fetch(`${servers.url}/signin`, {
83 method: "POST",
84 redirect: "manual",
85 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
86 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
87 });
88 const cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
89 const page = await (await fetch(`${servers.url}/flow`, { headers: { cookie } })).text();
90 expect(page).toContain('id="handover"');
91 expect(page).toContain(`href="/alice/r/pulls/${handed.n}"`);
92 expect(page).toContain(`data-near="1"`);
93 });
94
95 async function signin(): Promise<string> {
96 const res = await fetch(`${servers.url}/signin`, {
97 method: "POST",
98 redirect: "manual",
99 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
100 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
101 });
102 return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
103 }
104
105 test("rebase list: five at most, approved first; three rounds is not near; only lists with rows", () => {
106 const rows = Array.from({ length: 7 }, () => pull());
107 const approved = rows[6]!;
108 review(approved.id, "approved", 3);
109 const three = pull();
110 for (const h of [9, 8, 7]) review(three.id, "changes_requested", h);
111 const drifts = rows.map((p, i) => drift(p.n, 10 - i));
112 const out = String(flowHandoverSection(app.db, drifts, NOW));
113 expect(out.match(/\/update"/g)?.length).toBe(5);
114 // The approved pull is behind least yet comes first of the five.
115 expect(out.indexOf(`/pulls/${approved.n}/update`)).toBeGreaterThan(-1);
116 expect(out.indexOf(`/pulls/${approved.n}/update`)).toBeLessThan(out.indexOf(`/pulls/${rows[0]!.n}/update`));
117 expect(out).not.toContain(`/pulls/${rows[5]!.n}/update`);
118 expect(out).not.toContain(`/pulls/${three.n}"`);
119 expect(out).not.toContain("None.");
120 });
121
122 test("a closed pull is not a hand-over", () => {
123 const p = pull();
124 app.db.query("UPDATE issue SET assignee_id = ?1 WHERE id = ?2").run(alice, p.id);
125 app.db.query("INSERT INTO comment (id, issue_id, author_id, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)").run(id(), p.id, alice, `${HANDOVER_NOTE} x`, ago(1));
126 expect(readHandovers(app.db, NOW).some((h) => h.number === p.n)).toBe(true);
127 app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(p.id);
128 expect(readHandovers(app.db, NOW).some((h) => h.number === p.n)).toBe(false);
129 });
130
131 test("/flow shows a rebase form; an unsafe repository name is skipped and the page still renders", async () => {
132 const behind = pull();
133 const bad = id();
134 app.db.query("INSERT INTO repo (id, owner_id, name, visibility, description, created_at) VALUES (?1, ?2, 'bad name', 'public', '', ?3)").run(bad, alice, ago(1));
135 const badIssue = id();
136 app.db.query("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, 1, 'pull', 'P', '', ?3, 'open', ?4)").run(badIssue, bad, alice, ago(1));
137 app.db.query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) VALUES (?1, 'main', 'x', '', '', ?2)").run(badIssue, bad);
138 const drifts = await readDrifts(app, { read: async () => ({ landings: 4, since: NOW - 3_600_000 }) });
139 expect(drifts.some((d) => d.repo.includes("bad name"))).toBe(false);
140 expect(drifts.some((d) => d.number === behind.n)).toBe(true);
141 const res = await fetch(`${servers.url}/flow`, { headers: { cookie: await signin() } });
142 expect(res.status).toBe(200);
143 app.db.query("DELETE FROM pull WHERE issue_id = ?1").run(badIssue);
144 app.db.query("DELETE FROM issue WHERE id = ?1").run(badIssue);
145 app.db.query("DELETE FROM repo WHERE id = ?1").run(bad);
146 });
147
148 test("the update form's back field is only taken as a local path", async () => {
149 const p = pull();
150 const cookie = await signin();
151 for (const [back, expected] of [["https://evil.example/x", `/alice/r/pulls/${p.n}`], ["//evil.example", `/alice/r/pulls/${p.n}`], ["/flow", "/flow"]] as const) {
152 const res = await fetch(`${servers.url}/alice/r/pulls/${p.n}/update`, {
153 method: "POST",
154 redirect: "manual",
155 headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin: servers.url },
156 body: new URLSearchParams({ back }),
157 });
158 expect(res.status).toBe(303);
159 expect(res.headers.get("location")).toBe(expected);
160 }
161 });
162
163 test("the terminal lists the same rows, numbered", () => {
164 const lines = handoverLines({ handed: [{ repo: "a/r", number: 1 }], near: [], rebase: [{ repo: "a/r", number: 2 }] }, 56);
165 expect(lines.join("\n")).toContain("Handed to a person");
166 expect(lines.join("\n")).not.toContain("One review round");
167 expect(lines.join("\n")).toContain("1 a/r #2");
168 expect(handoverLines({ handed: [], near: [], rebase: [] }, 56)).toEqual([]);
169 });
test/plugin-claude-oauth.test.ts +1 −1
@@ -17,7 +17,7 @@ test("the description names every verb the command handles", async () => {
1717 const md = await Bun.file(`${dir}/commands/forge.md`).text();
1818 const list = md.match(/^description: Talk to your forge: (.*)$/m)![1] as string;
1919 const verbs = list.split(",").map((v) => v.trim());
20 expect(verbs.length).toBe(12);
20 expect(verbs.length).toBe(15);
2121 for (const v of verbs) expect(plugin.description).toContain(v);
2222 });
2323

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs