Public: anyone can read this; the crew can change it.
Shards: file an issue for a test that failed only beside others #2481
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2480-shards-file-an-issue-for-a-test-that-fai → main 3 commits fork 4 threads · 4 unresolved
README.md +2
| @@ -1339,6 +1339,8 @@ In the forge itself: a pull's check runs `fast` when the manifest has one, in a | ||
| 1339 | 1339 | |
| 1340 | 1340 | 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. |
| 1341 | 1341 | |
| 1342 | A test file the shard gate clears because it passed alone is still a failure: when the default branch's own check log says `fails only beside others: <files> (shard-mates: <files>)`, the runner opens one issue per file (at most 5 per run, the rest named in the log as not filed) or comments on the one already open. The issue is authored as the repository owner and its body says it was filed by the check runner. A pull or other branch's check files nothing. | |
| 1343 | ||
| 1342 | 1344 | ## Flags |
| 1343 | 1345 | |
| 1344 | 1346 | A forge admin sees every declared flag at `/settings/flags` (Settings → Flags): why it exists, its default, the owner issue and its live value. Set it for the whole forge, for listed user ids or roles, or for a percentage of people; Clear returns it to the default. A change is validated, kept in the change record and live on the next request, with no restart. In the terminal the same admin opens the command palette (`ctrl+k`), picks Flags, and presses enter to set a flag from a line such as `forge=on users=u1,u2 roles=admin percent=30` or `x` to clear it; an agent is never an admin there either. |
src/checks/order_dependent.ts added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | /** | |
| 2 | * A test file that failed in a sliced shard and passed alone is cleared by | |
| 3 | * the gate (`scripts/shards.ts`), which says so in the check log: `fails only | |
| 4 | * beside others: <files> (shard-mates: <files>)`. Cleared is not fixed (#2480): | |
| 5 | * an order-dependent failure must be tracked. The check cell cannot reach the | |
| 6 | * forge API, so the runner that holds the check's output files it: one open | |
| 7 | * issue per file, a comment on it when the file shows up again. | |
| 8 | * | |
| 9 | * Plain data in (the lines), the issue and the comment out. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Database } from "bun:sqlite"; | |
| 13 | import { addComment, createIssue } from "../core/issues.ts"; | |
| 14 | import type { Repo } from "../core/repos.ts"; | |
| 15 | import type { EventBus } from "../core/events.ts"; | |
| 16 | ||
| 17 | export interface OrderDependent { | |
| 18 | readonly file: string; | |
| 19 | readonly mates: readonly string[]; | |
| 20 | } | |
| 21 | ||
| 22 | const LINE = /fails only beside others: (.+?) \(shard-mates: (.*)\)\s*$/; | |
| 23 | ||
| 24 | /** A file name is a repository path; anything else in the log is not filed. */ | |
| 25 | const PATH = /^[\w@./+-]{1,200}$/; | |
| 26 | ||
| 27 | /** The log is the branch's own output: bound what one run may file. */ | |
| 28 | export const MAX_FILED = 5; | |
| 29 | const MAX_NAMES = 50; | |
| 30 | const MAX_LINE = 8_000; | |
| 31 | ||
| 32 | const names = (list: string): string[] => | |
| 33 | list.split(",", MAX_NAMES).map((s) => s.trim()).filter((s) => PATH.test(s)); | |
| 34 | ||
| 35 | const MAX_SEEN = 500; | |
| 36 | ||
| 37 | /** Every file the log's verdict lines cleared, once each, bounded by {@link MAX_SEEN}. */ | |
| 38 | export function clearedFiles(lines: readonly string[]): OrderDependent[] { | |
| 39 | const seen = new Map<string, OrderDependent>(); | |
| 40 | for (const line of lines) { | |
| 41 | if (seen.size >= MAX_SEEN) break; | |
| 42 | const m = LINE.exec(line.slice(0, MAX_LINE)); | |
| 43 | if (m === null) continue; | |
| 44 | const mates = m[2] === "none" ? [] : names(m[2] ?? ""); | |
| 45 | for (const file of names(m[1] ?? "")) { | |
| 46 | if (seen.size < MAX_SEEN && !seen.has(file)) seen.set(file, { file, mates }); | |
| 47 | } | |
| 48 | } | |
| 49 | return [...seen.values()]; | |
| 50 | } | |
| 51 | ||
| 52 | /** The files to file: the first {@link MAX_FILED} cleared. */ | |
| 53 | export const orderDependent = (lines: readonly string[]): OrderDependent[] => | |
| 54 | clearedFiles(lines).slice(0, MAX_FILED); | |
| 55 | ||
| 56 | /** The machine says so: the author is the owner, the text is not theirs. */ | |
| 57 | const FILED_BY = "_Filed by the check runner from a check log, not written by a person._"; | |
| 58 | ||
| 59 | export const titleOf = (file: string): string => `Test fails only beside others: ${file}`; | |
| 60 | ||
| 61 | /** | |
| 62 | * Open an issue for each cleared file, or comment on the one already open. | |
| 63 | * Returns the issue numbers touched. Never throws into the check. | |
| 64 | */ | |
| 65 | export function fileOrderDependent( | |
| 66 | db: Database, | |
| 67 | bus: EventBus, | |
| 68 | repo: Pick<Repo, "id" | "ownerId">, | |
| 69 | found: readonly OrderDependent[], | |
| 70 | ref: { runId: string; commit: string }, | |
| 71 | ): number[] { | |
| 72 | const touched: number[] = []; | |
| 73 | for (const { file, mates } of found) { | |
| 74 | const title = titleOf(file); | |
| 75 | const evidence = | |
| 76 | `${FILED_BY}\n\nCheck run ${ref.runId} (${ref.commit.slice(0, 7)}): \`${file}\` failed in its shard and passed when run alone.\n\n` + | |
| 77 | `Shard-mates: ${mates.length === 0 ? "none" : mates.map((m) => `\`${m}\``).join(", ")}`; | |
| 78 | const open = db | |
| 79 | .query<{ id: string; number: number }, [string, string]>( | |
| 80 | "SELECT id, number FROM issue WHERE repo_id = ?1 AND title = ?2 AND state = 'open' LIMIT 1", | |
| 81 | ) | |
| 82 | .get(repo.id, title); | |
| 83 | if (open !== null) { | |
| 84 | if (addComment(db, { issueId: open.id, authorId: repo.ownerId, body: evidence }).ok) | |
| 85 | touched.push(open.number); | |
| 86 | continue; | |
| 87 | } | |
| 88 | const made = createIssue(db, { | |
A new comment per main run adds noise on a persistent flake; skip when the last comment already lists the same shard-mates. |
||
| 89 | repoId: repo.id, | |
| 90 | authorId: repo.ownerId, | |
| 91 | title, | |
| 92 | body: | |
| 93 | `The gate cleared this test file because it passed alone, so the check stayed green. ` + | |
| 94 | `An order-dependent failure is still a failure: find what it shares with its shard-mates.\n\n${evidence}`, | |
| 95 | }); | |
| 96 | if (!made.ok) continue; | |
| 97 | bus.append({ | |
| 98 | kind: "issue.opened", | |
| 99 | repoId: repo.id, | |
| 100 | actorId: repo.ownerId, | |
| 101 | payload: { number: made.value.number }, | |
| 102 | }); | |
| 103 | touched.push(made.value.number); | |
| 104 | } | |
| 105 | return touched; | |
| 106 | } |
src/checks/service.ts +26
| @@ -48,6 +48,7 @@ import { checkKey, provenBy, recordProven } from "./proven.ts"; | ||
| 48 | 48 | import { giveHistory } from "./history.ts"; |
| 49 | 49 | import { readLearned } from "./learned.ts"; |
| 50 | 50 | import { keepCache, restoreCache } from "./cache.ts"; |
| 51 | import { MAX_FILED, clearedFiles, fileOrderDependent } from "./order_dependent.ts"; | |
| 51 | 52 | import { ASKED, redMain, unheld } from "./red_main.ts"; |
| 52 | 53 | import { IMPACT_FILE, keepImpact, seedImpact } from "./impact-store.ts"; |
| 53 | 54 | import { getIssueById } from "../core/issues.ts"; |
| @@ -582,6 +583,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 582 | 583 | tier: pull === undefined ? "run" : "fast", |
| 583 | 584 | keep: run.issueId === null && run.env === repo.defaultBranch, |
| 584 | 585 | keepImpact: pull === undefined && run.env === repo.defaultBranch, |
| 586 | fileOrderDependent: run.issueId === null && run.env === repo.defaultBranch, | |
| 585 | 587 | write, |
| 586 | 588 | signal, |
| 587 | 589 | }); |
| @@ -602,6 +604,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 602 | 604 | readonly keep?: boolean; |
| 603 | 605 | /** A green run keeps the impact map it measured; `full` always does. */ |
| 604 | 606 | readonly keepImpact?: boolean; |
| 607 | /** The default branch's own run: only its log may open issues. */ | |
| 608 | readonly fileOrderDependent?: boolean; | |
| 605 | 609 | readonly write: (line: string) => void; |
| 606 | 610 | readonly signal: AbortSignal; |
| 607 | 611 | } |
| @@ -857,6 +861,28 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 857 | 861 | }); |
| 858 | 862 | } |
| 859 | 863 | if (!ran.ok && ran.error === JOB_VANISHED) return { ok: false, error: JOB_VANISHED }; |
| 864 | // A file the gate cleared because it passed alone is still a failure | |
outdated
Gate this on a default-branch run (run.env === repo.defaultBranch, no pull). As written, a pull branch controls the log and so controls issue creation as the owner. |
||
| 865 | // to track: the cell cannot reach the forge, so it is filed from here. | |
| 866 | try { | |
| 867 | // Only the default branch's own run files: a pull branch controls | |
| 868 | // its check's output, and the issues are authored as the owner. | |
| 869 | const all = | |
| 870 | x.fileOrderDependent === true | |
| 871 | ? clearedFiles(checkLines) | |
Simplify: guard with if (x.fileOrderDependent === true) and call one helper that returns {filed, rest}; orderDependent() in order_dependent.ts is otherwise unused outside tests. |
||
| 872 | : []; | |
| 873 | const cleared = all.slice(0, MAX_FILED); | |
| 874 | if (cleared.length > 0) { | |
| 875 | fileOrderDependent(app.db, app.bus, repo, cleared, { | |
| 876 | runId: run.id, | |
| 877 | commit: run.commitOid, | |
| 878 | }); | |
| 879 | write(`filed: ${cleared.length} test file(s) that fail only beside others`); | |
| 880 | if (all.length > cleared.length) | |
| 881 | write(`not filed: ${all.length - cleared.length} more (at most ${MAX_FILED} per run)`); | |
| 882 | } | |
| 883 | } catch (error) { | |
| 884 | write(`order-dependent tests not filed: ${String(error)}`); | |
| 885 | } | |
| 860 | 886 | if (!ran.ok) { |
| 861 | 887 | const { summary, detail } = summariseCheckFailure( |
| 862 | 888 | checkLines, |
test/check-order-dependent.test.ts added +156
| @@ -0,0 +1,156 @@ | ||
| 1 | import { afterAll, beforeAll, 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 { createRepo } from "../src/actions/repos.ts"; | |
| 6 | import { openPull } from "../src/actions/pulls.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; | |
| 9 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 10 | import { listRuns } from "../src/core/runs.ts"; | |
| 11 | import { MAX_FILED, fileOrderDependent, orderDependent } from "../src/checks/order_dependent.ts"; | |
| 12 | import { createIssue, listComments, setIssueState } from "../src/core/issues.ts"; | |
| 13 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 14 | import { readRunLog } from "../src/runs/log.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | const line = "fails only beside others: test/a.test.ts, test/b.test.ts (shard-mates: test/c.test.ts)"; | |
| 20 | ||
| 21 | test("the verdict line names each cleared file and its shard-mates", () => { | |
| 22 | expect(orderDependent(["noise", line])).toEqual([ | |
| 23 | { file: "test/a.test.ts", mates: ["test/c.test.ts"] }, | |
| 24 | { file: "test/b.test.ts", mates: ["test/c.test.ts"] }, | |
| 25 | ]); | |
| 26 | expect(orderDependent(["fails only beside others: x.ts (shard-mates: none)"])[0]?.mates).toEqual([]); | |
| 27 | expect(orderDependent(["all green"])).toEqual([]); | |
| 28 | expect(orderDependent(["fails only beside others: $(rm) (shard-mates: none)"])).toEqual([]); | |
| 29 | }); | |
| 30 | ||
| 31 | let dir: string; | |
| 32 | let app: App; | |
| 33 | beforeAll(async () => { | |
| 34 | dir = await mkdtemp(join(tmpdir(), "od-")); | |
| 35 | app = createApp({ dataDir: dir, port: 0, host: "forge.test" }); | |
| 36 | }); | |
| 37 | afterAll(async () => { | |
| 38 | await rm(dir, { recursive: true, force: true }); | |
| 39 | }); | |
| 40 | ||
| 41 | test("a cleared file opens one issue, and its next appearance comments on it", async () => { | |
| 42 | const u = await createUser(app.db, { trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 43 | if (!u.ok) throw new Error(u.error); | |
| 44 | const made = await createRepo(app, u.value, { name: "r", visibility: "public" }); | |
| 45 | if (!made.ok) throw new Error("repo"); | |
| 46 | const repo = made.value; | |
| 47 | const found = orderDependent([line]); | |
| 48 | const ref = { runId: "run1", commit: "abcdef0123" }; | |
| 49 | expect(fileOrderDependent(app.db, app.bus, repo, found, ref).length).toBe(2); | |
| 50 | expect(fileOrderDependent(app.db, app.bus, repo, found, { ...ref, runId: "run2" }).length).toBe(2); | |
| 51 | const list = app.db.query("SELECT id, title, body FROM issue WHERE repo_id = ?1 ORDER BY number").all(repo.id); | |
| 52 | expect(list.length).toBe(2); | |
| 53 | const first = list[0] as { id: string; title: string; body: string }; | |
| 54 | expect(first.title).toContain("fails only beside others"); | |
| 55 | expect(first.body).toContain("test/c.test.ts"); | |
| 56 | expect(listComments(app.db, first.id).length).toBe(1); | |
| 57 | }); | |
| 58 | ||
| 59 | test("one printed line cannot file more than the cap", () => { | |
| 60 | const many = Array.from({ length: 300 }, (_, i) => `t/f${i}.test.ts`).join(", "); | |
| 61 | const found = orderDependent([`fails only beside others: ${many} (shard-mates: none)`]); | |
| 62 | expect(found.length).toBe(MAX_FILED); | |
| 63 | const lines = Array.from({ length: 40 }, (_, i) => `fails only beside others: g${i}.ts (shard-mates: none)`); | |
| 64 | expect(orderDependent(lines).length).toBe(MAX_FILED); | |
| 65 | }); | |
| 66 | ||
| 67 | test("a check run whose output has the verdict line files the issue; a closed one is not commented on", async () => { | |
| 68 | process.env["FORGE_CELLS"] = "off"; | |
| 69 | const checks: ChecksHandle = startChecks(app, { forgeUrl: "http://forge.test", concurrency: 1 }); | |
| 70 | try { | |
| 71 | const u = { value: getUserByName(app.db, "alice")! }; | |
| 72 | const made = await createRepo(app, u.value, { name: "wired", visibility: "public" }); | |
| 73 | if (!made.ok) throw new Error(String(made.error)); | |
| 74 | const repo = made.value; | |
| 75 | const file = "t/closed.test.ts"; | |
| 76 | // An earlier, closed issue for the file. | |
| 77 | const old = createIssue(app.db, { repoId: repo.id, authorId: u.value.id, title: `Test fails only beside others: ${file}` }); | |
| 78 | if (!old.ok) throw new Error(old.error); | |
| 79 | setIssueState(app.db, old.value.id, "closed"); | |
| 80 | const pushed = await commitToBranch(app, u.value, repo, { | |
| 81 | message: "check with a verdict line", | |
| 82 | files: [{ | |
| 83 | path: "forge.json", | |
| 84 | content: JSON.stringify({ check: { run: `echo 'fails only beside others: ${file}, t/w.test.ts (shard-mates: t/m.test.ts)'`, install: "" } }), | |
| 85 | }], | |
| 86 | }); | |
| 87 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); | |
| 88 | const oid = pushed.value.commitOid; | |
| 89 | const deadline = Date.now() + 30_000; | |
| 90 | while ( | |
| 91 | Date.now() < deadline && | |
| 92 | !listRuns(app.db, repo.id, { kind: "check", limit: 50 }).some((r) => r.commitOid === oid && r.state === "ok") | |
| 93 | ) await Bun.sleep(100); | |
| 94 | const rows = app.db | |
| 95 | .query<{ id: string; title: string; state: string; body: string }, [string]>( | |
| 96 | "SELECT id, title, state, body FROM issue WHERE repo_id = ?1 ORDER BY number") | |
| 97 | .all(repo.id); | |
| 98 | expect(rows.map((r) => [r.title, r.state])).toEqual([ | |
| 99 | [`Test fails only beside others: ${file}`, "closed"], | |
| 100 | [`Test fails only beside others: ${file}`, "open"], | |
| 101 | ["Test fails only beside others: t/w.test.ts", "open"], | |
| 102 | ]); | |
| 103 | expect(rows[1]!.body).toContain("t/m.test.ts"); | |
| 104 | expect(listComments(app.db, rows[0]!.id).length).toBe(0); | |
| 105 | } finally { | |
| 106 | await checks.stop(); | |
| 107 | } | |
| 108 | }); | |
| 109 | ||
| 110 | test("a branch's check log files nothing; the default branch's files at most the cap and says what it left", async () => { | |
| 111 | process.env["FORGE_CELLS"] = "off"; | |
| 112 | const checks: ChecksHandle = startChecks(app, { forgeUrl: "http://forge.test", concurrency: 1 }); | |
| 113 | try { | |
| 114 | const u = getUserByName(app.db, "alice")!; | |
| 115 | const made = await createRepo(app, u, { name: "gated", visibility: "public" }); | |
| 116 | if (!made.ok) throw new Error(String(made.error)); | |
| 117 | const repo = made.value; | |
| 118 | const files = Array.from({ length: MAX_FILED + 2 }, (_, i) => `t/g${i}.test.ts`).join(", "); | |
| 119 | const manifest = JSON.stringify({ | |
| 120 | check: { run: `echo 'fails only beside others: ${files} (shard-mates: none)'`, install: "" }, | |
| 121 | }); | |
| 122 | const waitOk = async (oid: string) => { | |
| 123 | const deadline = Date.now() + 30_000; | |
| 124 | while ( | |
| 125 | Date.now() < deadline && | |
| 126 | !listRuns(app.db, repo.id, { kind: "check", limit: 50 }).some((r) => r.commitOid === oid && r.state === "ok") | |
| 127 | ) await Bun.sleep(100); | |
| 128 | }; | |
| 129 | const count = () => app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM issue WHERE repo_id = ?1 AND kind = 'issue'").get(repo.id)!.n; | |
| 130 | const seed = await commitToBranch(app, u, repo, { message: "seed", files: [{ path: "a.txt", content: "a\n" }] }); | |
| 131 | if (!seed.ok) throw new Error(JSON.stringify(seed.error)); | |
| 132 | const branch = await commitToBranch(app, u, repo, { | |
| 133 | branch: "feature", message: "branch verdict", files: [{ path: "forge.json", content: manifest }], | |
| 134 | }); | |
| 135 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 136 | const pull = await openPull(app, u, repo, { title: "Verdict", baseBranch: "main", headBranch: "feature" }); | |
| 137 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 138 | const pullRuns = () => listRuns(app.db, repo.id, { kind: "check", limit: 50 }).filter((r) => r.issueId !== null); | |
| 139 | const deadline = Date.now() + 30_000; | |
| 140 | while (Date.now() < deadline && !pullRuns().some((r) => r.state === "ok")) await Bun.sleep(100); | |
| 141 | expect(pullRuns().some((r) => r.state === "ok")).toBe(true); | |
| 142 | expect(count()).toBe(0); | |
| 143 | const main = await commitToBranch(app, u, repo, { | |
| 144 | message: "main verdict", files: [{ path: "forge.json", content: manifest.replace("echo", "echo main;echo") }], | |
| 145 | }); | |
| 146 | if (!main.ok) throw new Error(JSON.stringify(main.error)); | |
| 147 | await waitOk(main.value.commitOid); | |
| 148 | expect(count()).toBe(MAX_FILED); | |
| 149 | const body = app.db.query<{ body: string }, [string]>("SELECT body FROM issue WHERE repo_id = ?1 AND kind = 'issue' LIMIT 1").get(repo.id)!.body; | |
| 150 | expect(body).toContain("Filed by the check runner"); | |
| 151 | const run = listRuns(app.db, repo.id, { kind: "check", limit: 50 }).find((r) => r.commitOid === main.value.commitOid)!; | |
| 152 | expect(readRunLog(app.config.dataDir, run.id).lines.join("\n")).toContain("not filed: 2 more (at most 5 per run)"); | |
| 153 | } finally { | |
| 154 | await checks.stop(); | |
| 155 | } | |
| 156 | }, 40_000); |
authorId is the repo owner, so machine-written text appears as theirs. Use a distinct identity or label the body.