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

The terminal's pull screen holds a pull with `h` and a reason, and lets it go with `h` again, as MCP `hold` does #7264

Merged opened this

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

Merged

scout/forge:scout/7257-the-terminal-s-pull-screen-holds-a-pull → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/7257-the-terminal-s-pull-screen-holds-a-pull was merged into main as f8ec782.

Review

Sign in to review this change.
3 files changed +221 −1
README.md +2
@@ -44,6 +44,8 @@ issue — use the link on the page). Both are a forge admin's only.
4444
4545 A hold can be set "until green" (a checkbox on the hold form, `until: "green"` on the hold API and the MCP `hold` tool): when a check passes on the pull's current head the forge releases it and the pull's timeline says `hold released: green on <short oid>`. A plain hold is never released by a check.
4646
47 In the terminal, `h` on the pull screen asks for a one-line reason and holds the pull (an empty or over-200-character reason is refused in the prompt, as on the web and in MCP); on a held pull `h` asks to confirm and lets it go. Both go through `holdPull` and `releasePull`, so the header shows `held by <who>: <why>`. `h` is offered to a person who can write only: an agent or a reader does not get it.
48
4749 A hold an agent placed does not stand forever: the tick releases one still standing after 24 h, in one transaction, with a comment on the pull, "Hold expired after 24 h: <why>. Back to @builder.", which shows in the pull's timeline like any comment, and the pull goes back to its builder (`expireCrewHolds` in `src/actions/holds.ts`). A person's hold is never expired. The window is fixed; a pull held again and expiring again leaves one such comment per round in its timeline.
4850
4951 In the terminal, `T` on a shipped pull's screen takes it back, as the pull page's button does: a one-line confirm names the pull, Yes opens the pull that undoes it (`revertPull`, `src/actions/revert.ts`) and shows it, No changes nothing. The key appears only to a person who can write a local repository (never an agent, a reader, a guest or a mirror), and refusals read as the page's do (`takeBackWords`). `T` also opens an app preview's terminal on an open pull; a shipped pull has no live preview, so the two never meet.
src/tui/screens/pull.ts +57 −1
@@ -39,6 +39,8 @@ import {
3939 resolveReviewThread,
4040 submitReview,
4141 } from "../../actions/pulls.ts";
42 import { holdPull, releasePull } from "../../actions/holds.ts";
43 import { getHold, type Hold } from "../../core/holds.ts";
4244 import { revertPull } from "../../actions/revert.ts";
4345 import { takeBackWords } from "../../actions/revert_words.ts";
4446 import { openEnvUrl } from "../../actions/apps.ts";
@@ -262,6 +264,11 @@ export function pullScreen(
262264 /** Agent runs about this pull, newest first. */
263265 const crew = new RunList(f);
264266 const mayWrite = (): boolean => src.capabilities.write && repo !== undefined;
267 /** The hold that stands on this pull; undefined when none does. */
268 let hold: Hold | undefined;
269 /** `h` is a person's key: an agent's hold is refused by the action, so it is not offered. */
270 const mayHold = (): boolean =>
271 mayWrite() && f.user?.kind === "user" && issueRow !== undefined;
265272 /** A writer, on a pull that is one of several variants. */
266273 const mayVote = (): boolean =>
267274 mayWrite() &&
@@ -292,6 +299,7 @@ export function pullScreen(
292299 : withOutdated(listReviewComments(f.db, row.id), diff);
293300 threads = row === undefined ? [] : readThreads(row);
294301 issueRow = repo === undefined ? undefined : getIssue(f.db, repo.id, number);
302 hold = row === undefined ? undefined : getHold(f.db, row.id);
295303 // The app half: the preview environment (in memory, from the runtime),
296304 // the checks at the live head (a row), and what the crew has done here.
297305 const record = repo === undefined ? undefined : getAppByRepo(f.db, repo.id);
@@ -844,6 +852,9 @@ export function pullScreen(
844852 ]
845853 : []),
846854 binding(["a"], "assign"),
855 ...(mayHold() && (hold !== undefined || detail?.issue.state === "open")
856 ? [binding(["h"], hold === undefined ? "hold" : "let it go")]
857 : []),
847858 ...(state() === "conflicts"
848859 ? [binding(["v"], "see the conflict blocks")]
849860 : []),
@@ -929,7 +940,7 @@ export function pullScreen(
929940 x1,
930941 head!.y + 1,
931942 truncate(
932 ` ${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}`}`,
943 ` ${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)}` : ""}${hold === undefined ? "" : ` · held by ${hold.heldByName}: ${hold.why}`}${next === null ? "" : ` · next: ${next.who}, ${next.why}`}`,
933944 Math.max(0, edge - x1),
934945 ),
935946 style(c.mutedFg, c.bg),
@@ -1074,6 +1085,51 @@ export function pullScreen(
10741085 assign();
10751086 return true;
10761087 }
outdated

Drop the invented held from the terminal reason; pass value through and let holdPull return empty_why in the prompt, as web and MCP do.

1088 if (e.key === "h" && mayHold() && repo !== undefined) {
1089 const target = repo;
1090 const user = f.user;
1091 const row = getPull(f.db, target.id, number);
1092 if (row === undefined) return true;
1093 const done = (words: string) =>
1094 runAsync(ctx, async () => {
1095 ctx.toast(words, "success");
1096 await reload();
1097 });
1098 if (hold !== undefined) {
1099 ctx.push(
1100 confirmScreen(ctx, screen, {
1101 title: `Let #${number} go`,
1102 body: [`Held by ${hold.heldByName}: ${hold.why}`],
1103 actions: [
1104 { label: "Cancel", variant: "ghost" },
1105 { label: "Let it go" },
1106 ],
1107 selected: 1,
1108 onChoose: (choice) => {
1109 if (choice !== 1) return;
1110 const out = releasePull(f.app, user, target, row);
1111 if (out.ok) done("let go");
1112 else ctx.toast(errorText(out.error), "danger");
1113 },
1114 }),
1115 );
1116 } else if (row.state === "open") {
1117 ctx.push(
1118 promptScreen(ctx, screen, {
1119 title: `Hold #${number}`,
1120 label: "Why",
1121 hint: "One line saying why. Enter holds it; Esc cancels.",
1122 onSubmit: (value) => {
1123 const out = holdPull(f.app, user, target, row, value);
1124 if (!out.ok) return errorText(out.error);
1125 done("held");
1126 return undefined;
1127 },
1128 }),
1129 );
1130 }
1131 return true;
1132 }
10771133 if (e.key === "v" && mayWrite() && state() === "conflicts") {
10781134 seeConflicts();
10791135 return true;
test/tui-pull-hold.test.ts added +162
@@ -0,0 +1,162 @@
1 /**
2 * The terminal's pull screen holds a pull with `h` and a reason, and lets it
3 * go with `h` again, through the same actions MCP `hold` uses (#7257).
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createAgent } from "../src/actions/crew.ts";
10 import { setIssueStateThrough } from "../src/actions/issues.ts";
11 import { getIssue } from "../src/core/issues.ts";
12 import { openPull } from "../src/actions/pulls.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { getHold, WHY_MAX } from "../src/core/holds.ts";
16 import { getPull } from "../src/core/pulls.ts";
17 import { createUser, getUserByName, type User } from "../src/core/users.ts";
18 import { createForgeApp } from "../src/tui/forge.ts";
19 import { pullScreen } from "../src/tui/screens/pull.ts";
20 import { local } from "../src/tui/screens/where.ts";
21 import { headlessTerminal } from "../src/tui/term/terminal.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23 import { settleApp } from "./helpers/tui.ts";
24
25 quietLogs();
26
27 let dataDir: string;
28 let app: App;
29
30 beforeAll(async () => {
31 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-hold-"));
32 app = createApp({ dataDir, port: 0, host: "forge.test" });
33 });
34
35 afterAll(async () => {
36 app.close();
37 await rm(dataDir, { recursive: true, force: true });
38 });
39
40 test("h holds with a reason, the header says held, h again releases; an agent has no h", async () => {
41 const made = await createUser(app.db, {
42 origin: "seed",
43 trusted: true,
44 username: "alice",
45 email: "[email protected]",
46 password: "a-good-passphrase",
47 adminIfFirst: true,
48 });
49 if (!made.ok) throw new Error(String(made.error));
50 const alice: User = made.value;
51 const r = await createRepo(app, alice, { name: "lib", visibility: "public" });
52 if (!r.ok) throw new Error(String(r.error));
53 const repo = r.value;
54 const agent = await createAgent(app, alice, { username: "bot" });
55 if (!agent.ok) throw new Error(String(agent.error));
56 for (const branch of ["main", "feat"]) {
57 const c = await commitToBranch(app, alice, repo, {
58 branch,
59 message: branch,
60 files: [{ path: `${branch}.md`, content: "x\n" }],
61 });
62 if (!c.ok) throw new Error(JSON.stringify(c.error));
63 }
64 const o = await openPull(app, alice, repo, {
65 title: "Pull feat",
66 body: "",
67 baseBranch: "main",
68 headBranch: "feat",
69 });
70 if (!o.ok) throw new Error(String(o.error));
71 const number = o.value.number;
72 const pullId = getPull(app.db, repo.id, number)!.id;
73
74 const open = async (user: User) => {
75 const { app: tui, session } = createForgeApp(
76 app,
77 {
78 sshUsername: user.username,
79 remoteAddress: "127.0.0.1",
80 fingerprint: "SHA256:t",
81 user,
82 },
83 { terminal: headlessTerminal(160, 44), exitHooks: false },
84 );
85 tui.start();
86 tui.ctx.push(pullScreen(session, tui.ctx, local(session, repo), number));
87 await settleApp(tui);
88 const press = async (...keys: string[]) => {
89 for (const key of keys)
90 tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
91 await settleApp(tui);
92 };
93 return { tui, press, text: () => tui.screen.toText() };
94 };
95
96 const h = await open(alice);
97 try {
98 // An empty reason and an over-long one stay in the prompt with the action's words.
99 await h.press("h", "enter");
100 expect(h.text()).toContain("Hold #");
101 expect(h.text()).toContain("empty why");
102 expect(getHold(app.db, pullId)).toBeUndefined();
103 for (const ch of "x".repeat(WHY_MAX + 1)) await h.press(ch);
104 await h.press("enter");
105 expect(h.text()).toContain("Hold #");
106 expect(h.text()).toContain("why too long");
107 expect(getHold(app.db, pullId)).toBeUndefined();
108 for (let i = 0; i < WHY_MAX + 1; i++) await h.press("backspace");
109 for (const ch of [..."wait for docs", "enter"]) await h.press(ch);
110 expect(getHold(app.db, pullId)?.why).toBe("wait for docs");
111 expect(h.text()).toContain("held by alice: wait for docs");
112
outdated

Add a reader session here (no h, no hold) and a closed-pull case; an agent alone does not prove the write gate for readers.

113 await h.press("h");
114 expect(h.text()).toContain("Let #");
115 await h.press("enter");
116 expect(getHold(app.db, pullId)).toBeUndefined();
117 expect(h.text()).not.toContain("held by alice");
118 } finally {
119 h.tui.stop();
120 }
121
122 const bot = getUserByName(app.db, "bot")!;
123 const a = await open(bot);
124 try {
125 await a.press("h");
126 expect(a.text()).not.toContain("Hold #");
127 expect(getHold(app.db, pullId)).toBeUndefined();
128 } finally {
129 a.tui.stop();
130 }
131
132 // A reader has no h and writes no hold.
133 const bobMade = await createUser(app.db, {
134 origin: "seed",
135 trusted: true,
136 username: "bob",
137 email: "[email protected]",
138 password: "a-good-passphrase",
139 });
140 if (!bobMade.ok) throw new Error(String(bobMade.error));
141 const reader = await open(bobMade.value);
142 try {
143 await reader.press("h");
144 expect(reader.text()).not.toContain("Hold #");
145 expect(getHold(app.db, pullId)).toBeUndefined();
146 } finally {
147 reader.tui.stop();
148 }
149
150 // A closed pull offers no hold prompt.
151 const issue = getIssue(app.db, repo.id, number)!;
152 const closed = await setIssueStateThrough(app, alice, repo, issue, "closed");
153 if (!closed.ok) throw new Error(String(closed.error));
154 const c = await open(alice);
155 try {
156 await c.press("h");
157 expect(c.text()).not.toContain("Hold #");
158 expect(getHold(app.db, pullId)).toBeUndefined();
159 } finally {
160 c.tui.stop();
161 }
162 }, 60_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs