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

Merged opened this

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

Merged

scout/forge:scout/2249-a-pull-whose-green-was-checked-on-an-old → main 6 commits fork 8 threads · 8 unresolved

  • Merged. scout/2249-a-pull-whose-green-was-checked-on-an-old was merged into main as 70b7745.

Review

Sign in to review this change.
10 files changed +256 −9
README.md +1 −1
@@ -1238,7 +1238,7 @@ Invariants are proven by faults, not by hope. No dependencies were added.
12381238
12391239 `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.
12401240
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.
12421242
12431243 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.
12441244
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 {
321321 } from "../core/issues.ts";
322322 import type { Closing } from "../actions/issues.ts";
323323 import {
324 checkSummary,
324325 checksForHead,
325326 getRun,
326327 isTerminal,
327328 listActiveRuns,
328329 type Run,
329330 } from "../core/runs.ts";
331 import { checkStale } from "../checks/stale.ts";
outdated

This adds a second import from ../core/runs.ts. Fold checkSummary into the existing import { …, listActiveRuns, type Run } from "../core/runs.ts" just above.

330332 import { resolveOid } from "../git/merge.ts";
331333 import { readUsage, usageDays, USAGE_DAYS_MAX } from "../actions/usage.ts";
332334 import { readReturns, reportSpend, RETURNS_DAYS } from "../actions/returns.ts";
@@ -3246,8 +3248,19 @@ export function apiRoutes(router: Router<Handler>): void {
32463248 { db: ctx.app.db, requireReview: found.repo.requireReview },
32473249 );
32483250 const hold = getHold(ctx.app.db, found.pull.id);
3251 const live = found.pull.state === "open" && found.pull.mergedAt === null;

Nit: checkStale here is true for any last run whose base moved, failed or running included. The page only changes its line for an ok run. The README and the MCP description say "last check recorded a base", so the three readings agree, but the issue's word is "green". If a reader should take checkStale to mean "this green is old", gate it on check.state === "ok" in all three places. live also repeats the condition used for mergeable a few lines below; one name would serve both.

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 true for every merged pull, while the page shows nothing. Gate it like mergeable/conflicts two lines down (open and not merged), and add a test that a merged pull reads false.

3259 ),
3260 );
32493261 return json({
32503262 ...withLabels(ctx, found.pull),
3263 checkStale: checkStaleNow,
32513264 variantSiblings: variantSiblings(ctx.app.db, [found.pull])[0] ?? [],
32523265 ...(found.pull.state === "open" && found.pull.mergedAt === null
32533266 ? { mergeable: status.mergeable, conflicts: status.conflicts }
src/http/mcp.ts +10 −5
@@ -93,6 +93,7 @@ import { waitsForPerson } from "../crew/review.ts";
9393 import { attachLook } from "../actions/attach_look.ts";
9494 import { lookUrl } from "../core/looks.ts";
9595 import { pullProposalOf } from "../core/proposal_token.ts";
96 import { resolveOid } from "../git/merge.ts";
9697 import { getPull, pullDir, pullMergeState } from "../core/pulls.ts";
9798 import { repoGraph } from "../core/dependencies.ts";
9899 import { pillarView } from "../core/pillars.ts";
@@ -108,6 +109,7 @@ import {
108109 type Repo,
109110 } from "../core/repos.ts";
110111 import { plannerDeclines } from "../crew/autonomy.ts";
112 import { checkStale } from "../checks/stale.ts";
111113 import { checkSummary, listActiveRuns } from "../core/runs.ts";
112114 import { scopeAllows } from "../core/tokens.ts";
113115 import { getUserByName, getUsersByIds, type User } from "../core/users.ts";
@@ -503,7 +505,7 @@ const TOOLS: readonly Tool[] = [
503505 {
504506 name: "pull",
505507 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.",
507509 inputSchema: {
508510 type: "object",
509511 properties: {
@@ -523,11 +525,13 @@ const TOOLS: readonly Tool[] = [
523525 : undefined;
524526 if (pull === undefined || !mayView(ctx.app.db, repo, pull, user))
525527 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);
530530 const check = checkSummary(ctx.app.db, pull.id, pull.headOid);
531 const stale =
outdated

Same as the API: gate this on pull.state === "open" && pull.mergedAt === null so the MCP agrees with the page and does not call a merged pull stale.

532 pull.state === "open" &&
533 pull.mergedAt === null &&
534 checkStale(check.run, await resolveOid(dir, `refs/heads/${pull.baseBranch}`));

Nit: checkStale here is true for a failed run on a moved base too, while the page shows its stale line only for ok. Consider check.state === "ok" && so the field means "this green is old" in all three readers.

531535 return good({
532536 number: pull.number,
533537 title: pull.title,
@@ -536,6 +540,7 @@ const TOOLS: readonly Tool[] = [
536540 head: pull.headBranch,
537541 proposal: pullProposalOf(ctx.app.db, pull.id) ?? null,
538542 check: { state: check.state, baseOid: check.run?.baseOid ?? "" },
543 checkStale: stale,
539544 ...(state ?? {}),
540545 });
541546 },
src/tui/screens/pull.ts +21 −2
@@ -49,6 +49,8 @@ import { proposedByText } from "../../core/proposal_token.ts";
4949 import { getAppByRepo } from "../../core/apps.ts";
5050 import { getIssue, type Issue } from "../../core/issues.ts";
5151 import { isProposed, pullMayRun } from "../../core/pull_trust.ts";
52 import { checkStale } from "../../checks/stale.ts";
53 import { resolveOid } from "../../git/merge.ts";
5254 import { getPull, pullDir, variantSiblings, type Pull } from "../../core/pulls.ts";
5355 import { voteVariant } from "../../actions/variants.ts";
5456 import { getRepoById, type Repo } from "../../core/repos.ts";
@@ -227,6 +229,8 @@ export function pullScreen(
227229 let check: CheckSummary = NO_CHECK;
228230 /** The pull's base branch, to name what the check was merged with. */
229231 let checkBase = "";
232 /** The check's green was against a base that has since moved. */
233 let checkOld = false;
230234 /** Agent runs about this pull, newest first. */
231235 const crew = new RunList(f);
232236 const mayWrite = (): boolean => src.capabilities.write && repo !== undefined;
@@ -275,6 +279,18 @@ export function pullScreen(
275279 ? NO_CHECK
276280 : checkSummary(f.db, row.id, row.headOid || (facts?.headOid ?? ""));
277281 checkBase = row?.baseBranch ?? facts?.baseBranch ?? "";
282 checkOld =

This load step is untested, and the pull says so itself. checksText is covered, but the screen reading the base tip is not. If a tui test can render this screen, assert the passed against an older main line there.

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 );
278294 const runs =
279295 repo === undefined || row === undefined
280296 ? undefined
@@ -757,7 +773,7 @@ export function pullScreen(
757773 // The box is exactly as tall as what is in it: the state, then a row
758774 // for the verdicts when there are any, then a row for the checks when
759775 // 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);
761777 const boxH =
762778 3 +
763779 (standingText(standing(), requireReview) === "" ? 0 : 1) +
@@ -1198,6 +1214,7 @@ export function checksText(
11981214 required: boolean,
11991215 isApp: boolean,
12001216 baseBranch = "",
1217 stale = false,
12011218 ): string {
12021219 if (check.state === "none")
12031220 return required
@@ -1215,7 +1232,9 @@ export function checksText(
12151232 }`;
12161233 switch (check.state) {
12171234 case "ok":
1218 return `checks: passed${summary}`;
1235 return stale
1236 ? `checks: passed against an older ${baseBranch || "base"}${summary}`
1237 : `checks: passed${summary}`;
12191238 case "failed":
12201239 return `checks: failed${summary}`;
12211240 case "running":
src/web/pages/pull.ts +15 −1
@@ -34,6 +34,7 @@ import {
3434 type PullStatus,
3535 } from "../../core/pulls.ts";
3636 import { isProposed, pullMayRun } from "../../core/pull_trust.ts";
37 import { checkStale } from "../../checks/stale.ts";
3738 import { checkSummary, type CheckSummary } from "../../core/runs.ts";
3839 import {
3940 anchorKey,
@@ -47,6 +48,7 @@ import {
4748 } from "../../core/reviews.ts";
4849 import { getUsersByIds } from "../../core/users.ts";
4950 import { canWrite, getRepoById, type Repo } from "../../core/repos.ts";
51 import { resolveOid } from "../../git/merge.ts";
5052 import { commitLog } from "../../git/read.ts";
5153 import { repoDir } from "../../git/repos.ts";
5254 import { wallOf } from "../../contract/policy.ts";
@@ -215,6 +217,8 @@ export function checksLine(
215217 conflicts = 0,
216218 /** The pull's base branch, to name what the check was merged with. */
217219 baseBranch = "",
220 /** The green was checked against a base that has since moved. */
221 stale = false,
218222 ): Html {
219223 if (!isApp && !required && check.state === "none" && !waiting && again === "")
220224 return html``;
@@ -238,6 +242,10 @@ export function checksLine(
238242 ? html`<span class="bad"
239243 >${icon("x")}Conflicts in ${conflicts} ${conflicts === 1 ? "file" : "files"}</span
240244 >${link}`
245 : check.state === "ok" && stale
246 ? html`<span class="now"
247 >${icon("clock")}Checked against an older ${baseBranch || "base"}</span
248 >${link}`
241249 : check.state === "ok"
242250 ? html`<span class="ok">${icon("check")}Checks passed</span>${link}`
243251 : check.state === "failed"
@@ -319,6 +327,7 @@ function mergeBox(
319327 check: CheckSummary,
320328 isApp: boolean,
321329 tier = "",
330 stale = false,
322331 ): Html {
323332 const thread = `${repoBase(repo)}/pulls/${pull.number}`;
324333 // A reader's head runs nothing until a writer approves it; say so rather
@@ -337,6 +346,7 @@ function mergeBox(
337346 : "",
338347 status.conflicts.length,
339348 pull.baseBranch,
349 stale,
340350 );
341351 const reopen = html`<form method="post" action="${thread}/state">
342352 <input type="hidden" name="state" value="open" />
@@ -744,6 +754,10 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
744754 const check = checkSummary(ctx.app.db, pull.id, status.headOid);
745755 const isApp = getAppByRepo(ctx.app.db, repo.id) !== undefined;
746756 const tier = await tierOf(dir, status.headOid, check);
757 const stale =

Nothing exercises this wiring: the only page test calls checksLine with stale = true directly. Add a GET of the pull page with a foreign base_oid that asserts the stale wording, so removing this would fail a test.

758 pull.state === "open" && pull.mergedAt === null
759 ? checkStale(check.run, await resolveOid(dir, `refs/heads/${pull.baseBranch}`))
760 : false;
747761 const threads = pullThreads(ctx.app, pull);
748762 const threadViews = threadStateViews(ctx, repo, threads);
749763 const unresolved = threads.filter((t) => t.resolvedAt === null).length;
@@ -833,7 +847,7 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
833847 </p>
834848 ${when(wall, (w) => wallCard(w))}
835849 ${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)}
837851 <div class="split">
838852 <div>
839853 ${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";
1010 import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { pullDir } from "../src/core/pulls.ts";
14 import { resolveOid } from "../src/git/merge.ts";
1315 import { openPull } from "../src/actions/pulls.ts";
1416 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
1517 import { createApp as insertApp } from "../src/core/apps.ts";
@@ -486,6 +488,16 @@ describe("the forge over MCP", () => {
486488 setRunBase(app.db, run.id, "abc1234abc1234abc1234abc1234abc1234abc12");
487489 const read = await tool(ro, "pull", { repo: "alice/game", number: pull.number });
488490 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);
489501 });
490502
491503 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";
55 import { createApp, type App } from "../src/app.ts";
66 import { serve, type Servers } from "../src/http/server.ts";
77 import { gitClientEnv } from "./helpers/git-client.ts";
8 import { createRun, setRunBase } from "../src/core/runs.ts";
89
910 const gitEnv = gitClientEnv();
1011
@@ -133,6 +134,7 @@ describe("a pull says whether it merges", () => {
133134
134135 const one = await api("/pulls/1");
135136 expect(one.mergeable).toBe(false);
137 expect(one.checkStale).toBe(false);
136138 expect(one.conflicts).toEqual(["code.txt"]);
137139 const listed = await api("/pulls");
138140 expect(listed.items[0].mergeable).toBe(false);
@@ -152,6 +154,28 @@ describe("a pull says whether it merges", () => {
152154 const mcpAfter = await mcp("pull", { repo: "dev/app", number: 1 });
153155 expect(mcpAfter.mergeable).toBe(true);
154156 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);
155179 });
156180
157181 test("a sealed pull answers no_such_pull to a reader who cannot see it", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs