Public: anyone can read this; the crew can change it.
A landed pull checks main's new head, so a red main clears on the next land #1888
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1883-a-landed-pull-checks-main-s-new-head-so → main 2 commits fork
3 files
changed +112
−0
scripts/gate.ts +1
| @@ -289,6 +289,7 @@ export const GATE: readonly string[] = [ | ||
| 289 | 289 | "test/check-budget.test.ts", |
| 290 | 290 | "test/check-proven.test.ts", |
| 291 | 291 | "test/check-list.test.ts", |
| 292 | "test/check-land-main.test.ts", | |
| 292 | 293 | "test/check-tiers.test.ts", |
| 293 | 294 | "test/pull-check-again.test.ts", |
| 294 | 295 | "test/holds.test.ts", |
src/checks/service.ts +17
| @@ -898,6 +898,23 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 898 | 898 | inFlight.get(run.id)?.abort.abort(); |
| 899 | 899 | announceRun(app, run); |
| 900 | 900 | } |
| 901 | // A land moves the default branch without a push event: check its | |
| 902 | // new head, or a red main stays red until someone pushes to it. | |
| 903 | const mergeOid = event.payload["mergeOid"]; | |
| 904 | if ( | |
| 905 | event.kind === "pull.merged" && | |
| 906 | pull.baseBranch === repo.defaultBranch && | |
| 907 | typeof mergeOid === "string" && | |
| 908 | /^[0-9a-f]{40}$/.test(mergeOid) | |
| 909 | ) | |
| 910 | await request( | |
| 911 | repo, | |
| 912 | repo.defaultBranch, | |
| 913 | mergeOid, | |
| 914 | "push", | |
| 915 | undefined, | |
| 916 | actor, | |
| 917 | ); | |
| 901 | 918 | return; |
| 902 | 919 | } |
| 903 | 920 | default: |
test/check-land-main.test.ts added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | /** | |
| 2 | * A landed pull checks main's new head (#1883): landing emits pull.merged, | |
| 3 | * not a push, so a red main only cleared when someone pushed to it. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { redMain } from "../src/checks/red_main.ts"; | |
| 13 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; | |
| 14 | import { listRuns, type Run } from "../src/core/runs.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import type { Repo } from "../src/core/repos.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | ||
| 19 | let dataDir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let checks: ChecksHandle; | |
| 23 | let alice: User; | |
| 24 | let repo: Repo; | |
| 25 | ||
| 26 | const checkJson = (run: string): string => | |
| 27 | `{"check": {"run": ${JSON.stringify(run)}, "install": ""}}\n`; | |
| 28 | ||
| 29 | async function finished(where: (r: Run) => boolean): Promise<Run> { | |
| 30 | const deadline = Date.now() + 30_000; | |
| 31 | while (Date.now() < deadline) { | |
| 32 | const run = listRuns(app.db, repo.id, { kind: "check", limit: 50 }).find(where); | |
| 33 | if (run && (run.state === "ok" || run.state === "failed")) return run; | |
| 34 | await Bun.sleep(50); | |
| 35 | } | |
| 36 | throw new Error("check did not settle"); | |
| 37 | } | |
| 38 | ||
| 39 | beforeAll(async () => { | |
| 40 | dataDir = await mkdtemp(join(tmpdir(), "forge-land-main-")); | |
| 41 | app = createApp({ dataDir, port: 0 }); | |
| 42 | servers = await serve(app); | |
| 43 | checks = startChecks(app, { forgeUrl: servers.url, concurrency: 1 }); | |
| 44 | const a = await createUser(app.db, { | |
| 45 | trusted: true, | |
| 46 | username: "alice", | |
| 47 | email: "[email protected]", | |
| 48 | password: "a-good-passphrase", | |
| 49 | adminIfFirst: true, | |
| 50 | }); | |
| 51 | if (!a.ok) throw new Error(a.error); | |
| 52 | alice = a.value; | |
| 53 | const made = await createRepo(app, alice, { name: "lib", visibility: "public" }); | |
| 54 | if (!made.ok) throw new Error(String(made.error)); | |
| 55 | repo = made.value; | |
| 56 | }); | |
| 57 | ||
| 58 | afterAll(async () => { | |
| 59 | await checks.stop(); | |
| 60 | servers.stop(); | |
| 61 | app.close(); | |
| 62 | await rm(dataDir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | test("landing a pull on a red main checks the merge commit, and a green one clears the red", async () => { | |
| 66 | const red = await commitToBranch(app, alice, repo, { | |
| 67 | message: "Declare a check that fails", | |
| 68 | files: [{ path: "forge.json", content: checkJson("test -f OK.md") }], | |
| 69 | }); | |
| 70 | if (!red.ok) throw new Error(JSON.stringify(red.error)); | |
| 71 | const failed = await finished((r) => r.commitOid === red.value.commitOid); | |
| 72 | expect(failed.state).toBe("failed"); | |
| 73 | expect(redMain(app.db, repo.id)).toBeDefined(); | |
| 74 | ||
| 75 | const branched = await commitToBranch(app, alice, repo, { | |
| 76 | branch: "fix", | |
| 77 | message: "Make the check pass", | |
| 78 | files: [{ path: "OK.md", content: "ok\n" }], | |
| 79 | }); | |
| 80 | if (!branched.ok) throw new Error(JSON.stringify(branched.error)); | |
| 81 | const opened = await openPull(app, alice, repo, { | |
| 82 | title: "Fix", | |
| 83 | baseBranch: "main", | |
| 84 | headBranch: "fix", | |
| 85 | }); | |
| 86 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 87 | const landed = await mergePull(app, alice, repo, opened.value, "merge"); | |
| 88 | if (!landed.ok) throw new Error(JSON.stringify(landed.error)); | |
| 89 | const { mergeOid } = landed.value; | |
| 90 | ||
| 91 | const own = await finished((r) => r.commitOid === mergeOid); | |
| 92 | expect(own).toMatchObject({ env: "main", issueId: null, state: "ok" }); | |
| 93 | expect(redMain(app.db, repo.id)).toBeUndefined(); | |
| 94 | }, 60_000); |
new above
offline