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

Every open pull's page says who acts next and why, in one line under its title #6369

Merged opened this

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

Merged

scout/forge:scout/6350-every-open-pull-s-page-says-who-acts-nex → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/6350-every-open-pull-s-page-says-who-acts-nex was merged into main as 2cfea22.

Review

Sign in to review this change.
10 files changed +299 −6
docs/MCP.md +1 −1
@@ -102,7 +102,7 @@ for a tunnel and a forge that sleeps.
102102 | `prompts` | `{ repo, action, kind?, token?, at?, id? }`: list, add, run or remove an app's prompt sources (Instagram), as the `…/prompts` API does; the token goes in once and is never shown; listed only for an admin-scope token, and only the repository's admin may use it | yes |
103103 | `proposal_tokens` | `{ repo, verb, child?, origin?, childRepo?, id? }`: give a child forge a propose-only credential: `mint` one (`child`, `origin`), `list` them, or `revoke` one by `id`; writers only | yes |
104104 | `propose_to_parent_forge` | `{ repo }`: offer this forge's landed change to its parent as a pull on `child/<name>/…`; the same tip answers with that pull | yes |
105 | `pull` | `{ 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 `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 | |
105 | `pull` | `{ 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), 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 | |
106106 | `pulls` | `{ repo, state?, limit? }`: a repository's pulls, newest first, one brief line each: `number`, `title`, `author`, `head`, `check` (state), `approvals`, `changesRequested`, `mergeable` (null once closed); `state` open (default) or closed, `limit` 1-50 (default 20); no bodies, no diffs | |
107107 | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents | |
108108 | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | |
src/flow/next_actor.ts added +73
@@ -0,0 +1,73 @@
1 /**
2 * Who acts next on an open pull, and why, in one line (#6350). `nextActor`
3 * is plain data in, plain data out; `nextOfPull` reads the data for one pull
4 * and the pull page and the MCP `pull` both answer from it.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { getHold } from "../core/holds.ts";
9 import type { Pull } from "../core/pulls.ts";
10 import { checkSummary, type RunState } from "../core/runs.ts";
11 import { reviewSummary } from "../core/reviews.ts";
12
13 export type Actor = "builder" | "reviewer" | "queue" | "person";
14
15 export interface Next {
16 readonly who: Actor;
17 readonly why: string;
18 }
19
20 export interface NextInput {
21 /** Who holds the pull and why; a hold keeps every rule off it. */
22 readonly held: { readonly by: string; readonly why: string } | null;
23 readonly conflicted: boolean;
24 readonly check: RunState | "none";
25 /** Reviewers whose current verdict, on this head, asks for changes / approves. */
26 readonly changesRequested: number;
27 readonly approvals: number;
28 /** Verdicts passed on an older head: the pull was pushed to since. */
29 readonly pushedSince: number;
30 }
31
32 const WAITING = {
33 none: "waiting for its check",
34 queued: "its check is queued",

Non-blocking: for a repository whose forge.json declares no check, none is permanent, so "waiting for its check" overstates the wait. Worth a follow-up when the check spec is at hand.

35 running: "its check is running",
36 cancelled: "its check was cancelled",
37 } as const;
38
39 export function nextActor(i: NextInput): Next {
40 if (i.held !== null)
41 return { who: "person", why: `${i.held.by} holds it: ${i.held.why}` };
42 if (i.conflicted) return { who: "builder", why: "it conflicts with its base" };
43 if (i.changesRequested > 0)
outdated

check can also be cancelled or queued. A cancelled check lands here and reads "its check is running", which is false. Give cancelled its own reason, and add table rows for both states.

44 return { who: "builder", why: "changes were asked on this head" };
45 if (i.check === "failed") return { who: "builder", why: "its check is red" };
46 if (i.approvals > 0) {
47 if (i.check === "ok") return { who: "queue", why: "approved and green" };
48 return { who: "queue", why: `approved, ${WAITING[i.check]}` };
49 }
50 return {
51 who: "reviewer",
52 why: i.pushedSince > 0 ? "pushed since the last review" : "no review yet",
53 };
54 }
55
56 /** The next actor of an open pull at `headOid`; null once it is merged or closed. */
57 export function nextOfPull(
58 db: Database,
59 pull: Pull,
60 live: { readonly headOid: string; readonly conflicts: number },
61 ): Next | null {
62 if (pull.state !== "open" || pull.mergedAt !== null) return null;
63 const reviews = reviewSummary(db, pull.id, live.headOid);
64 const hold = getHold(db, pull.id);
65 return nextActor({
66 held: hold === undefined ? null : { by: hold.heldByName, why: hold.why },
67 conflicted: live.conflicts > 0,
68 check: checkSummary(db, pull.id, live.headOid).state,
69 changesRequested: reviews.changesRequested,
70 approvals: reviews.approvals,
71 pushedSince: reviews.stale,
72 });
73 }
src/http/mcp/tools/pull.ts +6 −1
@@ -5,12 +5,13 @@ import { getPull, pullDir, pullMergeState } from "../../../core/pulls.ts";
55 import { previewEnv } from "../../../apps/data.ts";
66 import { checkStale } from "../../../checks/stale.ts";
77 import { checkSummary } from "../../../core/runs.ts";
8 import { nextOfPull } from "../../../flow/next_actor.ts";
89 import { appView } from "../../../actions/apps.ts";
910 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
1011
1112 export const tool: Tool = {
1213 name: "pull",
13 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 `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",
14 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), 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",
1415 description:
1516 "A pull: title, state, head, base; open: `mergeable`, `conflicts`, `proposal`, `checkStale`, `check`.",
1617 inputSchema: {
@@ -48,6 +49,10 @@ export const tool: Tool = {
4849 proposal: pullProposalOf(ctx.app.db, pull.id) ?? null,
4950 check: { state: check.state, baseOid: check.run?.baseOid ?? "" },
5051 checkStale: stale,
52 next: nextOfPull(ctx.app.db, pull, {
53 headOid: pull.headOid,
54 conflicts: state?.conflicts?.length ?? 0,
55 }),
5156 ...(state ?? {}),
5257 });
5358 // What the card needs and the text does not say: where the pull lives
src/tui/screens/pull.ts +11 −1
@@ -56,6 +56,7 @@ import { checkStale, checksHold } from "../../checks/stale.ts";
5656 import { checkWords } from "../../checks/summary.ts";
5757 import { oneLine } from "../../checks/words.ts";
5858 import { resolveOid } from "../../git/merge.ts";
59 import { nextOfPull, type Next } from "../../flow/next_actor.ts";
5960 import { getPull, pullDir, variantSiblings, type Pull } from "../../core/pulls.ts";
6061 import { voteVariant } from "../../actions/variants.ts";
6162 import { getRepoById, type Repo } from "../../core/repos.ts";
@@ -215,6 +216,8 @@ export function pullScreen(
215216 let diff: Diff = { files: [], additions: 0, deletions: 0 };
216217 let reviews: AnchoredReview[] = [];
217218 let threads: Thread[] = [];
219 /** Who acts next, for a local open pull; the page says the same line. */
220 let next: Next | null = null;
218221 /** Where each team the issue names stands; empty when it names none. */
219222 let teams: TeamVerdict[] = [];
220223 /** Settled threads the reader has opened back up, for this visit only. */
@@ -300,6 +303,13 @@ export function pullScreen(
300303 ? NO_CHECK
301304 : checkSummary(f.db, row.id, row.headOid || (facts?.headOid ?? ""));
302305 checkBase = row?.baseBranch ?? facts?.baseBranch ?? "";
306 next =
307 row === undefined
308 ? null
309 : nextOfPull(f.db, row, {
310 headOid: row.headOid || (facts?.headOid ?? ""),
311 conflicts: facts?.conflicts.length ?? 0,
312 });
303313 checkProgress = runningProgress(f.app.config.dataDir, check);
304314 const spec =
305315 repo === undefined || !isApp
@@ -869,7 +879,7 @@ export function pullScreen(
869879 x1,
870880 head!.y + 1,
871881 truncate(
872 ` ${facts?.mergedAt != null ? "merged" : issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)}${assignedText(issueRow, ctx.icons)}${facts?.proposal ? ` · ${proposedByText(facts.proposal)}` : ""}`,
882 ` ${facts?.mergedAt != null ? "merged" : issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)}${assignedText(issueRow, ctx.icons)}${facts?.proposal ? ` · ${proposedByText(facts.proposal)}` : ""}${next === null ? "" : ` · next: ${next.who}, ${next.why}`}`,
873883 Math.max(0, edge - x1),
874884 ),
875885 style(c.mutedFg, c.bg),
src/web/pages/pull.ts +3 −1
@@ -104,6 +104,7 @@ import { previewEnv } from "../../apps/data.ts";
104104 import { specFindings } from "./pull-spec.ts";
105105 import { pullPreviewFindings } from "../../core/preview_findings.ts";
106106 import { crewActivityCard } from "./runs.ts";
107 import { nextOfPull } from "../../flow/next_actor.ts";
107108 import { holdCard } from "../studio/hold.ts";
108109 import {
109110 changeHead,
@@ -949,7 +950,8 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
949950 compact: true,
950951 ownTitle: true,
951952 })}
952 ${changeHead(ctx, repo, pull)} ${changeTabs(repo, pull, "files")}
953 ${changeHead(ctx, repo, pull, nextOfPull(ctx.app.db, pull, { headOid: status.headOid, conflicts: status.conflicts.length }))}
954 ${changeTabs(repo, pull, "files")}
953955 <p class="files-where muted small" id="files">
954956 <span class="mono">${headLabel} → ${pull.baseBranch}</span>
955957 <span class="sep" aria-hidden="true">·</span>
src/web/studio/change.ts +8 −2
@@ -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 { nextOfPull, type Next } from "../../flow/next_actor.ts";
9192 import { changeHref } from "./nouns.ts";
9293 import { pullWord, servingHistory, wordBadge, type WordCtx } from "./words.ts";
9394
@@ -191,7 +192,7 @@ function reviewEntry(
191192 /** A change's head, the same on both of its tabs: the title, its state,
192193 * who opened it and in answer to whom, where it came from, and what the
193194 * crew spent on it. */
194 export function changeHead(ctx: Ctx, repo: Repo, pull: Pull): Html {
195 export function changeHead(ctx: Ctx, repo: Repo, pull: Pull, next: Next | null = null): Html {
195196 const who = toldBy(ctx.app.db, pull);
196197 const marks = marksByName(ctx.app.db, [pull.authorName, who ?? ""]);
197198 return html`<div class="page-head">
@@ -229,6 +230,10 @@ export function changeHead(ctx: Ctx, repo: Repo, pull: Pull): Html {
229230 >`,
230231 )}
231232 </div>
233 ${when(

No test draws this line. Add a page test: an open pull shows Next: reviewer, no review yet. on the change tab and on the Files tab, and a closed pull shows no line. The same line also belongs in src/tui/screens/pull.ts, so the terminal says what the page says.

234 next,
235 (n) => html`<p class="next-actor muted small">Next: ${n.who}, ${n.why}.</p>`,
236 )}
232237 </div>`;
233238 }
234239
@@ -597,7 +602,8 @@ async function changeConversation(
597602 compact: true,
598603 ownTitle: true,
599604 })}
600 ${changeHead(ctx, repo, pull)} ${changeTabs(repo, pull, "change")}
605 ${changeHead(ctx, repo, pull, status === undefined ? null : nextOfPull(db, pull, { headOid, conflicts: status.conflicts.length }))}
606 ${changeTabs(repo, pull, "change")}
601607 ${when(siblings.length > 0, () =>
602608 html`<p class="muted small variant-link">
603609 <a href="${base}/variants/${pull.variantGroup ?? ""}"
test/mcp.test.ts +1
@@ -1231,6 +1231,7 @@ describe("the forge over MCP", () => {
12311231 expect(card.rows.some((r: any) => r.label === "Preview")).toBe(false);
12321232 const text = JSON.parse(body.result.content[0].text);
12331233 expect(text.title).toBe("Card wire");
1234 expect(text.next).toEqual({ who: "reviewer", why: "no review yet" });
12341235 expect("card" in text).toBe(false);
12351236 });
12361237
test/next_actor.test.ts added +30
@@ -0,0 +1,30 @@
1 import { describe, expect, test } from "bun:test";
2 import { nextActor, type NextInput } from "../src/flow/next_actor.ts";
3
4 const base: NextInput = {
5 held: null,
6 conflicted: false,
7 check: "none",
8 changesRequested: 0,
9 approvals: 0,
10 pushedSince: 0,
11 };
12
13 const cases: [string, Partial<NextInput>, string, string][] = [
14 ["held", { held: { by: "sam", why: "wait for legal" }, approvals: 1, check: "ok" }, "person", "sam holds it: wait for legal"],
15 ["conflicted", { conflicted: true, approvals: 1, check: "ok" }, "builder", "it conflicts with its base"],
16 ["changes asked", { changesRequested: 1, check: "ok" }, "builder", "changes were asked on this head"],
17 ["red", { check: "failed", approvals: 1 }, "builder", "its check is red"],
18 ["pushed since review", { pushedSince: 1, check: "ok" }, "reviewer", "pushed since the last review"],
19 ["no review", { check: "running" }, "reviewer", "no review yet"],
20 ["approved and green", { approvals: 1, check: "ok" }, "queue", "approved and green"],
21 ["approved, no check", { approvals: 1 }, "queue", "approved, waiting for its check"],
22 ["approved, queued", { approvals: 1, check: "queued" }, "queue", "approved, its check is queued"],
23 ["approved, cancelled", { approvals: 1, check: "cancelled" }, "queue", "approved, its check was cancelled"],
24 ["approved, running", { approvals: 1, check: "running" }, "queue", "approved, its check is running"],
25 ];
26
27 describe("nextActor", () => {
28 for (const [name, input, who, why] of cases)
29 test(name, () => expect(nextActor({ ...base, ...input })).toEqual({ who: who as never, why }));
30 });
test/pull-next-line.test.ts added +90
@@ -0,0 +1,90 @@
1 /** The pull page says who acts next under its title, on both tabs, for an open pull only. */
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 { createApp, type App } from "../src/app.ts";
7 import { mergePull, openPull } from "../src/actions/pulls.ts";
8 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
9 import { createSession } from "../src/core/sessions.ts";
10 import { createUser, type User } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let alice: User;
20 const numbers: Record<string, number> = {};
21 const cells = process.env["FORGE_CELLS"];
22
23 const read = async (repo: string, tab: "" | "/files"): Promise<string> =>
24 (
25 await fetch(`${servers.url}/alice/${repo}/pulls/${numbers[repo]}${tab}`, {
26 headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` },
27 })
28 ).text();
29
30 beforeAll(async () => {
31 process.env["FORGE_CELLS"] = "off";
32 dataDir = await mkdtemp(join(tmpdir(), "forge-next-line-"));
33 app = createApp({ dataDir, port: 0 });
34 servers = await serve(app);
35 const made = await createUser(app.db, {
36 origin: "seed",
37 trusted: true,
38 username: "alice",
39 email: "[email protected]",
40 password: "a-good-passphrase",
41 adminIfFirst: true,
42 });
43 if (!made.ok) throw new Error(String(made.error));
44 alice = made.value;
45 for (const name of ["open", "merged"]) {
46 const repo = await createRepo(app, alice, { name, visibility: "public" });
47 if (!repo.ok) throw new Error(String(repo.error));
48 for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) {
49 const done = await commitToBranch(app, alice, repo.value, {
50 ...(branch === undefined ? {} : { branch }),
51 message: "change",
52 files: [{ path: file, content: file === "forge.json" ? "{}" : "x" }],
53 });
54 if (!done.ok) throw new Error(JSON.stringify(done.error));
55 }
56 const pull = await openPull(app, alice, repo.value, {
57 title: name,
58 body: "",
59 baseBranch: "main",
60 headBranch: "topic",
61 });
62 if (!pull.ok) throw new Error(JSON.stringify(pull.error));
63 numbers[name] = pull.value.number;
64 if (name === "merged") {
65 const done = await mergePull(app, alice, repo.value, pull.value, "merge");
66 if (!done.ok) throw new Error(JSON.stringify(done.error));
67 }
68 }
69 });
70
71 afterAll(async () => {
72 await servers.stop();
73 app.close();
74 if (cells === undefined) delete process.env["FORGE_CELLS"];
75 else process.env["FORGE_CELLS"] = cells;
76 await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
77 });
78
79 test("an open pull with no review names the reviewer on the change tab and on Files", async () => {
80 for (const tab of ["", "/files"] as const) {
81 const body = await read("open", tab);
82 expect(body).toContain("Next: reviewer, no review yet.");
83 expect(body).toContain('class="next-actor');
84 }
85 });
86
87 test("a merged pull draws no next line", async () => {
88 for (const tab of ["", "/files"] as const)
89 expect(await read("merged", tab)).not.toContain("next-actor");
90 });
test/pull-next-tui.test.ts added +76
@@ -0,0 +1,76 @@
1 import { afterAll, beforeAll, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { openPull } from "../src/actions/pulls.ts";
6 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createUser, type User } from "../src/core/users.ts";
9 import { createForgeApp } from "../src/tui/forge.ts";
10 import { pullScreen } from "../src/tui/screens/pull.ts";
11 import { local } from "../src/tui/screens/where.ts";
12 import { headlessTerminal } from "../src/tui/term/terminal.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14 import { settleApp } from "./helpers/tui.ts";
15
16 quietLogs();
17
18 let dataDir: string;
19 let app: App;
20 let alice: User;
21
22 beforeAll(async () => {
23 dataDir = await mkdtemp(join(tmpdir(), "forge-next-tui-"));
24 app = createApp({ dataDir, port: 0, host: "forge.test" });
25 const made = await createUser(app.db, {
26 origin: "seed",
27 trusted: true,
28 username: "alice",
29 email: "[email protected]",
30 password: "a-good-passphrase",
31 adminIfFirst: true,
32 });
33 if (!made.ok) throw new Error(String(made.error));
34 alice = made.value;
35 }, 60_000);
36
37 afterAll(async () => {
38 app.close();
39 await rm(dataDir, { recursive: true, force: true });
40 });
41
42 test("the terminal's pull screen says who acts next, as the page does", async () => {
43 const repo = await createRepo(app, alice, { name: "lib", visibility: "public" });
44 if (!repo.ok) throw new Error(String(repo.error));
45 for (const branch of ["main", "topic"]) {
46 const done = await commitToBranch(app, alice, repo.value, {
47 branch,
48 message: "edit",
49 files: [{ path: `${branch}.txt`, content: "x\n" }],
50 });
51 if (!done.ok) throw new Error(JSON.stringify(done.error));
52 }
53 const opened = await openPull(app, alice, repo.value, {
54 title: "Topic",
55 baseBranch: "main",
56 headBranch: "topic",
57 });
58 if (!opened.ok) throw new Error(String(opened.error));
59 const { app: tui, session } = createForgeApp(
60 app,
61 { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:alice", user: alice },
62 { terminal: headlessTerminal(140, 44), exitHooks: false },
63 );
64 tui.start();
65 try {
66 tui.ctx.push(pullScreen(session, tui.ctx, local(session, repo.value), opened.value.number));
67 const end = Date.now() + 20_000;
68 while (!tui.screen.toText().includes("next: reviewer, no review yet")) {
69 if (Date.now() > end) throw new Error(tui.screen.toText());
70 await settleApp(tui);
71 await Bun.sleep(20);
72 }
73 } finally {
74 tui.stop();
75 }
76 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs