Public: anyone can read this; the crew can change it.
A pull whose green was checked on an older main says so on its page, in its API reading and in the MCP #2261
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2249-a-pull-whose-green-was-checked-on-an-old → main 6 commits fork 8 threads · 8 unresolved
README.md +1 −1
| @@ -1238,7 +1238,7 @@ Invariants are proven by faults, not by hope. No dependencies were added. | ||
| 1238 | 1238 | |
| 1239 | 1239 | `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. |
| 1240 | 1240 | |
| 1241 | 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. | |
| 1241 | 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. | |
| 1242 | 1242 | |
| 1243 | 1243 | 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. |
| 1244 | 1244 |
src/checks/stale.ts added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | /** | |
| 2 | * Whether a pull's green was checked against an older base: the last check | |
| 3 | * run recorded the base tip it was merged with (`base_oid`), and the base | |
| 4 | * has moved since. A run that recorded no base, or a base that cannot be | |
| 5 | * read, is not called stale — the reading says only what it knows. | |
| 6 | */ | |
| 7 | ||
| 8 | export function checkStale( | |
| 9 | run: { readonly baseOid: string } | null, | |
| 10 | baseTip: string | undefined, | |
| 11 | ): boolean { | |
| 12 | if (run === null || run.baseOid === "") return false; | |
| 13 | if (baseTip === undefined || baseTip === "") return false; | |
| 14 | return run.baseOid !== baseTip; | |
| 15 | } |
src/http/api.ts +13
| @@ -321,12 +321,14 @@ import { | ||
| 321 | 321 | } from "../core/issues.ts"; |
| 322 | 322 | import type { Closing } from "../actions/issues.ts"; |
| 323 | 323 | import { |
| 324 | checkSummary, | |
| 324 | 325 | checksForHead, |
| 325 | 326 | getRun, |
| 326 | 327 | isTerminal, |
| 327 | 328 | listActiveRuns, |
| 328 | 329 | type Run, |
| 329 | 330 | } from "../core/runs.ts"; |
| 331 | import { checkStale } from "../checks/stale.ts"; | |
| 330 | 332 | import { resolveOid } from "../git/merge.ts"; |
| 331 | 333 | import { readUsage, usageDays, USAGE_DAYS_MAX } from "../actions/usage.ts"; |
| 332 | 334 | import { readReturns, reportSpend, RETURNS_DAYS } from "../actions/returns.ts"; |
| @@ -3246,8 +3248,19 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3246 | 3248 | { db: ctx.app.db, requireReview: found.repo.requireReview }, |
| 3247 | 3249 | ); |
| 3248 | 3250 | const hold = getHold(ctx.app.db, found.pull.id); |
| 3251 | const live = found.pull.state === "open" && found.pull.mergedAt === null; | |
Nit: |
||
| 3252 | const checkStaleNow = | |
| 3253 | live && | |
| 3254 | checkStale( | |
| 3255 | checkSummary(ctx.app.db, found.pull.id, status.headOid).run, | |
| 3256 | await resolveOid( | |
| 3257 | await pullDirOf(ctx, found.repo, found.pull), | |
| 3258 | `refs/heads/${found.pull.baseBranch}`, | |
outdated
This is computed for closed and merged pulls too. After a merge, main moves on and this reads |
||
| 3259 | ), | |
| 3260 | ); | |
| 3249 | 3261 | return json({ |
| 3250 | 3262 | ...withLabels(ctx, found.pull), |
| 3263 | checkStale: checkStaleNow, | |
| 3251 | 3264 | variantSiblings: variantSiblings(ctx.app.db, [found.pull])[0] ?? [], |
| 3252 | 3265 | ...(found.pull.state === "open" && found.pull.mergedAt === null |
| 3253 | 3266 | ? { mergeable: status.mergeable, conflicts: status.conflicts } |
src/http/mcp.ts +10 −5
| @@ -93,6 +93,7 @@ import { waitsForPerson } from "../crew/review.ts"; | ||
| 93 | 93 | import { attachLook } from "../actions/attach_look.ts"; |
| 94 | 94 | import { lookUrl } from "../core/looks.ts"; |
| 95 | 95 | import { pullProposalOf } from "../core/proposal_token.ts"; |
| 96 | import { resolveOid } from "../git/merge.ts"; | |
| 96 | 97 | import { getPull, pullDir, pullMergeState } from "../core/pulls.ts"; |
| 97 | 98 | import { repoGraph } from "../core/dependencies.ts"; |
| 98 | 99 | import { pillarView } from "../core/pillars.ts"; |
| @@ -108,6 +109,7 @@ import { | ||
| 108 | 109 | type Repo, |
| 109 | 110 | } from "../core/repos.ts"; |
| 110 | 111 | import { plannerDeclines } from "../crew/autonomy.ts"; |
| 112 | import { checkStale } from "../checks/stale.ts"; | |
| 111 | 113 | import { checkSummary, listActiveRuns } from "../core/runs.ts"; |
| 112 | 114 | import { scopeAllows } from "../core/tokens.ts"; |
| 113 | 115 | import { getUserByName, getUsersByIds, type User } from "../core/users.ts"; |
| @@ -503,7 +505,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 503 | 505 | { |
| 504 | 506 | name: "pull", |
| 505 | 507 | description: |
| 506 | "Read a pull: its title, state and head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), `proposal` ({child, origin}, or null) when a child's proposal token opened it, and `check`: the latest check's state and the base tip it was merged with (`baseOid`, empty when it merged nothing). `mergeable` and `conflicts` are the same fields as GET /api/v1/repos/:owner/:name/pulls/:number; `check` is the latest check's `state` and `run.baseOid` from GET …/pulls/:number/checks.", | |
| 508 | "Read a pull: its title, state and head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), `proposal` ({child, origin}, or null) when a child's proposal token opened it, `checkStale` (true when that check ran against an older tip of the base), and `check`: the latest check's state and the base tip it was merged with (`baseOid`, empty when it merged nothing). `mergeable` and `conflicts` are the same fields as GET /api/v1/repos/:owner/:name/pulls/:number; `check` is the latest check's `state` and `run.baseOid` from GET …/pulls/:number/checks.", | |
| 507 | 509 | inputSchema: { |
| 508 | 510 | type: "object", |
| 509 | 511 | properties: { |
| @@ -523,11 +525,13 @@ const TOOLS: readonly Tool[] = [ | ||
| 523 | 525 | : undefined; |
| 524 | 526 | if (pull === undefined || !mayView(ctx.app.db, repo, pull, user)) |
| 525 | 527 | return bad("no_such_pull", `no pull #${String(n)}`); |
| 526 | const state = await pullMergeState( | |
| 527 | await pullDir(ctx.app.db, ctx.app.reposRoot, repo, pull), | |
| 528 | pull, | |
| 529 | ); | |
| 528 | const dir = await pullDir(ctx.app.db, ctx.app.reposRoot, repo, pull); | |
| 529 | const state = await pullMergeState(dir, pull); | |
| 530 | 530 | const check = checkSummary(ctx.app.db, pull.id, pull.headOid); |
| 531 | const stale = | |
outdated
Same as the API: gate this on |
||
| 532 | pull.state === "open" && | |
| 533 | pull.mergedAt === null && | |
| 534 | checkStale(check.run, await resolveOid(dir, `refs/heads/${pull.baseBranch}`)); | |
Nit: |
||
| 531 | 535 | return good({ |
| 532 | 536 | number: pull.number, |
| 533 | 537 | title: pull.title, |
| @@ -536,6 +540,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 536 | 540 | head: pull.headBranch, |
| 537 | 541 | proposal: pullProposalOf(ctx.app.db, pull.id) ?? null, |
| 538 | 542 | check: { state: check.state, baseOid: check.run?.baseOid ?? "" }, |
| 543 | checkStale: stale, | |
| 539 | 544 | ...(state ?? {}), |
| 540 | 545 | }); |
| 541 | 546 | }, |
src/tui/screens/pull.ts +21 −2
| @@ -49,6 +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"; | |
| 53 | import { resolveOid } from "../../git/merge.ts"; | |
| 52 | 54 | import { getPull, pullDir, variantSiblings, type Pull } from "../../core/pulls.ts"; |
| 53 | 55 | import { voteVariant } from "../../actions/variants.ts"; |
| 54 | 56 | import { getRepoById, type Repo } from "../../core/repos.ts"; |
| @@ -227,6 +229,8 @@ export function pullScreen( | ||
| 227 | 229 | let check: CheckSummary = NO_CHECK; |
| 228 | 230 | /** The pull's base branch, to name what the check was merged with. */ |
| 229 | 231 | let checkBase = ""; |
| 232 | /** The check's green was against a base that has since moved. */ | |
| 233 | let checkOld = false; | |
| 230 | 234 | /** Agent runs about this pull, newest first. */ |
| 231 | 235 | const crew = new RunList(f); |
| 232 | 236 | const mayWrite = (): boolean => src.capabilities.write && repo !== undefined; |
| @@ -275,6 +279,18 @@ export function pullScreen( | ||
| 275 | 279 | ? NO_CHECK |
| 276 | 280 | : checkSummary(f.db, row.id, row.headOid || (facts?.headOid ?? "")); |
| 277 | 281 | checkBase = row?.baseBranch ?? facts?.baseBranch ?? ""; |
| 282 | checkOld = | |
This load step is untested, and the pull says so itself. |
||
| 283 | repo !== undefined && | |
| 284 | row !== undefined && | |
| 285 | row.state === "open" && | |
| 286 | row.mergedAt === null && | |
| 287 | checkStale( | |
| 288 | check.run, | |
| 289 | await resolveOid( | |
| 290 | await pullDir(f.app.db, f.app.reposRoot, repo, row), | |
| 291 | `refs/heads/${row.baseBranch}`, | |
| 292 | ), | |
| 293 | ); | |
| 278 | 294 | const runs = |
| 279 | 295 | repo === undefined || row === undefined |
| 280 | 296 | ? undefined |
| @@ -757,7 +773,7 @@ export function pullScreen( | ||
| 757 | 773 | // The box is exactly as tall as what is in it: the state, then a row |
| 758 | 774 | // for the verdicts when there are any, then a row for the checks when |
| 759 | 775 | // a deploy has run or the gate demands one. |
| 760 | const checksLine = checksText(check, requireChecks, isApp, checkBase); | |
| 776 | const checksLine = checksText(check, requireChecks, isApp, checkBase, checkOld); | |
| 761 | 777 | const boxH = |
| 762 | 778 | 3 + |
| 763 | 779 | (standingText(standing(), requireReview) === "" ? 0 : 1) + |
| @@ -1198,6 +1214,7 @@ export function checksText( | ||
| 1198 | 1214 | required: boolean, |
| 1199 | 1215 | isApp: boolean, |
| 1200 | 1216 | baseBranch = "", |
| 1217 | stale = false, | |
| 1201 | 1218 | ): string { |
| 1202 | 1219 | if (check.state === "none") |
| 1203 | 1220 | return required |
| @@ -1215,7 +1232,9 @@ export function checksText( | ||
| 1215 | 1232 | }`; |
| 1216 | 1233 | switch (check.state) { |
| 1217 | 1234 | case "ok": |
| 1218 | return `checks: passed${summary}`; | |
| 1235 | return stale | |
| 1236 | ? `checks: passed against an older ${baseBranch || "base"}${summary}` | |
| 1237 | : `checks: passed${summary}`; | |
| 1219 | 1238 | case "failed": |
| 1220 | 1239 | return `checks: failed${summary}`; |
| 1221 | 1240 | case "running": |
src/web/pages/pull.ts +15 −1
| @@ -34,6 +34,7 @@ import { | ||
| 34 | 34 | type PullStatus, |
| 35 | 35 | } from "../../core/pulls.ts"; |
| 36 | 36 | import { isProposed, pullMayRun } from "../../core/pull_trust.ts"; |
| 37 | import { checkStale } from "../../checks/stale.ts"; | |
| 37 | 38 | import { checkSummary, type CheckSummary } from "../../core/runs.ts"; |
| 38 | 39 | import { |
| 39 | 40 | anchorKey, |
| @@ -47,6 +48,7 @@ import { | ||
| 47 | 48 | } from "../../core/reviews.ts"; |
| 48 | 49 | import { getUsersByIds } from "../../core/users.ts"; |
| 49 | 50 | import { canWrite, getRepoById, type Repo } from "../../core/repos.ts"; |
| 51 | import { resolveOid } from "../../git/merge.ts"; | |
| 50 | 52 | import { commitLog } from "../../git/read.ts"; |
| 51 | 53 | import { repoDir } from "../../git/repos.ts"; |
| 52 | 54 | import { wallOf } from "../../contract/policy.ts"; |
| @@ -215,6 +217,8 @@ export function checksLine( | ||
| 215 | 217 | conflicts = 0, |
| 216 | 218 | /** The pull's base branch, to name what the check was merged with. */ |
| 217 | 219 | baseBranch = "", |
| 220 | /** The green was checked against a base that has since moved. */ | |
| 221 | stale = false, | |
| 218 | 222 | ): Html { |
| 219 | 223 | if (!isApp && !required && check.state === "none" && !waiting && again === "") |
| 220 | 224 | return html``; |
| @@ -238,6 +242,10 @@ export function checksLine( | ||
| 238 | 242 | ? html`<span class="bad" |
| 239 | 243 | >${icon("x")}Conflicts in ${conflicts} ${conflicts === 1 ? "file" : "files"}</span |
| 240 | 244 | >${link}` |
| 245 | : check.state === "ok" && stale | |
| 246 | ? html`<span class="now" | |
| 247 | >${icon("clock")}Checked against an older ${baseBranch || "base"}</span | |
| 248 | >${link}` | |
| 241 | 249 | : check.state === "ok" |
| 242 | 250 | ? html`<span class="ok">${icon("check")}Checks passed</span>${link}` |
| 243 | 251 | : check.state === "failed" |
| @@ -319,6 +327,7 @@ function mergeBox( | ||
| 319 | 327 | check: CheckSummary, |
| 320 | 328 | isApp: boolean, |
| 321 | 329 | tier = "", |
| 330 | stale = false, | |
| 322 | 331 | ): Html { |
| 323 | 332 | const thread = `${repoBase(repo)}/pulls/${pull.number}`; |
| 324 | 333 | // A reader's head runs nothing until a writer approves it; say so rather |
| @@ -337,6 +346,7 @@ function mergeBox( | ||
| 337 | 346 | : "", |
| 338 | 347 | status.conflicts.length, |
| 339 | 348 | pull.baseBranch, |
| 349 | stale, | |
| 340 | 350 | ); |
| 341 | 351 | const reopen = html`<form method="post" action="${thread}/state"> |
| 342 | 352 | <input type="hidden" name="state" value="open" /> |
| @@ -744,6 +754,10 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> { | ||
| 744 | 754 | const check = checkSummary(ctx.app.db, pull.id, status.headOid); |
| 745 | 755 | const isApp = getAppByRepo(ctx.app.db, repo.id) !== undefined; |
| 746 | 756 | const tier = await tierOf(dir, status.headOid, check); |
| 757 | const stale = | |
Nothing exercises this wiring: the only page test calls |
||
| 758 | pull.state === "open" && pull.mergedAt === null | |
| 759 | ? checkStale(check.run, await resolveOid(dir, `refs/heads/${pull.baseBranch}`)) | |
| 760 | : false; | |
| 747 | 761 | const threads = pullThreads(ctx.app, pull); |
| 748 | 762 | const threadViews = threadStateViews(ctx, repo, threads); |
| 749 | 763 | const unresolved = threads.filter((t) => t.resolvedAt === null).length; |
| @@ -833,7 +847,7 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> { | ||
| 833 | 847 | </p> |
| 834 | 848 | ${when(wall, (w) => wallCard(w))} |
| 835 | 849 | ${holdCard(ctx, repo, pull, { act: false })} |
| 836 | ${mergeBox(ctx, repo, pull, status, mayMerge, by, check, isApp, tier)} | |
| 850 | ${mergeBox(ctx, repo, pull, status, mayMerge, by, check, isApp, tier, stale)} | |
| 837 | 851 | <div class="split"> |
| 838 | 852 | <div> |
| 839 | 853 | ${approveHereCard(ctx, repo, pull, status.headOid, `${thread}/approve-here`)} |
test/check-stale-wiring.test.ts added +113
| @@ -0,0 +1,113 @@ | ||
| 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 type { Repo } from "../src/core/repos.ts"; | |
| 11 | import { createUser, type User } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 14 | import { pullScreen } from "../src/tui/screens/pull.ts"; | |
| 15 | import { local } from "../src/tui/screens/where.ts"; | |
| 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | import { settleApp } from "./helpers/tui.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | describe("a green from an older main says so where people read it", () => { | |
| 23 | let dataDir: string; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | let alice: User; | |
| 27 | let repo: Repo; | |
| 28 | let pull: Pull; | |
| 29 | ||
| 30 | const put = async (branch: string, content: string) => { | |
| 31 | const done = await commitToBranch(app, alice, repo, { | |
| 32 | branch, | |
| 33 | message: `edit ${branch}`, | |
| 34 | files: [{ path: "code.txt", content }], | |
| 35 | }); | |
| 36 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 37 | }; | |
| 38 | ||
| 39 | beforeAll(async () => { | |
| 40 | dataDir = await mkdtemp(join(tmpdir(), "forge-stale-")); | |
| 41 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 42 | servers = await serve(app); | |
| 43 | const made = await createUser(app.db, { | |
| 44 | trusted: true, | |
| 45 | username: "alice", | |
| 46 | email: "[email protected]", | |
| 47 | password: "a-good-passphrase", | |
| 48 | adminIfFirst: true, | |
| 49 | }); | |
| 50 | if (!made.ok) throw new Error(made.error); | |
| 51 | alice = made.value; | |
| 52 | const r = await createRepo(app, alice, { name: "lib", visibility: "public" }); | |
| 53 | if (!r.ok) throw new Error(String(r.error)); | |
| 54 | repo = r.value; | |
| 55 | await put("main", "top\nbase\n"); | |
| 56 | await put("feat", "top\nbase\nmore\n"); | |
| 57 | const opened = await openPull(app, alice, repo, { | |
| 58 | title: "Feat", | |
| 59 | baseBranch: "main", | |
| 60 | headBranch: "feat", | |
| 61 | }); | |
| 62 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 63 | pull = getPull(app.db, repo.id, opened.value.number)!; | |
| 64 | const run = createRun(app.db, { | |
| 65 | repoId: repo.id, | |
| 66 | kind: "check", | |
| 67 | issueId: pull.id, | |
| 68 | env: "pull-1", | |
| 69 | commitOid: pull.headOid, | |
| 70 | }); | |
| 71 | claimRun(app.db, run.id); | |
| 72 | finishRun(app.db, run.id, { state: "ok", summary: "all green" }); | |
| 73 | setRunBase(app.db, run.id, "abc1234abc1234abc1234abc1234abc1234abc12"); | |
| 74 | }, 120_000); | |
| 75 | ||
| 76 | afterAll(async () => { | |
| 77 | servers.stop(); | |
| 78 | app.close(); | |
| 79 | await rm(dataDir, { recursive: true, force: true }); | |
| 80 | }); | |
| 81 | ||
| 82 | test("the pull page reads the clock, not the green", async () => { | |
| 83 | const res = await fetch(`${servers.url}/alice/lib/pulls/${pull.number}/files`); | |
| 84 | const page = await res.text(); | |
| 85 | expect(page).toContain("Checked against an older main"); | |
| 86 | expect(page).not.toContain("Checks passed"); | |
| 87 | }); | |
| 88 | ||
| 89 | test("the terminal's pull screen says passed against an older main", async () => { | |
| 90 | const { app: tui, session } = createForgeApp( | |
| 91 | app, | |
| 92 | { | |
| 93 | sshUsername: "alice", | |
| 94 | remoteAddress: "127.0.0.1", | |
| 95 | fingerprint: "SHA256:alice", | |
| 96 | user: alice, | |
| 97 | }, | |
| 98 | { terminal: headlessTerminal(140, 44), exitHooks: false }, | |
| 99 | ); | |
| 100 | tui.start(); | |
| 101 | try { | |
| 102 | tui.ctx.push(pullScreen(session, tui.ctx, local(session, repo), pull.number)); | |
| 103 | const end = Date.now() + 20_000; | |
| 104 | while (!tui.screen.toText().includes("passed against an older main")) { | |
| 105 | if (Date.now() > end) throw new Error(tui.screen.toText()); | |
| 106 | await settleApp(tui); | |
| 107 | await Bun.sleep(20); | |
| 108 | } | |
| 109 | } finally { | |
| 110 | tui.stop(); | |
| 111 | } | |
| 112 | }, 60_000); | |
| 113 | }); |
test/check-stale.test.ts added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { checkStale } from "../src/checks/stale.ts"; | |
| 3 | import { checksLine } from "../src/web/pages/pull.ts"; | |
| 4 | ||
| 5 | test("the base moved after a green: stale", () => { | |
| 6 | expect(checkStale({ baseOid: "aaa" }, "bbb")).toBe(true); | |
| 7 | }); | |
| 8 | ||
| 9 | test("a check on the current tip is not stale", () => { | |
| 10 | expect(checkStale({ baseOid: "bbb" }, "bbb")).toBe(false); | |
| 11 | }); | |
| 12 | ||
| 13 | test("an empty base_oid, no run, or an unreadable base is not stale", () => { | |
| 14 | expect(checkStale({ baseOid: "" }, "bbb")).toBe(false); | |
| 15 | expect(checkStale(null, "bbb")).toBe(false); | |
| 16 | expect(checkStale({ baseOid: "aaa" }, undefined)).toBe(false); | |
| 17 | }); | |
| 18 | ||
| 19 | test("the pull page says so in place of green", () => { | |
| 20 | const ok = { state: "ok", run: null } as const; | |
| 21 | const stale = checksLine("/a/b", ok, false, true, false, "", "", 0, "main", true).text; | |
| 22 | expect(stale).toContain("Checked against an older main"); | |
| 23 | expect(stale).not.toContain('class="ok"'); | |
| 24 | expect(checksLine("/a/b", ok, false, true, false, "", "", 0, "main", false).text).toContain("Checks passed"); | |
| 25 | }); | |
| 26 | ||
| 27 | test("the terminal says the green is against an older main", async () => { | |
| 28 | const { checksText } = await import("../src/tui/screens/pull.ts"); | |
| 29 | const seen = { state: "ok", run: null } as const; | |
| 30 | expect(checksText(seen, false, true, "main", true)).toBe("checks: passed against an older main"); | |
| 31 | expect(checksText(seen, false, true, "main", false)).toBe("checks: passed"); | |
| 32 | }); |
test/mcp.test.ts +12
| @@ -10,6 +10,8 @@ import { tmpdir } from "node:os"; | ||
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 | import { pullDir } from "../src/core/pulls.ts"; | |
| 14 | import { resolveOid } from "../src/git/merge.ts"; | |
| 13 | 15 | import { openPull } from "../src/actions/pulls.ts"; |
| 14 | 16 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; |
| 15 | 17 | import { createApp as insertApp } from "../src/core/apps.ts"; |
| @@ -486,6 +488,16 @@ describe("the forge over MCP", () => { | ||
| 486 | 488 | setRunBase(app.db, run.id, "abc1234abc1234abc1234abc1234abc1234abc12"); |
| 487 | 489 | const read = await tool(ro, "pull", { repo: "alice/game", number: pull.number }); |
| 488 | 490 | expect(read.value.check.baseOid).toBe("abc1234abc1234abc1234abc1234abc1234abc12"); |
| 491 | // That base is not main's tip: the green is from an older main. | |
| 492 | expect(read.value.checkStale).toBe(true); | |
| 493 | const tip = await resolveOid( | |
| 494 | await pullDir(app.db, app.reposRoot, game, pull), | |
| 495 | "refs/heads/main", | |
| 496 | ); | |
| 497 | setRunBase(app.db, run.id, tip ?? ""); | |
| 498 | expect( | |
| 499 | (await tool(ro, "pull", { repo: "alice/game", number: pull.number })).value.checkStale, | |
| 500 | ).toBe(false); | |
| 489 | 501 | }); |
| 490 | 502 | |
| 491 | 503 | test("stop takes a run id from now, and refuses a stale or foreign one", async () => { |
test/pull-mergeable.test.ts +24
| @@ -5,6 +5,7 @@ import { join } from "node:path"; | ||
| 5 | 5 | import { createApp, type App } from "../src/app.ts"; |
| 6 | 6 | import { serve, type Servers } from "../src/http/server.ts"; |
| 7 | 7 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 8 | import { createRun, setRunBase } from "../src/core/runs.ts"; | |
| 8 | 9 | |
| 9 | 10 | const gitEnv = gitClientEnv(); |
| 10 | 11 | |
| @@ -133,6 +134,7 @@ describe("a pull says whether it merges", () => { | ||
| 133 | 134 | |
| 134 | 135 | const one = await api("/pulls/1"); |
| 135 | 136 | expect(one.mergeable).toBe(false); |
| 137 | expect(one.checkStale).toBe(false); | |
| 136 | 138 | expect(one.conflicts).toEqual(["code.txt"]); |
| 137 | 139 | const listed = await api("/pulls"); |
| 138 | 140 | expect(listed.items[0].mergeable).toBe(false); |
| @@ -152,6 +154,28 @@ describe("a pull says whether it merges", () => { | ||
| 152 | 154 | const mcpAfter = await mcp("pull", { repo: "dev/app", number: 1 }); |
| 153 | 155 | expect(mcpAfter.mergeable).toBe(true); |
| 154 | 156 | expect(mcpAfter.conflicts).toEqual([]); |
| 157 | ||
| 158 | // A check recorded against some other base tip is from an older main. | |
| 159 | const row = app.db | |
| 160 | .query<{ issue_id: string; repo_id: string; head_oid: string }, []>( | |
| 161 | "SELECT pull.issue_id, issue.repo_id, pull.head_oid FROM pull JOIN issue ON issue.id = pull.issue_id LIMIT 1", | |
| 162 | ) | |
| 163 | .get()!; | |
| 164 | const run = createRun(app.db, { | |
| 165 | repoId: row.repo_id, | |
| 166 | kind: "check", | |
| 167 | issueId: row.issue_id, | |
| 168 | env: "pull-1", | |
| 169 | commitOid: row.head_oid, | |
| 170 | }); | |
| 171 | setRunBase(app.db, run.id, "abc1234abc1234abc1234abc1234abc1234abc12"); | |
| 172 | expect((await api("/pulls/1")).checkStale).toBe(true); | |
| 173 | expect((await mcp("pull", { repo: "dev/app", number: 1 })).checkStale).toBe(true); | |
| 174 | // A merged pull is not called stale once main moves on. | |
| 175 | app.db.query("UPDATE pull SET merged_at = '2026-10-01T00:00:00.000Z' WHERE issue_id = ?1").run(row.issue_id); | |
| 176 | expect((await api("/pulls/1")).checkStale).toBe(false); | |
| 177 | expect((await mcp("pull", { repo: "dev/app", number: 1 })).checkStale).toBe(false); | |
| 178 | app.db.query("UPDATE pull SET merged_at = NULL WHERE issue_id = ?1").run(row.issue_id); | |
| 155 | 179 | }); |
| 156 | 180 | |
| 157 | 181 | test("a sealed pull answers no_such_pull to a reader who cannot see it", async () => { |
This adds a second import from
../core/runs.ts. FoldcheckSummaryinto the existingimport { …, listActiveRuns, type Run } from "../core/runs.ts"just above.