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

A pull drifting far behind main is flagged on /flow with the one action that rebases it #10877

Merged opened this

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

Merged

scout/forge:scout/10861-a-pull-drifting-far-behind-main-is-flagg → main 11 commits fork 2 threads · 2 unresolved

  • Merged. scout/10861-a-pull-drifting-far-behind-main-is-flagg was merged into main as 91a0659.

Review

Sign in to review this change.
15 files changed +427 −22
docs/MCP.md +6 −1
@@ -106,7 +106,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
106106 | `flag_set` | — | Set or clear a flag (admin) | 209 |
107107 | `flag_winner` | — | An A/B flag winner becomes default | 206 |
108108 | `flags` | — | Declared flags: why, default, owner issue, live value | 171 |
109 | `flow` | `bugs` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 480 |
109 | `flow` | `bugs`, `drift` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 488 |
110110 | `follow_mode` | — | How a spawned forge follows its parent: `tell-me` or `take-updates` | 265 |
111111 | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 |
112112 | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 |
@@ -536,3 +536,8 @@ answers
536536 (the clone URL as the forge prints it), then `/plugin install forge@forge`
537537 (the root `.claude-plugin/marketplace.json` names the marketplace `forge`).
538538 From a clone, `claude --plugin-dir plugins/claude-code` is the local way.
539
540 `flow` with `{ kind: "drift" }` (#10861) is another: `{ drifting: [{ repo, number, landings, hours }] }`,
541 open pulls over 25 landings or 6 h behind main, furthest first, from
542 `src/flow/alarm_drift.ts`. Each is brought up to date by
543 `POST /{repo}/pulls/{number}/update`, the button beside it on /boards.
docs/features/drift.md added +9
@@ -0,0 +1,9 @@
1 # Pulls drifting from main
2
3 An open pull more than 25 landings or 6 h behind its base is listed on `/boards` (and so `/flow`) under "Drifting from main", furthest first, each with one button, "Bring up to date". Nothing is shown when none drifts. The same fact is the `drift` alarm.
4
5 - **Page:** `src/web/pages/boards-drift.ts`, a plain section of `/boards` (admin only, as the page is). The button posts `POST /:owner/:name/pulls/:number/update` (`updateSubmit`, `src/web/studio/actions.ts`): same-origin, a viewer of the pull, and a writer of the repository; it asks the pull's agent to merge main into its branch (`bringUpToDate`) and lands nothing. Closed pulls, pulls with no agent, and a pull that has used its tries are refused in words.
6 - **Terminal:** the palette entry "Drifting pulls" (`src/tui/screens/drift.ts`, admins who are people); `b` or enter asks, `r` reads again. Same reads, same action, same refusals.
7 - **MCP:** `flow` with `{ kind: drift }` lists them: `{ drifting: [{ repo, number, landings, hours }] }`. There is no verb to ask the agent yet; use the route.
8 - **Reads:** `src/flow/alarm_drift.ts`, git per pull cached by head and tip; merged and closed pulls never count.
9 - **Tests:** `test/flow-alarm-drift.test.ts`, `test/studio-update-pull.test.ts` (writer, non-writer, closed, cross-origin), `test/tui-drift.test.ts`.
scripts/unreached-allow.json −1
@@ -1,7 +1,6 @@
11 [
22 "src/apps/cloud.ts",
33 "src/crew/governor_log.ts",
4 "src/flow/alarm_drift.ts",
54 "src/flow/alarm_unproposed.ts",
65 "src/flow/approved_wait.ts",
76 "src/flow/clog_words.ts",
src/flow/alarm_drift.ts +45 −14
@@ -6,6 +6,8 @@
66 * so a rebased pull would stay named); the hours run from the first of them. Merged and closed pulls never count.
77 */
88
9 import { existsSync } from "node:fs";
10
911 import type { App } from "../app.ts";
1012 import { runGit } from "../git/env.ts";
1113 import { isSafeOid, isSafeRef } from "../git/guard.ts";
@@ -40,7 +42,7 @@ interface Row {
4042 /** Landings on `branch` past where `headOid` forked from it and when the first happened, read from git; zero when unreadable. */
4143 export async function readDrift(dir: string, branch: string, headOid: string): Promise<Omit<Drift, "number">> {
4244 const none = { landings: 0, since: null };
43 if (!isSafeOid(headOid) || !isSafeRef(branch)) return none;
45 if (!isSafeOid(headOid) || !isSafeRef(branch) || !existsSync(dir)) return none;
4446 const tip = `refs/heads/${branch}`;
4547 const fork = await runGit(["merge-base", headOid, tip], { cwd: dir });
4648 const base = fork.ok ? fork.value.trim() : "";
@@ -64,7 +66,7 @@ export function clearDriftCache(): void {
6466
6567 /** The tip of `branch`, one cheap git call; null when unreadable. */
6668 export async function branchTip(dir: string, branch: string): Promise<string | null> {
67 if (!isSafeRef(branch)) return null;
69 if (!isSafeRef(branch) || !existsSync(dir)) return null;
6870 const out = await runGit(["rev-parse", "--verify", "-q", `refs/heads/${branch}`], { cwd: dir });
6971 const oid = out.ok ? out.value.trim() : "";
7072 return isSafeOid(oid) ? oid : null;
@@ -107,21 +109,25 @@ export function driftAlarm(drifts: readonly Drift[], now: number): DriftAlarm {
107109 return { fired: true, since: new Date(since).toISOString(), says: `${named.join("; ")}${more}.` };
108110 }
109111
110 /** Reads every open pull's drift from its repository's main and answers the alarm. */
111 export async function alarmDrift(
112 /** One open pull's drift and where it lives. */
113 export interface RepoDrift extends Drift {
114 readonly repo: string;
115 }
116
117 /** Every open pull's drift from its repository's main; `publicOnly` leaves private repositories out. Git reads are cached per head and tip. */
118 export async function readDrifts(
112119 app: App,
113 now: Date | number = Date.now(),
114 { read = readDrift, readTip = branchTip }: { read?: DriftReader; readTip?: typeof branchTip } = {},
115 ): Promise<DriftAlarm> {
116 const at = typeof now === "number" ? now : now.getTime();
120 { read = readDrift, readTip = branchTip, publicOnly = false }: { read?: DriftReader; readTip?: typeof branchTip; publicOnly?: boolean } = {},
121 ): Promise<RepoDrift[]> {
117122 const rows = app.db
118 .query<Row, []>(
123 .query<Row, [number]>(
119124 `SELECT i.number, p.base_branch, p.head_oid, o.username AS owner, r.name
120125 FROM issue i JOIN pull p ON p.issue_id = i.id
121126 JOIN repo r ON r.id = i.repo_id JOIN user o ON o.id = r.owner_id
122 WHERE i.kind = 'pull' AND i.state = 'open' AND p.merged_at IS NULL`,
127 WHERE i.kind = 'pull' AND i.state = 'open' AND p.merged_at IS NULL
128 AND (?1 = 0 OR r.visibility = 'public')`,
123129 )
124 .all();
130 .all(publicOnly ? 1 : 0);
125131 const tips = new Map<string, Promise<string | null>>();
126132 const tipOf = (dir: string, branch: string) => {
127133 const k = `${dir}\0${branch}`;
@@ -129,11 +135,36 @@ export async function alarmDrift(
129135 if (!t) tips.set(k, (t = readTip(dir, branch)));
130136 return t;
131137 };
132 const drifts = await Promise.all(
138 return Promise.all(
133139 rows.map(async (r) => {
134140 const dir = repoDir(app.reposRoot, r.owner, r.name);
135 return { number: r.number, ...(await cachedDrift(read, dir, r.base_branch, r.head_oid, await tipOf(dir, r.base_branch))) };
141 return { repo: `${r.owner}/${r.name}`, number: r.number, ...(await cachedDrift(read, dir, r.base_branch, r.head_oid, await tipOf(dir, r.base_branch))) };
136142 }),
137143 );
138 return driftAlarm(drifts, at);
144 }
145
146 export interface DriftingPull {
147 readonly repo: string;
148 readonly number: number;
149 readonly landings: number;
150 /** Hours since main first moved past the pull's base, one decimal; 0 when it has not. */
151 readonly hours: number;
152 }
153
154 /** The pulls past either limit, the furthest behind first: what /boards and the MCP `flow` tool list beside the one action that brings each up to date. */
155 export function driftingPulls(drifts: readonly RepoDrift[], now: number): DriftingPull[] {
156 return drifts
157 .filter((d) => drifting(d, now))
158 .sort((a, b) => b.landings - a.landings || behind(b, now) - behind(a, now) || a.repo.localeCompare(b.repo) || a.number - b.number)
159 .map((d) => ({ repo: d.repo, number: d.number, landings: d.landings, hours: Math.round(behind(d, now) / 360_000) / 10 }));
160 }
161
162 /** Reads every open pull's drift from its repository's main and answers the alarm. */
163 export async function alarmDrift(
164 app: App,
165 now: Date | number = Date.now(),
166 opts: { read?: DriftReader; readTip?: typeof branchTip } = {},
167 ): Promise<DriftAlarm> {
168 const at = typeof now === "number" ? now : now.getTime();
169 return driftAlarm(await readDrifts(app, opts), at);
139170 }
src/http/mcp/tools/flow.ts +8 −3
@@ -7,6 +7,7 @@ import { whyIssue, whyPull, liveHead, whyRepo, type Why } from "../../../flow/wh
77 import { countRead } from "../../../system/telemetry_reads.ts";
88 import { COLUMNS, readSort, sortRows } from "../../../fleet/columns.ts";
99 import { viewFleet } from "../../../fleet/rows.ts";
10 import { driftingPulls, readDrifts } from "../../../flow/alarm_drift.ts";
1011 import { openBugs } from "../../../flow/bugs.ts";
1112 import { flowCached } from "../../../system/flow_cache.ts";
1213 import { scopeAllows } from "../../../core/tokens.ts";
@@ -27,13 +28,13 @@ const said = (subject: string, why: Why) =>
2728
2829 export const tool: Tool = {
2930 name: "flow",
30 doc: "with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`, and `declines` (crew runs declined in 24 h, by reason) (the `ui://forge/flow` card); with `{ q, sort? }`, the rows a fleet query line selects: `{ kind, columns, items }`, only rows you may read, `sort` a column key led by `-` to reverse, a bad line answers its reason; with `{ q, action: { verb, value }, apply? }`, a fleet operation over those rows as `POST /api/v1/fleet/ops` answers it: the dry-run plan `{ dryRun: true, query, kind, verb, value, reversible, rows, counts }` and nothing changed, or with `apply: true` (an admin token, a person) the receipt `{ dryRun: false, id, rows, … }` whose id `limb` undoes; with `{ kind: bugs }`, `{ open, overdue, bugs: [{ repo, number, priority, dueAt }] }` soonest due first, at most 100 (an admin sees all, anyone else public repositories); with `{ repo, issue? or pull? }` (a repository writer), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of wall (a person decides access), ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders",
31 doc: "with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`, and `declines` (crew runs declined in 24 h, by reason) (the `ui://forge/flow` card); with `{ q, sort? }`, the rows a fleet query line selects: `{ kind, columns, items }`, only rows you may read, `sort` a column key led by `-` to reverse, a bad line answers its reason; with `{ q, action: { verb, value }, apply? }`, a fleet operation over those rows as `POST /api/v1/fleet/ops` answers it: the dry-run plan `{ dryRun: true, query, kind, verb, value, reversible, rows, counts }` and nothing changed, or with `apply: true` (an admin token, a person) the receipt `{ dryRun: false, id, rows, … }` whose id `limb` undoes; with `{ kind: drift }`, `{ drifting: [{ repo, number, landings, hours }] }` the open pulls over 25 landings or 6 h behind main, furthest first; each is brought up to date by `POST /{repo}/pulls/{number}/update`; with `{ kind: bugs }`, `{ open, overdue, bugs: [{ repo, number, priority, dueAt }] }` soonest due first, at most 100 (an admin sees all, anyone else public repositories); with `{ repo, issue? or pull? }` (a repository writer), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of wall (a person decides access), ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders",
3132 description:
3233 "Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck.",
3334 inputSchema: {
3435 type: "object",
3536 properties: {
36 kind: { type: "string", enum: ["bugs"] },
37 kind: { type: "string", enum: ["bugs", "drift"] },
3738 q: { type: "string", maxLength: 500 },
3839 sort: { type: "string" },
3940 action: { type: "object" },
@@ -79,7 +80,11 @@ export const tool: Tool = {
7980 return good({ kind: view.kind, columns: COLUMNS[view.kind], items: sortRows(view.rows, sort) });
8081 }
8182 if (args["kind"] !== undefined) {
82 if (args["kind"] !== "bugs") return bad("invalid", "kind is bugs");
83 if (args["kind"] === "drift") {
84 const all = user.isAdmin && user.kind !== "agent" ? {} : { publicOnly: true };
85 return good({ drifting: driftingPulls(await readDrifts(ctx.app, all), Date.now()).slice(0, 100) });
86 }
87 if (args["kind"] !== "bugs") return bad("invalid", "kind is bugs or drift");
8388 // As the terminal's bugs screen: an admin sees every repository, anyone else public ones.
8489 const all = openBugs(ctx.app.db, user.isAdmin && user.kind !== "agent" ? {} : { publicOnly: true });
8590 const now = Date.now();
src/http/server.ts +2
@@ -252,6 +252,7 @@ import {
252252 import {
253253 saySubmit,
254254 shipSubmit,
255 updateSubmit,
255256 takeBackSubmit,
256257 holdSubmit,
257258 releaseSubmit,
@@ -1196,6 +1197,7 @@ export function buildRouter(): Router<Handler> {
11961197 router.add("POST", "/feed/like", likeSubmit);
11971198 router.add("POST", "/:owner/:name/pulls/:number/checks", pullChecksSubmit);
11981199 router.add("POST", "/:owner/:name/pulls/:number/ship", shipSubmit);
1200 router.add("POST", "/:owner/:name/pulls/:number/update", updateSubmit);
11991201 router.add("POST", "/:owner/:name/pulls/:number/say", saySubmit);
12001202 router.add("POST", "/:owner/:name/pulls/:number/take-back", takeBackSubmit);
12011203 router.add("POST", "/:owner/:name/pulls/:number/hold", holdSubmit);
src/tui/screens/drift.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * Pulls drifting from main, in the terminal: the section /boards shows, from
3 * the same reads (`readDrifts`, `driftingPulls`), and the same one action, the
4 * route's: `b` (or enter) asks the pull's agent to bring it up to date, for a
5 * writer of its repository. Gated as /boards is: a site admin who is a person.
6 *
7 * b, enter bring the pull up to date r read again
8 */
9
10 import { bringUpToDate } from "../../crew/autonomy.ts";
11 import { driftingPulls, readDrifts, type DriftingPull } from "../../flow/alarm_drift.ts";
12 import { getPull } from "../../core/pulls.ts";
13 import { canWrite, getRepo } from "../../core/repos.ts";
14 import type { Ctx, Screen } from "../app/app.ts";
15 import { binding } from "../app/keys.ts";
16 import type { Session } from "../session.ts";
17 import { ListState, drawList, metaOf, runAsync, type ListRow } from "./common.ts";
18 import type { PaletteEntry } from "./entries.ts";
19 import { emptyState } from "../ui/widgets.ts";
20
21 export const mayDrift = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind !== "agent";
22
23 export function driftRows(pulls: readonly DriftingPull[]): ListRow[] {
24 return pulls.map((p) => ({ title: `${p.repo} #${p.number}`, meta: metaOf(`${p.landings} landings`, `${p.hours} h behind`) }));
25 }
26
27 /** 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 const [owner = "", name = ""] = p.repo.split("/");
30 const repo = getRepo(f.db, owner, name);
31 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 (!canWrite(f.db, repo, f.user)) return { ok: false, says: "Only someone who can write here brings a pull up to date." };
34 const who = pull.state === "open" && pull.mergedAt === null ? bringUpToDate(f.app, repo, pull, []) : undefined;
35 return who === undefined
36 ? { ok: false, says: "The crew could not take it now (closed, no agent, or it has already tried its limit); say what to do on the pull." }
37 : { ok: true, says: `${who} is bringing #${pull.number} up to date with ${pull.baseBranch}.` };
38 }
39
40 export function driftScreen(f: Session, ctx: Ctx): Screen {
41 const state = new ListState();
42 let pulls: readonly DriftingPull[] = [];
43 let loaded = false;
44 const read = async (): Promise<void> => {
45 pulls = driftingPulls(await readDrifts(f.app), Date.now());
46 loaded = true;
47 state.clamp(pulls.length);
48 ctx.invalidate();
49 };
50 const ask = (): void => {
51 const p = pulls[state.index];
52 if (p === undefined) return;
53 const r = askUpdate(f, p);
54 ctx.toast(r.says, r.ok ? "success" : "danger");
55 };
56 return {
57 title: "Drifting from main",
58 keys: [binding(["j", "k"], "move"), binding(["b", "enter"], "bring up to date"), binding(["r"], "read again"), binding(["esc", "q"], "close")],
59 load: read,
60 render(area, s) {
61 if (loaded && pulls.length === 0) {
62 emptyState(s, area, ctx.theme.c, { title: "Nothing drifts", body: "No open pull is more than 25 landings or 6 h behind main." });
63 return;
64 }
65 drawList(s, area, ctx.theme.c, state, driftRows(pulls));
66 },
67 key(e) {
68 if (e.key === "esc" || e.key === "q") ctx.pop();
69 else if (e.key === "b" || e.key === "enter") ask();
70 else if (e.key === "r") runAsync(ctx, read);
71 else state.key(e, pulls.length);
72 return true;
73 },
74 };
75 }
76
77 export const entry: PaletteEntry = {
78 id: "drift",
79 title: "Drifting pulls",
80 hint: "open pulls far behind main, and the one action that brings each up to date",
81 after: ["boards"],
82 offered: mayDrift,
83 run: (session, ctx) => ctx.push(driftScreen(session, ctx)),
84 };
src/tui/screens/entries.ts +2
@@ -18,6 +18,7 @@ import { entry as dotfiles } from "./dotfiles.ts";
1818 import { entry as devices } from "./devices.ts";
1919 import { entry as bets } from "./bets.ts";
2020 import { entry as boards } from "./boards.ts";
21 import { entry as drift } from "./drift.ts";
2122 import { entry as bugs } from "./bugs.ts";
2223 import { entry as bulk } from "./bulk.ts";
2324 import { entry as childCredit } from "./childcredit.ts";
@@ -93,6 +94,7 @@ export interface PaletteEntry {
9394 export const ENTRIES: readonly PaletteEntry[] = [
9495 sentinel,
9596 boards,
97 drift,
9698 askData,
9799 bugs,
98100 homePins,
src/web/pages/boards-drift.ts added +29
@@ -0,0 +1,29 @@
1 /**
2 * The drifting pulls on /boards (and so /flow): open pulls more than 25
3 * landings or 6 h behind main, each a row with the one button that asks its
4 * agent to bring it up to date (`POST …/pulls/N/update`). A plain section like
5 * the other /boards blocks, no frame around the list. Nothing shown when none drifts.
6 */
7
8 import type { DriftingPull } from "../../flow/alarm_drift.ts";
9 import { html, type Html } from "../html.ts";
10 import { btn } from "../ui.ts";
11
12 export function driftSection(pulls: readonly DriftingPull[]): Html | null {
13 if (pulls.length === 0) return null;
14 return html`<section class="settings-block" id="drifting">
15 <h2>Drifting from main</h2>
outdated

Principles 6 and 7 (rows over boxes, few borders): this wraps the list in card(), which is a box around a list. Render the heading and the rows as a plain section like the other /boards blocks (settings-block). Also each row holds a form with a button per line; keep it, but do not frame it.

16 <ul class="list" data-drifting="${String(pulls.length)}">
17 ${pulls.map(
18 (p) => html`<li data-drift="${p.repo}#${String(p.number)}">
19 <a href="/${p.repo}/pulls/${String(p.number)}">${p.repo} #${String(p.number)}</a>
20 <span class="muted small">${String(p.landings)} landings · ${String(p.hours)} h behind</span>
21 <form method="post" action="/${p.repo}/pulls/${String(p.number)}/update" class="bar">
22 <input type="hidden" name="back" value="/boards" />
23 ${btn("Bring up to date", { size: "sm" })}
24 </form>
25 </li>`,
26 )}
27 </ul>
28 </section>`;
29 }
src/web/pages/boards-view.ts +4 −1
@@ -13,6 +13,8 @@ import { CHART_NAMES } from "../../boards/chart.ts";
1313 import { MAX_H, MAX_PANELS, MAX_TITLE, MAX_W, RANGES, type Board, type Panel } from "../../boards/store.ts";
1414 import type { App } from "../../app.ts";
1515 import type { Ctx } from "../../http/context.ts";
16 import { driftingPulls, readDrifts } from "../../flow/alarm_drift.ts";
17 import { driftSection } from "./boards-drift.ts";
1618 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
1719 import { liveRegionsJs } from "../client.ts";
1820 import { notFoundPage, signInFirst } from "../chrome.ts";
@@ -113,6 +115,7 @@ export async function boardsList(ctx: Ctx): Promise<Response> {
113115 </ul>`
114116 }</section>`;
115117 const flow = timePhase(ctx.request, "flow", () => flowPanel(ctx.app, { kind: "owner" }, ctx.url, "/boards"));
118 const drift = driftSection(await timePhaseAsync(ctx.request, "drift", async () => driftingPulls(await readDrifts(ctx.app), Date.now())));
116119 const rendering = performance.now();
117120 const response = page({
118121 ctx,
@@ -121,7 +124,7 @@ export async function boardsList(ctx: Ctx): Promise<Response> {
121124 ${btn("Flow", { href: "/flow", variant: "ghost" })}
122125 ${btn("Explore the data", { href: "/boards/data", variant: "ghost" })}</div>
123126 ${alarmLines(cachedAlarms(ctx.app.db, new Date(), forgeCeiling(ctx.app.config)))}
124 ${body} ${make} ${flow} ${boardsHoldsCard(ctx.app.db, ctx.auth.user, true)}`,
127 ${body} ${make} ${drift} ${flow} ${boardsHoldsCard(ctx.app.db, ctx.auth.user, true)}`,
125128 });
126129 notePhase(ctx.request, "render", performance.now() - rendering);
127130 return response;
src/web/studio/actions.ts +23 −1
@@ -22,7 +22,7 @@ import { mergePull } from "../../actions/pulls.ts";
2222 import { revertPull } from "../../actions/revert.ts";
2323 import { takeBackWords } from "../../actions/revert_words.ts";
2424 import { getPull } from "../../core/pulls.ts";
25 import { canRead, getRepo, type Repo } from "../../core/repos.ts";
25 import { canRead, canWrite, getRepo, type Repo } from "../../core/repos.ts";
2626 import { bringUpToDate } from "../../crew/autonomy.ts";
2727 import type { Ctx } from "../../http/context.ts";
2828 import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, signInFirst } from "../chrome.ts";
@@ -278,6 +278,28 @@ export async function shipSubmit(ctx: Ctx): Promise<Response> {
278278 });
279279 }
280280
281 /** POST /:owner/:name/pulls/:number/update — ask the pull's agent to bring its branch up to date with main; nothing lands. A writer's. */

No test exercises this route. Add one for a writer, a non-writer, a closed pull and a cross-origin post; the test file only checks the card renders.

282 export async function updateSubmit(ctx: Ctx): Promise<Response> {
283 const repo = loadRepo(ctx);
284 if (!repo) return notFoundPage(ctx);
285 if (!ctx.auth.user) return signInFirst(ctx);
286 const form = await readForm(ctx, "rw");
287 if (form instanceof Response) return form;
288 const base = repoBase(repo);
289 const number = Number(ctx.params.number);
290 const pull = Number.isInteger(number) ? getPull(ctx.app.db, repo.id, number) : undefined;
291 if (!pull || !mayView(ctx.app.db, repo, pull, ctx.auth.user)) return notFoundPage(ctx);
292 const back = backTo(form, changeHref(base, pull.number));
293 if (!canWrite(ctx.app.db, repo, ctx.auth.user))
294 return flashOrJson(ctx, back, { tone: "danger", message: "Only someone who can write here brings a pull up to date." });
295 const who = pull.state === "open" && pull.mergedAt === null ? bringUpToDate(ctx.app, repo, pull, []) : undefined;
296 return flashOrJson(ctx, back, {
297 ...(who === undefined
298 ? { tone: "danger" as const, message: "The crew could not take it now (closed, no agent, or it has already tried its limit); say what to do on the pull." }
299 : { message: `${who} is bringing #${pull.number} up to date with ${pull.baseBranch}.` }),
300 });
301 }
302
281303 /**
282304 * POST /:owner/:name/pulls/:number/take-back — open a new change that
283305 * undoes exactly what a shipped one merged. Never touches the default
test/flow-alarm-drift.test.ts +13 −1
@@ -9,7 +9,8 @@ import { setIssueState } from "../src/core/issues.ts";
99 import { createPull } from "../src/core/pulls.ts";
1010 import { createRepo } from "../src/core/repos.ts";
1111 import { createUser } from "../src/core/users.ts";
12 import { alarmDrift, clearDriftCache, branchTip, cachedDrift, driftAlarm, readDrift } from "../src/flow/alarm_drift.ts";
12 import { driftSection } from "../src/web/pages/boards-drift.ts";
13 import { alarmDrift, driftingPulls, readDrifts, clearDriftCache, branchTip, cachedDrift, driftAlarm, readDrift } from "../src/flow/alarm_drift.ts";
1314 import { repoDir } from "../src/git/repos.ts";
1415 import { quietLogs } from "./helpers/globals.ts";
1516
@@ -86,6 +87,17 @@ test("read from git: landings past a pull's base, open pulls only", async () =>
8687 expect(fired.says).toContain(`#${open.number} is 26 landings`);
8788 const both = await alarmDrift(app, new Date(Date.now()));
8889 expect(both.says).toBe(`#${open.number} is 26 landings behind main; #${shut.number} is 26 landings behind main.`);
90 // The list /boards and the MCP `flow` tool answer: furthest behind first, each with its update button.
91 const listed = driftingPulls(await readDrifts(app), Date.now());
92 expect(listed.map((d) => [d.repo, d.number, d.landings])).toEqual([
93 ["ada/svc", open.number, 26],
94 ["ada/svc", shut.number, 26],
95 ]);
96 expect(driftingPulls(await readDrifts(app, { publicOnly: true }), Date.now())).toHaveLength(2);
97 const card = String(driftSection(listed));
98 expect(card).toContain(`action="/ada/svc/pulls/${open.number}/update"`);
99 expect(card).toContain("26 landings");
100 expect(driftSection([])).toBeNull();
89101 setIssueState(app.db, shut.id, "closed");
90102 const one = await alarmDrift(app, new Date(Date.now()));
91103 expect(one.says).toBe(`#${open.number} is 26 landings behind main.`);
test/palette-roster.test.ts +1
@@ -95,6 +95,7 @@ test("a site-admin person is offered the whole roster", () => {
9595 ["signins", "Signed in", "your browsers and tokens; d signs a browser out"],
9696 ["sentinel", "Sentinel", "the forge's open faults"],
9797 ["boards", "Boards", "the forge's boards and their panels"],
98 ["drift", "Drifting pulls", "open pulls far behind main, and the one action that brings each up to date"],
9899 ["ask-data", "Data explorer", "start from a question about the forge's data"],
99100 ["bugs", "Bugs", "open bugs and the bug clock"],
100101 ["home-pins", "Pinned panels", "the panels pinned to home: reorder and unpin"],
test/studio-update-pull.test.ts added +114
@@ -0,0 +1,114 @@
1 /** POST /:owner/:name/pulls/:number/update: a writer asks the pull's agent to bring it up to date; nobody else, no closed pull, no cross-origin post. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createAgent } from "../src/actions/crew.ts";
7 import { openPull } from "../src/actions/pulls.ts";
8 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
9 import { createApp, type App } from "../src/app.ts";
10 import { activeRunFor } from "../src/core/runs.ts";
11 import { setIssueState } from "../src/core/issues.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import type { Repo } from "../src/core/repos.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 let dataDir: string;
21 let app: App;
22 let servers: Servers;
23 let alice: User;
24 let repo: Repo;
25 let aliceCookie: string;
26 let bobCookie: string;
27 let n = 0;
28
29 beforeAll(async () => {
30 dataDir = await mkdtemp(join(tmpdir(), "forge-update-pull-"));
31 app = createApp({ dataDir, port: 0 });
32 servers = await serve(app);
33 const mk = async (username: string) => {
34 const u = await createUser(app.db, {
35 origin: "seed",
36 trusted: true,
37 username,
38 email: `${username}@forge.local`,
39 password: "a-good-passphrase",
40 adminIfFirst: username === "alice",
41 });
42 if (!u.ok) throw new Error(String(u.error));
43 return u.value;
44 };
45 alice = await mk("alice");
46 const bob = await mk("bob");
47 aliceCookie = `forge_session=${createSession(app.db, alice.id).token}`;
48 bobCookie = `forge_session=${createSession(app.db, bob.id).token}`;
49 const made = await createRepo(app, alice, { name: "notes", visibility: "public" });
50 if (!made.ok) throw new Error(String(made.error));
51 repo = made.value;
52 await commitToBranch(app, alice, repo, { message: "base", files: [{ path: "a.txt", content: "a\n" }] });
53 }, 30_000);
54
55 afterAll(async () => {
56 servers.stop();
57 app.close();
58 await rm(dataDir, { recursive: true, force: true });
59 });
60
61 /** An open pull assigned to a fresh agent, so there is someone to ask. */
62 async function agentPull() {
63 const branch = `b${++n}`;
64 const c = await commitToBranch(app, alice, repo, { branch, message: branch, files: [{ path: `${branch}.txt`, content: "x\n" }] });
65 if (!c.ok) throw new Error(JSON.stringify(c.error));
66 const opened = await openPull(app, alice, repo, { title: branch, baseBranch: "main", headBranch: branch });
67 if (!opened.ok) throw new Error(String(opened.error));
68 const agent = await createAgent(app, alice, { username: `bot${n}` });
69 if (!agent.ok) throw new Error(JSON.stringify(agent.error));
70 app.db.query("UPDATE issue SET assignee_id = ?1 WHERE id = ?2").run(agent.value.user.id, opened.value.id);
71 return opened.value;
72 }
73
74 const post = (number: number, cookie: string | undefined, origin = servers.url) =>
75 fetch(`${servers.url}/alice/notes/pulls/${number}/update`, {
76 method: "POST",
77 headers: { ...(cookie ? { cookie } : {}), origin, accept: "application/json" },
78 body: new URLSearchParams({ back: "/boards" }),
79 redirect: "manual",
80 });
81
82 test("a writer asks the agent: it is named and a run is queued", async () => {
83 const pull = await agentPull();
84 const res = await post(pull.number, aliceCookie);
85 expect(res.status).toBe(200);
86 const body = JSON.stringify(await res.json());
87 expect(body).toContain("is bringing");
88 expect(body).toContain(`#${pull.number}`);
89 expect(activeRunFor(app.db, "agent", pull.id)).toBeDefined();
90 });
91
92 test("someone who cannot write is refused and nothing is queued", async () => {
93 const pull = await agentPull();
94 const res = await post(pull.number, bobCookie);
95 expect(res.status).toBe(422);
96 expect(JSON.stringify(await res.json())).toContain("Only someone who can write");
97 expect(activeRunFor(app.db, "agent", pull.id)).toBeUndefined();
98 });
99
100 test("a closed pull cannot be asked", async () => {
101 const pull = await agentPull();
102 setIssueState(app.db, pull.id, "closed");
103 const res = await post(pull.number, aliceCookie);
104 expect(res.status).toBe(422);
105 expect(JSON.stringify(await res.json())).toContain("could not take it");
106 expect(activeRunFor(app.db, "agent", pull.id)).toBeUndefined();
107 });
108
109 test("a cross-origin post is refused and nothing is queued", async () => {
110 const pull = await agentPull();
111 const res = await post(pull.number, aliceCookie, "https://evil.example");
112 expect(res.status).toBe(403);
113 expect(activeRunFor(app.db, "agent", pull.id)).toBeUndefined();
114 });
test/tui-drift.test.ts added +87
@@ -0,0 +1,87 @@
1 /** The terminal's drifting-pulls screen: offered to a person admin only, says nothing drifts, and asks the agent as the route does. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createAgent } from "../src/actions/crew.ts";
7 import { openPull } from "../src/actions/pulls.ts";
8 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
9 import { createApp, type App } from "../src/app.ts";
10 import { createUser, type User } from "../src/core/users.ts";
11 import type { Repo } from "../src/core/repos.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { ENTRIES } from "../src/tui/screens/entries.ts";
14 import { askUpdate, driftRows, driftScreen, entry } from "../src/tui/screens/drift.ts";
15 import { headlessTerminal } from "../src/tui/term/terminal.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17 import { settleApp } from "./helpers/tui.ts";
18
19 quietLogs();
20
21 let dataDir: string;
22 let app: App;
23 let alice: User;
24 let bob: User;
25 let repo: Repo;
26
27 const open = (user: User) =>
28 createForgeApp(app, { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user }, { terminal: headlessTerminal(120, 30), exitHooks: false });
29
30 beforeAll(async () => {
31 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-drift-"));
32 app = createApp({ dataDir, port: 0 });
33 const mk = async (username: string) => {
34 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: username === "alice" });
35 if (!u.ok) throw new Error(String(u.error));
36 return u.value;
37 };
38 alice = await mk("alice");
39 bob = await mk("bob");
40 const made = await createRepo(app, alice, { name: "notes", visibility: "public" });
41 if (!made.ok) throw new Error(String(made.error));
42 repo = made.value;
43 await commitToBranch(app, alice, repo, { message: "base", files: [{ path: "a.txt", content: "a\n" }] });
44 }, 30_000);
45
46 afterAll(async () => {
47 app.close();
48 await rm(dataDir, { recursive: true, force: true });
49 });
50
51 test("the entry is in the palette list and offered to a person admin alone", () => {
52 expect(ENTRIES).toContain(entry);
53 const a = open(alice);
54 const b = open(bob);
55 expect(entry.offered(a.session)).toBe(true);
56 expect(entry.offered(b.session)).toBe(false);
57 });
58
59 test("rows say landings and hours; an empty forge says nothing drifts", async () => {
60 expect(driftRows([{ repo: "alice/notes", number: 3, landings: 26, hours: 1.5 }])).toEqual([
61 { title: "alice/notes #3", meta: expect.stringContaining("26 landings") },
62 ]);
63 const { app: tui, session } = open(alice);
64 tui.start();
65 await settleApp(tui);
66 tui.push(driftScreen(session, tui.ctx));
67 await settleApp(tui);
68 expect(tui.screen.toText()).toContain("Nothing drifts");
69 tui.stop();
70 });
71
72 test("asking: a writer's agent is named, a reader is refused", async () => {
73 const c = await commitToBranch(app, alice, repo, { branch: "w", message: "w", files: [{ path: "w.txt", content: "w\n" }] });
74 if (!c.ok) throw new Error(JSON.stringify(c.error));
75 const opened = await openPull(app, alice, repo, { title: "w", baseBranch: "main", headBranch: "w" });
76 if (!opened.ok) throw new Error(String(opened.error));
77 const agent = await createAgent(app, alice, { username: "bot" });
78 if (!agent.ok) throw new Error(JSON.stringify(agent.error));
79 app.db.query("UPDATE issue SET assignee_id = ?1 WHERE id = ?2").run(agent.value.user.id, opened.value.id);
80 const p = { repo: "alice/notes", number: opened.value.number, landings: 30, hours: 0 };
81 const refused = askUpdate(open(bob).session, p);
82 expect(refused.ok).toBe(false);
83 expect(refused.says).toContain("Only someone who can write");
84 const done = askUpdate(open(alice).session, p);
85 expect(done.ok).toBe(true);
86 expect(done.says).toContain("bot is bringing");
87 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs