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
Check · Review · Land · Live — It conflicts with its base.
scout/forge:scout/11456-flow-shows-hand-overs-to-a-person-in-the → main 8 commits fork 5 threads · 5 unresolved
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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "forge", |
| 3 | 3 | "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).", | |
| 5 | 5 | "mcpServers": { |
| 6 | 6 | "forge": { |
| 7 | 7 | "type": "http", |
scripts/unreached-allow.json −3
| @@ -5,9 +5,6 @@ | ||
| 5 | 5 | "src/flow/approved_wait.ts", |
| 6 | 6 | "src/flow/clog_words.ts", |
| 7 | 7 | "src/flow/conflict_pairs.ts", |
| 8 | "src/flow/handovers.ts", | |
| 9 | "src/flow/rebase_first.ts", | |
| 10 | "src/flow/review_rounds.ts", | |
| 11 | 8 | "src/flow/shared_red.ts", |
| 12 | 9 | "src/flow/stale_fold.ts", |
| 13 | 10 | "src/perf/page_budget.ts", |
src/flow/alarm_drift.ts +4 −2
| @@ -10,7 +10,7 @@ import { existsSync } from "node:fs"; | ||
| 10 | 10 | |
| 11 | 11 | import type { App } from "../app.ts"; |
| 12 | 12 | import { runGit } from "../git/env.ts"; |
| 13 | import { isSafeOid, isSafeRef } from "../git/guard.ts"; | |
| 13 | import { isSafeName, isSafeOid, isSafeRef } from "../git/guard.ts"; | |
| 14 | 14 | import { repoDir } from "../git/repos.ts"; |
| 15 | 15 | import { span } from "./pipeline.ts"; |
| 16 | 16 | |
| @@ -127,7 +127,9 @@ export async function readDrifts( | ||
| 127 | 127 | WHERE i.kind = 'pull' AND i.state = 'open' AND p.merged_at IS NULL |
| 128 | 128 | AND (?1 = 0 OR r.visibility = 'public')`, |
| 129 | 129 | ) |
| 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)); | |
| 131 | 133 | const tips = new Map<string, Promise<string | null>>(); |
| 132 | 134 | const tipOf = (dir: string, branch: string) => { |
| 133 | 135 | 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 { | ||
| 20 | 20 | |
| 21 | 21 | const DAY_MS = 24 * 60 * 60 * 1000; |
| 22 | 22 | |
| 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 | ||
| 23 | 29 | export function handovers(rows: readonly Handover[], now: number): Handovers { |
| 24 | 30 | const latest = new Map<number, number>(); |
| 25 | 31 | for (const r of rows) { |
| 32 | if (!inWindow(r.at, now)) continue; | |
| 26 | 33 | const t = Date.parse(r.at); |
| 27 | if (!Number.isFinite(t) || t > now || now - t > DAY_MS) continue; | |
| 28 | 34 | if (t > (latest.get(r.pull) ?? -Infinity)) latest.set(r.pull, t); |
| 29 | 35 | } |
| 30 | 36 | const newest = [...latest] |
src/tui/forge.ts +7 −1
| @@ -77,6 +77,9 @@ import { capacityScreen, mayCapacity } from "./screens/capacity.ts"; | ||
| 77 | 77 | import { crawlersScreen } from "./screens/crawlers.ts"; |
| 78 | 78 | import { forgeCeiling } from "../flow/ceiling.ts"; |
| 79 | 79 | 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"; | |
| 80 | 83 | import { readHeld } from "../flow/held.ts"; |
| 81 | 84 | import { answerKeeper, readKeeper } from "../flow/keeper.ts"; |
| 82 | 85 | import { readVerified } from "../flow/verified.ts"; |
| @@ -497,7 +500,10 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 497 | 500 | readVerified(session.app, flowReader.kind === "owner"), |
| 498 | 501 | keeper, |
| 499 | 502 | 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)); | |
| 501 | 507 | }, |
| 502 | 508 | }); |
| 503 | 509 | // 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 @@ | ||
| 10 | 10 | import { bringUpToDate } from "../../crew/autonomy.ts"; |
| 11 | 11 | import { driftingPulls, readDrifts, type DriftingPull } from "../../flow/alarm_drift.ts"; |
| 12 | 12 | import { getPull } from "../../core/pulls.ts"; |
| 13 | import { mayView } from "../../core/sealed.ts"; | |
| 13 | 14 | import { canWrite, getRepo } from "../../core/repos.ts"; |
| 14 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| 15 | 16 | import { binding } from "../app/keys.ts"; |
| @@ -25,11 +26,11 @@ export function driftRows(pulls: readonly DriftingPull[]): ListRow[] { | ||
| 25 | 26 | } |
| 26 | 27 | |
| 27 | 28 | /** 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 } { | |
| 29 | 30 | const [owner = "", name = ""] = p.repo.split("/"); |
| 30 | 31 | const repo = getRepo(f.db, owner, name); |
| 31 | 32 | 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." }; | |
| 33 | 34 | if (!canWrite(f.db, repo, f.user)) return { ok: false, says: "Only someone who can write here brings a pull up to date." }; |
| 34 | 35 | const who = pull.state === "open" && pull.mergedAt === null ? bringUpToDate(f.app, repo, pull, []) : undefined; |
| 35 | 36 | return who === undefined |
src/tui/screens/flow.ts +34 −3
| @@ -58,6 +58,19 @@ import { dialog } from "../ui/widgets.ts"; | ||
| 58 | 58 | import { live } from "../../system/live.ts"; |
| 59 | 59 | import { confirmScreen } from "./common.ts"; |
| 60 | 60 | 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 | } | |
| 61 | 74 | |
| 62 | 75 | const WIDTH = 64; |
| 63 | 76 | |
| @@ -288,14 +301,26 @@ export function flowScreen( | ||
| 288 | 301 | lines: readonly string[], |
| 289 | 302 | /** The keeper's open question (a site admin's); `answer` presses its one button and says what happened. */ |
| 290 | 303 | 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 } }, | |
| 291 | 306 | ): Screen { |
| 307 | let lists: HandoverLists | undefined; | |
| 292 | 308 | const self: Screen = { |
| 293 | 309 | 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 | }, | |
| 295 | 320 | render(area, s) { |
| 296 | 321 | const buttons = dialog(s, area, ctx.theme.c, { |
| 297 | 322 | title: "Flow", |
| 298 | body: lines, | |
| 323 | body: [...lines, ...(lists ? handoverLines(lists, WIDTH - 8) : [])], | |
| 299 | 324 | actions: [{ label: "Close" }], |
| 300 | 325 | selected: 0, |
| 301 | 326 | width: WIDTH, |
| @@ -304,7 +329,13 @@ export function flowScreen( | ||
| 304 | 329 | }, |
| 305 | 330 | key(e) { |
| 306 | 331 | 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) | |
| 308 | 339 | ctx.push( |
| 309 | 340 | confirmScreen(ctx, self, { |
| 310 | 341 | 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"; | ||
| 20 | 20 | import { html, when } from "../html.ts"; |
| 21 | 21 | import { flashTo, page } from "../layout.ts"; |
| 22 | 22 | import { btn, icon, row, time } from "../ui.ts"; |
| 23 | import { flowHandoverSection } from "./flow-handover.ts"; | |
| 24 | import { readDrifts } from "../../flow/alarm_drift.ts"; | |
| 23 | 25 | |
| 24 | 26 | const money = (usd: number): string => `$${usd.toFixed(2)}`; |
| 25 | 27 | 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) { | ||
| 87 | 89 | } |
| 88 | 90 | |
| 89 | 91 | // GET /flow |
| 90 | export function flowPage(ctx: Ctx): Response { | |
| 92 | export async function flowPage(ctx: Ctx): Promise<Response> { | |
| 91 | 93 | if (ctx.url.search !== "") return new Response(null, { status: 301, headers: { location: `/boards${ctx.url.search}` } }); |
| 92 | 94 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 93 | 95 | const user = ctx.auth.user; |
| @@ -99,6 +101,7 @@ export function flowPage(ctx: Ctx): Response { | ||
| 99 | 101 | ${backupRow(alarmBackup(ctx.app.config.dataDir, new Date()))} |
| 100 | 102 | ${pipesSvg(p)} |
| 101 | 103 | <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())} | |
| 102 | 105 | ${p.valves.length === 0 |
| 103 | 106 | ? html`<p class="muted">No repository has spent, queued or shipped anything this week, so there is no pipe to turn yet.</p>` |
| 104 | 107 | : 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 () => { | ||
| 17 | 17 | const md = await Bun.file(`${dir}/commands/forge.md`).text(); |
| 18 | 18 | const list = md.match(/^description: Talk to your forge: (.*)$/m)![1] as string; |
| 19 | 19 | const verbs = list.split(",").map((v) => v.trim()); |
| 20 | expect(verbs.length).toBe(12); | |
| 20 | expect(verbs.length).toBe(15); | |
| 21 | 21 | for (const v of verbs) expect(plugin.description).toContain(v); |
| 22 | 22 | }); |
| 23 | 23 |
The
.catch(() => [])onreadDriftshides 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.