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

The terminal's issue screen says what an issue waits for, and `w` adds or removes a wait, as MCP `wait_for` does #7248

Merged opened this

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

Merged

mason/forge:mason/7234-the-terminal-s-issue-screen-says-what-an → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/7234-the-terminal-s-issue-screen-says-what-an was merged into main as d151a81.

Review

Sign in to review this change.
4 files changed +424 −3
docs/OPERATING.md +2
@@ -173,6 +173,8 @@ Crew teams (`src/core/teams.ts`, `src/actions/teams.ts`) — a named group of ag
173173
174174 Cross-team work (`src/core/issue_teams.ts`, `src/actions/issue_teams.ts`) — an issue names up to two teams, and the pull whose body closes it (`Closes #N`) is sent to review once per named team. A writer names them on the issue page (Details → Teams), with `PUT /api/v1/repos/:owner/:name/issues/:number/teams` (`{"teams": ["design", "data-science"]}`; `GET` reads them back; a non-list is a 400, more than two or an unknown slug a 422, a mirror a 409), with the `issue_teams` MCP tool, or with `t` on an issue in the terminal — all through `nameTeams`, so the refusals and their sentences are the same. For each named team without a current verdict, a crew reviewer who is a member of it gets its own run (one reviewer on both teams gets one run, and their verdict answers both). The pull page and its Files step show each team's verdict, or that it is awaited; the terminal's pull screen shows a row per team in the Merge box (`Design: approved by quill`, `Data science: awaited`, with `(on an older head)` when the verdict is stale). A team nobody on the crew can ask is not a reason to skip review: when no named team has a reviewer here (teams start empty) the ordinary single review is sent, and when only some do, the rest wait and lapse to a person with the hour like any review. Not done: a team's approval is shown but not yet required by the merge gate.
175175
176 Waits between issues (`src/actions/dependencies.ts`, docs/PLANNING.md): a writer or a crew member links one issue to wait for another on the issue page (**Depends on**), with `POST`/`DELETE /api/v1/repos/:owner/:name/issues/:n/dependencies`, with the `wait_for` MCP tool, or with `w` on an issue in the terminal. A new number is linked, and one it already waits for asks a confirm, then is unlinked. All of these go through `linkIssue` and `unlinkIssue`, so they refuse the same links in the same `dependencyWords` sentences. The terminal's issue screen shows `Waits for #12 · Holds up #20` to every reader, and each number opens that issue.
177
176178 The forge setting `signup` (`src/settings/signup.ts`) is `open`, `invite` or `closed`, and `open` is what a forge with nothing stored reads as. A new forge — a fresh data directory or a spawned child — stores `invite` when it first boots; a forge that already has a stored value, or has run before without one, keeps what it had (no migration rewrites it). The seeding is done by `createApp` right after it makes the database, so a crash between those two steps would leave that forge `open`. **A new forge therefore refuses its first signup too**: make the first admin with `--first-admin` (a spawned forge's claim does it for the child), or set `FORGE_SIGNUP=open` before the first boot. A forge admin who is a person changes it live — **Signup** on `/admin/limits`, `PATCH /api/v1/admin/settings` (`{"signup":"closed"}`) or the `signup` MCP tool (dry by default, admin scope, never an agent) — with no restart; any other value is a 422. `FORGE_SIGNUP` sets it at boot, and a wrong value stops the boot with a plain message; while it stays set, every restart or deploy writes it again over a change made on the page (kept as a change by `boot`), so unset it to let the page decide. Every change is kept with who made it and when, and listed beside the form. While signup is `closed` (and `invite`, for anyone without an open invite link), `/signup` is one sentence with no form, the API and passkey signup answer 403 `signup_closed`, the terminal offers no Create account and refuses (with `invite` it asks for the token instead), and a first edge sign-in for a new name is 401. Existing accounts still sign in. A fresh forge's first account is held to the setting too; the operator's `--first-admin` link and a spawned forge's claim make the admin whatever it says.
177179
178180 ### A cell's memory ceiling
docs/PLANNING.md +8 −1
@@ -16,7 +16,14 @@ where it is not for assigning (which spends money) or merging (a person's
1616 call).
1717
1818 On the issue page, **Depends on** says _Waits for 2, 1 still open_ and
19 _Holds up 3_, with each issue linked and a field to add one. Over the API:
19 _Holds up 3_, with each issue linked and a field to add one. The terminal's
20 issue screen shows the same line, `Waits for #12, #15 · Holds up #20`, when
21 there is any, and a click on a number opens that issue. There a writer
22 presses `w` and types a number: a new one is linked, and one it already
23 waits for asks a confirm, then is unlinked. A refusal reads in
24 `dependencyWords`' sentences, as MCP `wait_for` says it. The page, the
25 terminal and `wait_for` all go through `linkIssue` and `unlinkIssue`
26 (`src/actions/dependencies.ts`). Over the API:
2027
2128 ```
2229 GET /api/v1/repos/:owner/:name/issues/:n/dependencies → { blockedBy, blocks }
src/tui/screens/issue.ts +129 −2
@@ -19,11 +19,18 @@ import {
1919 setIssueLabelsThrough,
2020 setIssueStateThrough,
2121 } from "../../actions/issues.ts";
22 import {
23 dependencyWords,
24 linkIssue,
25 unlinkIssue,
26 } from "../../actions/dependencies.ts";
27 import { THREAD_SAYS } from "../../actions/thread_words.ts";
2228 import { ISSUE_TEAMS_SAY, nameTeams } from "../../actions/issue_teams.ts";
2329 import { teamsOfIssue, TEAMS_MAX } from "../../core/issue_teams.ts";
2430 import { listTeams } from "../../core/teams.ts";
2531 import { listRepoRuns } from "../../actions/runs.ts";
2632 import { privateRepoForSealed } from "../../actions/repos.ts";
33 import { dependenciesOf } from "../../core/dependencies.ts";
2734 import { alreadyDone } from "../../core/done_on_main.ts";
2835 import { hasPicture } from "../../core/note_pictures.ts";
2936 import { getIssue, type Issue, type Label } from "../../core/issues.ts";
@@ -41,12 +48,15 @@ import {
4148 ORIGIN_MARK,
4249 Scroll,
4350 ago,
51 confirmScreen,
4452 drawLines,
4553 hitScroll,
4654 errorText,
55 later,
4756 live,
4857 pickerScreen,
4958 plural,
59 promptScreen,
5060 runAsync,
5161 } from "./common.ts";
5262 import { assignPicker, assignedText } from "./assign.ts";
@@ -71,6 +81,29 @@ const PRIVATE_SAY: Record<string, string> = {
7181 unverified: "Verify your email first: the private repository is for verified writers.",
7282 };
7383
84 /** What an issue waits for and holds up, by number, ascending. */
85 export interface Waits {
86 readonly blockedBy: readonly number[];
87 readonly blocks: readonly number[];
88 }
89
90 /** The waits line's parts: plain words, and the numbers a click opens. */
91 export function waitsParts(waits: Waits): { text: string; opens?: number }[] {
92 const parts: { text: string; opens?: number }[] = [];
93 const run = (words: string, numbers: readonly number[]): void => {
94 if (numbers.length === 0) return;
95 if (parts.length > 0) parts.push({ text: " · " });
96 parts.push({ text: `${words} ` });
97 numbers.forEach((n, i) => {
98 if (i > 0) parts.push({ text: ", " });
99 parts.push({ text: `#${n}`, opens: n });
100 });
101 };
102 run("Waits for", waits.blockedBy);
103 run("Holds up", waits.blocks);
104 return parts;
105 }
106
74107 /** The status-line tail for an issue the crew found done on main. */
75108 export function doneText(commits: readonly string[], canClose: boolean): string {
76109 if (commits.length === 0) return "";
@@ -96,6 +129,8 @@ export function issueScreen(
96129 let issueRow: Issue | undefined;
97130 /** The commits the crew found this issue done by on main; `x` closes it. */
98131 let doneBy: readonly string[] = [];
132 /** Which issues this one waits for and holds up; a line when any. */
133 let waits: Waits = { blockedBy: [], blocks: [] };
99134 /** What the crew has done here, newest first; a card when there is any. */
100135 const crew = new RunList(f);
101136 /** Which pane `j`/`k`/`enter` belong to; `tab` moves between them. */
@@ -126,6 +161,11 @@ export function issueScreen(
126161 issueRow?.state === "open"
127162 ? (alreadyDone(f.db, [issueRow.id]).get(issueRow.id) ?? [])
128163 : [];
164 const deps = issueRow === undefined ? undefined : dependenciesOf(f.db, issueRow.id);
165 waits = {
166 blockedBy: deps?.blockedBy.map((i) => i.number) ?? [],
167 blocks: deps?.blocks.map((i) => i.number) ?? [],
168 };
129169 crew.set(runs !== undefined && runs.ok ? runs.value : []);
130170 if (crew.runs.length === 0) focus = "thread";
131171 width = 0;
@@ -245,6 +285,69 @@ export function issueScreen(
245285 });
246286 };
247287
288 /**
289 * `w` — a number to wait for. A new one links it; one it already waits
290 * for asks first, then stops the wait. Refusals in `dependencyWords`.
291 */
292 const waitFor = (): void => {
293 const at = row();
294 if (at?.issue === undefined) return;
295 const { repo: target, issue } = at;
296 const refused = (code: string): string =>
297 dependencyWords[code] ?? (code === "forbidden" ? THREAD_SAYS.planForbidden : errorText(code));
298 const settled = (said: string): void => {
299 ctx.toast(said, "success");
300 runAsync(ctx, reload);

The forbidden to THREAD_SAYS.planForbidden mapping is untested. Add a test that reaches it (an action refusal as a person who can see the issue but not link it), or drop the special case.

301 };
302 ctx.push(
303 promptScreen(ctx, screen, {
304 title: "Waits for",
305 label: `#${number} waits for issue`,
306 placeholder: "12",
307 hint:
308 waits.blockedBy.length === 0
309 ? "the number of the issue that must land first"
310 : "a number it already waits for stops the wait",
311 onSubmit: (value) => {
312 const n = Number(value.replace(/^#/, ""));
313 if (!Number.isInteger(n) || n <= 0) return "an issue number, like 12";
314 const blocker = getIssue(f.db, target.id, n);
315 if (blocker === undefined) return `no #${n} here`;
316 // Asked now, not from the last reload: another door may have
317 // linked or unlinked since.
318 const waiting = dependenciesOf(f.db, issue.id).blockedBy.some((i) => i.id === blocker.id);
319 if (waiting)
320 return later(ctx, () =>
321 confirmScreen(ctx, screen, {
322 title: `Stop waiting for #${n}?`,
323 body: [`#${number} no longer waits for #${n}.`],
324 actions: [
325 { label: "Stop waiting", variant: "danger" },
326 { label: "Cancel", variant: "ghost" },
327 ],
328 onChoose: (choice) => {
329 if (choice !== 0) return;
330 const done = unlinkIssue(f.app, f.user, target, issue, blocker);
331 if (!done.ok) ctx.toast(refused(done.error), "danger");
332 else settled(`#${number} no longer waits for #${n}`);
333 },
334 }),
335 );
336 const done = linkIssue(f.app, f.user, target, issue, blocker);
337 // A refusal another number could fix stays in the prompt; one
338 // about who may plan here closes it and says so in full.
339 if (!done.ok && done.error in dependencyWords) return refused(done.error);
340 if (!done.ok) {
341 ctx.toast(refused(done.error), "danger");
342 return undefined;
343 }
344 settled(`#${number} waits for #${n}`);
345 return undefined;
346 },
347 }),
348 );
349 };
350
248351 const screen: Screen = {
249352 title: `#${number}`,
250353 get keys() {
@@ -267,6 +370,7 @@ export function issueScreen(
267370 }),
268371 binding(["l"], "labels"),
269372 binding(["t"], "teams"),
373 binding(["w"], "waits for"),
270374 ...(issueRow?.sealed === true
271375 ? [binding(["p"], "private repo")]
272376 : []),
@@ -291,12 +395,14 @@ export function issueScreen(
291395 // Then the crew card, only when an agent has worked here.
292396 const crewH =
293397 crew.runs.length === 0 ? 0 : Math.min(crew.runs.length, CREW_ROWS) + 2;
398 const waitsLine = waitsParts(waits);
399 const headH = waitsLine.length === 0 ? 3 : 4;
294400 const [strip, head, crewRow, body, box] = split(area, "v", [
295401 len(whereRows(src)),
296 len(3),
402 len(headH),
297403 len(crewH),
298404 fill(1),
299 len(Math.min(foot, Math.max(0, area.h - 7 - crewH - whereRows(src)))),
405 len(Math.min(foot, Math.max(0, area.h - 4 - headH - crewH - whereRows(src)))),
300406 ]);
301407 whereStrip(s, strip!, c, f, src);
302408 const right = head!.x + head!.w;
@@ -349,6 +455,23 @@ export function issueScreen(
349455 right,
350456 );
351457 }
458 // What this waits for and holds up; each number opens that issue.
459 let wx = head!.x;
460 for (const part of waitsLine) {
461 const start = wx;
462 wx = s.text(
463 wx,
464 head!.y + 2,
465 part.text,
466 style(part.opens === undefined ? c.mutedFg : c.fg, c.bg),
467 right,
468 );
469 const opens = part.opens;
470 if (opens !== undefined && wx > start)
471 ctx.hit({ x: start, y: head!.y + 2, w: wx - start, h: 1 }, () =>
472 ctx.push(issueScreen(f, ctx, src, opens)),
473 );
474 }
352475 if (crewH > 0) {
353476 const inner = card(s, crewRow!, c, {
354477 title: `Crew · ${plural(crew.runs.length, "run")}`,
@@ -475,6 +598,10 @@ export function issueScreen(
475598 );
476599 return true;
477600 }
601 if (e.key === "w" && mayWrite()) {
602 waitFor();
603 return true;
604 }
478605 if (e.key === "p" && mayWrite()) {
479606 privateRepo();
480607 return true;
test/tui-issue-waits.test.ts added +285
@@ -0,0 +1,285 @@
1 /**
2 * The terminal's issue screen shows what an issue waits for and holds up,
3 * each number opening that issue, and `w` adds or removes a wait through
4 * the same `linkIssue` and `unlinkIssue` the page and MCP `wait_for` use.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { dependencyWords, linkIssue } from "../src/actions/dependencies.ts";
12 import { THREAD_SAYS } from "../src/actions/thread_words.ts";
13 import { openIssue } from "../src/actions/issues.ts";
14 import { openPull } from "../src/actions/pulls.ts";
15 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
16 import { createApp, type App } from "../src/app.ts";
17 import { addDependency, dependenciesOf } from "../src/core/dependencies.ts";
18 import type { Issue } from "../src/core/issues.ts";
19 import { addTeamMember, createOrg, createTeam, removeTeamMember } from "../src/core/orgs.ts";
20 import { createRepo as createCoreRepo, getRepoById, type Repo } from "../src/core/repos.ts";
21 import { createUser, type User } from "../src/core/users.ts";
22 import { createForgeApp } from "../src/tui/forge.ts";
23 import { issueScreen } from "../src/tui/screens/issue.ts";
24 import { local } from "../src/tui/screens/where.ts";
25 import { headlessTerminal } from "../src/tui/term/terminal.ts";
26 import { quietLogs } from "./helpers/globals.ts";
27 import { settleApp } from "./helpers/tui.ts";
28
29 quietLogs();
30
31 describe("the terminal's issue screen and its waits", () => {
32 let dataDir = "";
33 let app: App;
34 let alice: User;
35 let bob: User;
36 let repo: Repo;
37 const fresh = () => getRepoById(app.db, repo.id)!;
38 const newIssue = (title: string): Issue => {
39 const o = openIssue(app, alice, repo, { title });
40 if (!o.ok) throw new Error(o.error);
41 return o.value;
42 };
43 const waitsFor = (issue: Issue) =>
44 dependenciesOf(app.db, issue.id).blockedBy.map((i) => i.number);
45
46 /** A terminal signed in as `user`, on the issue screen of `number`. */
47 const open = async (user: User, number: number, where: Repo = fresh(), rows = 40) => {
48 const terminal = headlessTerminal(140, rows);
49 const { app: tui, session } = createForgeApp(
50 app,
51 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
52 { terminal, exitHooks: false },
53 );
54 tui.start();
55 await settleApp(tui);
56 tui.push(issueScreen(session, tui.ctx, local(session, where), number));
57 await settleApp(tui);
58 const press = async (...keys: string[]) => {
59 for (const key of keys)
60 tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
61 await settleApp(tui);
62 };
63 const type = (text: string) => press(...text);
64 const text = () => tui.screen.toText();
65 /** Click the first cell of `needle` on the row that also holds `row`. */
66 const click = async (row: string, needle: string) => {
67 const rows = text().split("\n");
68 const y = rows.findIndex((r) => r.includes(row) && r.includes(needle));
69 if (y < 0) throw new Error(`no row with ${row} and ${needle}:\n${text()}`);
70 const x = rows[y]!.indexOf(needle, rows[y]!.indexOf(row));
71 tui.dispatch({ type: "mouse", kind: "press", button: 0, x, y, ctrl: false, alt: false, shift: false });
72 tui.dispatch({ type: "mouse", kind: "release", button: 0, x, y, ctrl: false, alt: false, shift: false });
73 await settleApp(tui);
74 };
75 return { tui, press, type, text, click, stop: () => tui.stop() };
76 };
77
78 beforeAll(async () => {
79 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-issue-waits-"));
80 app = createApp({ dataDir, port: 0 });
81 for (const username of ["alice", "bob"]) {
82 const made = await createUser(app.db, {
83 origin: "seed",
84 trusted: true,
85 username,
86 email: `${username}@forge.local`,
87 password: "a-good-passphrase",
88 adminIfFirst: username === "alice",
89 });
90 if (!made.ok) throw new Error(made.error);
91 if (username === "alice") alice = made.value;
92 else bob = made.value;
93 }
94 const r = await createRepo(app, alice, { name: "garden", visibility: "public" });
95 if (!r.ok) throw new Error(String(r.error));
96 repo = r.value;
97 const seeded = await commitToBranch(app, alice, repo, {
98 message: "seed",
99 files: [{ path: "README.md", content: "# garden\n" }],
100 });
101 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
102 }, 30_000);
103
104 afterAll(async () => {
105 app.close();
106 await rm(dataDir, { recursive: true, force: true });
107 });
108
109 test("w links a new number, the line shows it, and the number opens that issue", async () => {
110 const blocker = newIssue("Lay the soil");
111 const issue = newIssue("Plant the beds");
112 const t = await open(alice, issue.number);
113 try {
114 expect(t.text()).not.toContain("Waits for #");
115 await t.press("w");
116 expect(t.text()).toContain(`#${issue.number} waits for issue`);
117 await t.type(String(blocker.number));
118 await t.press("enter");
119 expect(waitsFor(issue)).toEqual([blocker.number]);
120 expect(t.text()).toContain(`Waits for #${blocker.number}`);
121 await t.click("Waits for", `#${blocker.number}`);
122 expect(t.text()).toContain("Lay the soil");
123 // The blocker's own screen says what it holds up.
124 expect(t.text()).toContain(`Holds up #${issue.number}`);
125 } finally {
126 t.stop();
127 }
128 });
129
130 test("w on a number it already waits for asks, then unlinks", async () => {
131 const blocker = newIssue("Buy seeds");
132 const issue = newIssue("Sow");
133 const t = await open(alice, issue.number);
134 try {
135 await t.press("w");
136 await t.type(`#${blocker.number}`);
137 await t.press("enter");
138 expect(waitsFor(issue)).toEqual([blocker.number]);
139 await t.press("w");
140 await t.type(String(blocker.number));
141 await t.press("enter");
142 expect(t.text()).toContain(`Stop waiting for #${blocker.number}?`);
143 // Cancel keeps the wait.
144 await t.press("esc");
145 expect(waitsFor(issue)).toEqual([blocker.number]);
146 await t.press("w");
147 await t.type(String(blocker.number));
148 await t.press("enter");
149 await t.press("enter");
150 expect(waitsFor(issue)).toEqual([]);
151 expect(t.text()).not.toContain(`Waits for #${blocker.number}`);
152 } finally {
153 t.stop();
154 }
155 });
156
157 test("a pull number and a loop are refused in dependencyWords' words", async () => {
158 const c = await commitToBranch(app, alice, repo, {
159 branch: "fence",
160 base: "main",
161 message: "fence",
162 files: [{ path: "fence.md", content: "x\n" }],
163 });
164 if (!c.ok) throw new Error(JSON.stringify(c.error));
165 const pull = await openPull(app, alice, repo, {
166 title: "A fence",
167 body: "",
168 baseBranch: "main",
169 headBranch: "fence",
170 });
171 if (!pull.ok) throw new Error(String(pull.error));
172 const first = newIssue("Water");
173 const issue = newIssue("Harvest");
174 const t = await open(alice, issue.number);
175 try {
176 await t.press("w");
177 await t.type(String(pull.value.number));
178 await t.press("enter");
179 expect(t.text()).toContain(dependencyWords.not_an_issue!);
180 expect(waitsFor(issue)).toEqual([]);
181 await t.press("esc");
182 // #first waits for this one already; this waiting for #first loops.
183 const back = await open(alice, first.number);
184 await back.press("w");
185 await back.type(String(issue.number));
186 await back.press("enter");
187 back.stop();
188 await t.press("w");
189 await t.type(String(first.number));
190 await t.press("enter");
191 expect(t.text()).toContain(dependencyWords.cycle!);
192 expect(waitsFor(issue)).toEqual([]);
193 } finally {
194 t.stop();
195 }
196 });
197
outdated

This test asserts that bob has no w, but never shows that a refused action reads as a person would want. Add the refusal case described in the review body.

198 test("a reader sees the line but has no w", async () => {
199 const blocker = newIssue("Dig");
200 const issue = newIssue("Fill");
201 const t = await open(alice, issue.number);
202 await t.press("w");
203 await t.type(String(blocker.number));
204 await t.press("enter");
205 t.stop();
206 const r = await open(bob, issue.number);
207 try {
208 expect(r.text()).toContain(`Waits for #${blocker.number}`);
209 expect(r.text()).not.toContain("waits for issue");
210 await r.press("w");
211 expect(r.text()).not.toContain(`#${issue.number} waits for issue`);
212 expect(waitsFor(issue)).toEqual([blocker.number]);
213 } finally {
214 r.stop();
215 }
216 });
217
218 test("a refused link reads the planner words: write taken away while the screen is open", async () => {
219 const org = createOrg(app.db, "acme", alice.id);
220 if (!org.ok) throw new Error(org.error);
221 const writers = createTeam(app.db, org.value.id, "Writers", "write");
222 if (!writers.ok) throw new Error(writers.error);
223 addTeamMember(app.db, writers.value, bob.id);
224 const made = await createCoreRepo(app.db, app.reposRoot, {
225 ownerId: org.value.id,
226 name: "orchard",
227 visibility: "public",
228 });
229 if (!made.ok) throw new Error(String(made.error));
230 const orchard = made.value;
231 const opened = (title: string): Issue => {
232 const o = openIssue(app, alice, orchard, { title });
233 if (!o.ok) throw new Error(o.error);
234 return o.value;
235 };
236 const blocker = opened("Graft");
237 const issue = opened("Prune");
238 const r = await open(bob, issue.number, orchard);
239 try {
240 // Bob could write when the screen opened, so `w` is there; by the
241 // time he uses it he is off the team, and `linkIssue` says so.
242 removeTeamMember(app.db, writers.value, bob.id);
243 await r.press("w");
244 await r.type(String(blocker.number));
245 await r.press("enter");
246 // Said in full, and the prompt closed: no other number would do.
247 expect(r.text()).toContain(THREAD_SAYS.planForbidden);
248 expect(r.text()).not.toContain(`#${issue.number} waits for issue`);
249 expect(waitsFor(issue)).toEqual([]);
250 } finally {
251 r.stop();
252 }
253 });
254
255 test("w unlinks a wait written since the screen last loaded", async () => {
256 const blocker = newIssue("Mulch");
257 const issue = newIssue("Weed");
258 const t = await open(alice, issue.number);
259 try {
260 // Written under the screen with no event, so nothing reloads it.
261 expect(addDependency(app.db, issue, blocker, alice.id).ok).toBe(true);
262 await t.press("w");
263 await t.type(String(blocker.number));
264 await t.press("enter");
265 expect(t.text()).toContain(`Stop waiting for #${blocker.number}?`);
266 await t.press("enter");
267 expect(waitsFor(issue)).toEqual([]);
268 } finally {
269 t.stop();
270 }
271 });
272
273 test("on a 24-row terminal the waits line still leaves the conversation room", async () => {
274 const blocker = newIssue("Rake");
275 const issue = newIssue("Edge the lawn");
276 expect(linkIssue(app, alice, fresh(), issue, blocker).ok).toBe(true);
277 const t = await open(alice, issue.number, fresh(), 24);
278 try {
279 expect(t.text()).toContain(`Waits for #${blocker.number}`);
280 expect(t.text()).toContain("No description.");
281 } finally {
282 t.stop();
283 }
284 });
285 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs