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

The terminal's issue screen lets a writer say an issue may go ahead when what it waited for closed as dropped or split, as MCP `confirm_wait` does #7308

Merged opened this

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

Merged

scout/forge:scout/7295-the-terminal-s-issue-screen-lets-a-write → main 6 commits fork 4 threads · 4 unresolved

  • Merged. scout/7295-the-terminal-s-issue-screen-lets-a-write was merged into main as 88aafd3.

Review

Sign in to review this change.
4 files changed +332 −5
README.md +1
@@ -60,6 +60,7 @@ A pull the merge queue holds as red alone (red on its own merge at its current h
6060
6161 On a sealed issue, a writer gets the private repository to fix it in: `POST /api/v1/repos/:owner/:name/issues/:number/private`, the issue page's button, and `p` on the issue's terminal screen, which shows the repository and its clone URL (built from the forge's origin). All three refuse alike: not found for anyone who may not see sealed reports, `not_sealed`, and `unverified`.
6262
63 When an open issue waits for a blocker that closed as dropped or split, the terminal's issue screen pins a line under the title, "waits for #N (dropped), closed without being done". A writer who is a person (not a reader, not on the crew) sees `g` there: after a confirm (Cancel is the default) it calls `confirmIssue`, as the issue page and MCP `confirm_wait` do, and the line goes. With several blockers held, `g` first asks which one (or all of them).
6364 An issue's title and body are edited on its page, with MCP `edit`, or with `e` on the issue's terminal screen: the editor opens with the title on the first line, a blank line, then the body; `ctrl+d` saves and `esc` discards. All three go through `editIssue`, so the same people may (the author until a writer takes it up, any person who writes the repository; never on a mirror) and a refusal reads the same, with the terminal's draft kept.
6465
6566 Reports from outside (sent with no account) are decided in the terminal too: open the repository as a person who writes it, press `:` and pick "Reports (N)"; beside each title is when it was sent, the page it came from and its words. `a` accepts the one under the cursor as an issue and says its number, `d` dismisses it after a confirm, and one someone else already decided says "this report was already decided". The row shows only while reports wait, and only to a person — not an agent — who can write the repository (`src/tui/screens/reports.ts`).
src/core/dependencies.ts +22 −2
@@ -182,6 +182,27 @@ export function dependenciesOf(
182182 return { blockedBy: pick(blockedBy), blocks: pick(blocks) };
183183 }
184184
185 /** An edge `d` whose blocker `b` closed without being done and nobody confirmed. */
186 const HELD_EDGE = "d.confirmed = 0 AND b.close_reason IN ('dropped','split')";
187
188 /**
189 * The blockers of `issueId` that closed as dropped or split and that no
190 * person has confirmed, by number: what holds the issue though it is closed.
191 */
192 export function heldBlockers(db: Database, issueId: string): Issue[] {
193 const ids = db
194 .query<{ blocked_by_id: string }, [string]>(
195 `SELECT d.blocked_by_id FROM issue_dependency d
196 JOIN issue b ON b.id = d.blocked_by_id AND b.state = 'closed'
197 WHERE d.issue_id = ?1 AND ${HELD_EDGE}
outdated

Same JOIN and WHERE as the held query in the repo graph. Extract one shared predicate so the two cannot disagree.

198 ORDER BY b.number`,
199 )
200 .all(issueId)
201 .map((r) => r.blocked_by_id);
202 const found = getIssuesByIds(db, ids);
203 return ids.map((id) => found.get(id)).filter((i): i is Issue => i !== undefined);
204 }
205
185206 // ---- the graph of one repository ----
186207
187208 /** An open issue held by a blocker that closed without being done. */
@@ -365,8 +386,7 @@ export function repoGraph(
365386 FROM issue_dependency d
366387 JOIN issue i ON i.id = d.issue_id AND i.state = 'open'
367388 JOIN issue b ON b.id = d.blocked_by_id AND b.state = 'closed'
368 WHERE i.repo_id = ?1 AND d.confirmed = 0
369 AND b.close_reason IN ('dropped','split')
389 WHERE i.repo_id = ?1 AND ${HELD_EDGE}
370390 ORDER BY b.number`,
371391 )
372392 .all(repoId);
src/tui/screens/issue.ts +88 −3
@@ -33,7 +33,9 @@ import { teamsOfIssue, TEAMS_MAX } from "../../core/issue_teams.ts";
3333 import { listTeams } from "../../core/teams.ts";
3434 import { listRepoRuns } from "../../actions/runs.ts";
3535 import { privateRepoForSealed } from "../../actions/repos.ts";
36 import { dependenciesOf } from "../../core/dependencies.ts";
36 import { confirmIssue } from "../../actions/dependencies.ts";
37 import { isCrew } from "../../core/agents.ts";
38 import { dependenciesOf, heldBlockers } from "../../core/dependencies.ts";
3739 import { alreadyDone } from "../../core/done_on_main.ts";
3840 import { hasPicture } from "../../core/note_pictures.ts";
3941 import { isMirror } from "../../origins/write-through.ts";
@@ -154,6 +156,8 @@ export function issueScreen(
154156 let issueRow: Issue | undefined;
155157 /** The commits the crew found this issue done by on main; `x` closes it. */
156158 let doneBy: readonly string[] = [];
159 /** Closed-as-dropped-or-split blockers nobody has said may be passed. */
160 let held: Issue[] = [];
157161 /** Which issues this one waits for and holds up; a line when any. */
158162 let waits: Waits = { blockedBy: [], blocks: [] };
159163 /** What the crew has done here, newest first; a card when there is any. */
@@ -165,6 +169,13 @@ export function issueScreen(
165169 /** The text `e` opened the editor with; set while the composer edits, not comments. */
166170 let editing: string | undefined;
167171 const mayWrite = (): boolean => src.capabilities.write && repo !== undefined;
172 /** `g` is a person's: the crew parked the issue and may not unpark itself. */
173 const mayGoAhead = (): boolean =>
174 mayWrite() &&
175 held.length > 0 &&
176 f.user !== undefined &&
177 repo !== undefined &&
178 !isCrew(f.db, repo.id, f.user.id);
168179 scroll.top = keep.top ?? 0;
169180
170181 const reload = async (): Promise<void> => {
@@ -188,6 +199,8 @@ export function issueScreen(
188199 issueRow?.state === "open"
189200 ? (alreadyDone(f.db, [issueRow.id]).get(issueRow.id) ?? [])
190201 : [];
202 held =
203 issueRow?.state === "open" ? heldBlockers(f.db, issueRow.id) : [];
191204 const deps = issueRow === undefined ? undefined : dependenciesOf(f.db, issueRow.id);
192205 waits = {
193206 blockedBy: deps?.blockedBy.map((i) => i.number) ?? [],
@@ -330,6 +343,61 @@ export function issueScreen(
330343 });
331344 };
332345
346 /** `g` — say the issue may go ahead without its dropped or split blockers. */
347 const goAhead = (): void => {
outdated

Nit: the budget area.h - 7 - crewH - whereRows(src) does not count the new wait row. On a short terminal the composer can take the body's last row while the line shows. Subtracting (held.length === 0 ? 0 : 1) here would keep the layout exact.

348 const at = row();
349 if (at?.issue === undefined || held.length === 0) return;
350 const { repo: target, issue } = at;
351 const ask = (blockers: Issue[]): Screen => {
352 const names = blockers.map((b) => `#${b.number}`).join(", ");
353 return confirmScreen(ctx, screen, {
354 title: "Let it go ahead?",
355 body: [`#${issue.number} waited for ${names}, closed without being done.`],
356 actions: [{ label: "Go ahead" }, { label: "Cancel" }],
357 selected: 1,
358 onChoose: (index) => {
359 if (index !== 0) return;
360 runAsync(ctx, async () => {
361 for (const b of blockers) {
362 const done = confirmIssue(f.app, f.user, target, issue, b);
363 if (!done.ok) {
364 ctx.toast(errorText(done.error), "danger");
365 return;
366 }
367 }
368 await reload();
369 ctx.toast("may go ahead", "success");
370 });
371 },
372 });
373 };
374 if (held.length === 1) {
375 ctx.push(ask(held));
376 return;
377 }
378 // Several held: name one, or all of them.
379 const all = "all";
380 const blockers = held;
381 ctx.push(
382 pickerScreen(ctx, screen, {
383 title: "Go ahead without",
384 items: [
385 ...blockers.map((b) => ({
386 id: String(b.number),
387 label: `#${b.number} ${b.title}`,
388 hint: b.closeReason ?? "closed",
389 })),
390 { id: all, label: "all of them" },
391 ],
392 onPick: (ids) => {
393 const picked =
394 ids[0] === all ? blockers : blockers.filter((b) => String(b.number) === ids[0]);
395 if (picked.length > 0) void later(ctx, () => ask(picked));
396 },
397 }),
398 );
399 };
400
333401 /** `p` — the private repository to fix this sealed report in. */
334402 const privateRepo = (): void => {
335403 const at = row();
@@ -435,6 +503,7 @@ export function issueScreen(
435503 }),
436504 binding(["l"], "labels"),
437505 binding(["t"], "teams"),
506 ...(mayGoAhead() ? [binding(["g"], "go ahead")] : []),
438507 binding(["w"], "waits for"),
439508 ...(issueRow?.sealed === true
440509 ? [binding(["p"], "private repo")]
@@ -465,12 +534,13 @@ export function issueScreen(
465534 crew.runs.length === 0 ? 0 : Math.min(crew.runs.length, CREW_ROWS) + 2;
466535 const waitsLine = waitsParts(waits);
467536 const headH = waitsLine.length === 0 ? 3 : 4;
468 const [strip, head, crewRow, body, box] = split(area, "v", [
537 const [strip, head, waitRow, crewRow, body, box] = split(area, "v", [
469538 len(whereRows(src)),
470539 len(headH),
540 len(held.length === 0 ? 0 : 1),
471541 len(crewH),
472542 fill(1),
473 len(Math.min(foot, Math.max(0, area.h - 4 - headH - crewH - whereRows(src)))),
543 len(Math.min(foot, Math.max(0, area.h - 4 - headH - crewH - whereRows(src) - (held.length === 0 ? 0 : 1)))),
474544 ]);
475545 whereStrip(s, strip!, c, f, src);
476546 const right = head!.x + head!.w;
@@ -540,6 +610,17 @@ export function issueScreen(
540610 ctx.push(issueScreen(f, ctx, src, opens)),
541611 );
542612 }
613 if (held.length > 0)
614 s.text(
615 waitRow!.x,
616 waitRow!.y,
617 truncate(
618 `waits for ${held.map((b) => `#${b.number} (${b.closeReason ?? "closed"})`).join(", ")}, closed without being done${mayGoAhead() ? " · g lets it go ahead" : ""}`,
619 waitRow!.w,
620 ),
621 style(c.fg, c.bg),
622 waitRow!.x + waitRow!.w,
623 );
543624 if (crewH > 0) {
544625 const inner = card(s, crewRow!, c, {
545626 title: `Crew · ${plural(crew.runs.length, "run")}`,
@@ -673,6 +754,10 @@ export function issueScreen(
673754 );
674755 return true;
675756 }
757 if (e.key === "g" && mayGoAhead()) {
758 goAhead();
759 return true;
760 }
676761 if (e.key === "w" && mayWrite()) {
677762 waitFor();
678763 return true;
test/tui-issue-goahead.test.ts added +221
@@ -0,0 +1,221 @@
1 /** The issue screen's `g` (#7295): a person lets an issue pass a dropped blocker. */
2
3 import { afterAll, beforeAll, describe, 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 { linkIssue } from "../src/actions/dependencies.ts";
8 import { openIssue } from "../src/actions/issues.ts";
9 import { createRepo } from "../src/actions/repos.ts";
10 import { createApp, type App as Forge } from "../src/app.ts";
11 import { addCrew, createAgentRow } from "../src/core/agents.ts";
12 import { heldBlockers } from "../src/core/dependencies.ts";
13 import { setIssueState, type Issue } from "../src/core/issues.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { createForgeApp } from "../src/tui/forge.ts";
16 import type { TuiIdentity } from "../src/tui/session.ts";
17 import { headlessTerminal } from "../src/tui/term/terminal.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { settleApp, settleUntil } from "./helpers/tui.ts";
20 import { seedAdmin } from "./helpers/admin.ts";
21
22 quietLogs();
23
24 describe("go ahead in the terminal", () => {
25 let dataDir: string;
26 let forge: Forge;
27 let alice: User;
28 let bot: User;
29 let reader: User;
30 let many: Issue;
31 let manyBlockers: Issue[];
32 let waiting: Issue;
33 let blocker: Issue;
34
35 function boot(user: User, guest = false) {
36 const identity: TuiIdentity = guest
37 ? { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:unknown" }
38 : {
39 sshUsername: user.username,
40 remoteAddress: "127.0.0.1",
41 fingerprint: "SHA256:test",
42 user,
43 };
44 const { app } = createForgeApp(forge, identity, {
45 terminal: headlessTerminal(140, 40),
46 exitHooks: false,
47 });
48 app.start();
49 const press = async (...keys: string[]): Promise<void> => {
50 for (const key of keys)
51 app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
52 await settleApp(app);
53 };
54 return { app, press, text: () => app.screen.toText() };
55 }
56
57 async function openWaiting(h: ReturnType<typeof boot>, title = "waiter", number = waiting.number): Promise<void> {
58 await settleApp(h.app);
59 await h.press("/");
60 for (const ch of "beacon") await h.press(ch);
61 await h.press("enter");
62 await h.press("enter");
63 await settleUntil(h.app, "alice/beacon");
64 await h.press("i");
65 await settleUntil(h.app, "Open 2");
66 // Newest first: "pending" sits above "waiter".
67 if (title === "waiter") await h.press("j");
68 await h.press("enter");
69 await settleUntil(h.app, `#${number}`);
70 }
71
72 beforeAll(async () => {
73 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-goahead-"));
74 forge = createApp({ dataDir, port: 0 });
75 const a = await seedAdmin(forge, {
76 username: "alice",
77 email: "[email protected]",
78 password: "a-long-passphrase",
79 });
80 if (!a.ok) throw new Error("signup failed");
81 alice = a.value;
82 const made = await createRepo(forge, alice, { name: "beacon", visibility: "public" });
83 if (!made.ok) throw new Error("repo failed");
84 const w = await openIssue(forge, alice, made.value, { title: "waiter", body: "x" });
85 const b = await openIssue(forge, alice, made.value, { title: "dropped one", body: "x" });
86 if (!w.ok || !b.ok) throw new Error("issue failed");
87 waiting = w.value;
88 blocker = b.value;
89 expect(linkIssue(forge, alice, made.value, waiting, blocker).ok).toBe(true);
90 setIssueState(forge.db, blocker.id, "closed", "dropped");
91 const u = await createUser(forge.db, {
92 username: "bot",
93 email: "[email protected]",
94 agent: true,
95 origin: "agent",
96 });
97 if (!u.ok) throw new Error("agent failed");
98 bot = u.value;
99 createAgentRow(forge.db, { userId: bot.id });
100 addCrew(forge.db, made.value.id, bot.id);
101 const r = await createUser(forge.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase" });
102 if (!r.ok) throw new Error("reader failed");
103 reader = r.value;
104 const m = await openIssue(forge, alice, made.value, { title: "pending", body: "x" });
105 if (!m.ok) throw new Error("issue failed");
106 many = m.value;
107 manyBlockers = [];
108 for (const title of ["first drop", "second drop"]) {
109 const o = await openIssue(forge, alice, made.value, { title, body: "x" });
110 if (!o.ok) throw new Error("issue failed");
111 expect(linkIssue(forge, alice, made.value, many, o.value).ok).toBe(true);
112 setIssueState(forge.db, o.value.id, "closed", "dropped");
113 manyBlockers.push(o.value);
114 }
115 });
outdated

Add a reader or guest session (no g, no prompt) and a Cancel case (enter on the default leaves heldBlockers at 1). Neither gate is covered now.

116
117 afterAll(async () => {
118 await rm(dataDir, { recursive: true, force: true });
119 });
120
121 test("an agent on the crew is not offered `g`", async () => {
122 const h = boot(bot);
123 try {
124 await openWaiting(h);
125 expect(h.text()).not.toContain("go ahead");
126 await h.press("g");
127 expect(heldBlockers(forge.db, waiting.id)).toHaveLength(1);
128 } finally {
129 h.app.stop();
130 }
131 });
132
133 test("a guest sees the line but is offered no `g` and no prompt", async () => {
134 const h = boot(alice, true);
135 try {
136 await openWaiting(h);
137 expect(h.text()).toContain(`#${blocker.number} (dropped)`);
138 expect(h.text()).not.toContain("go ahead");
139 await h.press("g");
140 expect(h.text()).not.toContain("Let it go ahead?");
141 expect(heldBlockers(forge.db, waiting.id)).toHaveLength(1);
142 } finally {
143 h.app.stop();
144 }
145 });
146
147 test("a signed-in reader sees the line but is offered no `g` and no prompt", async () => {
148 const h = boot(reader);
149 try {
150 await openWaiting(h);
151 expect(h.text()).toContain(`#${blocker.number} (dropped)`);
152 expect(h.text()).not.toContain("go ahead");
153 await h.press("g");
154 expect(h.text()).not.toContain("Let it go ahead?");
155 expect(heldBlockers(forge.db, waiting.id)).toHaveLength(1);
156 } finally {
157 h.app.stop();
158 }
159 });
160
161 test("enter on the default choice cancels and leaves the wait", async () => {
162 const h = boot(alice);
163 try {
164 await openWaiting(h);
165 await h.press("g");
166 await settleUntil(h.app, "Let it go ahead?");
167 await h.press("enter");
168 expect(heldBlockers(forge.db, waiting.id)).toHaveLength(1);
169 expect(h.text()).toContain(`#${blocker.number} (dropped)`);
170 } finally {
171 h.app.stop();
172 }
173 });
174
175 test("the line names the dropped blocker, and `g` with a confirm clears it", async () => {
176 const h = boot(alice);
177 try {
178 await openWaiting(h);
179 expect(h.text()).toContain(`#${blocker.number} (dropped)`);
180 expect(h.text()).toContain("go ahead");
181 await h.press("g");
182 await settleUntil(h.app, "Let it go ahead?");
183 await h.press("left", "enter");
184 await settleUntil(h.app, "may go ahead");
185 expect(heldBlockers(forge.db, waiting.id)).toHaveLength(0);
186 expect(h.text()).not.toContain("closed without being done");
187 } finally {
188 h.app.stop();
189 }
190 });
191
192 test("an issue with nothing held never shows the line", async () => {
193 const h = boot(alice);
194 try {
195 await openWaiting(h);
196 expect(h.text()).not.toContain("closed without being done");
197 await h.press("g");
198 expect(h.text()).not.toContain("Let it go ahead?");
199 } finally {
200 h.app.stop();
201 }
202 });
203
204 test("with several held, `g` lets a writer pick one and only that one is cleared", async () => {
205 const h = boot(alice);
206 try {
207 await openWaiting(h, "pending", many.number);
208 await h.press("g");
209 await settleUntil(h.app, "Go ahead without");
210 await h.press("enter");
211 await settleUntil(h.app, "Let it go ahead?");
212 expect(h.text()).toContain(`#${manyBlockers[0]!.number}`);
213 await h.press("left", "enter");
214 await settleUntil(h.app, "may go ahead");
215 const left = heldBlockers(forge.db, many.id);
216 expect(left.map((b) => b.id)).toEqual([manyBlockers[1]!.id]);
217 } finally {
218 h.app.stop();
219 }
220 });
221 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs