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

An approved pull says when it will land #10879

Merged opened this

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

Merged

scout/forge:scout/10860-an-approved-pull-says-when-it-will-land → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/10860-an-approved-pull-says-when-it-will-land was merged into main as cd8a8ba.

Review

Sign in to review this change.
9 files changed +318 −5
docs/features/landing-line.md added +18
@@ -0,0 +1,18 @@
1 # The landing line: when an approved pull will land
2
3 This page is for anyone waiting on a pull. Once a pull is open and approved, it says where it stands in the landing queue and about when it will land.
4
5 ## Where to read it
6
7 - **The page:** a muted line under the pull's path, for example `first in the queue: lands in about 15 min`.
8 - **The terminal:** the same words in the pull screen's header line, after the step.
9 - **The `pull` tool:** `eta: { minutes, says }`. `says` is the same text; `minutes` is the number, or null when there is no pace to read. `eta` is null unless the pull is open, approved and not landed.
10
11 All three call `landingLine` in `src/flow/landing_line.ts`, so they never disagree.
12
13 ## How place and pace are read
14
15 - **Place:** the repository's open, unlanded pulls whose latest review is an approval (no changes requested after it), ordered by when they were approved. The pull's index is the number ahead of it: `first`, then `number 2`, and so on.
16 - **Pace:** the pulls the repository landed into its default branch in the last hour, and the median length of its finished merge-queue gates in the last 6 hours. `src/flow/landing_eta.ts` turns place and pace into minutes and words; with no pace at all it says so instead of guessing.
17
18 The line is an estimate from recent history, not a promise: a red check, a conflict or a hold moves it.
scripts/unreached-allow.json −1
@@ -10,7 +10,6 @@
1010 "src/flow/conflict_pairs.ts",
1111 "src/flow/declines.ts",
1212 "src/flow/handovers.ts",
13 "src/flow/landing_eta.ts",
1413 "src/flow/rebase_first.ts",
1514 "src/flow/review_rounds.ts",
1615 "src/flow/shared_red.ts",
src/flow/landing_line.ts added +68
@@ -0,0 +1,68 @@
1 /**

No docs/features/landing-line.md for this feature; add the how-to there (not in README).

2 * Where an approved pull stands in the landing queue, read from the rows
3 * (#10860): its place among the approved open pulls of its repository (those
4 * approved earlier are ahead), the pulls the repository landed in the last
5 * hour, and the median of its finished merge-queue gates in the last 6 h.
6 * `landing_eta.ts` turns those into minutes and words; the pull page and the
7 * MCP `pull` tool both call this, so they say the same.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import { etaSays, landingEta } from "./landing_eta.ts";
12 import { median } from "./pipeline.ts";
13
14 const HOUR_MS = 3_600_000;
15
16 export interface LandingLine {
17 /** Minutes until it lands, or null when there is no pace to read. */
18 readonly minutes: number | null;
19 readonly says: string;
20 }
21
22 /** The pull's estimate, or null when it is not an open, approved, unlanded pull. */
23 export function landingLine(
24 db: Database,
25 pull: { readonly id: string; readonly number: number; readonly state: string; readonly mergedAt: string | null },
26 repoId: string,
27 now: Date = new Date(),
28 ): LandingLine | null {
29 if (pull.state !== "open" || pull.mergedAt !== null) return null;
30 // Open approved pulls, each by its latest approval with no changes-requested after it.
31 const approved = db
32 .query<{ id: string; number: number; at: string }, [string]>(
33 `SELECT i.id AS id, i.number AS number, a.at AS at
34 FROM pull p JOIN issue i ON i.id = p.issue_id
35 JOIN (SELECT issue_id, MAX(created_at) AS at FROM review WHERE state = 'approved' GROUP BY issue_id) a
36 ON a.issue_id = i.id
37 WHERE i.repo_id = ?1 AND i.state = 'open' AND p.merged_at IS NULL
38 AND NOT EXISTS (SELECT 1 FROM review c WHERE c.issue_id = i.id AND c.state = 'changes_requested' AND c.created_at > a.at)
39 ORDER BY a.at ASC, i.number ASC`,
40 )
41 .all(repoId);
42 const place = approved.findIndex((a) => a.id === pull.id);
43 if (place < 0) return null;
44 const since = new Date(now.getTime() - HOUR_MS).toISOString();
45 const landed =
46 db
47 .query<{ n: number }, [string, string, string]>(
48 `SELECT COUNT(*) AS n FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id
49 WHERE r.id = ?1 AND p.base_branch = r.default_branch AND p.merged_at >= ?2 AND p.merged_at <= ?3`,
50 )
51 .get(repoId, since, now.toISOString())?.n ?? 0;
52 const gates = db
53 .query<{ ms: number }, [string, string, string]>(
54 `SELECT (julianday(finished_at) - julianday(started_at)) * 86400000 AS ms FROM check_gate
55 WHERE repo_id = ?1 AND finished_at IS NOT NULL AND finished_at >= ?2 AND finished_at <= ?3`,
56 )
57 .all(repoId, new Date(now.getTime() - 6 * HOUR_MS).toISOString(), now.toISOString())
58 .map((g) => g.ms)
59 .filter((ms) => ms > 0);
60 const gate = median(gates);
61 const raw = landingEta({
62 ahead: place,
63 landedLastHour: landed,
64 medianGateMinutes: gate === null ? null : gate / 60_000,
65 });
66 const minutes = raw === null ? null : Math.round(raw * 10) / 10;
67 return { minutes, says: `${place === 0 ? "first" : `number ${place + 1}`} in the queue: ${etaSays(minutes)}` };
68 }
src/http/mcp/tools/pull.ts +3 −1
@@ -9,6 +9,7 @@ import { nextOfPull } from "../../../flow/next_actor.ts";
99 import { stepOfPull } from "../../../flow/path.ts";
1010 import { appView } from "../../../actions/apps.ts";
1111 import { countThread, EMBEDDED_COMMENTS, listComments } from "../../../core/issues.ts";
12 import { landingLine } from "../../../flow/landing_line.ts";
1213 import { pullLooks } from "../../../looks/readable.ts";
1314 import { isCrew } from "../../../core/agents.ts";
1415 import { markReaders } from "../../../crew/direct.ts";
@@ -16,7 +17,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"
1617
1718 export const tool: Tool = {
1819 name: "pull",
19 doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), `step: { step, why, done }` (check, review, land or live; null once closed unlanded), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), and `looks` (newest first: `{ id, width, says, by, commit, url, at }`, the same as `GET …/pulls/:number/looks`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment",
20 doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), `step: { step, why, done }` (check, review, land or live; null once closed unlanded), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), and `looks` (newest first: `{ id, width, says, by, commit, url, at }`, the same as `GET …/pulls/:number/looks`), and `eta: { minutes, says }` (approved open pull only, else null), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment",
2021 description:
2122 "A pull: title, state, head, base; open: `mergeable`, `conflicts`, `checkStale`, `check`.",
2223 inputSchema: {
@@ -66,6 +67,7 @@ export const tool: Tool = {
6667 state: pull.mergedAt !== null ? "merged" : pull.state,
6768 base: pull.baseBranch,
6869 head: pull.headBranch,
70 eta: landingLine(ctx.app.db, pull, repo.id),
6971 proposal: pullProposalOf(ctx.app.db, pull.id) ?? null,
7072 check: { state: check.state, baseOid: check.run?.baseOid ?? "" },
7173 checkStale: stale,
src/tui/screens/pull.ts +6 −1
@@ -72,6 +72,7 @@ import { checkWords } from "../../checks/summary.ts";
7272 import { oneLine } from "../../checks/words.ts";
7373 import { resolveOid } from "../../git/merge.ts";
7474 import { nextOfPull, type Next } from "../../flow/next_actor.ts";
75 import { landingLine } from "../../flow/landing_line.ts";
7576 import { stepLine, stepOfPull, type PathStep } from "../../flow/path.ts";
7677 import { fromKeptFork } from "../../core/landing_kind.ts";
7778 import { getPull, pullDir, variantSiblings, type MergePullError, type Pull } from "../../core/pulls.ts";
@@ -258,6 +259,8 @@ export function pullScreen(
258259 let next: Next | null = null;
259260 /** Which of check, review, land, live the pull is at; the page's step line. */
260261 let step: PathStep | null = null;
262 /** Where an approved open pull stands in the landing queue; the page's line under its path (#10860). */
263 let landing: string | null = null;
261264 /** Where each team the issue names stands; empty when it names none. */
262265 let teams: TeamVerdict[] = [];
263266 /** The named teams `mergePull` would refuse for (`team_review_required`), by name. */
@@ -410,6 +413,8 @@ export function pullScreen(
410413 },
411414 next,
412415 );
416 landing =
417 row === undefined || repo === undefined || repo.origin != null ? null : (landingLine(f.db, row, repo.id)?.says ?? null);
413418 checkProgress = runningProgress(f.app.config.dataDir, check);
414419 const spec =
415420 repo === undefined || !isApp
@@ -1237,7 +1242,7 @@ export function pullScreen(
12371242 x1,
12381243 head!.y + 1,
12391244 truncate(
1240 ` ${facts?.mergedAt != null ? "merged" : issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)}${guestWaiting() ? ` · ${repo !== undefined && crewFilters(repo) ? GUEST_NOTE_FILTERED : GUEST_NOTE}` : ""}${assignedText(issueRow, ctx.icons)}${facts?.proposal ? ` · ${proposedByText(facts.proposal)}` : ""}${hold === undefined ? "" : ` · held by ${hold.heldByName}: ${hold.why}`}${step === null ? "" : ` · step: ${stepLine(step)}`}${next === null ? "" : ` · next: ${next.who}, ${next.why}`}`,
1245 ` ${facts?.mergedAt != null ? "merged" : issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)}${guestWaiting() ? ` · ${repo !== undefined && crewFilters(repo) ? GUEST_NOTE_FILTERED : GUEST_NOTE}` : ""}${assignedText(issueRow, ctx.icons)}${facts?.proposal ? ` · ${proposedByText(facts.proposal)}` : ""}${hold === undefined ? "" : ` · held by ${hold.heldByName}: ${hold.why}`}${step === null ? "" : ` · step: ${stepLine(step)}`}${landing === null ? "" : ` · ${landing}`}${next === null ? "" : ` · next: ${next.who}, ${next.why}`}`,
12411246 Math.max(0, edge - x1),
12421247 ),
12431248 style(c.mutedFg, c.bg),
src/web/studio/change.ts +5 −1
@@ -88,6 +88,7 @@ import { previewVision } from "../../crew/vision.ts";
8888 import { visionPreviewCard } from "./direction.ts";
8989 import { holdCard } from "./hold.ts";
9090 import { pullMayRun } from "../../core/pull_trust.ts";
91 import { landingLine } from "../../flow/landing_line.ts";
9192 import { nextOfPull } from "../../flow/next_actor.ts";
9293 import { STEP_MEANS, STEPS, stepOfPull, stepWord, type PathStep } from "../../flow/path.ts";
9394 import { changeHref } from "./nouns.ts";
@@ -267,7 +268,10 @@ export function changeHead(
267268 >`,
268269 )}
269270 </div>
270 ${when(step, (st) => pathLine(st, next === null ? undefined : `${next.who}, ${next.why}`))}
271 ${when(step, (st) => pathLine(st, next === null ? undefined : `${next.who}, ${next.why}`))}${when(

The page shows the line but the terminal pull screen in src/tui/ does not: both doors must. Call landingLine there too and test it.

272 live === null ? null : landingLine(ctx.app.db, pull, repo.id),
273 (eta) => html`<p class="muted small" data-landing-eta>${eta.says}</p>`,
274 )}
271275 </div>`;
272276 }
273277
test/landing-line.test.ts added +126
@@ -0,0 +1,126 @@
1 /** An approved pull says when it will land, on its page and in the `pull` tool (#10860): a fixed queue and pace. */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { addToCrew, createAgent } from "../src/actions/crew.ts";
9 import { openPull } from "../src/actions/pulls.ts";
10 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
11 import { submitReview } from "../src/core/reviews.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import type { Pull } from "../src/core/pulls.ts";
14 import type { Repo } from "../src/core/repos.ts";
15 import { createUser, getUserByName, type User } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 const PASSWORD = "a-long-step-passphrase";
22
23 let dataDir = "";
24 let app: App;
25 let servers: Servers;
26 let alice: User;
27 let cookie = "";
28 let repo: Repo;
29 let pull: Pull;
30
31 beforeAll(async () => {
32 dataDir = await mkdtemp(join(tmpdir(), "forge-landing-line-"));
33 app = createApp({ dataDir, port: 0 });
34 servers = await serve(app);
35 const a = await createUser(app.db, {
36 origin: "seed",
37 trusted: true,
38 username: "alice",
39 email: "[email protected]",
40 password: PASSWORD,
41 adminIfFirst: true,
42 });
43 if (!a.ok) throw new Error(a.error);
44 alice = a.value;
45 const res = await fetch(`${servers.url}/signin`, {
46 method: "POST",
47 body: new URLSearchParams({ username: "alice", password: PASSWORD }),
48 redirect: "manual",
49 });
50 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
51 await createAgent(app, alice, { username: "bot" });
52 const made = await createRepo(app, alice, { name: "site", visibility: "public" });
53 if (!made.ok) throw new Error(String(made.error));
54 repo = made.value;
55 await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# site\n" }] });
56 addToCrew(app, alice, repo, "bot");
57 await commitToBranch(app, alice, repo, {
58 branch: "bot/1",
59 base: "main",
60 message: "Tidy",
61 files: [{ path: "F.md", content: "1\n" }],
62 });
63 const opened = await openPull(app, getUserByName(app.db, "bot")!, repo, {
64 title: "Tidy the footer",
65 body: "",
66 baseBranch: "main",
67 headBranch: "bot/1",
68 });
69 if (!opened.ok) throw new Error(String(opened.error));
70 pull = opened.value;
71 }, 30_000);
72
73 afterAll(async () => {
74 await servers.stop();
75 app.close();
76 await rm(dataDir, { recursive: true, force: true });
77 });
78
79 const LINE = "first in the queue: lands in about 15 min";
80
81 async function pageLine(): Promise<string> {
82 const res = await fetch(`${servers.url}/alice/site/pulls/${pull.number}`, { headers: { cookie } });
83 const m = /data-landing-eta>([^<]*)</.exec(await res.text());
84 return m === null ? "" : m[1]!.trim();
85 }
86
87 async function tool(): Promise<{ eta: { minutes: number | null; says: string } | null }> {
88 const res = await fetch(`${servers.url}/mcp`, {
89 method: "POST",
90 headers: {
91 "content-type": "application/json",
92 accept: "application/json, text/event-stream",
93 authorization: `Bearer ${createToken(app.db, alice.id, "t", "ro").token}`,
94 },
95 body: JSON.stringify({
96 jsonrpc: "2.0",
97 id: 1,
98 method: "tools/call",
99 params: { name: "pull", arguments: { repo: "alice/site", number: pull.number } },
100 }),
101 });
102 return JSON.parse(((await res.json()) as any).result.content[0].text);
103 }
104
105 test("an unapproved pull says nothing about landing", async () => {
106 expect(await pageLine()).toBe("");
107 expect((await tool()).eta).toBeNull();
108 });
109
110 test("an approved pull says its place and pace on the page and in the tool", async () => {
111 // A fixed pace: nothing landed in the last hour, one gate of 14 min.
112 const end = new Date(Date.now() - 10 * 60_000);
113 app.db
114 .query("INSERT INTO check_gate (id, repo_id, started_at, finished_at) VALUES ('g1', ?1, ?2, ?3)")
115 .run(repo.id, new Date(end.getTime() - 14 * 60_000).toISOString(), end.toISOString());
116 const r = submitReview(app.db, {
117 issueId: pull.id,
118 authorId: alice.id,
119 state: "approved",
120 commitOid: pull.headOid,
121 } as Parameters<typeof submitReview>[1]);
122 expect(r.ok).toBe(true);
123 expect(await pageLine()).toBe(LINE);
124 const eta = (await tool()).eta;
125 expect(eta).toEqual({ minutes: 14, says: LINE });
126 });
test/pages-budget.test.ts +1 −1
@@ -34,7 +34,7 @@ const DEFAULT = { statements: 60, git: 8 };
3434 const HEAVIER: Record<string, { statements?: number; git?: number }> = {
3535 "/:owner/:name/issues/:number": { statements: 70 },
3636 "/:owner/:name/next": { statements: 64 },
37 "/:owner/:name/pulls/:number": { statements: 109 }, // +1: the header Talk count (#10327)
37 "/:owner/:name/pulls/:number": { statements: 110 }, // +1: the header Talk count (#10327); +1: the landing line (#10860)
3838 "/board": { statements: 64 },
3939 "/settings/capacity": { statements: 71 },
4040 };
test/tui-pull-landing.test.ts added +91
@@ -0,0 +1,91 @@
1 /** The terminal's pull screen says when an approved pull will land, as the page does (#10860). */
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 { addToCrew, 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 as Forge } from "../src/app.ts";
10 import { submitReview } from "../src/core/reviews.ts";
11 import type { Pull } from "../src/core/pulls.ts";
12 import type { Repo } from "../src/core/repos.ts";
13 import { getUserByName, type User } from "../src/core/users.ts";
14 import { createForgeApp } from "../src/tui/forge.ts";
15 import { pullScreen } from "../src/tui/screens/pull.ts";
16 import { local } from "../src/tui/screens/where.ts";
17 import { headlessTerminal } from "../src/tui/term/terminal.ts";
18 import { seedAdmin } from "./helpers/admin.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20 import { settleApp } from "./helpers/tui.ts";
21
22 quietLogs();
23
24 let dataDir: string;
25 let forge: Forge;
26 let alice: User;
27 let repo: Repo;
28 let pull: Pull;
29
30 beforeAll(async () => {
31 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-landing-"));
32 forge = createApp({ dataDir, port: 0 });
33 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
34 if (!a.ok) throw new Error("signup failed");
35 alice = a.value;
36 await createAgent(forge, alice, { username: "bot" });
37 const r = await createRepo(forge, alice, { name: "site", visibility: "public" });
38 if (!r.ok) throw new Error("repo creation failed");
39 repo = r.value;
40 await commitToBranch(forge, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# s\n" }] });
41 addToCrew(forge, alice, repo, "bot");
42 await commitToBranch(forge, alice, repo, {
43 branch: "bot/1",
44 base: "main",
45 message: "Tidy",
46 files: [{ path: "F.md", content: "1\n" }],
47 });
48 const opened = await openPull(forge, getUserByName(forge.db, "bot")!, repo, {
49 title: "Tidy",
50 body: "",
51 baseBranch: "main",
52 headBranch: "bot/1",
53 });
54 if (!opened.ok) throw new Error(String(opened.error));
55 pull = opened.value;
56 }, 30_000);
57
58 afterAll(async () => {
59 forge.close();
60 await rm(dataDir, { recursive: true, force: true });
61 });
62
63 async function screenText(): Promise<string> {
64 const { app, session } = createForgeApp(
65 forge,
66 { sshUsername: "alice", remoteAddress: "127.0.0.1", user: alice },
67 { terminal: headlessTerminal(200, 44), exitHooks: false },
68 );
69 app.start();
70 app.ctx.push(pullScreen(session, app.ctx, local(session, repo), pull.number));
71 await settleApp(app);
72 const text = app.screen.toText();
73 app.stop();
74 return text;
75 }
76
77 test("the pull screen says its place in the queue once approved, and not before", async () => {
78 expect(await screenText()).not.toContain("in the queue");
79 const end = new Date(Date.now() - 10 * 60_000);
80 forge.db
81 .query("INSERT INTO check_gate (id, repo_id, started_at, finished_at) VALUES ('g1', ?1, ?2, ?3)")
82 .run(repo.id, new Date(end.getTime() - 14 * 60_000).toISOString(), end.toISOString());
83 const r = submitReview(forge.db, {
84 issueId: pull.id,
85 authorId: alice.id,
86 state: "approved",
87 commitOid: pull.headOid,
88 } as Parameters<typeof submitReview>[1]);
89 expect(r.ok).toBe(true);
90 expect(await screenText()).toContain("first in the queue: lands in about 15 min");
91 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs