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

The decider stops a red pull only at p ≥ 0.7, and a parked pull closes as parked, not done #1774

Merged opened this

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

Merged

scout/forge:scout/1624-the-decider-stops-a-red-pull-only-at-p-0 → main 7 commits fork 4 threads · 4 unresolved

  • Merged. scout/1624-the-decider-stops-a-red-pull-only-at-p-0 was merged into main as b6a35f8.

Review

Sign in to review this change.
18 files changed +378 −20
README.md +9
@@ -471,6 +471,15 @@ or `x` on the issue in the terminal); the close writes "Closed: already done
471471 on main in …" on the thread. A claim with no commit, or with one that is not
472472 on the base, is an ordinary decline and is parked as one.
473473
474 **A red pull.** The decider stops a red check only when it is at least 70%
475 sure; below that the pull gets another feedback run. A stop parks the pull: it
476 closes as *parked*, not done, with a comment that says why. The pull page's
477 badge reads "Parked" instead of "Closed", the terminal's merge box says "Parked
478 by the crew, not merged", and the lists note it as stopped. Reopening it clears
479 the mark. Only the crew parks: the API and the page refuse `reason: "parked"`.
480 In the database the pull's `close_reason` stays NULL (its CHECK predates the
481 word) and `issue.parked = 1` carries it, so raw SQL never reads it as done.
482
474483 An autonomous app's planner is **held** while nothing is landing or the plate
475484 is full: no pull has merged in 48 h while one was closed unmerged, or more than
476485 `slots × 3` ready, unassigned issues are waiting. The tick log says which
scripts/gate.ts +1
@@ -121,6 +121,7 @@ export const GATE: readonly string[] = [
121121 "test/checks.test.ts",
122122 "test/dependencies.test.ts",
123123 "test/map-closing.test.ts",
124 "test/parked-close.test.ts",
124125 "test/autonomy.test.ts",
125126 "test/studio.test.ts",
126127 "test/studio-words.test.ts",
src/actions/issues.ts +8 −2
@@ -411,7 +411,11 @@ export function setIssueState(
411411 const children: Issue[] = [];
412412 if (closing.reason !== undefined || closing.into !== undefined) {
413413 // A reason is how an issue closes; a pull or a reopen has none.
414 if (state !== "closed" || issue.kind !== "issue")
414 if (
415 state !== "closed" ||
416 issue.kind !== (reason === "parked" ? "pull" : "issue") ||
417 (reason === "parked" && closing.into !== undefined)
418 )
415419 return err("invalid_reason");
416420 if (reason === "split") {
417421 for (const number of closing.into ?? []) {
@@ -451,7 +455,9 @@ export function setIssueState(
451455 actorId: user.value.id,
452456 payload: {
453457 number: issue.number,
454 ...(issue.kind === "issue" && reason !== undefined ? { reason } : {}),
458 ...(reason !== undefined && (issue.kind === "issue" || reason === "parked")
459 ? { reason }
460 : {}),
455461 ...(children.length > 0 ? { into: children.map((c) => c.number) } : {}),
456462 },
457463 });
src/core/dependencies.ts +1 −1
@@ -188,7 +188,7 @@ export function dependenciesOf(
188188 export interface Held {
189189 readonly issueId: string;
190190 readonly blockedById: string;
191 readonly reason: Exclude<CloseReason, "done">;
191 readonly reason: Exclude<CloseReason, "done" | "parked">;
192192 }
193193
194194 export interface Graph {
src/core/issues.ts +14 −6
@@ -12,8 +12,12 @@ import { repoVisible } from "./visibility.ts";
1212
1313 export type IssueKind = "issue" | "pull";
1414 export type IssueState = "open" | "closed";
15 /** Why an issue closed: done frees what waited on it; the others hold it. */
16 export type CloseReason = "done" | "dropped" | "split";
15 /**
16 * Why an issue closed: done frees what waited on it; the others hold it.
17 * `parked` is the crew's own, on a pull it stopped for a person; it is never
18 * one a request may name, so it is not in `CLOSE_REASONS`.
19 */
20 export type CloseReason = "done" | "dropped" | "split" | "parked";
1721 export const CLOSE_REASONS: readonly CloseReason[] = [
1822 "done",
1923 "dropped",
@@ -78,6 +82,7 @@ interface IssueRow {
7882 updated_at: string;
7983 closed_at: string | null;
8084 close_reason: CloseReason | null;
85 parked: number;
8186 origin_author: string | null;
8287 origin_html_url: string | null;
8388 assignee_id: string | null;
@@ -107,7 +112,7 @@ function toIssue(row: IssueRow): Issue {
107112 createdAt: row.created_at,
108113 updatedAt: row.updated_at,
109114 closedAt: row.closed_at,
110 closeReason: row.close_reason,
115 closeReason: row.parked === 1 ? "parked" : row.close_reason,
111116 originAuthor: row.origin_author,
112117 originUrl: row.origin_html_url,
113118 assigneeId: row.assignee_id,
@@ -469,12 +474,15 @@ export function setIssueState(
469474 ): Issue | undefined {
470475 const now = new Date().toISOString();
471476 const closedAt = state === "closed" ? now : null;
472 const why = state === "closed" ? reason : null;
477 const why = state === "closed" && reason !== "parked" ? reason : null;
478 const parked = state === "closed" && reason === "parked" ? 1 : 0;
473479 const changed = db
474480 .query(
475 "UPDATE issue SET state = ?1, closed_at = ?2, updated_at = ?3, close_reason = ?5 WHERE id = ?4 AND state != ?1",
481 `UPDATE issue SET state = ?1, closed_at = ?2, updated_at = ?3,
482 close_reason = ?5, parked = ?6
483 WHERE id = ?4 AND state != ?1`,
476484 )
477 .run(state, closedAt, now, issueId, why).changes;
485 .run(state, closedAt, now, issueId, why, parked).changes;
478486 return changed > 0 ? getIssueById(db, issueId) : undefined;
479487 }
480488
src/core/schema.ts +7
@@ -1787,6 +1787,13 @@ export const migrations: readonly string[] = [
17871787 PRIMARY KEY (step, at, target)
17881788 ) WITHOUT ROWID;
17891789 `,
1790 // 89 — a pull the crew parked (#1624) closes as parked, not done. The
1791 // column carries it, and `close_reason` stays NULL: its CHECK cannot be
1792 // widened in place, and NULL is never read as done by raw SQL.
1793 // `closeReason` reads 'parked' from it.
1794 `
1795 ALTER TABLE issue ADD COLUMN parked INTEGER NOT NULL DEFAULT 0;
1796 `,
17901797 ];
17911798
17921799 /**
src/crew/autonomy.ts +3 −1
@@ -957,7 +957,9 @@ export function parkPull(
957957 const issue = getIssueById(app.db, pull.id);
958958 if (!issue || issue.state !== "open") return false;
959959 addComment(app, by, repo, issue, `${PARK_NOTE}${why}`);
960 const closed = setIssueState(app, by, repo, issue, "closed");
960 const closed = setIssueState(app, by, repo, issue, "closed", {
961 reason: "parked",
962 });
961963 if (!closed.ok) return false;
962964 for (const number of closesFromBody(pull.body)) {
963965 const about = getIssue(app.db, repo.id, number);
src/crew/dispatch.ts +6 −1
@@ -858,7 +858,12 @@ export async function judgeFailedCheck(
858858 cause: certain(CAUSE_OPTIONS, "change"),
859859 }),
860860 );
861 const retry = decided.answers.retry.choice === "stop" ? "stop" : "again";
861 // A stop is a pull closed on a person's behalf: it takes the same
862 // confidence as a cause. Below it the builder gets its one more try.
863 const retry =
864 decided.answers.retry.choice === "stop" && decided.answers.retry.p >= 0.7
865 ? "stop"
866 : "again";
862867 // The change is what a half-sure answer means: an environment called
863868 // that way on a hunch is how a broken change escapes a rework.
864869 const cause =
src/http/api.ts +3
@@ -3018,6 +3018,9 @@ export function apiRoutes(router: Router<Handler>): void {
30183018 const wanted = input.state === undefined ? undefined : str(input.state);
30193019 if (wanted !== undefined && wanted !== "open" && wanted !== "closed")
30203020 return json({ error: "invalid_state" }, 422);
3021 // A pull has no reason to give: "parked" is the crew's word alone.
3022 if (input.reason !== undefined || input.into !== undefined)
3023 return json({ error: "invalid_reason" }, 422);
30213024 if (
30223025 wanted === undefined &&
30233026 text.title === undefined &&
src/tui/screens/pull.ts +3 −1
@@ -1241,7 +1241,9 @@ function drawMergeBox(
12411241 state === "merged"
12421242 ? `Merged ${relTime(pull?.mergedAt ?? "")} into ${pull?.baseBranch ?? ""}`
12431243 : state === "closed"
1244 ? "Closed without merging"
1244 ? issue.parked === true
1245 ? "Parked by the crew, not merged"

The terminal door has no test. Render the merge box for a parked pull and assert this text. Also, issue is not optional in drawMergeBox, so issue?.parked can be issue.parked.

1246 : "Closed without merging"
12451247 : state === "mirrored"
12461248 ? `Merge on the origin${issue.originUrl === null ? "" : ` — ${issue.originUrl}`}`
12471249 : state === "conflicts"
src/view/local.ts +2
@@ -96,6 +96,7 @@ export function localSource(
9696 createdAt: string;
9797 updatedAt: string;
9898 closedAt: string | null;
99 closeReason?: string | null;
99100 id: string;
100101 }
101102
@@ -119,6 +120,7 @@ export function localSource(
119120 createdAt: issue.createdAt,
120121 updatedAt: issue.updatedAt,
121122 closedAt: issue.closedAt,
123 ...(issue.closeReason === "parked" ? { parked: true } : {}),
122124 labels,
123125 commentCount,
124126 });
src/view/source.ts +2
@@ -137,6 +137,8 @@ export interface IssueSummary {
137137 readonly createdAt: string;
138138 readonly updatedAt: string;
139139 readonly closedAt: string | null;
140 /** A pull the crew stopped for a person: closed, but not as done. */
141 readonly parked?: boolean;
140142 readonly labels: readonly Label[];
141143 readonly commentCount: number;
142144 }
src/web/pages/thread.ts +3 −1
@@ -903,7 +903,9 @@ export function dependenciesCard(
903903 ${icon(i.state === "closed" ? "issueClosed" : "issue")}
904904 <a href="${tellHref(base, i.number)}">${i.title}</a>
905905 <span class="muted small">#${i.number}</span>
906 ${i.closeReason === "dropped" || i.closeReason === "split"
906 ${i.closeReason === "dropped" ||
907 i.closeReason === "split" ||
908 i.closeReason === "parked"
907909 ? html`<span class="muted small">${i.closeReason}</span>`
908910 : null}
909911 ${removable &&
src/web/studio/change.ts +5 −1
@@ -9,6 +9,7 @@
99 * bigger.
1010 */
1111
12 import { getIssueById } from "../../core/issues.ts";
1213 import { mayEditIssue } from "../../actions/issues.ts";
1314 import { marksByName, type Mark } from "../../core/marks.ts";
1415 import { mayView } from "../../core/sealed.ts";
@@ -185,7 +186,10 @@ export function changeHead(ctx: Ctx, repo: Repo, pull: Pull): Html {
185186 return html`<div class="issue-head">
186187 <h1>${pull.title} <span class="number">#${pull.number}</span></h1>
187188 <div class="issue-meta">
188 ${stateBadge(pullState(pull))}
189 ${stateBadge(
190 pullState(pull),
191 getIssueById(ctx.app.db, pull.id)?.closeReason === "parked",

No test fails if this line goes. Cover it with the page fetch suggested in the test file.

192 )}
189193 <span
190194 >${pull.originAuthor
191195 ? originTag(pull.originAuthor, originGlyph(repo))
src/web/studio/words.ts +7 −1
@@ -210,7 +210,13 @@ export async function pullWord(
210210 return mergedPullWord(production, commitOid);
211211 }
212212 if (pull.state === "closed")
213 return { word: "stopped", note: "closed without shipping" };
213 return {
214 word: "stopped",
215 note:
216 getIssueById(ctx.db, pull.id)?.closeReason === "parked"
217 ? "parked by the crew"
218 : "closed without shipping",
219 };
214220 const check = checkSummary(ctx.db, pull.id, pull.headOid);
215221 // A repository that is not an app never deploys a head, so it never gets
216222 // a look: an open change there waits on a person, and says so.
src/web/ui.ts +5 −2
@@ -239,14 +239,17 @@ export function badge(
239239
240240 /** The one badge whose value is a state machine, so it renders identically
241241 * on issues, pulls and lists. */
242 export function stateBadge(state: "open" | "closed" | "merged"): Html {
242 export function stateBadge(
243 state: "open" | "closed" | "merged",
244 parked = false,
245 ): Html {
243246 if (state === "merged")
244247 return html`<span class="badge" data-pr="merged"
245248 >${icon("merge")}Merged</span
246249 >`;
247250 if (state === "closed")
248251 return html`<span class="badge" data-state="closed"
249 >${icon("issueClosed")}Closed</span
252 >${icon("issueClosed")}${parked ? "Parked" : "Closed"}</span
250253 >`;
251254 return html`<span class="badge" data-state="open"
252255 >${icon("issue")}Open</span
test/ledger.test.ts +75 −3
@@ -37,7 +37,10 @@ import { startChecks } from "../src/checks/service.ts";
3737 import { serve, type Servers } from "../src/http/server.ts";
3838
3939 /** A decider that answers from a script: question name → choice. */
40 function scripted(answers: Record<string, string>): Decider & {
40 function scripted(
41 answers: Record<string, string>,
42 sureness: Record<string, number> = {},
43 ): Decider & {
4144 seen: unknown[];
4245 } {
4346 const seen: unknown[] = [];
@@ -51,7 +54,7 @@ function scripted(answers: Record<string, string>): Decider & {
5154 const choice = answers[key] ?? q.options[0]!;
5255 const probabilities: Record<string, number> = {};
5356 for (const o of q.options) probabilities[o] = o === choice ? 0.8 : 0.2;
54 out[key] = { choice, p: 0.8, probabilities };
57 out[key] = { choice, p: sureness[key] ?? 0.8, probabilities };
5558 }
5659 return out as { [K in keyof Q]: Answer };
5760 },
@@ -66,7 +69,8 @@ describe("the decision ledger", () => {
6669 let scout: User;
6770 let repo: Repo;
6871 const script: Record<string, string> = {};
69 const decider = scripted(script);
72 const sure: Record<string, number> = {};
73 const decider = scripted(script, sure);
7074 const fresh = () => getRepoById(app.db, repo.id)!;
7175
7276 beforeAll(async () => {
@@ -422,6 +426,74 @@ describe("the decision ledger", () => {
422426 }
423427 });
424428
429 test("a stop the decider is not sure of (p < 0.7) is a feedback run; a sure one parks, and the pull closes as parked", async () => {
430 const { getAgentByName } = await import("../src/core/agents.ts");
431 const scoutAgent = getAgentByName(app.db, "scout")!;
432 const redPull = async (name: string) => {
433 const branch = await commitToBranch(app, alice, repo, {
434 branch: `feature/${name}`,
435 base: "main",
436 message: name,
437 files: [{ path: `${name}.ts`, content: "export const x = 1;\n" }],
438 });
439 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
440 const opened = await openPull(app, scout, repo, {
441 title: name,
442 baseBranch: "main",
443 headBranch: `feature/${name}`,
444 });
445 if (!opened.ok) throw new Error(String(opened.error));
446 const pull = opened.value;
447 const run = createRun(app.db, {
448 repoId: repo.id,
449 kind: "check",
450 issueId: pull.id,
451 env: `pull-${pull.number}`,
452 commitOid: pull.headOid,
453 reason: "pull",
454 });
455 claimRun(app.db, run.id);
456 finishRun(app.db, run.id, { state: "failed", summary: "test failed: 1 of 9" });
457 return {
458 pull,
459 failure: {
460 runId: run.id,
461 kind: "check",
462 commitOid: pull.headOid,
463 env: `pull-${pull.number}`,
464 since: pull.createdAt,
465 },
466 };
467 };
468 script["retry"] = "stop";
469 script["cause"] = "change";
470 try {
471 sure["retry"] = 0.59;
472 const unsure = await redPull("unsure");
473 expect(
474 await answerFailedCheck(app, fresh(), unsure.pull, scoutAgent, unsure.failure),
475 ).toBe("reworked");
476 expect(getPull(app.db, repo.id, unsure.pull.number)?.state).toBe("open");
477 expect(
478 listRuns(app.db, repo.id, { kind: "agent" }).some(
479 (r) => r.issueId === unsure.pull.id && r.reason === "check_failed",
480 ),
481 ).toBe(true);
482
483 sure["retry"] = 0.77;
484 const certain = await redPull("certain");
485 expect(
486 await answerFailedCheck(app, fresh(), certain.pull, scoutAgent, certain.failure),
487 ).toBe("parked");
488 const closed = getIssueById(app.db, certain.pull.id)!;
489 expect(closed.state).toBe("closed");
490 expect(closed.closeReason).toBe("parked");
491 } finally {
492 delete script["cause"];
493 delete sure["retry"];
494 }
495 });
496
425497 test("conflicts come first: a green pull that no longer merges gets a conflict run before anyone decides", async () => {
426498 const branch = await commitToBranch(app, alice, repo, {
427499 branch: "feature/readme",
test/parked-close.test.ts added +224
@@ -0,0 +1,224 @@
1 /**
2 * A pull the crew parked closes as parked, not done (#1624). Only the crew
3 * may say so: the API and the thread page refuse the word, a parked pull
4 * stores no close_reason a raw query could read as done, and a reopen
5 * clears it. Both doors name it.
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 { createApp, type App } from "../src/app.ts";
12 import { openIssue, setIssueState } from "../src/actions/issues.ts";
13 import { openPull } from "../src/actions/pulls.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { getIssueById, type Issue } from "../src/core/issues.ts";
16 import type { Repo } from "../src/core/repos.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { createForgeApp } from "../src/tui/forge.ts";
20 import { pullScreen } from "../src/tui/screens/pull.ts";
21 import { local } from "../src/tui/screens/where.ts";
22 import { headlessTerminal } from "../src/tui/term/terminal.ts";
23 import { settleApp } from "./helpers/tui.ts";
24 import { serve, type Servers } from "../src/http/server.ts";
25
26 describe("a parked pull", () => {
27 let dataDir: string;
28 let app: App;
29 let servers: Servers;
30 let alice: User;
31 let repo: Repo;
32 let headers: Record<string, string>;
33 let count = 0;
34
35 beforeAll(async () => {
36 dataDir = await mkdtemp(join(tmpdir(), "forge-parked-"));
37 app = createApp({ dataDir, port: 0 });
38 servers = await serve(app);
39 const a = await createUser(app.db, {
40 trusted: true,
41 username: "alice",
42 email: "[email protected]",
43 password: "a-good-passphrase",
44 adminIfFirst: true,
45 });
46 if (!a.ok) throw new Error(a.error);
47 alice = a.value;
48 const made = await createRepo(app, alice, {
49 name: "shop",
50 visibility: "private",
51 });
52 if (!made.ok) throw new Error(String(made.error));
53 repo = made.value;
54 const seeded = await commitToBranch(app, alice, repo, {
55 message: "Start",
56 files: [{ path: "README.md", content: "# shop\n" }],
57 });
58 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
59 headers = {
60 authorization: `Bearer ${createToken(app.db, alice.id, "t", "rw").token}`,
61 "content-type": "application/json",
62 };
63 });
64
65 afterAll(async () => {
66 servers.stop();
67 app.close();
68 await rm(dataDir, { recursive: true, force: true });
69 });
70
71 const makePull = async (): Promise<Issue> => {
72 const name = `feature/p${++count}`;
73 const branch = await commitToBranch(app, alice, repo, {
74 branch: name,
75 base: "main",
76 message: name,
77 files: [
78 { path: `${name.slice(8)}.ts`, content: "export const x = 1;\n" },
79 ],
80 });
81 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
82 const opened = await openPull(app, alice, repo, {
83 title: `Pull ${count}`,
84 baseBranch: "main",
85 headBranch: name,
86 });
87 if (!opened.ok) throw new Error(String(opened.error));
88 return getIssueById(app.db, opened.value.id)!;
89 };
90
91 const rawReason = (id: string) =>
92 app.db
93 .query<{ close_reason: string | null }, [string]>(
94 "SELECT close_reason FROM issue WHERE id = ?1",
95 )
96 .get(id)!.close_reason;
97
98 test("closes as parked and leaves no 'done' for raw SQL to find", async () => {
99 const pull = await makePull();
100 const closed = setIssueState(app, alice, repo, pull, "closed", {
101 reason: "parked",
102 });
103 expect(closed.ok).toBe(true);
104 expect(getIssueById(app.db, pull.id)!.closeReason).toBe("parked");
105 expect(rawReason(pull.id)).toBeNull();
106 });
107
108 test("reopening clears it", async () => {
109 const pull = await makePull();
110 setIssueState(app, alice, repo, pull, "closed", { reason: "parked" });
111 const reopened = setIssueState(
112 app,
113 alice,
114 repo,
115 getIssueById(app.db, pull.id)!,
116 "open",
117 );
118 expect(reopened.ok).toBe(true);
119 const after = getIssueById(app.db, pull.id)!;
120 expect(after.state).toBe("open");
121 expect(after.closeReason).toBeNull();
122 const parked = app.db
123 .query<{ parked: number }, [string]>(
124 "SELECT parked FROM issue WHERE id = ?1",
125 )
126 .get(pull.id)!.parked;
127 expect(parked).toBe(0);
128 });
129
130 test("an issue cannot be parked, and a parked pull takes no children", async () => {
131 const made = openIssue(app, alice, repo, { title: "Plain", body: "" });
132 if (!made.ok) throw new Error(String(made.error));
133 const issue = made.value;
134 const asIssue = setIssueState(app, alice, repo, issue, "closed", {
135 reason: "parked",
136 });
137 expect(asIssue.ok).toBe(false);
138 if (!asIssue.ok) expect(asIssue.error).toBe("invalid_reason");
139 const pull = await makePull();
140 const into = setIssueState(app, alice, repo, pull, "closed", {
141 reason: "parked",
142 into: [issue.number],
143 });
144 expect(into.ok).toBe(false);
145 if (!into.ok) expect(into.error).toBe("invalid_reason");
146 expect(getIssueById(app.db, pull.id)!.state).toBe("open");
147 });
148
149 test("the API and the thread page refuse the word", async () => {
150 const pull = await makePull();
151 const res = await fetch(
152 `${servers.url}/api/v1/repos/alice/shop/pulls/${pull.number}`,
153 {
154 method: "PATCH",
155 headers,
156 body: JSON.stringify({ state: "closed", reason: "parked" }),
157 },
158 );
159 expect(res.status).toBe(422);
160 expect(getIssueById(app.db, pull.id)!.state).toBe("open");
161 const { CLOSE_REASONS } = await import("../src/core/issues.ts");
162 expect(CLOSE_REASONS).not.toContain("parked");
163 });
164
outdated

This tests stateBadge on its own, not the pull page. Without the change.ts wiring it still passes, and the makePull/setIssueState lines above do nothing. Fetch the pull's page for the parked pull and assert Parked is in the HTML.

165 test("the pull page and the pulls list say parked, and an ordinary close still says closed", async () => {
166 const signin = await fetch(`${servers.url}/signin`, {
167 method: "POST",
168 body: new URLSearchParams({
169 username: "alice",
170 password: "a-good-passphrase",
171 }),
172 redirect: "manual",
173 });
174 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
175 const get = async (path: string) =>
176 (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
177 const parked = await makePull();
178 setIssueState(app, alice, repo, parked, "closed", { reason: "parked" });
179 const plain = await makePull();
180 setIssueState(app, alice, repo, plain, "closed");
181
182 const page = await get(`/alice/shop/pulls/${parked.number}`);
183 expect(page).toMatch(/data-state="closed"[^>]*>[^<]*<svg[\s\S]*?<\/svg>Parked</);
184 expect(page).not.toMatch(/<\/svg>Closed</);
185 expect(await get(`/alice/shop/pulls/${plain.number}`)).toMatch(
186 /<\/svg>Closed</,
187 );
188
189 const list = await get("/alice/shop/pulls?word=stopped");
190 expect(list).toContain("parked by the crew");
191 expect(list).toContain("closed without shipping");
192 });
193
194 test("the terminal's merge box says parked", async () => {
195 const pull = await makePull();
196 setIssueState(app, alice, repo, pull, "closed", { reason: "parked" });
197 const terminal = headlessTerminal(140, 44);
198 const { app: tui, session } = createForgeApp(
199 app,
200 {
201 sshUsername: "alice",
202 remoteAddress: "127.0.0.1",
203 fingerprint: "SHA256:alice",
204 user: alice,
205 },
206 { terminal, exitHooks: false },
207 );
208 tui.start();
209 try {
210 tui.ctx.push(
211 pullScreen(session, tui.ctx, local(session, repo), pull.number),
212 );
213 const end = Date.now() + 20_000;
214 while (!tui.screen.toText().includes("Parked by the crew, not merged")) {
215 if (Date.now() > end) throw new Error(tui.screen.toText());
216 await settleApp(tui);
217 await Bun.sleep(20);
218 }
219 expect(tui.screen.toText()).not.toContain("Closed without merging");
220 } finally {
221 tui.stop();
222 }
223 }, 60_000);
224 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs