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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1624-the-decider-stops-a-red-pull-only-at-p-0 → main 7 commits fork 4 threads · 4 unresolved
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 | ||
| 471 | 471 | on main in …" on the thread. A claim with no commit, or with one that is not |
| 472 | 472 | on the base, is an ordinary decline and is parked as one. |
| 473 | 473 | |
| 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 | ||
| 474 | 483 | An autonomous app's planner is **held** while nothing is landing or the plate |
| 475 | 484 | is full: no pull has merged in 48 h while one was closed unmerged, or more than |
| 476 | 485 | `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[] = [ | ||
| 121 | 121 | "test/checks.test.ts", |
| 122 | 122 | "test/dependencies.test.ts", |
| 123 | 123 | "test/map-closing.test.ts", |
| 124 | "test/parked-close.test.ts", | |
| 124 | 125 | "test/autonomy.test.ts", |
| 125 | 126 | "test/studio.test.ts", |
| 126 | 127 | "test/studio-words.test.ts", |
src/actions/issues.ts +8 −2
| @@ -411,7 +411,11 @@ export function setIssueState( | ||
| 411 | 411 | const children: Issue[] = []; |
| 412 | 412 | if (closing.reason !== undefined || closing.into !== undefined) { |
| 413 | 413 | // 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 | ) | |
| 415 | 419 | return err("invalid_reason"); |
| 416 | 420 | if (reason === "split") { |
| 417 | 421 | for (const number of closing.into ?? []) { |
| @@ -451,7 +455,9 @@ export function setIssueState( | ||
| 451 | 455 | actorId: user.value.id, |
| 452 | 456 | payload: { |
| 453 | 457 | number: issue.number, |
| 454 | ...(issue.kind === "issue" && reason !== undefined ? { reason } : {}), | |
| 458 | ...(reason !== undefined && (issue.kind === "issue" || reason === "parked") | |
| 459 | ? { reason } | |
| 460 | : {}), | |
| 455 | 461 | ...(children.length > 0 ? { into: children.map((c) => c.number) } : {}), |
| 456 | 462 | }, |
| 457 | 463 | }); |
src/core/dependencies.ts +1 −1
| @@ -188,7 +188,7 @@ export function dependenciesOf( | ||
| 188 | 188 | export interface Held { |
| 189 | 189 | readonly issueId: string; |
| 190 | 190 | readonly blockedById: string; |
| 191 | readonly reason: Exclude<CloseReason, "done">; | |
| 191 | readonly reason: Exclude<CloseReason, "done" | "parked">; | |
| 192 | 192 | } |
| 193 | 193 | |
| 194 | 194 | export interface Graph { |
src/core/issues.ts +14 −6
| @@ -12,8 +12,12 @@ import { repoVisible } from "./visibility.ts"; | ||
| 12 | 12 | |
| 13 | 13 | export type IssueKind = "issue" | "pull"; |
| 14 | 14 | 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"; | |
| 17 | 21 | export const CLOSE_REASONS: readonly CloseReason[] = [ |
| 18 | 22 | "done", |
| 19 | 23 | "dropped", |
| @@ -78,6 +82,7 @@ interface IssueRow { | ||
| 78 | 82 | updated_at: string; |
| 79 | 83 | closed_at: string | null; |
| 80 | 84 | close_reason: CloseReason | null; |
| 85 | parked: number; | |
| 81 | 86 | origin_author: string | null; |
| 82 | 87 | origin_html_url: string | null; |
| 83 | 88 | assignee_id: string | null; |
| @@ -107,7 +112,7 @@ function toIssue(row: IssueRow): Issue { | ||
| 107 | 112 | createdAt: row.created_at, |
| 108 | 113 | updatedAt: row.updated_at, |
| 109 | 114 | closedAt: row.closed_at, |
| 110 | closeReason: row.close_reason, | |
| 115 | closeReason: row.parked === 1 ? "parked" : row.close_reason, | |
| 111 | 116 | originAuthor: row.origin_author, |
| 112 | 117 | originUrl: row.origin_html_url, |
| 113 | 118 | assigneeId: row.assignee_id, |
| @@ -469,12 +474,15 @@ export function setIssueState( | ||
| 469 | 474 | ): Issue | undefined { |
| 470 | 475 | const now = new Date().toISOString(); |
| 471 | 476 | 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; | |
| 473 | 479 | const changed = db |
| 474 | 480 | .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`, | |
| 476 | 484 | ) |
| 477 | .run(state, closedAt, now, issueId, why).changes; | |
| 485 | .run(state, closedAt, now, issueId, why, parked).changes; | |
| 478 | 486 | return changed > 0 ? getIssueById(db, issueId) : undefined; |
| 479 | 487 | } |
| 480 | 488 |
src/core/schema.ts +7
| @@ -1787,6 +1787,13 @@ export const migrations: readonly string[] = [ | ||
| 1787 | 1787 | PRIMARY KEY (step, at, target) |
| 1788 | 1788 | ) WITHOUT ROWID; |
| 1789 | 1789 | `, |
| 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 | `, | |
| 1790 | 1797 | ]; |
| 1791 | 1798 | |
| 1792 | 1799 | /** |
src/crew/autonomy.ts +3 −1
| @@ -957,7 +957,9 @@ export function parkPull( | ||
| 957 | 957 | const issue = getIssueById(app.db, pull.id); |
| 958 | 958 | if (!issue || issue.state !== "open") return false; |
| 959 | 959 | 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 | }); | |
| 961 | 963 | if (!closed.ok) return false; |
| 962 | 964 | for (const number of closesFromBody(pull.body)) { |
| 963 | 965 | const about = getIssue(app.db, repo.id, number); |
src/crew/dispatch.ts +6 −1
| @@ -858,7 +858,12 @@ export async function judgeFailedCheck( | ||
| 858 | 858 | cause: certain(CAUSE_OPTIONS, "change"), |
| 859 | 859 | }), |
| 860 | 860 | ); |
| 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"; | |
| 862 | 867 | // The change is what a half-sure answer means: an environment called |
| 863 | 868 | // that way on a hunch is how a broken change escapes a rework. |
| 864 | 869 | const cause = |
src/http/api.ts +3
| @@ -3018,6 +3018,9 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3018 | 3018 | const wanted = input.state === undefined ? undefined : str(input.state); |
| 3019 | 3019 | if (wanted !== undefined && wanted !== "open" && wanted !== "closed") |
| 3020 | 3020 | 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); | |
| 3021 | 3024 | if ( |
| 3022 | 3025 | wanted === undefined && |
| 3023 | 3026 | text.title === undefined && |
src/tui/screens/pull.ts +3 −1
| @@ -1241,7 +1241,9 @@ function drawMergeBox( | ||
| 1241 | 1241 | state === "merged" |
| 1242 | 1242 | ? `Merged ${relTime(pull?.mergedAt ?? "")} into ${pull?.baseBranch ?? ""}` |
| 1243 | 1243 | : state === "closed" |
| 1244 | ? "Closed without merging" | |
| 1244 | ? issue.parked === true | |
| 1245 | ? "Parked by the crew, not merged" | |
| 1246 | : "Closed without merging" | |
| 1245 | 1247 | : state === "mirrored" |
| 1246 | 1248 | ? `Merge on the origin${issue.originUrl === null ? "" : ` — ${issue.originUrl}`}` |
| 1247 | 1249 | : state === "conflicts" |
src/view/local.ts +2
| @@ -96,6 +96,7 @@ export function localSource( | ||
| 96 | 96 | createdAt: string; |
| 97 | 97 | updatedAt: string; |
| 98 | 98 | closedAt: string | null; |
| 99 | closeReason?: string | null; | |
| 99 | 100 | id: string; |
| 100 | 101 | } |
| 101 | 102 | |
| @@ -119,6 +120,7 @@ export function localSource( | ||
| 119 | 120 | createdAt: issue.createdAt, |
| 120 | 121 | updatedAt: issue.updatedAt, |
| 121 | 122 | closedAt: issue.closedAt, |
| 123 | ...(issue.closeReason === "parked" ? { parked: true } : {}), | |
| 122 | 124 | labels, |
| 123 | 125 | commentCount, |
| 124 | 126 | }); |
src/view/source.ts +2
| @@ -137,6 +137,8 @@ export interface IssueSummary { | ||
| 137 | 137 | readonly createdAt: string; |
| 138 | 138 | readonly updatedAt: string; |
| 139 | 139 | readonly closedAt: string | null; |
| 140 | /** A pull the crew stopped for a person: closed, but not as done. */ | |
| 141 | readonly parked?: boolean; | |
| 140 | 142 | readonly labels: readonly Label[]; |
| 141 | 143 | readonly commentCount: number; |
| 142 | 144 | } |
src/web/pages/thread.ts +3 −1
| @@ -903,7 +903,9 @@ export function dependenciesCard( | ||
| 903 | 903 | ${icon(i.state === "closed" ? "issueClosed" : "issue")} |
| 904 | 904 | <a href="${tellHref(base, i.number)}">${i.title}</a> |
| 905 | 905 | <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" | |
| 907 | 909 | ? html`<span class="muted small">${i.closeReason}</span>` |
| 908 | 910 | : null} |
| 909 | 911 | ${removable && |
src/web/studio/change.ts +5 −1
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | * bigger. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { getIssueById } from "../../core/issues.ts"; | |
| 12 | 13 | import { mayEditIssue } from "../../actions/issues.ts"; |
| 13 | 14 | import { marksByName, type Mark } from "../../core/marks.ts"; |
| 14 | 15 | import { mayView } from "../../core/sealed.ts"; |
| @@ -185,7 +186,10 @@ export function changeHead(ctx: Ctx, repo: Repo, pull: Pull): Html { | ||
| 185 | 186 | return html`<div class="issue-head"> |
| 186 | 187 | <h1>${pull.title} <span class="number">#${pull.number}</span></h1> |
| 187 | 188 | <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 | )} | |
| 189 | 193 | <span |
| 190 | 194 | >${pull.originAuthor |
| 191 | 195 | ? originTag(pull.originAuthor, originGlyph(repo)) |
src/web/studio/words.ts +7 −1
| @@ -210,7 +210,13 @@ export async function pullWord( | ||
| 210 | 210 | return mergedPullWord(production, commitOid); |
| 211 | 211 | } |
| 212 | 212 | 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 | }; | |
| 214 | 220 | const check = checkSummary(ctx.db, pull.id, pull.headOid); |
| 215 | 221 | // A repository that is not an app never deploys a head, so it never gets |
| 216 | 222 | // 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( | ||
| 239 | 239 | |
| 240 | 240 | /** The one badge whose value is a state machine, so it renders identically |
| 241 | 241 | * 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 { | |
| 243 | 246 | if (state === "merged") |
| 244 | 247 | return html`<span class="badge" data-pr="merged" |
| 245 | 248 | >${icon("merge")}Merged</span |
| 246 | 249 | >`; |
| 247 | 250 | if (state === "closed") |
| 248 | 251 | return html`<span class="badge" data-state="closed" |
| 249 | >${icon("issueClosed")}Closed</span | |
| 252 | >${icon("issueClosed")}${parked ? "Parked" : "Closed"}</span | |
| 250 | 253 | >`; |
| 251 | 254 | return html`<span class="badge" data-state="open" |
| 252 | 255 | >${icon("issue")}Open</span |
test/ledger.test.ts +75 −3
| @@ -37,7 +37,10 @@ import { startChecks } from "../src/checks/service.ts"; | ||
| 37 | 37 | import { serve, type Servers } from "../src/http/server.ts"; |
| 38 | 38 | |
| 39 | 39 | /** 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 & { | |
| 41 | 44 | seen: unknown[]; |
| 42 | 45 | } { |
| 43 | 46 | const seen: unknown[] = []; |
| @@ -51,7 +54,7 @@ function scripted(answers: Record<string, string>): Decider & { | ||
| 51 | 54 | const choice = answers[key] ?? q.options[0]!; |
| 52 | 55 | const probabilities: Record<string, number> = {}; |
| 53 | 56 | 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 }; | |
| 55 | 58 | } |
| 56 | 59 | return out as { [K in keyof Q]: Answer }; |
| 57 | 60 | }, |
| @@ -66,7 +69,8 @@ describe("the decision ledger", () => { | ||
| 66 | 69 | let scout: User; |
| 67 | 70 | let repo: Repo; |
| 68 | 71 | const script: Record<string, string> = {}; |
| 69 | const decider = scripted(script); | |
| 72 | const sure: Record<string, number> = {}; | |
| 73 | const decider = scripted(script, sure); | |
| 70 | 74 | const fresh = () => getRepoById(app.db, repo.id)!; |
| 71 | 75 | |
| 72 | 76 | beforeAll(async () => { |
| @@ -422,6 +426,74 @@ describe("the decision ledger", () => { | ||
| 422 | 426 | } |
| 423 | 427 | }); |
| 424 | 428 | |
| 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 | ||
| 425 | 497 | test("conflicts come first: a green pull that no longer merges gets a conflict run before anyone decides", async () => { |
| 426 | 498 | const branch = await commitToBranch(app, alice, repo, { |
| 427 | 499 | 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 |
||
| 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 | }); |
new above
offline
The terminal door has no test. Render the merge box for a parked pull and assert this text. Also,
issueis not optional indrawMergeBox, soissue?.parkedcan beissue.parked.