Public: anyone can read this; the crew can change it.
A stale green holds back the inbox's look ask and disables the merge button (after #1179) #2288
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2028-a-stale-green-holds-back-the-inbox-s-loo → main 5 commits fork 1 thread · 1 unresolved
7 files
changed +267
−21
README.md +1 −1
| @@ -1291,7 +1291,7 @@ Invariants are proven by faults, not by hope. No dependencies were added. | ||
| 1291 | 1291 | |
| 1292 | 1292 | `forge.json`'s `check` may declare `fast` beside `run` (the full gate; `full` names it explicitly). `bun scripts/test-shards.ts --tier fast --diff <base>..<head>` runs the tests that touch the diff (changed, named for a changed source file, or importing one within two hops), every test the times table records under 1 s, and any named by `--learned a,b`. `src/checks/tiers.ts` decides; it adds no dependency. |
| 1293 | 1293 | |
| 1294 | In the forge itself: a pull's check runs `fast` when the manifest has one, in a clone holding the fork point and the head as `FORGE_CHECK_BASE` and `HEAD` (`origin/main` is not a ref there), with `FORGE_CHECK_LEARNED` naming tests earlier land attempts taught the pull (`src/checks/history.ts`, `learned.ts`). Landing (`landIfGreen`) runs `full` once on the merge with its base before merging (`src/checks/landing.ts`); red comments on the pull with the tests named, is remembered per head and base, and the files ride the pull's next fast run. The pull page's checks line shows the tier words. The checks line (page and terminal) also names the base tip the fast check merged with ("checked against main at abc1234"); `GET …/pulls/:number/checks` returns it as `run.baseOid`, empty for a default-branch push and for the `full` land gate. When the base has moved since that green, the page's line reads "Checked against an older main" (a clock, not a green mark) and the terminal's reads `checks: passed against an older main`. `GET /api/v1/repos/:owner/:name/pulls/:number` and the MCP `pull` tool carry `checkStale`, true only for an open, unmerged pull whose last check recorded a base that is no longer the base branch's tip. | |
| 1294 | In the forge itself: a pull's check runs `fast` when the manifest has one, in a clone holding the fork point and the head as `FORGE_CHECK_BASE` and `HEAD` (`origin/main` is not a ref there), with `FORGE_CHECK_LEARNED` naming tests earlier land attempts taught the pull (`src/checks/history.ts`, `learned.ts`). Landing (`landIfGreen`) runs `full` once on the merge with its base before merging (`src/checks/landing.ts`); red comments on the pull with the tests named, is remembered per head and base, and the files ride the pull's next fast run. The pull page's checks line shows the tier words. The checks line (page and terminal) also names the base tip the fast check merged with ("checked against main at abc1234"); `GET …/pulls/:number/checks` returns it as `run.baseOid`, empty for a default-branch push and for the `full` land gate. When the base has moved since that green, the page's line reads "Checked against an older main" (a clock, not a green mark) and the terminal's reads `checks: passed against an older main`. `GET /api/v1/repos/:owner/:name/pulls/:number` and the MCP `pull` tool carry `checkStale`, true only for an open, unmerged pull whose last check recorded a base that is no longer the base branch's tip. With `require_checks` on, such a stale green counts as a check still to come (`checksHold` in `src/checks/stale.ts`): the page reads "Waiting on checks" and disables **Merge pull request**, the terminal refuses `m` the same way, and the inbox's `look` ask and an autonomous repository's `review` ask wait for the re-check. The ask side works from the stale-ask sweep's last read of the base tip, so it drops after the next sweep, not the instant the base moves. `mergePull` itself does not yet refuse a stale green (API and MCP merges are not held). | |
| 1295 | 1295 | |
| 1296 | 1296 | While the default branch's own last check is red, the forge holds its pulls' checks queued rather than fail each one on the same test (`src/checks/red_main.ts`): the vitals carry one card naming the failing test and how many checks wait, and main going green releases them. A check a person asks for again runs at once, and no check is held more than 20 minutes, so the pull that fixes main can always show green. |
| 1297 | 1297 |
src/checks/stale.ts +14
| @@ -13,3 +13,17 @@ export function checkStale( | ||
| 13 | 13 | if (baseTip === undefined || baseTip === "") return false; |
| 14 | 14 | return run.baseOid !== baseTip; |
| 15 | 15 | } |
| 16 | ||
| 17 | /** | |
| 18 | * Whether `require_checks` holds a merge back: the live head's check is not | |
| 19 | * green, or is green only against an older base — a check still to come. | |
| 20 | * The page and the terminal read this one answer, so neither offers a merge | |
| 21 | * the other would not. | |
| 22 | */ | |
| 23 | export function checksHold( | |
| 24 | requireChecks: boolean, | |
| 25 | state: string, | |
| 26 | stale: boolean, | |
| 27 | ): boolean { | |
| 28 | return requireChecks && (state !== "ok" || stale); | |
| 29 | } |
src/crew/stale_asks.ts +28 −5
| @@ -18,6 +18,7 @@ import { joinMany, pullStatus } from "../core/pulls.ts"; | ||
| 18 | 18 | import { getIssuesByIds } from "../core/issues.ts"; |
| 19 | 19 | import { getRepoById } from "../core/repos.ts"; |
| 20 | 20 | import { latestRunsForIssues } from "../core/runs.ts"; |
| 21 | import { checkStale } from "../checks/stale.ts"; | |
| 21 | 22 | import { resolveOid } from "../git/merge.ts"; |
| 22 | 23 | import { repoDir } from "../git/repos.ts"; |
| 23 | 24 | |
| @@ -27,6 +28,8 @@ interface Known { | ||
| 27 | 28 | /** The pull's head the answer was taken at, as the database names it. */ |
| 28 | 29 | readonly head: string; |
| 29 | 30 | readonly stale: boolean; |
| 31 | /** The base's tip when it was last read; empty when it could not be. */ | |
| 32 | readonly base: string; | |
| 30 | 33 | /** The files that stop it merging; 0 when it merges or is simply already in. */ |
| 31 | 34 | readonly conflicts: number; |
| 32 | 35 | /** When a sweep last saw it go from not merging to merging: its ask is new from then. */ |
| @@ -51,6 +54,21 @@ export function isStalePull( | ||
| 51 | 54 | return known !== undefined && known.stale && known.head === headOid; |
| 52 | 55 | } |
| 53 | 56 | |
| 57 | /** | |
| 58 | * Whether this pull's green was checked against an older tip of its base, as | |
| 59 | * the last sweep read that tip (#1179's `checkStale`). A tip not yet read is | |
| 60 | * no answer, and the green stands. | |
| 61 | */ | |
| 62 | export function staleGreen( | |
| 63 | db: Database, | |
| 64 | pull: { readonly id: string; readonly headOid: string }, | |
| 65 | check: { readonly baseOid: string } | null | undefined, | |
| 66 | ): boolean { | |
| 67 | const known = knownByDb.get(db)?.get(pull.id); | |
| 68 | if (known === undefined || known.head !== pull.headOid) return false; | |
| 69 | return checkStale(check ?? null, known.base); | |
| 70 | } | |
| 71 | ||
| 54 | 72 | /** |
| 55 | 73 | * How many files stop this pull, at this head, from merging, as the last |
| 56 | 74 | * sweep found it: 0 when it merges, or when no sweep has answered yet. |
| @@ -96,7 +114,6 @@ export async function sweepStaleAsks(app: App): Promise<void> { | ||
| 96 | 114 | .query<{ id: string }, [number]>( |
| 97 | 115 | `SELECT issue.id AS id FROM issue JOIN repo ON repo.id = issue.repo_id |
| 98 | 116 | WHERE issue.kind = 'pull' AND issue.state = 'open' |
| 99 | AND repo.autonomy <> 'autonomous' | |
| 100 | 117 | ORDER BY issue.updated_at DESC LIMIT ?1`, |
| 101 | 118 | ) |
| 102 | 119 | .all(SWEEP_LIMIT) |
| @@ -115,10 +132,14 @@ export async function sweepStaleAsks(app: App): Promise<void> { | ||
| 115 | 132 | (await resolveOid(dir, `refs/heads/${pull.baseBranch}`)) ?? ""; |
| 116 | 133 | const between = `${pull.headOid}:${base}`; |
| 117 | 134 | if (known.get(pull.id)?.between === between) continue; |
| 118 | const status = await pullStatus(dir, pull); | |
| 135 | // An autonomous repository settles its own mergeability; only the | |
| 136 | // base tip is read there, for the stale green. | |
| 137 | const status = | |
| 138 | repo.autonomy === "autonomous" ? undefined : await pullStatus(dir, pull); | |
| 119 | 139 | const stale = |
| 120 | status.ahead === 0 || | |
| 121 | (!status.mergeable && status.conflicts.length > 0); | |
| 140 | status !== undefined && | |
| 141 | (status.ahead === 0 || | |
| 142 | (!status.mergeable && status.conflicts.length > 0)); | |
| 122 | 143 | const was = known.get(pull.id); |
| 123 | 144 | const mergesSince = |
| 124 | 145 | !stale && was?.stale === true && was.head === pull.headOid |
| @@ -130,7 +151,9 @@ export async function sweepStaleAsks(app: App): Promise<void> { | ||
| 130 | 151 | between, |
| 131 | 152 | head: pull.headOid, |
| 132 | 153 | stale, |
| 133 | conflicts: status.mergeable ? 0 : status.conflicts.length, | |
| 154 | base, | |
| 155 | conflicts: | |
| 156 | status === undefined || status.mergeable ? 0 : status.conflicts.length, | |
| 134 | 157 | ...(mergesSince === undefined ? {} : { mergesSince }), |
| 135 | 158 | }); |
| 136 | 159 | } catch (error) { |
src/tui/screens/pull.ts +34 −9
| @@ -49,7 +49,8 @@ import { proposedByText } from "../../core/proposal_token.ts"; | ||
| 49 | 49 | import { getAppByRepo } from "../../core/apps.ts"; |
| 50 | 50 | import { getIssue, type Issue } from "../../core/issues.ts"; |
| 51 | 51 | import { isProposed, pullMayRun } from "../../core/pull_trust.ts"; |
| 52 | import { checkStale } from "../../checks/stale.ts"; | |
| 52 | import { askCheck } from "../../checks/again.ts"; | |
| 53 | import { checkStale, checksHold } from "../../checks/stale.ts"; | |
| 53 | 54 | import { resolveOid } from "../../git/merge.ts"; |
| 54 | 55 | import { getPull, pullDir, variantSiblings, type Pull } from "../../core/pulls.ts"; |
| 55 | 56 | import { voteVariant } from "../../actions/variants.ts"; |
| @@ -471,7 +472,7 @@ export function pullScreen( | ||
| 471 | 472 | * `checks_required` on its own. |
| 472 | 473 | */ |
| 473 | 474 | const checksGated = (): boolean => |
| 474 | repo !== undefined && requireChecks && check.state !== "ok"; | |
| 475 | repo !== undefined && checksHold(requireChecks, check.state, checkOld); | |
| 475 | 476 | |
| 476 | 477 | /** `O` — the preview, through a grant when the app is private. */ |
| 477 | 478 | // Production beside the preview: comparing the two is the review. |
| @@ -729,6 +730,7 @@ export function pullScreen( | ||
| 729 | 730 | ? [ |
| 730 | 731 | binding(["c"], tab === 1 ? "review line" : "comment"), |
| 731 | 732 | binding(["R"], "review"), |
| 733 | binding(["C"], "check again"), | |
| 732 | 734 | ...(mayVote() ? [binding(["+", "-"], "like/dislike a variant")] : []), |
| 733 | 735 | ...(mayApproveHere() |
| 734 | 736 | ? [ |
| @@ -829,6 +831,7 @@ export function pullScreen( | ||
| 829 | 831 | drawMergeBox(s, boxRow!, c, issue, facts, view.summary(), mirrored(), { |
| 830 | 832 | gated: gated(), |
| 831 | 833 | checksGated: checksGated(), |
| 834 | checkOld, | |
| 832 | 835 | check, |
| 833 | 836 | checksLine, |
| 834 | 837 | standing: standing(), |
| @@ -1016,8 +1019,24 @@ export function pullScreen( | ||
| 1016 | 1019 | ); |
| 1017 | 1020 | return true; |
| 1018 | 1021 | } |
| 1022 | // `C` asks for the check again at the head, on the base as it is now. | |
| 1023 | if (e.key === "C" && mayWrite() && repo !== undefined && f.user !== undefined) { | |
| 1024 | const user = f.user; | |
| 1025 | const target = repo; | |
| 1026 | const row = getPull(f.db, target.id, number); | |
| 1027 | if (row === undefined) return true; | |
| 1028 | runAsync(ctx, async () => { | |
| 1029 | const asked = await askCheck(f.app, user, target, row); | |
| 1030 | ctx.toast( | |
| 1031 | asked.ok ? "check asked for at the current head" : `no check was asked for: ${asked.message}`, | |
| 1032 | asked.ok ? "success" : "danger", | |
| 1033 | ); | |
| 1034 | if (asked.ok) await reload(); | |
| 1035 | }); | |
| 1036 | return true; | |
| 1037 | } | |
| 1019 | 1038 | if (e.key === "m" && mayWrite() && state() === "checks_required") { |
| 1020 | ctx.toast(checksRefusal(check), "attn"); | |
| 1039 | ctx.toast(checksRefusal(check, checkOld), "attn"); | |
| 1021 | 1040 | return true; |
| 1022 | 1041 | } |
| 1023 | 1042 | if ( |
| @@ -1249,7 +1268,9 @@ export function checksText( | ||
| 1249 | 1268 | } |
| 1250 | 1269 | |
| 1251 | 1270 | /** Why `m` is refused by the checks gate, in words a person can act on. */ |
| 1252 | export function checksRefusal(check: CheckSummary): string { | |
| 1271 | export function checksRefusal(check: CheckSummary, old = false): string { | |
| 1272 | if (old && check.state === "ok") | |
| 1273 | return "checked against an older base; press C to check again"; | |
| 1253 | 1274 | switch (check.state) { |
| 1254 | 1275 | case "failed": |
| 1255 | 1276 | return `the deploy of this head failed${check.run?.summary ? `: ${check.run.summary}` : ""}`; |
| @@ -1302,6 +1323,8 @@ function drawMergeBox( | ||
| 1302 | 1323 | o: { |
| 1303 | 1324 | gated: boolean; |
| 1304 | 1325 | checksGated: boolean; |
| 1326 | /** The green was checked against an older base. */ | |
| 1327 | checkOld: boolean; | |
| 1305 | 1328 | check: CheckSummary; |
| 1306 | 1329 | /** The checks row, already worded; "" draws none. */ |
| 1307 | 1330 | checksLine: string; |
| @@ -1351,11 +1374,13 @@ function drawMergeBox( | ||
| 1351 | 1374 | ? `Changes requested by ${o.standing.blocking.join(", ")}` |
| 1352 | 1375 | : "Needs an approval of this head before it can merge" |
| 1353 | 1376 | : state === "checks_required" |
| 1354 | ? o.check.state === "failed" | |
| 1355 | ? "The deploy of this head failed; it cannot merge until one passes" | |
| 1356 | : o.check.state === "running" || o.check.state === "queued" | |
| 1357 | ? "Waiting for the deploy of this head to pass" | |
| 1358 | : "Needs a passing deploy of this head before it can merge" | |
| 1377 | ? o.check.state === "ok" | |
| 1378 | ? "Waiting on checks: checked against an older base; press C to check again" | |
| 1379 | : o.check.state === "failed" | |
| 1380 | ? "The deploy of this head failed; it cannot merge until one passes" | |
| 1381 | : o.check.state === "running" || o.check.state === "queued" | |
| 1382 | ? "Waiting for the deploy of this head to pass" | |
| 1383 | : "Needs a passing deploy of this head before it can merge" | |
| 1359 | 1384 | : `Ready to merge · ${summary}`; |
| 1360 | 1385 | const x = badge(s, inner.x, inner.y, c, { |
| 1361 | 1386 | text: |
src/web/pages/pull.ts +7 −3
| @@ -35,7 +35,7 @@ import { | ||
| 35 | 35 | } from "../../core/pulls.ts"; |
| 36 | 36 | import { isProposed, pullMayRun } from "../../core/pull_trust.ts"; |
| 37 | 37 | import { checkWords } from "../../checks/summary.ts"; |
| 38 | import { checkStale } from "../../checks/stale.ts"; | |
| 38 | import { checkStale, checksHold } from "../../checks/stale.ts"; | |
| 39 | 39 | import { checkSummary, type CheckSummary } from "../../core/runs.ts"; |
| 40 | 40 | import { |
| 41 | 41 | anchorKey, |
| @@ -492,9 +492,13 @@ function mergeBox( | ||
| 492 | 492 | // The check gate, `mergePull`'s `checks_required`: the deploy of the |
| 493 | 493 | // commit that is head right now must have passed. Read live here so the |
| 494 | 494 | // button is honest — the refusal itself happens in the merge. |
| 495 | if (repo.requireChecks && check.state !== "ok") { | |
| 495 | if (checksHold(repo.requireChecks, check.state, stale)) { | |
| 496 | 496 | const why = |
| 497 | check.state === "failed" | |
| 497 | check.state === "ok" | |
| 498 | ? html`This head was checked against an older | |
| 499 | <span class="mono">${pull.baseBranch}</span>. Use Check again to judge | |
| 500 | it on the current base; the merge unlocks when that passes.` | |
| 501 | : check.state === "failed" | |
| 498 | 502 | ? html`The deploy of this head failed. Push a fix — a passing deploy is |
| 499 | 503 | required before merging.` |
| 500 | 504 | : check.state === "running" || check.state === "queued" |
src/web/studio/asks.ts +6 −3
| @@ -58,7 +58,7 @@ import { | ||
| 58 | 58 | import { breakerParked, WAITING_NOTES } from "../../crew/breaker.ts"; |
| 59 | 59 | import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts"; |
| 60 | 60 | import { handoverLines, handoverOf, type Handover } from "../../crew/guard.ts"; |
| 61 | import { conflictsOf, isStalePull, mergesAgainSince } from "../../crew/stale_asks.ts"; | |
| 61 | import { conflictsOf, isStalePull, mergesAgainSince, staleGreen } from "../../crew/stale_asks.ts"; | |
| 62 | 62 | import { reviewAttempts, waitsForPerson, waitsForReviewer, type ReviewWait } from "../../crew/review.ts"; |
| 63 | 63 | import type { Ctx } from "../../http/context.ts"; |
| 64 | 64 | import { repoBase } from "../chrome.ts"; |
| @@ -379,7 +379,9 @@ function askCandidatesFor( | ||
| 379 | 379 | repo.autonomy !== "autonomous" && |
| 380 | 380 | // A green pull that no longer merges as it stands cannot be shipped: |
| 381 | 381 | // bringing it up to date is work, not a person's decision. |
| 382 | !isStalePull(db, pull.id, pull.headOid) | |
| 382 | !isStalePull(db, pull.id, pull.headOid) && | |
| 383 | // A green checked against an older base is a check still to come. | |
| 384 | !staleGreen(db, pull, check) | |
| 383 | 385 | ) { |
| 384 | 386 | // An agent's green pull waits for its reviewer first: it is the |
| 385 | 387 | // crew's to wait on, and reaches the owner with the verdict (#1602). |
| @@ -403,7 +405,8 @@ function askCandidatesFor( | ||
| 403 | 405 | } else if ( |
| 404 | 406 | check?.state === "ok" && |
| 405 | 407 | repo.autonomy === "autonomous" && |
| 406 | !isStalePull(db, pull.id, pull.headOid) | |
| 408 | !isStalePull(db, pull.id, pull.headOid) && | |
| 409 | !staleGreen(db, pull, check) | |
| 407 | 410 | ) { |
| 408 | 411 | // An autonomous repository lands its own green pulls once reviewed; |
| 409 | 412 | // an agent's change with no reviewer, or whose review lapsed, waits |
test/check-stale-gate.test.ts added +177
| @@ -0,0 +1,177 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { openPull } from "../src/actions/pulls.ts"; | |
| 6 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { claimRun, createRun, finishRun, setRunBase } from "../src/core/runs.ts"; | |
| 9 | import { getPull, type Pull } from "../src/core/pulls.ts"; | |
| 10 | import { updateRepoSettings, type Repo } from "../src/core/repos.ts"; | |
| 11 | import { createSession } from "../src/core/sessions.ts"; | |
| 12 | import { createUser, type User } from "../src/core/users.ts"; | |
| 13 | import { sweepStaleAsks } from "../src/crew/stale_asks.ts"; | |
| 14 | import { resolveOid } from "../src/git/merge.ts"; | |
| 15 | import { repoDir } from "../src/git/repos.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { askLinesFor } from "../src/web/studio/asks.ts"; | |
| 18 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 19 | import { pullScreen } from "../src/tui/screens/pull.ts"; | |
| 20 | import { local } from "../src/tui/screens/where.ts"; | |
| 21 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 22 | import { settleApp } from "./helpers/tui.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | describe("a green checked against an older main waits for its re-check", () => { | |
| 28 | let dataDir: string; | |
| 29 | let app: App; | |
| 30 | let servers: Servers; | |
| 31 | let alice: User; | |
| 32 | let repo: Repo; | |
| 33 | let pull: Pull; | |
| 34 | let cookie: string; | |
| 35 | ||
| 36 | const put = async (branch: string, path: string, content: string) => { | |
| 37 | const done = await commitToBranch(app, alice, repo, { | |
| 38 | branch, | |
| 39 | message: `edit ${path}`, | |
| 40 | files: [{ path, content }], | |
| 41 | }); | |
| 42 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 43 | }; | |
| 44 | const mainTip = async () => | |
| 45 | (await resolveOid(repoDir(app.reposRoot, "alice", "lib"), "refs/heads/main"))!; | |
| 46 | const green = async (baseOid: string) => { | |
| 47 | const run = createRun(app.db, { | |
| 48 | repoId: repo.id, | |
| 49 | kind: "check", | |
| 50 | issueId: pull.id, | |
| 51 | env: "pull-1", | |
| 52 | commitOid: pull.headOid, | |
| 53 | }); | |
| 54 | claimRun(app.db, run.id); | |
| 55 | finishRun(app.db, run.id, { state: "ok", summary: "all green" }); | |
| 56 | setRunBase(app.db, run.id, baseOid); | |
| 57 | }; | |
| 58 | const looks = () => | |
| 59 | askLinesFor(app.db, alice).some((r) => r.items.includes(`look:${pull.id}`)); | |
| 60 | const mergeButton = async () => { | |
| 61 | const res = await fetch(`${servers.url}/alice/lib/pulls/${pull.number}/files`, { | |
| 62 | headers: { cookie }, | |
| 63 | }); | |
| 64 | const page = await res.text(); | |
| 65 | const at = page.indexOf("Merge pull request"); | |
| 66 | expect(at).toBeGreaterThan(-1); | |
| 67 | return { page, tag: page.slice(page.lastIndexOf("<button", at), at) }; | |
| 68 | }; | |
| 69 | ||
| 70 | // The terminal's pull screen, after the keys, as text. | |
| 71 | const terminalText = async (keys: string[] = []) => { | |
| 72 | const { app: tui, session } = createForgeApp( | |
| 73 | app, | |
| 74 | { | |
| 75 | sshUsername: "alice", | |
| 76 | remoteAddress: "127.0.0.1", | |
| 77 | fingerprint: "SHA256:alice", | |
| 78 | user: alice, | |
| 79 | }, | |
| 80 | { terminal: headlessTerminal(140, 44), exitHooks: false }, | |
| 81 | ); | |
| 82 | tui.start(); | |
| 83 | try { | |
| 84 | tui.ctx.push(pullScreen(session, tui.ctx, local(session, repo), pull.number)); | |
| 85 | const end = Date.now() + 20_000; | |
| 86 | while (!tui.screen.toText().includes("Feat")) { | |
| 87 | if (Date.now() > end) throw new Error(tui.screen.toText()); | |
| 88 | await settleApp(tui); | |
| 89 | await Bun.sleep(20); | |
| 90 | } | |
| 91 | await settleApp(tui); | |
| 92 | for (const key of keys) { | |
| 93 | tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 94 | await settleApp(tui); | |
| 95 | } | |
| 96 | return tui.screen.toText(); | |
| 97 | } finally { | |
| 98 | tui.stop(); | |
| 99 | } | |
| 100 | }; | |
| 101 | ||
| 102 | beforeAll(async () => { | |
| 103 | dataDir = await mkdtemp(join(tmpdir(), "forge-stale-gate-")); | |
| 104 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 105 | servers = await serve(app); | |
| 106 | const made = await createUser(app.db, { | |
| 107 | trusted: true, | |
| 108 | username: "alice", | |
| 109 | email: "[email protected]", | |
| 110 | password: "a-good-passphrase", | |
| 111 | adminIfFirst: true, | |
| 112 | }); | |
| 113 | if (!made.ok) throw new Error(made.error); | |
| 114 | alice = made.value; | |
| 115 | cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 116 | const r = await createRepo(app, alice, { name: "lib", visibility: "public" }); | |
| 117 | if (!r.ok) throw new Error(String(r.error)); | |
| 118 | repo = updateRepoSettings(app.db, r.value, { requireChecks: true }); | |
| 119 | await put("main", "code.txt", "top\nbase\n"); | |
| 120 | await put("feat", "feat.txt", "feature\n"); | |
| 121 | const opened = await openPull(app, alice, repo, { | |
| 122 | title: "Feat", | |
| 123 | baseBranch: "main", | |
| 124 | headBranch: "feat", | |
| 125 | }); | |
| 126 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 127 | pull = getPull(app.db, repo.id, opened.value.number)!; | |
| 128 | await green(await mainTip()); | |
| 129 | await sweepStaleAsks(app); | |
| 130 | }, 120_000); | |
| 131 | ||
| 132 | afterAll(async () => { | |
| 133 | servers.stop(); | |
| 134 | app.close(); | |
| 135 | await rm(dataDir, { recursive: true, force: true }); | |
| 136 | }); | |
| 137 | ||
| 138 | test("a green on the current main asks for a look and offers the merge", async () => { | |
| 139 | expect(looks()).toBe(true); | |
| 140 | const { tag } = await mergeButton(); | |
| 141 | expect(tag).not.toContain("disabled"); | |
| 142 | }); | |
| 143 | ||
| 144 | test("main moves: no look ask, and the merge button is disabled", async () => { | |
| 145 | await put("main", "other.txt", "moved\n"); | |
| 146 | await sweepStaleAsks(app); | |
| 147 | expect(looks()).toBe(false); | |
| 148 | const { page, tag } = await mergeButton(); | |
| 149 | expect(tag).toContain("disabled"); | |
| 150 | expect(page).toContain("Waiting on checks"); | |
| 151 | expect(page).toContain("checked against an older"); | |
| 152 | expect(page).toContain("Check again"); | |
| 153 | }); | |
| 154 | ||
| 155 | test("the terminal holds the merge too: m is refused on a stale green", async () => { | |
| 156 | const text = await terminalText(["m"]); | |
| 157 | expect(text).toContain("older base"); | |
| 158 | expect(text).toContain("press C to check again"); | |
| 159 | expect(text).not.toContain("Ready to merge"); | |
| 160 | expect(text).not.toContain("Merge pull request?"); | |
| 161 | expect(getPull(app.db, repo.id, pull.number)!.mergedAt).toBeNull(); | |
| 162 | }, 60_000); | |
| 163 | ||
| 164 | test("the terminal's C asks for the check again", async () => { | |
| 165 | // No check service runs in this test: the refusal proves C reached askCheck. | |
| 166 | expect(await terminalText(["C"])).toContain("no check was asked for"); | |
| 167 | }, 60_000); | |
| 168 | ||
| 169 | test("a re-check green against the new main brings both back", async () => { | |
| 170 | await green(await mainTip()); | |
| 171 | await sweepStaleAsks(app); | |
| 172 | expect(looks()).toBe(true); | |
| 173 | const { tag } = await mergeButton(); | |
| 174 | expect(tag).not.toContain("disabled"); | |
| 175 | expect(await terminalText()).toContain("Ready to merge"); | |
| 176 | }, 60_000); | |
| 177 | }); |
new above
offline
This gates the page only. The terminal merge (src/tui/screens/pull.ts:474) uses check.state alone, so the same stale green is mergeable there. Share one predicate between the two doors.