Public: anyone can read this; the crew can change it.

Shards: file an issue for a test that failed only beside others #2481

Merged opened this

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

Merged

scout/forge:scout/2480-shards-file-an-issue-for-a-test-that-fai → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/2480-shards-file-an-issue-for-a-test-that-fai was merged into main as 427a46e.

Review

Sign in to review this change.
4 files changed +290 −0
README.md +2
@@ -1339,6 +1339,8 @@ In the forge itself: a pull's check runs `fast` when the manifest has one, in a
13391339
13401340 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.
13411341
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
13421344 ## Flags
13431345
13441346 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
outdated

authorId is the repo owner, so machine-written text appears as theirs. Use a distinct identity or label the body.

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";
4848 import { giveHistory } from "./history.ts";
4949 import { readLearned } from "./learned.ts";
5050 import { keepCache, restoreCache } from "./cache.ts";
51 import { MAX_FILED, clearedFiles, fileOrderDependent } from "./order_dependent.ts";
5152 import { ASKED, redMain, unheld } from "./red_main.ts";
5253 import { IMPACT_FILE, keepImpact, seedImpact } from "./impact-store.ts";
5354 import { getIssueById } from "../core/issues.ts";
@@ -582,6 +583,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
582583 tier: pull === undefined ? "run" : "fast",
583584 keep: run.issueId === null && run.env === repo.defaultBranch,
584585 keepImpact: pull === undefined && run.env === repo.defaultBranch,
586 fileOrderDependent: run.issueId === null && run.env === repo.defaultBranch,
585587 write,
586588 signal,
587589 });
@@ -602,6 +604,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
602604 readonly keep?: boolean;
603605 /** A green run keeps the impact map it measured; `full` always does. */
604606 readonly keepImpact?: boolean;
607 /** The default branch's own run: only its log may open issues. */
608 readonly fileOrderDependent?: boolean;
605609 readonly write: (line: string) => void;
606610 readonly signal: AbortSignal;
607611 }
@@ -857,6 +861,28 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
857861 });
858862 }
859863 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 }
860886 if (!ran.ok) {
861887 const { summary, detail } = summariseCheckFailure(
862888 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);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs