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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7295-the-terminal-s-issue-screen-lets-a-write → main 6 commits fork 4 threads · 4 unresolved
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 | ||
| 60 | 60 | |
| 61 | 61 | 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`. |
| 62 | 62 | |
| 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). | |
| 63 | 64 | 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. |
| 64 | 65 | |
| 65 | 66 | 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( | ||
| 182 | 182 | return { blockedBy: pick(blockedBy), blocks: pick(blocks) }; |
| 183 | 183 | } |
| 184 | 184 | |
| 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} | |
| 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 | ||
| 185 | 206 | // ---- the graph of one repository ---- |
| 186 | 207 | |
| 187 | 208 | /** An open issue held by a blocker that closed without being done. */ |
| @@ -365,8 +386,7 @@ export function repoGraph( | ||
| 365 | 386 | FROM issue_dependency d |
| 366 | 387 | JOIN issue i ON i.id = d.issue_id AND i.state = 'open' |
| 367 | 388 | 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} | |
| 370 | 390 | ORDER BY b.number`, |
| 371 | 391 | ) |
| 372 | 392 | .all(repoId); |
src/tui/screens/issue.ts +88 −3
| @@ -33,7 +33,9 @@ import { teamsOfIssue, TEAMS_MAX } from "../../core/issue_teams.ts"; | ||
| 33 | 33 | import { listTeams } from "../../core/teams.ts"; |
| 34 | 34 | import { listRepoRuns } from "../../actions/runs.ts"; |
| 35 | 35 | 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"; | |
| 37 | 39 | import { alreadyDone } from "../../core/done_on_main.ts"; |
| 38 | 40 | import { hasPicture } from "../../core/note_pictures.ts"; |
| 39 | 41 | import { isMirror } from "../../origins/write-through.ts"; |
| @@ -154,6 +156,8 @@ export function issueScreen( | ||
| 154 | 156 | let issueRow: Issue | undefined; |
| 155 | 157 | /** The commits the crew found this issue done by on main; `x` closes it. */ |
| 156 | 158 | let doneBy: readonly string[] = []; |
| 159 | /** Closed-as-dropped-or-split blockers nobody has said may be passed. */ | |
| 160 | let held: Issue[] = []; | |
| 157 | 161 | /** Which issues this one waits for and holds up; a line when any. */ |
| 158 | 162 | let waits: Waits = { blockedBy: [], blocks: [] }; |
| 159 | 163 | /** What the crew has done here, newest first; a card when there is any. */ |
| @@ -165,6 +169,13 @@ export function issueScreen( | ||
| 165 | 169 | /** The text `e` opened the editor with; set while the composer edits, not comments. */ |
| 166 | 170 | let editing: string | undefined; |
| 167 | 171 | 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); | |
| 168 | 179 | scroll.top = keep.top ?? 0; |
| 169 | 180 | |
| 170 | 181 | const reload = async (): Promise<void> => { |
| @@ -188,6 +199,8 @@ export function issueScreen( | ||
| 188 | 199 | issueRow?.state === "open" |
| 189 | 200 | ? (alreadyDone(f.db, [issueRow.id]).get(issueRow.id) ?? []) |
| 190 | 201 | : []; |
| 202 | held = | |
| 203 | issueRow?.state === "open" ? heldBlockers(f.db, issueRow.id) : []; | |
| 191 | 204 | const deps = issueRow === undefined ? undefined : dependenciesOf(f.db, issueRow.id); |
| 192 | 205 | waits = { |
| 193 | 206 | blockedBy: deps?.blockedBy.map((i) => i.number) ?? [], |
| @@ -330,6 +343,61 @@ export function issueScreen( | ||
| 330 | 343 | }); |
| 331 | 344 | }; |
| 332 | 345 | |
| 346 | /** `g` — say the issue may go ahead without its dropped or split blockers. */ | |
| 347 | const goAhead = (): void => { | |
outdated
Nit: the budget |
||
| 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 | ||
| 333 | 401 | /** `p` — the private repository to fix this sealed report in. */ |
| 334 | 402 | const privateRepo = (): void => { |
| 335 | 403 | const at = row(); |
| @@ -435,6 +503,7 @@ export function issueScreen( | ||
| 435 | 503 | }), |
| 436 | 504 | binding(["l"], "labels"), |
| 437 | 505 | binding(["t"], "teams"), |
| 506 | ...(mayGoAhead() ? [binding(["g"], "go ahead")] : []), | |
| 438 | 507 | binding(["w"], "waits for"), |
| 439 | 508 | ...(issueRow?.sealed === true |
| 440 | 509 | ? [binding(["p"], "private repo")] |
| @@ -465,12 +534,13 @@ export function issueScreen( | ||
| 465 | 534 | crew.runs.length === 0 ? 0 : Math.min(crew.runs.length, CREW_ROWS) + 2; |
| 466 | 535 | const waitsLine = waitsParts(waits); |
| 467 | 536 | 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", [ | |
| 469 | 538 | len(whereRows(src)), |
| 470 | 539 | len(headH), |
| 540 | len(held.length === 0 ? 0 : 1), | |
| 471 | 541 | len(crewH), |
| 472 | 542 | 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)))), | |
| 474 | 544 | ]); |
| 475 | 545 | whereStrip(s, strip!, c, f, src); |
| 476 | 546 | const right = head!.x + head!.w; |
| @@ -540,6 +610,17 @@ export function issueScreen( | ||
| 540 | 610 | ctx.push(issueScreen(f, ctx, src, opens)), |
| 541 | 611 | ); |
| 542 | 612 | } |
| 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 | ); | |
| 543 | 624 | if (crewH > 0) { |
| 544 | 625 | const inner = card(s, crewRow!, c, { |
| 545 | 626 | title: `Crew · ${plural(crew.runs.length, "run")}`, |
| @@ -673,6 +754,10 @@ export function issueScreen( | ||
| 673 | 754 | ); |
| 674 | 755 | return true; |
| 675 | 756 | } |
| 757 | if (e.key === "g" && mayGoAhead()) { | |
| 758 | goAhead(); | |
| 759 | return true; | |
| 760 | } | |
| 676 | 761 | if (e.key === "w" && mayWrite()) { |
| 677 | 762 | waitFor(); |
| 678 | 763 | 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 |
||
| 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 | }); |
new above
offline
Same JOIN and WHERE as the
heldquery in the repo graph. Extract one shared predicate so the two cannot disagree.