Public: anyone can read this; the crew can change it.
Rule watcher: gate-only reds, rule breakers with exact fixes, flaky and load-sensitive budget tests #11260
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7560-rule-watcher-gate-only-reds-rule-breaker → main 7 commits fork 3 threads · 3 unresolved
24 files
changed +921
−12
docs/features/rule-watcher.md added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | # The rule watcher (#7560) | |
| 2 | ||
| 3 | The maintenance duty `rule-watcher` (`src/maintain/duties/rule_watcher.ts`) runs every 15 minutes and reads the check record. Each read is idempotent: one open issue per title, one comment per text. | |
| 4 | ||
| 5 | - **Gate-only reds** (`src/rules/gate_only.ts`): a test red in a merge-queue gate whose pulls were all green on their own latest check. Two such gates in a week file one issue ("Gate-only red: …") naming the test, the gates, and whether its file is in the pull check's rule set (`RULE_TESTS`, the #7138 class). One files none. | |
| 6 | - **Rule breakers** (`src/rules/breaker.ts`, table in `src/rules/table.ts`): an open pull whose latest check is red on a rule test gets one comment with the rule, `file:line` and the fix. The same text is never posted twice; a red outside the table gets no comment. A new rule is one entry in `RULES`. | |
| 7 | - **Flake trend** (`src/rules/flake_trend.ts`): once a week, the week's `check_flake` list ordered by the gate minutes each flake delayed; the worst three get an issue (the same `Flaky test:` title the check files, so it is not doubled). Quarantine is not allowed. | |
| 8 | - **Budget headroom** (`src/rules/budget_rerun.ts`): a test whose name says it holds a speed, re-run green three times in a week, files "Budget headroom: …" with the p95 of its failed attempts (`check_flake.ms`, #7354). | |
| 9 | ||
| 10 | Each run's findings and probes (each probe names the real query, to run again) are on `/maintenance` and, in the terminal, the "maintenance duty runs" screen (`maintenance-runs`, `src/tui/screens/maintainruns.ts`), which lists every duty including this one. The flake trend is said once a week, on the first run of the week, or when it files an issue. | |
| 11 | ||
| 12 | Rule breaker comments are posted by the `rule-watcher` agent account (`src/rules/bot.ts`), never as the repository's owner. Issues the watcher files still go through the shared `fileIssueOnce`, authored by the owner as the check's own flake issues are. |
src/checks/flake.ts +21 −5
| @@ -36,8 +36,20 @@ const NAME_MAX = 300; | ||
| 36 | 36 | export interface Flaky { |
| 37 | 37 | readonly file: string; |
| 38 | 38 | readonly name: string; |
| 39 | /** How long the failed attempt took, when the log said (#7560: the budget watcher's p95). */ | |
| 40 | readonly ms?: number; | |
| 39 | 41 | } |
| 40 | 42 | |
| 43 | const FAIL_TIME = /^\(fail\) .+? \[([0-9.]+)(m?s)\]/; | |
| 44 | /** The milliseconds a `(fail) name [1.2ms]` line says, or undefined. */ | |
| 45 | export function failMs(line: string): number | undefined { | |
| 46 | const m = FAIL_TIME.exec(line); | |
| 47 | if (m === null) return undefined; | |
| 48 | const n = Number(m[1]) * (m[2] === "s" ? 1000 : 1); | |
| 49 | return Number.isFinite(n) ? Math.round(n) : undefined; | |
| 50 | } | |
| 51 | const withMs = (t: Flaky, ms: number | undefined): Flaky => (ms === undefined ? t : { ...t, ms }); | |
| 52 | ||
| 41 | 53 | /** |
| 42 | 54 | * The tests a failed check can re-run alone: at most {@link MAX_RERUN} |
| 43 | 55 | * failures, every one named with the file whose report printed it. Anything |
| @@ -50,6 +62,7 @@ export function rerunnable( | ||
| 50 | 62 | if (detail === undefined || detail.failed < 1 || detail.failed > MAX_RERUN) return undefined; |
| 51 | 63 | if (detail.names.length !== detail.failed) return undefined; |
| 52 | 64 | const found = new Map<string, string>(); |
| 65 | const took = new Map<string, number>(); | |
| 53 | 66 | let file: string | undefined; |
| 54 | 67 | for (const line of lines) { |
| 55 | 68 | const header = HEADER.exec(line); |
| @@ -58,8 +71,11 @@ export function rerunnable( | ||
| 58 | 71 | continue; |
| 59 | 72 | } |
| 60 | 73 | const name = FAIL.exec(line)?.[1]; |
| 61 | if (name !== undefined && detail.names.includes(name) && !found.has(name) && file !== undefined) | |
| 74 | if (name !== undefined && detail.names.includes(name) && !found.has(name) && file !== undefined) { | |
| 62 | 75 | found.set(name, file); |
| 76 | const ms = failMs(line); | |
| 77 | if (ms !== undefined) took.set(name, ms); | |
| 78 | } | |
| 63 | 79 | } |
| 64 | 80 | const out: Flaky[] = []; |
| 65 | 81 | for (const name of detail.names) { |
| @@ -73,7 +89,7 @@ export function rerunnable( | ||
| 73 | 89 | name.includes("(timeout)") |
| 74 | 90 | ) |
| 75 | 91 | return undefined; |
| 76 | out.push({ file: at, name: name }); | |
| 92 | out.push(withMs({ file: at, name: name }, took.get(name))); | |
| 77 | 93 | } |
| 78 | 94 | return out; |
| 79 | 95 | } |
| @@ -232,8 +248,8 @@ export function recordFlakes( | ||
| 232 | 248 | const opened: number[] = []; |
| 233 | 249 | for (const t of tests) { |
| 234 | 250 | db.query( |
| 235 | "INSERT INTO check_flake (id, repo_id, run_id, commit_oid, test, file, at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", | |
| 236 | ).run(id(), repo.id, ref.runId, ref.commit, t.name, t.file, new Date(now).toISOString()); | |
| 251 | "INSERT INTO check_flake (id, repo_id, run_id, commit_oid, test, file, at, ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", | |
| 252 | ).run(id(), repo.id, ref.runId, ref.commit, t.name, t.file, new Date(now).toISOString(), t.ms ?? null); | |
| 237 | 253 | const since = new Date(now - WINDOW_MS).toISOString(); |
| 238 | 254 | const count = db |
| 239 | 255 | .query<{ n: number }, [string, string, string]>( |
| @@ -307,7 +323,7 @@ export function failedTests(lines: readonly string[]): Flaky[] { | ||
| 307 | 323 | if (name === undefined || file === undefined || !PATH.test(file)) continue; |
| 308 | 324 | if (name.length > NAME_MAX || CONTROL.test(name) || seen.has(`${file}\0${name}`)) continue; |
| 309 | 325 | seen.add(`${file}\0${name}`); |
| 310 | out.push({ file, name }); | |
| 326 | out.push(withMs({ file, name }, failMs(line))); | |
| 311 | 327 | } |
| 312 | 328 | return out; |
| 313 | 329 | } |
src/core/migration-pins/20261111120000-check-flake-ms.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 98a3fb7e1e4b46c3f12fa4dc4798741657a2b6169bfdeba7a67164950313e979 |
src/core/migrations/20261111120000-check-flake-ms.ts added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | // How long the failed attempt of a flaked test took (#7560): the rule watcher | |
| 2 | // reads the p95 of a budget test's attempts to say how much headroom it lacks. | |
| 3 | // Null for rows written before this column. Additive. | |
| 4 | export default ` | |
| 5 | ALTER TABLE check_flake ADD COLUMN ms INTEGER; | |
| 6 | `; |
src/maintain/duties/rule_watcher.ts added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | /** | |
| 2 | * The rule watcher (#7560): four reads of the check record, once an interval. | |
| 3 | * The reads live in `src/rules/watch.ts`; this file only calls them in turn. | |
| 4 | * Findings and actions are on `/maintenance` and the terminal's "maintenance | |
| 5 | * duty runs" screen (`src/tui/screens/maintainruns.ts`), as for every duty. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import type { Repo } from "../../core/repos.ts"; | |
| 10 | import { breakers, budgets, flakeWeek, gateOnly, type Watch } from "../../rules/watch.ts"; | |
| 11 | import type { Duty } from "../duty.ts"; | |
| 12 | import { lastStarted } from "../runs.ts"; | |
| 13 | ||
| 14 | function reposOf(db: Database): Map<string, Pick<Repo, "id" | "ownerId">> { | |
| 15 | const rows = db.query<{ id: string; owner_id: string }, []>("SELECT id, owner_id FROM repo").all(); | |
| 16 | return new Map(rows.map((r) => [r.id, { id: r.id, ownerId: r.owner_id }])); | |
| 17 | } | |
| 18 | ||
| 19 | export const duty: Duty = { | |
| 20 | name: "rule-watcher", | |
| 21 | intervalMs: 15 * 60_000, | |
| 22 | run({ db, bus, now }) { | |
| 23 | const w: Watch = { db, bus, repos: reposOf(db), now }; | |
| 24 | // The runner records this run after it returns, so the last start on record is the previous run's. | |
| 25 | const reads = [breakers(w), gateOnly(w), flakeWeek(w, lastStarted(db, "rule-watcher")), budgets(w)]; | |
| 26 | return { findings: reads.flatMap((r) => r.findings), actions: reads.flatMap((r) => r.actions) }; | |
| 27 | }, | |
| 28 | }; |
src/maintain/duty.ts +3
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | /** What a maintenance duty is (#11204): a name, an interval and a handler; plain data in, plain data out. */ |
| 2 | 2 | |
| 3 | 3 | import type { Database } from "bun:sqlite"; |
| 4 | import type { EventBus } from "../core/events.ts"; | |
| 4 | 5 | import type { App } from "../app.ts"; |
| 5 | 6 | |
| 6 | 7 | /** The number a finding rests on and when it was read: a query's result or a measurement. */ |
| @@ -26,6 +27,8 @@ export interface DutyResult { | ||
| 26 | 27 | |
| 27 | 28 | export interface DutyContext { |
| 28 | 29 | readonly db: Database; |
| 30 | /** The forge's event bus, for a duty that files an issue. */ | |
| 31 | readonly bus: EventBus; | |
| 29 | 32 | readonly now: Date; |
| 30 | 33 | /** The running app, for a duty that files or comments through the same actions a person uses. */ |
| 31 | 34 | readonly app?: App | undefined; |
src/maintain/registry.ts +2 −1
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | |
| 3 | 3 | import type { Duty } from "./duty.ts"; |
| 4 | 4 | import { duty as pullsWaiting } from "./duties/stale_pulls.ts"; |
| 5 | import { duty as ruleWatcher } from "./duties/rule_watcher.ts"; | |
| 5 | 6 | import { duty as efficiencyWatcher } from "./duties/efficiency_watcher.ts"; |
| 6 | 7 | import { duty as flowKeeper } from "./duties/flow_keeper.ts"; |
| 7 | 8 | |
| 8 | export const duties: readonly Duty[] = [pullsWaiting, efficiencyWatcher, flowKeeper]; | |
| 9 | export const duties: readonly Duty[] = [pullsWaiting, ruleWatcher, efficiencyWatcher, flowKeeper]; |
src/maintain/runner.ts +1 −1
| @@ -43,7 +43,7 @@ export function startMaintain(app: App, options: { duties?: readonly Duty[]; eve | ||
| 43 | 43 | let outcome = "ok"; |
| 44 | 44 | try { |
| 45 | 45 | // The duty keeps its `running` slot until it really settles, so a timed-out duty never overlaps itself. |
| 46 | const work = Promise.resolve().then(() => duty.run({ db: app.db, now, app })); | |
| 46 | const work = Promise.resolve().then(() => duty.run({ db: app.db, bus: app.bus, now, app })); | |
| 47 | 47 | work.then(() => running.delete(duty.name), () => running.delete(duty.name)); |
| 48 | 48 | let timer: ReturnType<typeof setTimeout> | undefined; |
| 49 | 49 | const deadline = new Promise<never>((_, reject) => { |
src/rules/bot.ts added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | /** | |
| 2 | * The rule watcher's own account (#7560): an agent user with no password, so a | |
| 3 | * comment it posts shows as the watcher's and never as the repository owner's | |
| 4 | * words. Made on first use; the same row is found afterwards. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import { id } from "../kernel/id.ts"; | |
| 9 | ||
| 10 | export const BOT_NAME = "rule-watcher"; | |
| 11 | ||
| 12 | export function watcherId(db: Database): string { | |
| 13 | const found = db.query<{ id: string }, [string]>("SELECT id FROM user WHERE username = ?1 COLLATE NOCASE").get(BOT_NAME); | |
| 14 | if (found !== null) return found.id; | |
| 15 | const made = id(); | |
| 16 | db.query( | |
| 17 | "INSERT OR IGNORE INTO user (id, username, email, password_hash, display_name, is_admin, is_agent) VALUES (?1, ?2, ?3, '', 'Rule watcher', 0, 1)", | |
| 18 | ).run(made, BOT_NAME, `${BOT_NAME}@agents.invalid`); | |
| 19 | return db.query<{ id: string }, [string]>("SELECT id FROM user WHERE username = ?1 COLLATE NOCASE").get(BOT_NAME)!.id; | |
| 20 | } |
src/rules/breaker.ts added +69
| @@ -0,0 +1,69 @@ | ||
| 1 | /** | |
| 2 | * Rule breakers (#7560): when a pull's own check is red on a rule test, the | |
| 3 | * pull gets one comment with the rule, the file and line, and the fix, from | |
| 4 | * `table.ts`. A comment whose text a pull already carries is not repeated, so | |
| 5 | * pushing again with the same breach says nothing new. A red that breaks no | |
| 6 | * rule in the table gets no comment: never a vague "tests failed". | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { addComment } from "../core/issues.ts"; | |
| 11 | import { watcherId } from "./bot.ts"; | |
| 12 | import { breachOf, type Breach } from "./table.ts"; | |
| 13 | ||
| 14 | /** How far back a failed check is read. */ | |
| 15 | export const LOOKBACK_MS = 24 * 3_600_000; | |
| 16 | ||
| 17 | const MARK = "_Posted by the rule watcher, not written by a person._"; | |
| 18 | ||
| 19 | export function breakerComment(breaches: readonly Breach[]): string { | |
| 20 | const lines = breaches.map( | |
| 21 | (b) => `- **${b.rule}** at \`${b.file}${b.line === undefined ? "" : `:${b.line}`}\`\n Fix: ${b.fix}.`, | |
| 22 | ); | |
| 23 | return `${MARK}\n\nThis pull's check is red on ${breaches.length === 1 ? "a repo rule" : "repo rules"}:\n\n${lines.join("\n")}`; | |
| 24 | } | |
| 25 | ||
| 26 | interface Failed { | |
| 27 | readonly pull: string; | |
| 28 | readonly repo: string; | |
| 29 | readonly names: readonly string[]; | |
| 30 | } | |
| 31 | ||
| 32 | /** The read the breaker rests on (`?1` = the earliest a failed check may be): each open pull's latest check, when failed. */ | |
| 33 | export const FAILED_CHECKS_SQL = `SELECT c.issue_id, c.repo_id, c.check_detail, c.id | |
| 34 | FROM run c | |
| 35 | JOIN issue i ON i.id = c.issue_id AND i.kind = 'pull' AND i.state = 'open' | |
| 36 | WHERE c.kind = 'check' AND c.state = 'failed' AND c.created_at >= ?1 AND c.check_detail <> '' | |
| 37 | AND c.id = (SELECT l.id FROM run l WHERE l.issue_id = c.issue_id AND l.kind = 'check' ORDER BY l.created_at DESC, l.id DESC LIMIT 1) | |
| 38 | ORDER BY c.id`; | |
| 39 | ||
| 40 | function failedChecks(db: Database, since: string): Failed[] { | |
| 41 | const rows = db.query<{ issue_id: string; repo_id: string; check_detail: string; id: string }, [string]>(FAILED_CHECKS_SQL).all(since); | |
| 42 | const out: Failed[] = []; | |
| 43 | for (const r of rows) { | |
| 44 | try { | |
| 45 | const names = (JSON.parse(r.check_detail) as { names?: unknown }).names; | |
| 46 | if (Array.isArray(names)) out.push({ pull: r.issue_id, repo: r.repo_id, names: names.filter((n): n is string => typeof n === "string") }); | |
| 47 | } catch { | |
| 48 | // A detail this build cannot read says nothing. | |
| 49 | } | |
| 50 | } | |
| 51 | return out; | |
| 52 | } | |
| 53 | ||
| 54 | /** Comment on each open pull whose failed check broke a rule; returns the pull ids commented, once per distinct text. */ | |
| 55 | export function commentBreakers(db: Database, now: Date): string[] { | |
| 56 | const posted: string[] = []; | |
| 57 | const bot = watcherId(db); | |
| 58 | for (const f of failedChecks(db, new Date(now.getTime() - LOOKBACK_MS).toISOString())) { | |
| 59 | const found = f.names.flatMap((n) => breachOf(n) ?? []); | |
| 60 | if (found.length === 0) continue; | |
| 61 | const body = breakerComment(found); | |
| 62 | const same = db | |
| 63 | .query<{ n: number }, [string, string]>("SELECT COUNT(*) AS n FROM comment WHERE issue_id = ?1 AND body = ?2") | |
| 64 | .get(f.pull, body)!.n; | |
| 65 | if (same > 0) continue; | |
| 66 | if (addComment(db, { issueId: f.pull, authorId: bot, body }).ok) posted.push(f.pull); | |
outdated
authorId is the repo owner: the comment appears as the owner's. Use a bot/system author. |
||
| 67 | } | |
| 68 | return posted; | |
| 69 | } |
src/rules/budget_rerun.ts added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | /** | |
| 2 | * Budget tests under load (#7560): a test that holds a speed and failed in a | |
| 3 | * full run, then passed when run alone, three times in a week has no headroom | |
| 4 | * under the gate's load. One issue per such test, with the p95 of the failed | |
| 5 | * attempts' measured times (`check_flake.ms`); #7354 is the first of its kind. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import type { EventBus } from "../core/events.ts"; | |
| 10 | import type { Repo } from "../core/repos.ts"; | |
| 11 | import { fileIssueOnce } from "../checks/flake.ts"; | |
| 12 | ||
| 13 | export const WINDOW_MS = 7 * 24 * 3_600_000; | |
| 14 | export const FILE_AT = 3; | |
| 15 | ||
| 16 | /** | |
| 17 | * A test whose name says it holds a speed: the word budget, a percentile, or | |
| 18 | * "under"/"within" a stated time. A bare "N ms" or "answers in" is not enough: | |
| 19 | * plenty of tests mention a time without being held to one. | |
| 20 | */ | |
| 21 | export const BUDGET_NAME = /\b(budget|p9[059]|(under|within) \d[\d_.]* ?m?s)\b/i; | |
| 22 | ||
| 23 | export interface BudgetRerun { | |
| 24 | readonly repoId: string; | |
| 25 | readonly test: string; | |
| 26 | readonly file: string; | |
| 27 | readonly passes: number; | |
| 28 | /** Nearest-rank p95 of the attempts' ms; undefined when none was measured. */ | |
| 29 | readonly p95Ms: number | undefined; | |
| 30 | } | |
| 31 | ||
| 32 | export function p95(values: readonly number[]): number | undefined { | |
| 33 | if (values.length === 0) return undefined; | |
| 34 | const sorted = [...values].sort((a, b) => a - b); | |
| 35 | return sorted[Math.max(0, Math.ceil(0.95 * sorted.length) - 1)]; | |
| 36 | } | |
| 37 | ||
| 38 | /** The read behind the budget rows: `?1` = the week's start (ISO); names are then filtered by {@link BUDGET_NAME}. */ | |
| 39 | export const BUDGET_SQL = "SELECT repo_id, test, file, ms FROM check_flake WHERE at >= ?1 ORDER BY at"; | |
| 40 | ||
| 41 | export function budgetReruns(db: Database, now: number): BudgetRerun[] { | |
| 42 | const rows = db | |
| 43 | .query<{ repo_id: string; test: string; file: string; ms: number | null }, [string]>(BUDGET_SQL) | |
| 44 | .all(new Date(now - WINDOW_MS).toISOString()); | |
| 45 | const by = new Map<string, { repoId: string; test: string; file: string; passes: number; ms: number[] }>(); | |
| 46 | for (const r of rows) { | |
| 47 | if (!BUDGET_NAME.test(r.test)) continue; | |
| 48 | const key = `${r.repo_id}\0${r.test}`; | |
| 49 | const at = by.get(key) ?? { repoId: r.repo_id, test: r.test, file: r.file, passes: 0, ms: [] }; | |
| 50 | at.passes += 1; | |
| 51 | if (r.file !== "") at.file = r.file; | |
| 52 | if (r.ms !== null) at.ms.push(r.ms); | |
| 53 | by.set(key, at); | |
| 54 | } | |
| 55 | return [...by.values()].map((b) => ({ repoId: b.repoId, test: b.test, file: b.file, passes: b.passes, p95Ms: p95(b.ms) })); | |
| 56 | } | |
| 57 | ||
| 58 | export const headroomTitle = (test: string): string => `Budget headroom: ${test.slice(0, 200)}`; | |
| 59 | ||
| 60 | /** One headroom issue per budget test re-run green {@link FILE_AT} times in the week; returns the numbers opened. */ | |
| 61 | export function fileHeadroom(db: Database, bus: EventBus, repos: ReadonlyMap<string, Pick<Repo, "id" | "ownerId">>, now: number): number[] { | |
| 62 | const opened: number[] = []; | |
| 63 | for (const b of budgetReruns(db, now)) { | |
| 64 | const repo = repos.get(b.repoId); | |
| 65 | if (repo === undefined || b.passes < FILE_AT || b.file === "") continue; | |
| 66 | const filed = fileIssueOnce(db, bus, repo, { | |
| 67 | title: headroomTitle(b.test), | |
| 68 | body: | |
| 69 | `_Filed by the rule watcher from the flake record, not written by a person._\n\n` + | |
| 70 | `\`${b.test}\` in \`${b.file}\` failed in a full run and passed when re-run alone ${b.passes} times this week: ` + | |
| 71 | `its budget leaves no headroom under the gate's load (see #7354). ` + | |
| 72 | `Measured p95 of the failed attempts: ${b.p95Ms === undefined ? "not measured (older rows carry no time)" : `${b.p95Ms} ms`}.\n\n` + | |
| 73 | `Count work instead of milliseconds (\`expectWork\`), or give the budget room above that p95; do not quarantine it.\n\nFiles: ${b.file}`, | |
| 74 | }); | |
| 75 | if (filed.opened) opened.push(filed.number); | |
| 76 | } | |
| 77 | return opened; | |
| 78 | } |
src/rules/flake_trend.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * The flake trend (#7560): the week's flaky tests from `check_flake`, ordered | |
| 3 | * by the gate minutes each cost. A flake costs the minutes of the check it | |
| 4 | * delayed: the gate's `duration_ms` when it failed in a gate, else the | |
| 5 | * check run's own. The worst three get an issue for the crew to build (the | |
| 6 | * same title the check's own filing uses, so one is never doubled). Quarantine | |
| 7 | * is not an option the issue offers: the fix is a deterministic or | |
| 8 | * budget-aware test. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { Database } from "bun:sqlite"; | |
| 12 | import type { EventBus } from "../core/events.ts"; | |
| 13 | import type { Repo } from "../core/repos.ts"; | |
| 14 | import { fileIssueOnce, flakeTitle } from "../checks/flake.ts"; | |
| 15 | ||
| 16 | export const WINDOW_MS = 7 * 24 * 3_600_000; | |
| 17 | export const WORST = 3; | |
| 18 | ||
| 19 | export interface Trend { | |
| 20 | readonly repoId: string; | |
| 21 | readonly repo: string; | |
| 22 | readonly test: string; | |
| 23 | readonly file: string; | |
| 24 | readonly count: number; | |
| 25 | /** Gate minutes the flakes delayed, one decimal. */ | |
| 26 | readonly minutes: number; | |
| 27 | } | |
| 28 | ||
| 29 | /** The read behind the trend: `?1` = the week's start (ISO), `?2` = the row limit. */ | |
| 30 | export const FLAKE_TREND_SQL = `SELECT f.repo_id AS repoId, u.username || '/' || r.name AS repo, f.test AS test, MAX(f.file) AS file, | |
| 31 | COUNT(*) AS count, | |
| 32 | ROUND(SUM(COALESCE( | |
| 33 | (SELECT g.duration_ms FROM gate_result g WHERE g.run_id = f.run_id AND g.repo_id = f.repo_id LIMIT 1), | |
| 34 | (SELECT MAX(0, (julianday(c.finished_at) - julianday(c.started_at)) * 86400000) FROM run c WHERE c.id = f.run_id), | |
| 35 | 0)) / 60000.0, 1) AS minutes | |
| 36 | FROM check_flake f | |
| 37 | JOIN repo r ON r.id = f.repo_id | |
| 38 | JOIN user u ON u.id = r.owner_id | |
| 39 | WHERE f.at >= ?1 | |
| 40 | GROUP BY f.repo_id, f.test | |
| 41 | ORDER BY minutes DESC, count DESC, test | |
| 42 | LIMIT ?2`; | |
| 43 | ||
| 44 | /** Most minutes first; then most flakes; then the name, so the order is whole. */ | |
| 45 | export function flakeTrend(db: Database, now: number, limit = 20): Trend[] { | |
| 46 | const rows = db | |
| 47 | .query<Trend, [string, number]>(FLAKE_TREND_SQL) | |
| 48 | .all(new Date(now - WINDOW_MS).toISOString(), limit); | |
| 49 | return rows; | |
| 50 | } | |
| 51 | ||
| 52 | /** The week's list as lines for a summary: `test ×n, m min (repo)`. */ | |
| 53 | export const trendLines = (list: readonly Trend[]): string[] => list.map((t) => `${t.test} ×${t.count}, ${t.minutes} min (${t.repo})`); | |
| 54 | ||
| 55 | /** An ISO-ish label for the week `now` falls in: the Monday's date. */ | |
| 56 | export function weekOf(now: number): string { | |
| 57 | const d = new Date(now); | |
| 58 | const back = (d.getUTCDay() + 6) % 7; | |
| 59 | return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - back)).toISOString().slice(0, 10); | |
| 60 | } | |
| 61 | ||
| 62 | /** An issue for each of the worst {@link WORST} that names a file; returns the numbers opened. */ | |
| 63 | export function fileWorst(db: Database, bus: EventBus, repos: ReadonlyMap<string, Pick<Repo, "id" | "ownerId">>, list: readonly Trend[]): number[] { | |
| 64 | const opened: number[] = []; | |
| 65 | for (const t of list.slice(0, WORST)) { | |
| 66 | const repo = repos.get(t.repoId); | |
| 67 | // No file, no "Files:" line to route it to a builder: no issue. | |
| 68 | if (repo === undefined || t.file === "") continue; | |
| 69 | const filed = fileIssueOnce(db, bus, repo, { | |
| 70 | title: flakeTitle(t.test), | |
| 71 | body: | |
| 72 | `_Filed by the rule watcher from the week's flake list, not written by a person._\n\n` + | |
| 73 | `\`${t.test}\` in \`${t.file}\` failed and passed alone ${t.count} time${t.count === 1 ? "" : "s"} this week, delaying checks by about ${t.minutes} minutes. ` + | |
| 74 | `It is among the three costliest flakes. Make it deterministic or budget-aware; quarantine is not allowed.\n\nFiles: ${t.file}`, | |
| 75 | }); | |
| 76 | if (filed.opened) opened.push(filed.number); | |
| 77 | } | |
| 78 | return opened; | |
| 79 | } |
src/rules/gate_only.ts added +126
| @@ -0,0 +1,126 @@ | ||
| 1 | /** | |
| 2 | * Gate-only reds (#7560): a test that failed in a merge-queue gate while the | |
| 3 | * pull the gate carried had passed its own check. Two such gates in a week | |
| 4 | * file one issue naming the test and what differs; one files none. The pull's | |
| 5 | * own check is its latest finished `check` run: green (`ok`) means every test | |
| 6 | * it ran passed there. Reads `gate_result` (kept a week) and `run`. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import type { EventBus } from "../core/events.ts"; | |
| 11 | import type { Repo } from "../core/repos.ts"; | |
| 12 | import { fileIssueOnce } from "../checks/flake.ts"; | |
| 13 | import { RULE_TESTS } from "../checks/tiers.ts"; | |
| 14 | ||
| 15 | export const WINDOW_MS = 7 * 24 * 3_600_000; | |
| 16 | export const FILE_AT = 2; | |
| 17 | ||
| 18 | export interface GateOnly { | |
| 19 | readonly repoId: string; | |
| 20 | readonly test: string; | |
| 21 | readonly file: string; | |
| 22 | /** Gates that failed it while their pulls' own checks were green. */ | |
| 23 | readonly gates: readonly { readonly id: string; readonly pulls: readonly number[] }[]; | |
| 24 | } | |
| 25 | ||
| 26 | interface GateRow { | |
| 27 | id: string; | |
| 28 | repo_id: string; | |
| 29 | pulls: string; | |
| 30 | tests: string; | |
| 31 | } | |
| 32 | ||
| 33 | const numbers = (raw: string): number[] => { | |
| 34 | try { | |
| 35 | const v = JSON.parse(raw) as unknown; | |
| 36 | return Array.isArray(v) ? v.filter((n): n is number => Number.isInteger(n)) : []; | |
| 37 | } catch { | |
| 38 | return []; | |
| 39 | } | |
| 40 | }; | |
| 41 | ||
| 42 | function failing(raw: string): { name: string; file: string }[] { | |
| 43 | try { | |
| 44 | const v = JSON.parse(raw) as unknown; | |
| 45 | if (!Array.isArray(v)) return []; | |
| 46 | return v.flatMap((t: { name?: unknown; file?: unknown }) => | |
| 47 | typeof t?.name === "string" ? [{ name: t.name, file: typeof t.file === "string" ? t.file : "" }] : [], | |
| 48 | ); | |
| 49 | } catch { | |
| 50 | return []; | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | /** The red gates of the week: `?1` = the week's start (ISO). */ | |
| 55 | export const GATE_RED_SQL = "SELECT id, repo_id, pulls, tests FROM gate_result WHERE state = 'red' AND finished_at >= ?1 ORDER BY id"; | |
| 56 | ||
| 57 | /** | |
| 58 | * The latest finished own check of every pull the gates carried, in one read: | |
| 59 | * `?1` = the repository ids, `?2` = the pull numbers, both as JSON arrays. | |
| 60 | * Keyed `repoId#number`; true when it was green. | |
| 61 | */ | |
| 62 | export const OWN_CHECK_SQL = `SELECT i.repo_id AS repo_id, i.number AS number, | |
| 63 | (SELECT c.state FROM run c WHERE c.issue_id = i.id AND c.kind = 'check' AND c.state IN ('ok','failed') | |
| 64 | ORDER BY c.created_at DESC, c.id DESC LIMIT 1) AS state | |
| 65 | FROM issue i | |
| 66 | WHERE i.kind = 'pull' AND i.repo_id IN (SELECT value FROM json_each(?1)) AND i.number IN (SELECT value FROM json_each(?2))`; | |
| 67 | ||
| 68 | function ownChecksGreen(db: Database, repoIds: readonly string[], numbers: readonly number[]): Set<string> { | |
| 69 | if (numbers.length === 0) return new Set(); | |
| 70 | const rows = db | |
| 71 | .query<{ repo_id: string; number: number; state: string | null }, [string, string]>(OWN_CHECK_SQL) | |
| 72 | .all(JSON.stringify(repoIds), JSON.stringify(numbers)); | |
| 73 | return new Set(rows.filter((r) => r.state === "ok").map((r) => `${r.repo_id}#${r.number}`)); | |
| 74 | } | |
| 75 | ||
| 76 | /** The tests that are gate-only red at least once in the week, with the gates that showed it. */ | |
| 77 | export function gateOnlyReds(db: Database, now: number): GateOnly[] { | |
| 78 | const rows = db.query<GateRow, [string]>(GATE_RED_SQL).all(new Date(now - WINDOW_MS).toISOString()); | |
| 79 | const carried = rows.map((g) => ({ g, pulls: numbers(g.pulls) })); | |
| 80 | const green = ownChecksGreen( | |
| 81 | db, | |
| 82 | [...new Set(rows.map((g) => g.repo_id))], | |
| 83 | [...new Set(carried.flatMap((c) => c.pulls))], | |
| 84 | ); | |
| 85 | const by = new Map<string, { repoId: string; test: string; file: string; gates: { id: string; pulls: number[] }[] }>(); | |
| 86 | for (const { g, pulls } of carried) { | |
| 87 | // A gate whose pulls were not all green on their own: its red is a pull's, not the gate's. | |
| 88 | if (pulls.length === 0 || !pulls.every((n) => green.has(`${g.repo_id}#${n}`))) continue; | |
outdated
pull: green[0] names the first pull for every test in the gate; wrong for multi-pull gates. Also ownCheckGreen above is an N+1 query per tick. |
||
| 89 | for (const t of failing(g.tests)) { | |
| 90 | const key = `${g.repo_id}\0${t.name}`; | |
| 91 | const at = by.get(key) ?? { repoId: g.repo_id, test: t.name, file: t.file, gates: [] }; | |
| 92 | at.gates.push({ id: g.id, pulls }); | |
| 93 | by.set(key, at); | |
| 94 | } | |
| 95 | } | |
| 96 | return [...by.values()]; | |
| 97 | } | |
| 98 | ||
| 99 | export const gateOnlyTitle = (test: string): string => `Gate-only red: ${test.slice(0, 200)}`; | |
| 100 | ||
| 101 | /** Say what the pull's check did not run, from the rule set it always runs. */ | |
| 102 | function differs(file: string): string { | |
| 103 | if (file !== "" && RULE_TESTS.includes(file)) | |
| 104 | return `\`${file}\` is in the pull check's rule set (the #7138 class) yet passed there: look for what the gate adds, the other pulls' changes in one candidate tree, or its load and file order.`; | |
| 105 | return `${file === "" ? "The test's file is unknown" : `\`${file}\` is not in the pull check's rule set`}: the pull's fast check runs the tests its diff reaches, so a repo-wide scan, an ordering or a load-sensitive step shows only in the gate (#7138, #7354).`; | |
| 106 | } | |
| 107 | ||
| 108 | /** File one issue per test that was gate-only red {@link FILE_AT} times or more in the week; returns the numbers opened. */ | |
| 109 | export function fileGateOnly(db: Database, bus: EventBus, repos: ReadonlyMap<string, Pick<Repo, "id" | "ownerId">>, now: number): number[] { | |
| 110 | const opened: number[] = []; | |
| 111 | for (const g of gateOnlyReds(db, now)) { | |
| 112 | const repo = repos.get(g.repoId); | |
| 113 | if (repo === undefined || g.gates.length < FILE_AT) continue; | |
| 114 | const shown = g.gates.slice(0, 5).map((x) => `gate ${x.id} (${x.pulls.length === 1 ? "pull" : "pulls"} ${x.pulls.map((n) => `#${n}`).join(", ")})`).join(", "); | |
| 115 | const filed = fileIssueOnce(db, bus, repo, { | |
| 116 | title: gateOnlyTitle(g.test), | |
| 117 | body: | |
| 118 | `_Filed by the rule watcher from the gate record, not written by a person._\n\n` + | |
| 119 | `\`${g.test}\` failed in ${g.gates.length} merge-queue gates this week while each carried pull's own check was green: ${shown}.\n\n` + | |
| 120 | `${differs(g.file)}\n\nMake it deterministic or budget-aware; do not quarantine it.` + | |
| 121 | (g.file === "" ? "" : `\n\nFiles: ${g.file}`), | |
| 122 | }); | |
| 123 | if (filed.opened) opened.push(filed.number); | |
| 124 | } | |
| 125 | return opened; | |
| 126 | } |
src/rules/table.ts added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | /** | |
| 2 | * The repo-wide rules and the fix for each (#7560): one entry per rule test. | |
| 3 | * A failing rule test is named `<describe> > <breach>`, the breach being | |
| 4 | * `path:line: source` (`test/helpers/rule.ts`) or `path is not pinned: …`. | |
| 5 | * Plain text in, plain data out. | |
| 6 | */ | |
| 7 | ||
| 8 | export interface RuleFix { | |
| 9 | /** The rule, as its describe says it. */ | |
| 10 | readonly rule: string; | |
| 11 | /** What the describe's name starts with. */ | |
| 12 | readonly describe: RegExp; | |
| 13 | /** The fix, one sentence. */ | |
| 14 | readonly fix: string; | |
| 15 | } | |
| 16 | ||
| 17 | export const RULES: readonly RuleFix[] = [ | |
| 18 | { | |
| 19 | rule: "no raw wall-clock budget in a test (#4429)", | |
| 20 | describe: /^no raw wall-clock budget in a test \(#4429\)/, | |
| 21 | fix: "hold the speed by counting work with `expectWork(measured, { max })` from test/helpers/work.ts (SQL statements, bytes, git spawns), not by milliseconds; a hang guard above 1 s may stay a plain expect", | |
| 22 | }, | |
| 23 | { | |
| 24 | rule: "no tight wall-clock bound in a test", | |
| 25 | describe: /^no tight wall-clock bound in a test/, | |
| 26 | fix: "assert a speed relative to a baseline measured in the same run, or bound it at 200 ms or more (WALL_CLOCK_FLOOR_MS in scripts/flake.ts) so only the failure the test names crosses it", | |
| 27 | }, | |
| 28 | { | |
| 29 | rule: "no wait that runs out of time and carries on (#9017)", | |
| 30 | describe: /^a wait that runs out of time and carries on/, | |
| 31 | fix: "wait on the event itself, or throw when the deadline passes instead of falling through to the next line", | |
| 32 | }, | |
| 33 | { | |
| 34 | rule: "every migration is pinned by its own pull (#783)", | |
| 35 | describe: /^a migration on main is never edited/, | |
| 36 | fix: "run `bun scripts/pin-migrations.ts` and commit the one `.pin` file it writes beside the migration; never edit a migration that is already pinned, add a new one", | |
| 37 | }, | |
| 38 | ]; | |
| 39 | ||
| 40 | export interface Breach { | |
| 41 | readonly rule: string; | |
| 42 | readonly fix: string; | |
| 43 | readonly file: string; | |
| 44 | readonly line?: number; | |
| 45 | /** The breach as the test named it, cut. */ | |
| 46 | readonly said: string; | |
| 47 | } | |
| 48 | ||
| 49 | const AT = /^([\w@./+-]{1,200}):(\d+): /; | |
| 50 | const FILE_ONLY = /^([\w@./+-]{1,200}) is not pinned/; | |
| 51 | const SAID_MAX = 200; | |
| 52 | ||
| 53 | /** The rule a failing test's name breaks, with the place it names; undefined when it is no rule's. */ | |
| 54 | export function breachOf(name: string): Breach | undefined { | |
| 55 | const cut = name.indexOf(" > "); | |
| 56 | if (cut < 0) return undefined; | |
| 57 | const entry = RULES.find((r) => r.describe.test(name.slice(0, cut))); | |
| 58 | if (entry === undefined) return undefined; | |
| 59 | const said = name.slice(cut + 3); | |
| 60 | const at = AT.exec(said); | |
| 61 | if (at !== null) return { rule: entry.rule, fix: entry.fix, file: at[1]!, line: Number(at[2]), said: said.slice(0, SAID_MAX) }; | |
| 62 | const file = FILE_ONLY.exec(said)?.[1]; | |
| 63 | // A rule test red with no place (its aggregate test) names no file: no exact comment. | |
| 64 | if (file === undefined) return undefined; | |
| 65 | return { rule: entry.rule, fix: entry.fix, file, said: said.slice(0, SAID_MAX) }; | |
| 66 | } |
src/rules/watch.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | /** | |
| 2 | * The rule watcher's four reads, each one function returning its findings and | |
| 3 | * actions (#7560). The duty calls them in turn; each is idempotent (one open | |
| 4 | * issue per title, one comment per text). A probe names the real query the | |
| 5 | * finding rests on, so anyone can run it again. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import type { EventBus } from "../core/events.ts"; | |
| 10 | import type { Repo } from "../core/repos.ts"; | |
| 11 | import type { Finding } from "../maintain/duty.ts"; | |
| 12 | import { BUDGET_SQL, budgetReruns, fileHeadroom, FILE_AT as BUDGET_AT } from "./budget_rerun.ts"; | |
| 13 | import { commentBreakers, FAILED_CHECKS_SQL } from "./breaker.ts"; | |
| 14 | import { FLAKE_TREND_SQL, fileWorst, flakeTrend, trendLines, weekOf } from "./flake_trend.ts"; | |
| 15 | import { fileGateOnly, GATE_RED_SQL, gateOnlyReds, FILE_AT as GATE_AT, OWN_CHECK_SQL } from "./gate_only.ts"; | |
| 16 | ||
| 17 | export interface Read { | |
| 18 | readonly findings: Finding[]; | |
| 19 | readonly actions: string[]; | |
| 20 | } | |
| 21 | ||
| 22 | export interface Watch { | |
| 23 | readonly db: Database; | |
| 24 | readonly bus: EventBus; | |
| 25 | readonly repos: ReadonlyMap<string, Pick<Repo, "id" | "ownerId">>; | |
| 26 | readonly now: Date; | |
| 27 | } | |
| 28 | ||
| 29 | const plural = (n: number, one: string, many = `${one}s`): string => `${n} ${n === 1 ? one : many}`; | |
| 30 | const probe = (w: Watch, what: string, number: number) => ({ kind: "query" as const, what, number, at: w.now.toISOString() }); | |
| 31 | const numbered = (list: readonly number[]): string => `#${list.join(", #")}`; | |
| 32 | ||
| 33 | export function breakers(w: Watch): Read { | |
| 34 | const posted = commentBreakers(w.db, w.now); | |
| 35 | if (posted.length === 0) return { findings: [], actions: [] }; | |
| 36 | return { | |
| 37 | findings: [{ summary: `${plural(posted.length, "pull")} red on a repo rule`, probe: probe(w, FAILED_CHECKS_SQL, posted.length) }], | |
| 38 | actions: [`commented the rule and its fix on ${posted.length} pull(s)`], | |
| 39 | }; | |
| 40 | } | |
| 41 | ||
| 42 | export function gateOnly(w: Watch): Read { | |
| 43 | const at = w.now.getTime(); | |
| 44 | const reds = gateOnlyReds(w.db, at).filter((g) => g.gates.length >= GATE_AT); | |
| 45 | const filed = fileGateOnly(w.db, w.bus, w.repos, at); | |
| 46 | const findings: Finding[] = []; | |
| 47 | if (reds.length > 0) | |
| 48 | findings.push({ | |
| 49 | summary: `${plural(reds.length, "test")} red in the gate and green on the pull, ${GATE_AT}+ times this week`, | |
| 50 | probe: probe(w, `${GATE_RED_SQL}; ${OWN_CHECK_SQL}`, reds.length), | |
| 51 | }); | |
| 52 | return { findings, actions: filed.length > 0 ? [`filed gate-only issue(s) ${numbered(filed)}`] : [] }; | |
| 53 | } | |
| 54 | ||
| 55 | /** `previousStart`: when the duty last started before this run; the week's trend is said on the first run of a week. */ | |
| 56 | export function flakeWeek(w: Watch, previousStart: number | undefined): Read { | |
| 57 | const at = w.now.getTime(); | |
| 58 | const trend = flakeTrend(w.db, at); | |
| 59 | if (trend.length === 0) return { findings: [], actions: [] }; | |
| 60 | const label = weekOf(at); | |
| 61 | const worst = fileWorst(w.db, w.bus, w.repos, trend); | |
| 62 | const firstOfWeek = previousStart === undefined || previousStart < Date.parse(label); | |
| 63 | const actions = worst.length > 0 ? [`filed flake issue(s) ${numbered(worst)}`] : []; | |
| 64 | if (!firstOfWeek && worst.length === 0) return { findings: [], actions }; | |
| 65 | return { | |
| 66 | findings: [{ summary: `flake trend ${label}: ${trendLines(trend.slice(0, 5)).join("; ")}`, probe: probe(w, FLAKE_TREND_SQL, trend.length) }], | |
| 67 | actions, | |
| 68 | }; | |
| 69 | } | |
| 70 | ||
| 71 | export function budgets(w: Watch): Read { | |
| 72 | const at = w.now.getTime(); | |
| 73 | const reruns = budgetReruns(w.db, at).filter((b) => b.passes >= BUDGET_AT); | |
| 74 | const filed = fileHeadroom(w.db, w.bus, w.repos, at); | |
| 75 | const findings: Finding[] = []; | |
| 76 | if (reruns.length > 0) | |
| 77 | findings.push({ | |
| 78 | summary: `${plural(reruns.length, "budget test")} re-run green ${BUDGET_AT}+ times this week`, | |
| 79 | probe: probe(w, BUDGET_SQL, reruns.length), | |
| 80 | }); | |
| 81 | return { findings, actions: filed.length > 0 ? [`filed headroom issue(s) ${numbered(filed)}`] : [] }; | |
| 82 | } |
test/check-flake.test.ts +1 −1
| @@ -24,7 +24,7 @@ const report = (names: string[], file = "t/a.test.ts"): string[] => [ | ||
| 24 | 24 | |
| 25 | 25 | test("one or two named failures can be re-run; more, or a hook, cannot", () => { |
| 26 | 26 | const one = report(["grp > flaky"]); |
| 27 | expect(rerunnable(one, parseBunTestFailure(one))).toEqual([{ file: "t/a.test.ts", name: "grp > flaky" }]); | |
| 27 | expect(rerunnable(one, parseBunTestFailure(one))).toEqual([{ file: "t/a.test.ts", name: "grp > flaky", ms: 1 }]); | |
| 28 | 28 | const three = report(["a", "b", "c"]); |
| 29 | 29 | expect(rerunnable(three, parseBunTestFailure(three))).toBeUndefined(); |
| 30 | 30 | const hook = ["t/a.test.ts:", "(fail) t/a.test.ts > beforeEach (timeout)", " 1 pass", " 1 fail"]; |
test/full-suite-report.test.ts +3 −3
| @@ -58,8 +58,8 @@ const BUN_LOG = [ | ||
| 58 | 58 | describe("failedTests", () => { |
| 59 | 59 | test("bun's closing summary gives no failure to the last file printed", () => { |
| 60 | 60 | expect(failedTests(BUN_LOG)).toEqual([ |
| 61 | { file: "test/a.test.ts", name: "a > one" }, | |
| 62 | { file: "test/b.test.ts", name: "b > two" }, | |
| 61 | { file: "test/a.test.ts", name: "a > one", ms: 0 }, | |
| 62 | { file: "test/b.test.ts", name: "b > two", ms: 0 }, | |
| 63 | 63 | ]); |
| 64 | 64 | expect(failedFiles(BUN_LOG)).toEqual(["test/a.test.ts", "test/b.test.ts"]); |
| 65 | 65 | }); |
| @@ -84,7 +84,7 @@ describe("failedTests", () => { | ||
| 84 | 84 | "(fail) b > two", |
| 85 | 85 | ]; |
| 86 | 86 | expect(failedTests(lines)).toEqual([ |
| 87 | { file: "test/a.test.ts", name: "a > one" }, | |
| 87 | { file: "test/a.test.ts", name: "a > one", ms: 3 }, | |
| 88 | 88 | { file: "test/b.test.ts", name: "b > two" }, |
| 89 | 89 | ]); |
| 90 | 90 | }); |
test/helpers/keeper-seed.ts +1 −1
| @@ -82,5 +82,5 @@ export async function seed(names: string[] = ["ada", "bo"]): Promise<Seed> { | ||
| 82 | 82 | export async function runKeeper(s: Seed, now: Date, deps: import("../../src/maintain/duties/flow_keeper.ts").Deps & { withApp?: boolean } = {}) { |
| 83 | 83 | const { flowKeeper } = await import("../../src/maintain/duties/flow_keeper.ts"); |
| 84 | 84 | const { withApp, ...rest } = deps; |
| 85 | return flowKeeper({ builders: () => undefined, ...(rest.vantages === undefined && !withApp ? { vantages: () => [] } : {}), ...rest }).run({ db: s.db, now, ...(withApp ? { app: s.app } : {}) }); | |
| 85 | return flowKeeper({ builders: () => undefined, ...(rest.vantages === undefined && !withApp ? { vantages: () => [] } : {}), ...rest }).run({ db: s.db, bus: s.app.bus, now, ...(withApp ? { app: s.app } : {}) }); | |
| 86 | 86 | } |
test/helpers/rules-app.ts added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | /** An app with one repo owned by alice, for the rule watcher's tests (#7560). */ | |
| 2 | import { afterAll, beforeAll } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createRepo } from "../../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../../src/app.ts"; | |
| 8 | import { createUser } from "../../src/core/users.ts"; | |
| 9 | import { quietLogs } from "./globals.ts"; | |
| 10 | ||
| 11 | export interface RulesWorld { | |
| 12 | app: App; | |
| 13 | repoId: string; | |
| 14 | ownerId: string; | |
| 15 | repos: Map<string, { id: string; ownerId: string }>; | |
| 16 | titles(): string[]; | |
| 17 | } | |
| 18 | ||
| 19 | export function rulesWorld(): RulesWorld { | |
| 20 | quietLogs(); | |
| 21 | let dir = ""; | |
| 22 | const world = {} as RulesWorld; | |
| 23 | beforeAll(async () => { | |
| 24 | dir = await mkdtemp(join(tmpdir(), "rules-")); | |
| 25 | world.app = createApp({ dataDir: dir, port: 0, host: "forge.test" }); | |
| 26 | const u = await createUser(world.app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 27 | if (!u.ok) throw new Error(u.error); | |
| 28 | const made = await createRepo(world.app, u.value, { name: "rules", visibility: "public" }); | |
| 29 | if (!made.ok) throw new Error("repo"); | |
| 30 | world.repoId = made.value.id; | |
| 31 | world.ownerId = u.value.id; | |
| 32 | world.repos = new Map([[made.value.id, { id: made.value.id, ownerId: u.value.id }]]); | |
| 33 | world.titles = () => world.app.db.query<{ title: string }, [string]>("SELECT title FROM issue WHERE repo_id = ?1 AND kind = 'issue'").all(world.repoId).map((r) => r.title); | |
| 34 | }); | |
| 35 | afterAll(async () => { | |
| 36 | await rm(dir, { recursive: true, force: true }); | |
| 37 | }); | |
| 38 | return world; | |
| 39 | } |
test/helpers/rules-seed.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | /** Rows the rule watcher reads (#7560): a repo with pulls, their check runs, gate results and flakes, written straight. */ | |
| 2 | import type { Database } from "bun:sqlite"; | |
| 3 | import { createIssue } from "../../src/core/issues.ts"; | |
| 4 | import { createRun } from "../../src/core/runs.ts"; | |
| 5 | import { id } from "../../src/kernel/id.ts"; | |
| 6 | ||
| 7 | let last = 0; | |
| 8 | /** A clock that never repeats, so two runs seeded in one millisecond keep their order. */ | |
| 9 | export const tick = (): number => (last = Math.max(Date.now(), last + 1)); | |
| 10 | ||
| 11 | export const iso = (ms: number): string => new Date(ms).toISOString(); | |
| 12 | ||
| 13 | export function seedPull(db: Database, repoId: string, authorId: string, title = "a pull"): { id: string; number: number } { | |
| 14 | const made = createIssue(db, { repoId, authorId, title, body: "", kind: "pull" }); | |
| 15 | if (!made.ok) throw new Error(made.error); | |
| 16 | return { id: made.value.id, number: made.value.number }; | |
| 17 | } | |
| 18 | ||
| 19 | /** A finished pull check: `ok`, or `failed` naming the failing tests. */ | |
| 20 | export function seedCheck(db: Database, repoId: string, pullId: string, state: "ok" | "failed", names: string[] = [], at = tick()): string { | |
| 21 | const run = createRun(db, { repoId, kind: "check", issueId: pullId }); | |
| 22 | db.query("UPDATE run SET state = ?2, check_detail = ?3, created_at = ?4, started_at = ?4, finished_at = ?5 WHERE id = ?1").run( | |
| 23 | run.id, | |
| 24 | state, | |
| 25 | state === "failed" ? JSON.stringify({ passed: 1, failed: names.length, names }) : "", | |
| 26 | iso(at), | |
| 27 | iso(at + 120_000), | |
| 28 | ); | |
| 29 | return run.id; | |
| 30 | } | |
| 31 | ||
| 32 | /** Keeps one red gate; returns its check-lane run id, which a flake in that gate carries. */ | |
| 33 | export function seedGate(db: Database, repoId: string, pulls: number[], failing: { name: string; file?: string }[], at = Date.now(), minutes = 10): string { | |
| 34 | const gate = id(); | |
| 35 | const runId = `gate-${gate}`; | |
| 36 | db.query( | |
| 37 | `INSERT INTO gate_result (id, repo_id, run_id, tip_oid, pulls, state, failed, tests, started_at, finished_at, duration_ms) | |
| 38 | VALUES (?1, ?2, ?3, 'abc', ?4, 'red', ?5, ?6, ?7, ?8, ?9)`, | |
| 39 | ).run(gate, repoId, runId, JSON.stringify(pulls), failing.length, JSON.stringify(failing.map((f) => ({ ...f, errors: [] }))), iso(at - minutes * 60_000), iso(at), minutes * 60_000); | |
| 40 | return runId; | |
| 41 | } | |
| 42 | ||
| 43 | export function seedFlake(db: Database, repoId: string, test: string, file: string, at = Date.now(), ms: number | null = null, runId = id()): void { | |
| 44 | db.query("INSERT INTO check_flake (id, repo_id, run_id, commit_oid, test, file, at, ms) VALUES (?1, ?2, ?3, 'abc', ?4, ?5, ?6, ?7)").run(id(), repoId, runId, test, file, iso(at), ms); | |
| 45 | } |
test/rules-breaker-comment.test.ts added +69
| @@ -0,0 +1,69 @@ | ||
| 1 | /** Rule breakers (#7560): one comment with rule, place and fix; a repeat says nothing; a plain red gets none. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { commentBreakers } from "../src/rules/breaker.ts"; | |
| 4 | import { breachOf } from "../src/rules/table.ts"; | |
| 5 | import { rulesWorld } from "./helpers/rules-app.ts"; | |
| 6 | import { seedCheck, seedPull } from "./helpers/rules-seed.ts"; | |
| 7 | ||
| 8 | const w = rulesWorld(); | |
| 9 | const RAW = "no raw wall-clock budget in a test (#4429) > test/x.test.ts:4: expect(performance.now() - t0).toBe" + "LessThan(5);"; | |
| 10 | const PIN = "a migration on main is never edited (#783) > src/core/migrations/2026-x.ts is not pinned: run bun scripts/pin-migrations.ts"; | |
| 11 | const comments = (pullId: string) => w.app.db.query<{ body: string }, [string]>("SELECT body FROM comment WHERE issue_id = ?1").all(pullId).map((c) => c.body); | |
| 12 | ||
| 13 | test("the table names the rule, the file, the line and the fix", () => { | |
| 14 | expect(breachOf(RAW)).toMatchObject({ file: "test/x.test.ts", line: 4, rule: expect.stringContaining("#4429"), fix: expect.stringContaining("expectWork") }); | |
| 15 | expect(breachOf("a migration on main is never edited (#783) > src/core/migrations/2026-x.ts is not pinned: run it")).toMatchObject({ file: "src/core/migrations/2026-x.ts", fix: expect.stringContaining("pin-migrations") }); | |
| 16 | expect(breachOf("some other > test")).toBeUndefined(); | |
| 17 | expect(breachOf("no raw wall-clock budget in a test (#4429) > clean")).toBeUndefined(); | |
| 18 | }); | |
| 19 | ||
| 20 | test("a pull red on a rule gets one comment; pushing again with the same breach adds none", () => { | |
| 21 | const p = seedPull(w.app.db, w.repoId, w.ownerId); | |
| 22 | seedCheck(w.app.db, w.repoId, p.id, "failed", [RAW]); | |
| 23 | expect(commentBreakers(w.app.db, new Date())).toEqual([p.id]); | |
| 24 | const [body] = comments(p.id); | |
| 25 | expect(comments(p.id)).toHaveLength(1); | |
| 26 | expect(body).toContain("test/x.test.ts:4"); | |
| 27 | expect(body).toContain("expectWork"); | |
| 28 | expect(body).toContain("#4429"); | |
| 29 | ||
| 30 | seedCheck(w.app.db, w.repoId, p.id, "failed", [RAW]); | |
| 31 | expect(commentBreakers(w.app.db, new Date())).toEqual([]); | |
| 32 | expect(commentBreakers(w.app.db, new Date())).toEqual([]); | |
| 33 | expect(comments(p.id)).toHaveLength(1); | |
| 34 | }); | |
| 35 | ||
| 36 | test("a different breach is a new comment; a red that breaks no rule, or a green check, gets none", () => { | |
| 37 | const p = seedPull(w.app.db, w.repoId, w.ownerId); | |
| 38 | seedCheck(w.app.db, w.repoId, p.id, "failed", [RAW]); | |
| 39 | commentBreakers(w.app.db, new Date()); | |
| 40 | seedCheck(w.app.db, w.repoId, p.id, "failed", [PIN]); | |
| 41 | expect(commentBreakers(w.app.db, new Date())).toEqual([p.id]); | |
| 42 | expect(comments(p.id)).toHaveLength(2); | |
| 43 | ||
| 44 | const plain = seedPull(w.app.db, w.repoId, w.ownerId); | |
| 45 | seedCheck(w.app.db, w.repoId, plain.id, "failed", ["grp > ordinary failure"]); | |
| 46 | const green = seedPull(w.app.db, w.repoId, w.ownerId); | |
| 47 | seedCheck(w.app.db, w.repoId, green.id, "ok"); | |
| 48 | expect(commentBreakers(w.app.db, new Date())).toEqual([]); | |
| 49 | expect(comments(plain.id)).toEqual([]); | |
| 50 | expect(comments(green.id)).toEqual([]); | |
| 51 | }); | |
| 52 | ||
| 53 | test("only a pull's latest check speaks: a breach already fixed is not commented", () => { | |
| 54 | const p = seedPull(w.app.db, w.repoId, w.ownerId); | |
| 55 | seedCheck(w.app.db, w.repoId, p.id, "failed", [RAW.replace("x.test", "fixed.test")]); | |
| 56 | seedCheck(w.app.db, w.repoId, p.id, "ok"); | |
| 57 | expect(commentBreakers(w.app.db, new Date())).toEqual([]); | |
| 58 | }); | |
| 59 | ||
| 60 | test("the comment is the rule watcher's, never the repository owner's", () => { | |
| 61 | const p = seedPull(w.app.db, w.repoId, w.ownerId); | |
| 62 | seedCheck(w.app.db, w.repoId, p.id, "failed", [RAW]); | |
| 63 | commentBreakers(w.app.db, new Date()); | |
| 64 | const row = w.app.db | |
| 65 | .query<{ author_id: string; username: string; is_agent: number }, [string]>("SELECT c.author_id, u.username, u.is_agent FROM comment c JOIN user u ON u.id = c.author_id WHERE c.issue_id = ?1") | |
| 66 | .get(p.id)!; | |
| 67 | expect(row.author_id).not.toBe(w.ownerId); | |
| 68 | expect(row).toMatchObject({ username: "rule-watcher", is_agent: 1 }); | |
| 69 | }); |
test/rules-budget-rerun-count.test.ts added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | /** Budget tests under load (#7560): re-run green three times in a week files a headroom issue with its p95. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { duty } from "../src/maintain/duties/rule_watcher.ts"; | |
| 4 | import { budgetReruns, fileHeadroom, headroomTitle, p95 } from "../src/rules/budget_rerun.ts"; | |
| 5 | import { failMs, recordFlakes } from "../src/checks/flake.ts"; | |
| 6 | import { rulesWorld } from "./helpers/rules-app.ts"; | |
| 7 | import { seedFlake } from "./helpers/rules-seed.ts"; | |
| 8 | ||
| 9 | const w = rulesWorld(); | |
| 10 | const now = Date.now(); | |
| 11 | const BUDGET = "feed > the feed answers in under 50" + " ms p95 budget"; | |
| 12 | ||
| 13 | test("p95 is the nearest rank; the failed attempt's time is read from its line", () => { | |
| 14 | expect(p95([])).toBeUndefined(); | |
| 15 | expect(p95([10, 20, 30, 40, 50, 60, 70, 80, 90, 100])).toBe(100); | |
| 16 | expect(p95([5])).toBe(5); | |
| 17 | expect(failMs("(fail) a > b [123.45ms]")).toBe(123); | |
| 18 | expect(failMs("(fail) a > b [1.5s]")).toBe(1500); | |
| 19 | expect(failMs("(fail) a > b")).toBeUndefined(); | |
| 20 | }); | |
| 21 | ||
| 22 | test("two re-run passes file nothing; the third files one issue with the measured p95", () => { | |
| 23 | seedFlake(w.app.db, w.repoId, BUDGET, "test/feed.test.ts", now - 3000, 60); | |
| 24 | seedFlake(w.app.db, w.repoId, BUDGET, "test/feed.test.ts", now - 2000, 90); | |
| 25 | seedFlake(w.app.db, w.repoId, "feed > ordinary flake", "test/feed.test.ts", now - 2000); | |
| 26 | seedFlake(w.app.db, w.repoId, "feed > ordinary flake", "test/feed.test.ts", now - 1000); | |
| 27 | seedFlake(w.app.db, w.repoId, "feed > ordinary flake", "test/feed.test.ts", now - 500); | |
| 28 | expect(fileHeadroom(w.app.db, w.app.bus, w.repos, now)).toEqual([]); | |
| 29 | seedFlake(w.app.db, w.repoId, BUDGET, "test/feed.test.ts", now - 1000, 120); | |
| 30 | expect(budgetReruns(w.app.db, now).map((b) => [b.test, b.passes, b.p95Ms])).toEqual([[BUDGET, 3, 120]]); | |
| 31 | expect(fileHeadroom(w.app.db, w.app.bus, w.repos, now)).toHaveLength(1); | |
| 32 | const body = w.app.db.query<{ body: string }, [string]>("SELECT body FROM issue WHERE title = ?1").get(headroomTitle(BUDGET))!.body; | |
| 33 | expect(body).toContain("p95 of the failed attempts: 120 ms"); | |
| 34 | expect(body).toContain("#7354"); | |
| 35 | expect(fileHeadroom(w.app.db, w.app.bus, w.repos, now)).toEqual([]); | |
| 36 | }); | |
| 37 | ||
| 38 | test("recordFlakes keeps the time the failed attempt took", () => { | |
| 39 | recordFlakes(w.app.db, w.app.bus, w.repos.get(w.repoId)!, [{ file: "test/t.test.ts", name: "t > timed", ms: 77 }], { runId: "r1", commit: "abc" }); | |
| 40 | expect(w.app.db.query<{ ms: number }, []>("SELECT ms FROM check_flake WHERE test = 't > timed'").get()!.ms).toBe(77); | |
| 41 | }); | |
| 42 | ||
| 43 | test("the duty runs all four reads and reports each finding on a probe", async () => { | |
| 44 | const result = await duty.run({ db: w.app.db, bus: w.app.bus, now: new Date(now) }); | |
| 45 | expect(result.findings.some((f) => f.summary.includes("budget test"))).toBe(true); | |
| 46 | expect(result.findings.some((f) => f.summary.startsWith("flake trend "))).toBe(true); | |
| 47 | for (const f of result.findings) expect(f.probe?.what).toBeTruthy(); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a name that only mentions a time is not a budget test", async () => { | |
| 51 | const { BUDGET_NAME } = await import("../src/rules/budget_rerun.ts"); | |
| 52 | expect(BUDGET_NAME.test("the feed answers within the latency budget")).toBe(true); | |
| 53 | expect(BUDGET_NAME.test(["the feed answers in", "under 50", "ms"].join(" "))).toBe(true); | |
| 54 | expect(BUDGET_NAME.test("p95 holds")).toBe(true); | |
| 55 | expect(BUDGET_NAME.test("retries after 500 ms of silence")).toBe(false); | |
| 56 | expect(BUDGET_NAME.test("the page answers in json")).toBe(false); | |
| 57 | }); |
test/rules-flake-trend.test.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | /** The flake trend (#7560): ordered by gate minutes delayed; the worst three get issues, once. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { fileWorst, flakeTrend, trendLines, weekOf } from "../src/rules/flake_trend.ts"; | |
| 4 | import { rulesWorld } from "./helpers/rules-app.ts"; | |
| 5 | import { seedFlake, seedGate } from "./helpers/rules-seed.ts"; | |
| 6 | ||
| 7 | const w = rulesWorld(); | |
| 8 | const now = Date.now(); | |
| 9 | ||
| 10 | test("the list is ordered by wasted gate minutes, not by count", () => { | |
| 11 | // Many flakes in short gates, few in long ones. | |
| 12 | for (let i = 0; i < 4; i++) seedFlake(w.app.db, w.repoId, "busy > cheap", "test/cheap.test.ts", now - i * 1000, null, seedGate(w.app.db, w.repoId, [], [], now - i * 1000, 0.25)); | |
| 13 | for (let i = 0; i < 2; i++) seedFlake(w.app.db, w.repoId, "slow > costly", "test/costly.test.ts", now - i * 1000, null, seedGate(w.app.db, w.repoId, [], [], now - i * 1000, 30)); | |
| 14 | seedFlake(w.app.db, w.repoId, "mid > one", "test/mid.test.ts", now, null, seedGate(w.app.db, w.repoId, [], [], now, 12)); | |
| 15 | seedFlake(w.app.db, w.repoId, "tiny > one", "test/tiny.test.ts", now, null, seedGate(w.app.db, w.repoId, [], [], now, 2)); | |
| 16 | const list = flakeTrend(w.app.db, now); | |
| 17 | expect(list.map((t) => t.test)).toEqual(["slow > costly", "mid > one", "tiny > one", "busy > cheap"]); | |
| 18 | expect(list[0]).toMatchObject({ count: 2, minutes: 60 }); | |
| 19 | expect(trendLines(list)[0]).toBe("slow > costly ×2, 60 min (alice/rules)"); | |
| 20 | }); | |
| 21 | ||
| 22 | test("the worst three get one issue each, with no quarantine; a repeat files none; the fourth gets none", () => { | |
| 23 | const list = flakeTrend(w.app.db, now); | |
| 24 | expect(fileWorst(w.app.db, w.app.bus, w.repos, list)).toHaveLength(3); | |
| 25 | expect(w.titles()).toEqual(expect.arrayContaining(["Flaky test: slow > costly", "Flaky test: mid > one", "Flaky test: tiny > one"])); | |
| 26 | expect(w.titles()).not.toContain("Flaky test: busy > cheap"); | |
| 27 | expect(fileWorst(w.app.db, w.app.bus, w.repos, list)).toEqual([]); | |
| 28 | const body = w.app.db.query<{ body: string }, []>("SELECT body FROM issue WHERE title = 'Flaky test: slow > costly'").get()!.body; | |
| 29 | expect(body).toContain("quarantine is not allowed"); | |
| 30 | expect(body).toContain("60 minutes"); | |
| 31 | }); | |
| 32 | ||
| 33 | test("the week is labelled by its Monday", () => { | |
| 34 | expect(weekOf(Date.parse("2026-11-11T10:00:00Z"))).toBe("2026-11-09"); | |
| 35 | expect(weekOf(Date.parse("2026-11-15T23:59:00Z"))).toBe("2026-11-09"); | |
| 36 | expect(weekOf(Date.parse("2026-11-16T00:00:00Z"))).toBe("2026-11-16"); | |
| 37 | }); | |
| 38 | ||
| 39 | test("the trend is said on the first run of a week, and its probe is the real query", async () => { | |
| 40 | const { flakeWeek } = await import("../src/rules/watch.ts"); | |
| 41 | const { FLAKE_TREND_SQL } = await import("../src/rules/flake_trend.ts"); | |
| 42 | const watch = { db: w.app.db, bus: w.app.bus, repos: w.repos, now: new Date(now) }; | |
| 43 | const monday = Date.parse(weekOf(now)); | |
| 44 | const first = flakeWeek(watch, monday - 1000); | |
| 45 | expect(first.findings).toHaveLength(1); | |
| 46 | expect(first.findings[0]!.probe).toMatchObject({ what: FLAKE_TREND_SQL }); | |
| 47 | expect(() => w.app.db.query(FLAKE_TREND_SQL).all("2000-01-01T00:00:00Z", 5)).not.toThrow(); | |
| 48 | expect(flakeWeek(watch, monday + 1000).findings).toEqual([]); | |
| 49 | }); |
test/rules-gate-only.test.ts added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | /** Gate-only reds (#7560): two in a week file one issue; one files none; a red the pull shared is not gate-only. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { fileGateOnly, gateOnlyTitle } from "../src/rules/gate_only.ts"; | |
| 4 | import { rulesWorld } from "./helpers/rules-app.ts"; | |
| 5 | import { seedCheck, seedGate, seedPull } from "./helpers/rules-seed.ts"; | |
| 6 | ||
| 7 | const w = rulesWorld(); | |
| 8 | const NAME = "sweep > repo-wide scan"; | |
| 9 | const now = Date.now(); | |
| 10 | ||
| 11 | test("a test red once in the gate with its pull green files nothing; twice files one issue, once more", () => { | |
| 12 | const p1 = seedPull(w.app.db, w.repoId, w.ownerId); | |
| 13 | const p2 = seedPull(w.app.db, w.repoId, w.ownerId); | |
| 14 | seedCheck(w.app.db, w.repoId, p1.id, "ok"); | |
| 15 | seedCheck(w.app.db, w.repoId, p2.id, "ok"); | |
| 16 | seedGate(w.app.db, w.repoId, [p1.number], [{ name: NAME, file: "test/unreached.test.ts" }], now - 1000); | |
| 17 | expect(fileGateOnly(w.app.db, w.app.bus, w.repos, now)).toEqual([]); | |
| 18 | expect(w.titles()).not.toContain(gateOnlyTitle(NAME)); | |
| 19 | ||
| 20 | seedGate(w.app.db, w.repoId, [p2.number], [{ name: NAME, file: "test/unreached.test.ts" }], now - 500); | |
| 21 | const opened = fileGateOnly(w.app.db, w.app.bus, w.repos, now); | |
| 22 | expect(opened).toHaveLength(1); | |
| 23 | expect(w.titles().filter((t) => t === gateOnlyTitle(NAME))).toHaveLength(1); | |
| 24 | const body = w.app.db.query<{ body: string }, [string]>("SELECT body FROM issue WHERE title = ?1").get(gateOnlyTitle(NAME))!.body; | |
| 25 | expect(body).toContain("pull #"); | |
| 26 | expect(body).toContain("Files: test/unreached.test.ts"); | |
| 27 | expect(body).toContain("do not quarantine"); | |
| 28 | ||
| 29 | expect(fileGateOnly(w.app.db, w.app.bus, w.repos, now)).toEqual([]); | |
| 30 | }); | |
| 31 | ||
| 32 | test("a red whose pull was red on its own check is the pull's, not the gate's", () => { | |
| 33 | const a = seedPull(w.app.db, w.repoId, w.ownerId); | |
| 34 | const b = seedPull(w.app.db, w.repoId, w.ownerId); | |
| 35 | seedCheck(w.app.db, w.repoId, a.id, "failed", ["x > y"]); | |
| 36 | seedCheck(w.app.db, w.repoId, b.id, "failed", ["x > y"]); | |
| 37 | seedGate(w.app.db, w.repoId, [a.number], [{ name: "x > shared red", file: "test/x.test.ts" }], now - 300); | |
| 38 | seedGate(w.app.db, w.repoId, [b.number], [{ name: "x > shared red", file: "test/x.test.ts" }], now - 200); | |
| 39 | expect(fileGateOnly(w.app.db, w.app.bus, w.repos, now)).toEqual([]); | |
| 40 | }); | |
| 41 | ||
| 42 | test("a gate older than the week counts for nothing", () => { | |
| 43 | const a = seedPull(w.app.db, w.repoId, w.ownerId); | |
| 44 | seedCheck(w.app.db, w.repoId, a.id, "ok"); | |
| 45 | const old = now - 8 * 86_400_000; | |
| 46 | seedGate(w.app.db, w.repoId, [a.number], [{ name: "old > red", file: "test/o.test.ts" }], old); | |
| 47 | seedGate(w.app.db, w.repoId, [a.number], [{ name: "old > red", file: "test/o.test.ts" }], old + 1000); | |
| 48 | expect(fileGateOnly(w.app.db, w.app.bus, w.repos, now)).toEqual([]); | |
| 49 | }); | |
| 50 | ||
| 51 | test("a gate that carried several pulls names every one of them, not the first", () => { | |
| 52 | const a = seedPull(w.app.db, w.repoId, w.ownerId); | |
| 53 | const b = seedPull(w.app.db, w.repoId, w.ownerId); | |
| 54 | seedCheck(w.app.db, w.repoId, a.id, "ok"); | |
| 55 | seedCheck(w.app.db, w.repoId, b.id, "ok"); | |
| 56 | const name = "sweep > multi-pull scan"; | |
| 57 | seedGate(w.app.db, w.repoId, [a.number, b.number], [{ name, file: "test/m.test.ts" }], now - 900); | |
| 58 | seedGate(w.app.db, w.repoId, [b.number], [{ name, file: "test/m.test.ts" }], now - 800); | |
| 59 | fileGateOnly(w.app.db, w.app.bus, w.repos, now); | |
| 60 | const body = w.app.db.query<{ body: string }, [string]>("SELECT body FROM issue WHERE title = ?1").get(gateOnlyTitle(name))!.body; | |
| 61 | expect(body).toContain(`pulls #${a.number}, #${b.number}`); | |
| 62 | expect(body).toContain(`pull #${b.number})`); | |
| 63 | }); |
FLAKE_SQL is not a real query; a probe must be reproducible. Likewise the LIKE-on-findings week de-dupe at ~line 40 is fragile.