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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7257-the-terminal-s-pull-screen-holds-a-pull → main 3 commits fork 2 threads · 2 unresolved
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. | ||
| 44 | 44 | |
| 45 | 45 | 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. |
| 46 | 46 | |
| 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 | ||
| 47 | 49 | 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. |
| 48 | 50 | |
| 49 | 51 | 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 { | ||
| 39 | 39 | resolveReviewThread, |
| 40 | 40 | submitReview, |
| 41 | 41 | } from "../../actions/pulls.ts"; |
| 42 | import { holdPull, releasePull } from "../../actions/holds.ts"; | |
| 43 | import { getHold, type Hold } from "../../core/holds.ts"; | |
| 42 | 44 | import { revertPull } from "../../actions/revert.ts"; |
| 43 | 45 | import { takeBackWords } from "../../actions/revert_words.ts"; |
| 44 | 46 | import { openEnvUrl } from "../../actions/apps.ts"; |
| @@ -262,6 +264,11 @@ export function pullScreen( | ||
| 262 | 264 | /** Agent runs about this pull, newest first. */ |
| 263 | 265 | const crew = new RunList(f); |
| 264 | 266 | 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; | |
| 265 | 272 | /** A writer, on a pull that is one of several variants. */ |
| 266 | 273 | const mayVote = (): boolean => |
| 267 | 274 | mayWrite() && |
| @@ -292,6 +299,7 @@ export function pullScreen( | ||
| 292 | 299 | : withOutdated(listReviewComments(f.db, row.id), diff); |
| 293 | 300 | threads = row === undefined ? [] : readThreads(row); |
| 294 | 301 | issueRow = repo === undefined ? undefined : getIssue(f.db, repo.id, number); |
| 302 | hold = row === undefined ? undefined : getHold(f.db, row.id); | |
| 295 | 303 | // The app half: the preview environment (in memory, from the runtime), |
| 296 | 304 | // the checks at the live head (a row), and what the crew has done here. |
| 297 | 305 | const record = repo === undefined ? undefined : getAppByRepo(f.db, repo.id); |
| @@ -844,6 +852,9 @@ export function pullScreen( | ||
| 844 | 852 | ] |
| 845 | 853 | : []), |
| 846 | 854 | binding(["a"], "assign"), |
| 855 | ...(mayHold() && (hold !== undefined || detail?.issue.state === "open") | |
| 856 | ? [binding(["h"], hold === undefined ? "hold" : "let it go")] | |
| 857 | : []), | |
| 847 | 858 | ...(state() === "conflicts" |
| 848 | 859 | ? [binding(["v"], "see the conflict blocks")] |
| 849 | 860 | : []), |
| @@ -929,7 +940,7 @@ export function pullScreen( | ||
| 929 | 940 | x1, |
| 930 | 941 | head!.y + 1, |
| 931 | 942 | 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}`}`, | |
| 933 | 944 | Math.max(0, edge - x1), |
| 934 | 945 | ), |
| 935 | 946 | style(c.mutedFg, c.bg), |
| @@ -1074,6 +1085,51 @@ export function pullScreen( | ||
| 1074 | 1085 | assign(); |
| 1075 | 1086 | return true; |
| 1076 | 1087 | } |
| 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 | } | |
| 1077 | 1133 | if (e.key === "v" && mayWrite() && state() === "conflicts") { |
| 1078 | 1134 | seeConflicts(); |
| 1079 | 1135 | 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); |
new above
offline
Drop the invented
held from the terminalreason; passvaluethrough and let holdPull return empty_why in the prompt, as web and MCP do.