Public: anyone can read this; the crew can change it.
The merge queue says why a gate went red, and never bisects a merge conflict or an install failure (8-pull batch red in 6 s, then bisected) #6615
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/5276-the-merge-queue-says-why-a-gate-went-red → main 1 commit fork
7 files
changed +329
−12
src/checks/gate_reason.ts added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | /** | |
| 2 | * Why a gate went red (#5276), read once where the gate ends and carried on | |
| 3 | * the verdict, so the queue's log says it and acts on it: | |
| 4 | * | |
| 5 | * - `tests` — the check ran and named red tests: the code's, searched. | |
| 6 | * - `typecheck` — tsc printed an error: the code's, searched. | |
| 7 | * - `check` — the check step exited red and named neither: the code's, | |
| 8 | * searched, as every red was before this. | |
| 9 | * - `install` — the install step failed (#5093), `setup` — the gate failed | |
| 10 | * before any step ran (export, history, a manifest it could not read), | |
| 11 | * `timeout` — a step ran past its time. None of these says which pull is | |
| 12 | * at fault: the queue gates once more and then calls it infrastructure, | |
| 13 | * never red for a pull, and never searched. | |
| 14 | * | |
| 15 | * Plain data in, plain data out: no I/O here. | |
| 16 | */ | |
| 17 | ||
| 18 | /** Where in its run the gate stopped red, as the check service saw it. */ | |
| 19 | export type GateStage = "setup" | "install" | "check" | "timeout"; | |
| 20 | ||
| 21 | export type GateReason = | |
| 22 | | { readonly kind: "tests"; readonly failed: number } | |
| 23 | | { readonly kind: "typecheck" } | |
| 24 | | { readonly kind: "check" } | |
| 25 | | { readonly kind: "install" } | |
| 26 | | { readonly kind: "setup" } | |
| 27 | | { readonly kind: "timeout" }; | |
| 28 | ||
| 29 | /** `src/x.ts(3,5): error TS2322: …` or `src/x.ts:3:5 - error TS2322: …`. */ | |
| 30 | const TSC_ERROR = /\berror TS\d+:/; | |
| 31 | ||
| 32 | /** What a step's error says when it ran past its time (`apps/process.ts`). */ | |
| 33 | const TOOK_LONGER = /^\S+ took longer than \d+s/; | |
| 34 | ||
| 35 | /** | |
| 36 | * The reason of a red gate: its stage, and for the check step what its | |
| 37 | * lines name. `failedTests` is how many red tests `failuresOf` read. | |
| 38 | */ | |
| 39 | export function gateReason(stage: GateStage, error: string, lines: readonly string[], failedTests: number): GateReason { | |
| 40 | if (stage === "timeout" || TOOK_LONGER.test(error)) return { kind: "timeout" }; | |
| 41 | if (stage === "install") return { kind: "install" }; | |
| 42 | if (stage === "setup") return { kind: "setup" }; | |
| 43 | if (failedTests > 0) return { kind: "tests", failed: failedTests }; | |
| 44 | if (lines.some((line) => TSC_ERROR.test(line))) return { kind: "typecheck" }; | |
| 45 | return { kind: "check" }; | |
| 46 | } | |
| 47 | ||
| 48 | /** Whether a red with this reason is the code's, and so searched for its culprit. */ | |
| 49 | export function searched(reason: GateReason | undefined): boolean { | |
| 50 | if (reason === undefined) return true; | |
| 51 | return reason.kind === "tests" || reason.kind === "typecheck" || reason.kind === "check"; | |
| 52 | } | |
| 53 | ||
| 54 | /** The reason in the queue's log: `install`, `typecheck`, `tests: 3 failed`. */ | |
| 55 | export function reasonWords(reason: GateReason | undefined): string { | |
| 56 | if (reason === undefined) return "check"; | |
| 57 | return reason.kind === "tests" ? `tests: ${reason.failed} failed` : reason.kind; | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * A merge conflict in a batch, the earlier pull first and the one left out | |
| 62 | * second: `merge-conflict (#5088 × #5090)`; `main × #5090` against the base. | |
| 63 | */ | |
| 64 | export function conflictWords(earlier: number | undefined, pull: number): string { | |
| 65 | return `merge-conflict (${earlier === undefined ? "main" : `#${earlier}`} × #${pull})`; | |
| 66 | } | |
| 67 | ||
| 68 | /** The stage of a check step that ended red: `timeout` when it ran past its time. */ | |
| 69 | export function checkStage(error: string): GateStage { | |
| 70 | return TOOK_LONGER.test(error) ? "timeout" : "check"; | |
| 71 | } |
src/checks/landing.ts +4 −1
| @@ -26,6 +26,7 @@ import type { Pull } from "../core/pulls.ts"; | ||
| 26 | 26 | import type { Repo } from "../core/repos.ts"; |
| 27 | 27 | import { failuresOf } from "./failures.ts"; |
| 28 | 28 | import { keepForgeGate } from "./gate_last.ts"; |
| 29 | import { gateReason } from "./gate_reason.ts"; | |
| 29 | 30 | import { keepGateLog } from "./gate_log.ts"; |
| 30 | 31 | import { checksFor } from "./service.ts"; |
| 31 | 32 | import { landVerdict, type LandVerdict } from "./tiers.ts"; |
| @@ -63,7 +64,9 @@ export async function gateCandidate( | ||
| 63 | 64 | const kept = keepGateLog(app.config.dataDir, slug, commitOid, done.ok, done.lines, Date.now()); |
| 64 | 65 | const failed = failuresOf(done.lines); |
| 65 | 66 | const link = kept === undefined ? undefined : `/api/v1/repos/${slug}/gates/${kept}/log`; |
| 66 | return landVerdict(done.ok, failed.tests, failed.files, link); | |
| 67 | // Why it went red, said in the queue's log and read by its search (#5276). | |
| 68 | const reason = done.ok ? undefined : gateReason(done.stage ?? "check", done.error, done.lines, failed.tests.length); | |
| 69 | return landVerdict(done.ok, failed.tests, failed.files, link, reason); | |
| 67 | 70 | } |
| 68 | 71 | |
| 69 | 72 | /** |
src/checks/queue.ts +61 −5
| @@ -42,6 +42,13 @@ | ||
| 42 | 42 | * green alone it rides at the tail of a batch, never at its head, and the |
| 43 | 43 | * search finds it there if it is red on top of others. |
| 44 | 44 | * |
| 45 | * Every red says why (#5276, `gate_reason.ts`): `tests: N failed`, | |
| 46 | * `typecheck` or `check` is the code's and searched as above; `install`, | |
| 47 | * `setup` or `timeout` names no pull's code, so the gate runs once more and | |
| 48 | * red that way twice is infrastructure — no verdict, nothing handed back, | |
| 49 | * nothing bisected. A pull that conflicts with an earlier one of its batch | |
| 50 | * is never red: it is left out, the pair named, and the rest gate at once. | |
| 51 | * | |
| 45 | 52 | * What is queued is not stored: it is the pulls `landIfGreen` admitted, and |
| 46 | 53 | * every sweep admits them again. The queue only orders them and remembers |
| 47 | 54 | * what is at the gate and what its searches found, for the tick line and |
| @@ -52,6 +59,7 @@ import type { Pull } from "../core/pulls.ts"; | ||
| 52 | 59 | import { runGit } from "../git/env.ts"; |
| 53 | 60 | import { mergeable, resolveOid, treeOf } from "../git/merge.ts"; |
| 54 | 61 | import { createLog } from "../kernel/log.ts"; |
| 62 | import { conflictWords, reasonWords, searched } from "./gate_reason.ts"; | |
| 55 | 63 | import type { LandVerdict } from "./tiers.ts"; |
| 56 | 64 | |
| 57 | 65 | /** The base a step must land on, and the tree it must write. */ |
| @@ -457,10 +465,15 @@ function hhmm(ms: number): string { | ||
| 457 | 465 | return new Date(ms).toISOString().slice(11, 16); |
| 458 | 466 | } |
| 459 | 467 | |
| 460 | /** A gate's verdict in a word, for the log. */ | |
| 468 | /** A gate's verdict in a few words, for the log: a red says why (#5276). */ | |
| 461 | 469 | function outcome(verdict: LandVerdict): string { |
| 462 | 470 | if (verdict.lands) return "green"; |
| 463 | return "interrupted" in verdict ? "cut off" : "red"; | |
| 471 | return "interrupted" in verdict ? "cut off" : `red (${reasonWords(verdict.reason)})`; | |
| 472 | } | |
| 473 | ||
| 474 | /** A red that names no pull's code — install, setup, timeout: gated once more, never searched. */ | |
| 475 | function machines(verdict: LandVerdict): verdict is Red { | |
| 476 | return !verdict.lands && !("interrupted" in verdict) && !searched(verdict.reason); | |
| 464 | 477 | } |
| 465 | 478 | |
| 466 | 479 | /** Gate runs a repository's line has made since boot. */ |
| @@ -469,11 +482,32 @@ export function gateRuns(repoId: string): number { | ||
| 469 | 482 | } |
| 470 | 483 | |
| 471 | 484 | /** |
| 472 | * The line's gate: counted, logged as it starts and ends (a gate takes | |
| 485 | * The line's gate (`attempt`), and once more when it was red for a reason | |
| 486 | * that names no pull's code (#5276, #5093): an install, the gate's own | |
| 487 | * setup, a timeout. Red that way twice is infrastructure — no verdict, as a | |
| 488 | * gate cut off is not: no pull is handed back and nothing is searched. | |
| 489 | */ | |
| 490 | function counted(line: Line, gate: GateCandidate): Gated { | |
| 491 | const once = attempt(line, gate); | |
| 492 | return async (candidate, signal) => { | |
| 493 | const first = await once(candidate, signal); | |
| 494 | if (!machines(first) || signal?.aborted === true) return first; | |
| 495 | const again = await once(candidate, signal); | |
| 496 | if (!machines(again)) return again; | |
| 497 | log.warn(`gated ${pullsWord(candidate.steps.length)}: infrastructure (${reasonWords(again.reason)}, twice)`, { | |
| 498 | repo: line.repoId, | |
| 499 | pulls: candidate.steps.map((s) => `#${s.entry.pull.number}`).join(","), | |
| 500 | }); | |
| 501 | return UNDECIDED; | |
| 502 | }; | |
| 503 | } | |
| 504 | ||
| 505 | /** | |
| 506 | * One gate run: counted, logged as it starts and ends (a gate takes | |
| 473 | 507 | * minutes, #3430), and abandoned at the deadline or when `signal` aborts — |
| 474 | 508 | * a speculative candidate discarded. Abandoned is no verdict. |
| 475 | 509 | */ |
| 476 | function counted(line: Line, gate: GateCandidate): Gated { | |
| 510 | function attempt(line: Line, gate: GateCandidate): Gated { | |
| 477 | 511 | return async (candidate, signal) => { |
| 478 | 512 | const beyond = line.flights.size > 0; |
| 479 | 513 | const flight: InFlight = { pulls: candidate.steps.map((s) => s.entry.pull.id), since: Date.now() }; |
| @@ -995,7 +1029,16 @@ async function build(batch: readonly Waiting[], on?: Candidate): Promise<Candida | ||
| 995 | 1029 | const steps: Step[] = []; |
| 996 | 1030 | for (const { entry } of batch) { |
| 997 | 1031 | const merged = await mergeable(dir, tipOid, entry.pull.headOid); |
| 998 | if (!merged.ok || merged.value.treeOid === treeOid) continue; | |
| 1032 | if (!merged.ok) { | |
| 1033 | // Left out, never red (#5276): the rest gate without it, and once the | |
| 1034 | // pull it clashes with lands, the conflict lane brings it up to date. | |
| 1035 | if (merged.error.kind === "conflict") | |
| 1036 | log.info(`left out of the batch: ${conflictWords(await clashesWith(dir, baseOid, steps, entry), entry.pull.number)}`, { | |
| 1037 | files: merged.error.files.join(","), | |
| 1038 | }); | |
| 1039 | continue; | |
| 1040 | } | |
| 1041 | if (merged.value.treeOid === treeOid) continue; | |
| 999 | 1042 | const commit = await runGit( |
| 1000 | 1043 | ["commit-tree", merged.value.treeOid, "-p", tipOid, "-m", `merge queue: #${entry.pull.number}`], |
| 1001 | 1044 | { cwd: dir, env: IDENTITY }, |
| @@ -1008,3 +1051,16 @@ async function build(batch: readonly Waiting[], on?: Candidate): Promise<Candida | ||
| 1008 | 1051 | } |
| 1009 | 1052 | return { dir, baseOid, baseTreeOid, steps, tipOid, treeOid }; |
| 1010 | 1053 | } |
| 1054 | ||
| 1055 | /** | |
| 1056 | * The earlier step a pull that does not merge onto the chain clashes with: | |
| 1057 | * the first whose prefix it does not merge onto. Undefined when it does not | |
| 1058 | * merge onto the base itself. Read only on a conflict, so the extra merges | |
| 1059 | * cost nothing on a clean batch. | |
| 1060 | */ | |
| 1061 | async function clashesWith(dir: string, baseOid: string, steps: readonly Step[], entry: Queued): Promise<number | undefined> { | |
| 1062 | if (!(await mergeable(dir, baseOid, entry.pull.headOid)).ok) return undefined; | |
| 1063 | for (const step of steps) | |
| 1064 | if (!(await mergeable(dir, step.commitOid, entry.pull.headOid)).ok) return step.entry.pull.number; | |
| 1065 | return steps.at(-1)?.entry.pull.number; | |
| 1066 | } |
src/checks/service.ts +20 −6
| @@ -92,6 +92,7 @@ import type { Result } from "../kernel/result.ts"; | ||
| 92 | 92 | import { placementFromEnv, type Placement } from "./placement.ts"; |
| 93 | 93 | import { JOB_VANISHED, runJobStep } from "./job_step.ts"; |
| 94 | 94 | import { checkInstallCache, installWithRetry, withLock } from "./install.ts"; |
| 95 | import { checkStage, type GateStage } from "./gate_reason.ts"; | |
| 95 | 96 | import { extraGates, gateQueued, gatesInFlight, watchGates } from "./queue.ts"; |
| 96 | 97 | import type { JobOptions } from "../cluster/jobs.ts"; |
| 97 | 98 | import type { CheckDetail } from "../core/runs.ts"; |
| @@ -163,7 +164,7 @@ export interface ChecksHandle { | ||
| 163 | 164 | pull: Pull, |
| 164 | 165 | commitOid: string, |
| 165 | 166 | signal?: AbortSignal, |
| 166 | ): Promise<{ ok: boolean; error: string; lines: string[]; interrupted?: boolean }>; | |
| 167 | ): Promise<{ ok: boolean; error: string; lines: string[]; interrupted?: boolean; stage?: GateStage }>; | |
| 167 | 168 | /** |
| 168 | 169 | * Stop checking. Checks under way get `drainMs` to finish unless `drain` |
| 169 | 170 | * is false (a person's Ctrl-C): then they are cut off and queued again now. |
| @@ -716,7 +717,14 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 716 | 717 | x: Execution, |
| 717 | 718 | ): Promise< |
| 718 | 719 | | { ok: true; proven?: string } |
| 719 | | { ok: false; error: string; detail?: CheckDetail | undefined; infrastructure?: boolean } | |
| 720 | | { | |
| 721 | ok: false; | |
| 722 | error: string; | |
| 723 | detail?: CheckDetail | undefined; | |
| 724 | infrastructure?: boolean; | |
| 725 | /** Where it stopped red (#5276); none is before any step ran. */ | |
| 726 | stage?: GateStage; | |
| 727 | } | |
| 720 | 728 | > { |
| 721 | 729 | const { bare, repo, write, signal } = x; |
| 722 | 730 | // A pull's fast check is of the pull as it would land: its head merged |
| @@ -966,7 +974,12 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 966 | 974 | { log: write, signal, retryMs: options.installRetryMs }, |
| 967 | 975 | ); |
| 968 | 976 | if (!installed.ok) |
| 969 | return { ok: false, error: installed.error, infrastructure: installed.infrastructure }; | |
| 977 | return { | |
| 978 | ok: false, | |
| 979 | error: installed.error, | |
| 980 | infrastructure: installed.infrastructure, | |
| 981 | stage: "install", | |
| 982 | }; | |
| 970 | 983 | } |
| 971 | 984 | // The check step's own lines are kept beside what `write` already |
| 972 | 985 | // logs, so a failure can be summarised once here — never by reading |
| @@ -1015,7 +1028,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1015 | 1028 | checkLines, |
| 1016 | 1029 | ran.error, |
| 1017 | 1030 | ); |
| 1018 | return { ok: false, error: summary, detail }; | |
| 1031 | return { ok: false, error: summary, detail, stage: checkStage(ran.error) }; | |
| 1019 | 1032 | } |
| 1020 | 1033 | // Only a green check of the default branch is kept for the next. |
| 1021 | 1034 | if ( |
| @@ -1251,6 +1264,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1251 | 1264 | signal: controller.signal, |
| 1252 | 1265 | }); |
| 1253 | 1266 | if (done.ok) return { ok: true, error: "", lines }; |
| 1267 | const stage = done.stage ?? "setup"; | |
| 1254 | 1268 | // Cut off by this forge stopping, the Job lost under it, or an |
| 1255 | 1269 | // install the machine failed twice: the code was not judged, so |
| 1256 | 1270 | // this is no red. |
| @@ -1260,8 +1274,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1260 | 1274 | done.error === JOB_VANISHED || |
| 1261 | 1275 | done.infrastructure === true |
| 1262 | 1276 | ) |
| 1263 | return { ok: false, error: done.error, lines, interrupted: true }; | |
| 1264 | return { ok: false, error: done.error, lines }; | |
| 1277 | return { ok: false, error: done.error, lines, interrupted: true, stage }; | |
| 1278 | return { ok: false, error: done.error, lines, stage }; | |
| 1265 | 1279 | } finally { |
| 1266 | 1280 | endGate(app.db, traced); |
| 1267 | 1281 | signal?.removeEventListener("abort", abandon); |
src/checks/tiers.ts +5
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | * only decides. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import type { GateReason } from "./gate_reason.ts"; | |
| 12 | 13 | import { GATE_FILE, needsFull, reach, reachLine, type Impact } from "./impact.ts"; |
| 13 | 14 | |
| 14 | 15 | /** A test under this many seconds is always in the fast tier. */ |
| @@ -250,6 +251,8 @@ export type Judged = | ||
| 250 | 251 | readonly comment: string; |
| 251 | 252 | /** Joins the pull's fast set. */ |
| 252 | 253 | readonly learn: readonly string[]; |
| 254 | /** Why the gate went red (#5276); a red read without one is the code's. */ | |
| 255 | readonly reason?: GateReason; | |
| 253 | 256 | }; |
| 254 | 257 | |
| 255 | 258 | /** |
| @@ -271,6 +274,7 @@ export function landVerdict( | ||
| 271 | 274 | failedTests: readonly string[], |
| 272 | 275 | failedFiles: readonly string[] = [], |
| 273 | 276 | logLink?: string, |
| 277 | reason?: GateReason, | |
| 274 | 278 | ): Judged { |
| 275 | 279 | if (green) return { lands: true }; |
| 276 | 280 | const names = failedTests.map((name) => `- ${name}`).join("\n"); |
| @@ -283,5 +287,6 @@ export function landVerdict( | ||
| 283 | 287 | ? `The full gate on land was red and named no test; the pull is open again.${kept}` |
| 284 | 288 | : `The full gate on land was red; the pull is open again. Red tests:\n${names}${kept}`, |
| 285 | 289 | learn: [...new Set(failedFiles)], |
| 290 | ...(reason === undefined ? {} : { reason }), | |
| 286 | 291 | }; |
| 287 | 292 | } |
test/check-tiers.test.ts +2
| @@ -575,6 +575,8 @@ describe("the tiers in the forge's own checks service", () => { | ||
| 575 | 575 | const pull = listPulls(app.db, repo.id, { state: "open", limit: 5 })[0]!; |
| 576 | 576 | const done = await checks.gate(repo, pull, red.value.commitOid); |
| 577 | 577 | expect(done.ok).toBe(false); |
| 578 | // Red in the check step, not before it: the queue searches it (#5276). | |
| 579 | expect(done.stage).toBe("check"); | |
| 578 | 580 | const failed = failuresOf(done.lines); |
| 579 | 581 | expect(failed).toEqual({ tests: ["boom"], files: ["test/x.test.ts"] }); |
| 580 | 582 | const verdict = landVerdict(false, failed.tests, failed.files); |
test/merge-queue-reason.test.ts added +166
| @@ -0,0 +1,166 @@ | ||
| 1 | /** | |
| 2 | * The merge queue says why a gate went red, and searches only a red that is | |
| 3 | * the code's (#5276). An install, setup or timeout red is gated once more, | |
| 4 | * then called infrastructure: no pull is handed back and nothing is | |
| 5 | * bisected. A pull that conflicts with an earlier one of its batch is left | |
| 6 | * out, named with it, and the rest land in that one gate. | |
| 7 | */ | |
| 8 | import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { checkStage, conflictWords, gateReason, reasonWords, searched } from "../src/checks/gate_reason.ts"; | |
| 13 | import { type Candidate, enqueue, type Expect, type Queued } from "../src/checks/queue.ts"; | |
| 14 | import { landVerdict, type LandVerdict } from "../src/checks/tiers.ts"; | |
| 15 | import type { Pull } from "../src/core/pulls.ts"; | |
| 16 | ||
| 17 | describe("gateReason reads why a gate went red", () => { | |
| 18 | test("each stage and what the check printed", () => { | |
| 19 | expect(gateReason("install", "install exited with code 1", [], 0)).toEqual({ kind: "install" }); | |
| 20 | expect(gateReason("setup", "the pull's base branch could not be found", [], 0)).toEqual({ kind: "setup" }); | |
| 21 | expect(gateReason("timeout", "", [], 0)).toEqual({ kind: "timeout" }); | |
| 22 | expect(gateReason("check", "check took longer than 7200s", [], 0)).toEqual({ kind: "timeout" }); | |
| 23 | expect(gateReason("check", "2 of 9 tests", ["(fail) a", "(fail) b"], 2)).toEqual({ kind: "tests", failed: 2 }); | |
| 24 | expect(gateReason("check", "x", ["src/a.ts(3,5): error TS2322: Type 'string' is not assignable"], 0)).toEqual({ | |
| 25 | kind: "typecheck", | |
| 26 | }); | |
| 27 | expect(gateReason("check", "check exited with code 1", ["lint: bad"], 0)).toEqual({ kind: "check" }); | |
| 28 | }); | |
| 29 | ||
| 30 | test("words for the log, and which reds are searched", () => { | |
| 31 | expect(reasonWords({ kind: "tests", failed: 3 })).toBe("tests: 3 failed"); | |
| 32 | expect(reasonWords({ kind: "install" })).toBe("install"); | |
| 33 | expect(reasonWords(undefined)).toBe("check"); | |
| 34 | expect(conflictWords(5088, 5090)).toBe("merge-conflict (#5088 × #5090)"); | |
| 35 | expect(conflictWords(undefined, 5090)).toBe("merge-conflict (main × #5090)"); | |
| 36 | for (const kind of ["tests", "typecheck", "check"] as const) | |
| 37 | expect(searched(kind === "tests" ? { kind, failed: 1 } : { kind })).toBe(true); | |
| 38 | for (const kind of ["install", "setup", "timeout"] as const) expect(searched({ kind })).toBe(false); | |
| 39 | expect(searched(undefined)).toBe(true); | |
| 40 | expect(checkStage("check took longer than 60s")).toBe("timeout"); | |
| 41 | expect(checkStage("check exited with code 1")).toBe("check"); | |
| 42 | }); | |
| 43 | ||
| 44 | test("the land verdict carries the reason", () => { | |
| 45 | expect(landVerdict(false, [], [], undefined, { kind: "install" })).toMatchObject({ reopen: true, reason: { kind: "install" } }); | |
| 46 | expect(landVerdict(false, [], [])).not.toHaveProperty("reason"); | |
| 47 | }); | |
| 48 | }); | |
| 49 | ||
| 50 | describe("the merge queue acts on why a gate went red", () => { | |
| 51 | let dir: string; | |
| 52 | const told: number[] = []; | |
| 53 | let logged: string[] = []; | |
| 54 | const spies: { mockRestore(): void }[] = []; | |
| 55 | ||
| 56 | const git = (...args: string[]) => | |
| 57 | Bun.spawnSync(["git", "-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args]).stdout.toString().trim(); | |
| 58 | ||
| 59 | beforeAll(async () => { | |
| 60 | dir = await mkdtemp(join(tmpdir(), "forge-merge-queue-reason-")); | |
| 61 | Bun.spawnSync(["git", "init", "-q", "-b", "main", dir]); | |
| 62 | git("commit", "-q", "--allow-empty", "-m", "start"); | |
| 63 | for (const level of ["info", "warn"] as const) | |
| 64 | spies.push(spyOn(console, level).mockImplementation((...args: unknown[]) => void logged.push(args.join(" ")))); | |
| 65 | }); | |
| 66 | ||
| 67 | afterEach(() => { | |
| 68 | logged = []; | |
| 69 | told.length = 0; | |
| 70 | }); | |
| 71 | ||
| 72 | afterAll(async () => { | |
| 73 | for (const s of spies) s.mockRestore(); | |
| 74 | await rm(dir, { recursive: true, force: true }); | |
| 75 | }); | |
| 76 | ||
| 77 | async function headWriting(branch: string, file: string, content = `${file}\n`): Promise<string> { | |
| 78 | git("checkout", "-q", "-B", branch, "main"); | |
| 79 | git("reset", "-q", "--hard", "main"); | |
| 80 | await Bun.write(join(dir, file), content); | |
| 81 | git("add", file); | |
| 82 | git("commit", "-q", "-m", file); | |
| 83 | git("checkout", "-q", "--detach"); | |
| 84 | return git("rev-parse", branch); | |
| 85 | } | |
| 86 | ||
| 87 | function entry(prefix: string, number: number, headOid: string): Queued { | |
| 88 | return { | |
| 89 | pull: { id: `${prefix}-${number}`, number, baseBranch: "main", headOid } as Pull, | |
| 90 | dir, | |
| 91 | land: async (expect: Expect) => { | |
| 92 | if (git("rev-parse", "refs/heads/main") !== expect.baseOid) return undefined; | |
| 93 | const tip = git("commit-tree", expect.treeOid, "-p", expect.baseOid, "-m", `#${number}`); | |
| 94 | git("update-ref", "refs/heads/main", tip); | |
| 95 | return tip; | |
| 96 | }, | |
| 97 | release: () => {}, | |
| 98 | handBack: () => told.push(number), | |
| 99 | }; | |
| 100 | } | |
| 101 | ||
| 102 | const pullsOf = (c: Candidate) => c.steps.map((s) => s.entry.pull.number); | |
| 103 | const red = (reason: NonNullable<Extract<LandVerdict, { reopen: true }>["reason"]>): LandVerdict => ({ | |
| 104 | lands: false, | |
| 105 | reopen: true, | |
| 106 | comment: "red", | |
| 107 | learn: [], | |
| 108 | reason, | |
| 109 | }); | |
| 110 | ||
| 111 | test("an install red twice is infrastructure: gated twice, never bisected, no pull handed back", async () => { | |
| 112 | const pulls: Queued[] = []; | |
| 113 | for (let i = 1; i <= 4; i++) pulls.push(entry("install", i, await headWriting(`i${i}`, `i${i}.txt`))); | |
| 114 | const gates: number[][] = []; | |
| 115 | const gate = async (c: Candidate) => { | |
| 116 | gates.push(pullsOf(c)); | |
| 117 | return red({ kind: "install" }); | |
| 118 | }; | |
| 119 | const answers = await enqueue("reason-install", pulls, gate, { depth: 1, room: () => 3 }); | |
| 120 | expect(gates).toEqual([[1, 2, 3, 4], [1, 2, 3, 4]]); | |
| 121 | expect(told).toEqual([]); | |
| 122 | expect(answers).toEqual([false, false, false, false]); | |
| 123 | expect(logged.some((l) => l.includes("gated 4 pulls: red (install)"))).toBe(true); | |
| 124 | expect(logged.some((l) => l.includes("gated 4 pulls: infrastructure (install, twice)"))).toBe(true); | |
| 125 | }); | |
| 126 | ||
| 127 | test("an install red once, then green: the batch lands whole on the second gate", async () => { | |
| 128 | const pulls: Queued[] = []; | |
| 129 | for (let i = 11; i <= 13; i++) pulls.push(entry("flake", i, await headWriting(`j${i}`, `j${i}.txt`))); | |
| 130 | let n = 0; | |
| 131 | const gate = async (): Promise<LandVerdict> => (n++ === 0 ? red({ kind: "install" }) : { lands: true }); | |
| 132 | const answers = await enqueue("reason-flake", pulls, gate, { depth: 1, room: () => 0 }); | |
| 133 | expect(n).toBe(2); | |
| 134 | expect(answers).toEqual([true, true, true]); | |
| 135 | expect(told).toEqual([]); | |
| 136 | }); | |
| 137 | ||
| 138 | test("a test red says how many failed and is still searched for its culprit", async () => { | |
| 139 | const pulls: Queued[] = []; | |
| 140 | for (let i = 21; i <= 22; i++) pulls.push(entry("tests", i, await headWriting(`k${i}`, i === 22 ? "bad.txt" : `k${i}.txt`))); | |
| 141 | const gate = async (c: Candidate): Promise<LandVerdict> => | |
| 142 | git("ls-tree", "--name-only", c.tipOid).split("\n").includes("bad.txt") ? red({ kind: "tests", failed: 2 }) : { lands: true }; | |
| 143 | const answers = await enqueue("reason-tests", pulls, gate, { depth: 1, room: () => 0 }); | |
| 144 | expect(answers).toEqual([true, false]); | |
| 145 | expect(told).toEqual([22]); | |
| 146 | expect(logged.some((l) => l.includes("gated 2 pulls: red (tests: 2 failed)"))).toBe(true); | |
| 147 | }); | |
| 148 | ||
| 149 | test("#b conflicting with #a is left out and named; everything else lands in one gate", async () => { | |
| 150 | const a = entry("clash", 31, await headWriting("c31", "same.txt", "one\n")); | |
| 151 | const x = entry("clash", 32, await headWriting("c32", "c32.txt")); | |
| 152 | const b = entry("clash", 33, await headWriting("c33", "same.txt", "two\n")); | |
| 153 | const y = entry("clash", 34, await headWriting("c34", "c34.txt")); | |
| 154 | const gates: number[][] = []; | |
| 155 | const gate = async (c: Candidate): Promise<LandVerdict> => { | |
| 156 | gates.push(pullsOf(c)); | |
| 157 | return { lands: true }; | |
| 158 | }; | |
| 159 | const answers = await enqueue("reason-clash", [a, x, b, y], gate, { depth: 1, room: () => 3 }); | |
| 160 | expect(gates).toEqual([[31, 32, 34]]); | |
| 161 | expect(answers).toEqual([true, true, false, true]); | |
| 162 | expect(told).toEqual([]); | |
| 163 | expect(logged.some((l) => l.includes("left out of the batch: merge-conflict (#31 × #33)"))).toBe(true); | |
| 164 | expect(logged.some((l) => l.includes(": red"))).toBe(false); | |
| 165 | }); | |
| 166 | }); |
new above
offline