Public: anyone can read this; the crew can change it.
Batch doctor: on a red merge batch, find the culprit, tell load reds from real ones, hold with the exact failing test #11254
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7555-batch-doctor-on-a-red-merge-batch-find-t → main 10 commits fork 3 threads · 3 unresolved
18 files
changed +722
−3
docs/features/batch-doctor.md added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | # Batch doctor (#7555) | |
| 2 | ||
| 3 | When the merge queue's re-gate hands a pull back red (the re-gate carries it alone, so a red on top of other pulls is the queue's own search, #4999), `src/checks/doctor.ts` classes the red (`doctor_classify.ts`) and acts, in `crew/autonomy.ts`'s `handBack`: | |
| 4 | ||
| 5 | - **functional, alone on its merge**: the pull is held "until green" (out of the line), commented with the test, file, error line and `bun test ./file` to reproduce, and assigned back to its builder. A new head that passes its own check lifts the hold (`releaseOnGreen`). A red is held only when it reproduces on the queue's re-gate (the same head and base red again), so a flaky red still lands (#3155). A hold already on the pull (a person's) is kept, never overwritten. | |
| 6 | - **budget / infra**: recorded as `recheck`, not held. the queue re-gates a red pull alone later (#3155). | |
| 7 | ||
| 8 | Every act is a `maintenance_run` row (`duty = batch-doctor`): batch (`base:head`), culprit pull, class, action, probe (the first error line); the newest 200 per repository are kept (`RUNS_KEPT`). A sealed pull's number, batch and probe are told by no door. Read them on the flow panel (Batch doctor card), in the terminal Flow dialog, or as `doctor` in MCP `queue_gates` (all `doctorRuns`). A doctor's hold reason begins `batch doctor:` (`HOLD_PREFIX`, `isDoctorHold`). The infra class matches errno codes, `no space left`, `disk quota` and `tar:` lines only. |
src/checks/doctor.ts added +184
| @@ -0,0 +1,184 @@ | ||
| 1 | /** | |
| 2 | * The batch doctor (#7555): a merge-queue gate found one pull red. It classes | |
| 3 | * the red (`doctor_classify.ts`), and only a functional red of a pull alone | |
| 4 | * on its merge is held — out of the line, with a comment naming the exact | |
| 5 | * test, its file, the error line and the command to reproduce, and the pull | |
| 6 | * handed back to its builder. The hold is "until green": the first check | |
| 7 | * that passes on a new head lets it go (`actions/holds.ts` `releaseOnGreen`). | |
| 8 | * A hold already on the pull — a person's above all — is never overwritten. | |
| 9 | * Budget and infra reds are recorded and left to the queue's own re-gate. | |
| 10 | * It is called only on the queue's re-gate, which carries the pull alone. | |
| 11 | * Every act is one `maintenance_run` row; the newest `RUNS_KEPT` per | |
| 12 | * repository are kept. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { Database } from "bun:sqlite"; | |
| 16 | import type { App } from "../app.ts"; | |
| 17 | import { builderOf } from "../actions/give_back.ts"; | |
| 18 | import { addComment } from "../actions/issues.ts"; | |
| 19 | import { getHold, setHold } from "../core/holds.ts"; | |
| 20 | import { getIssueById, setAssignee } from "../core/issues.ts"; | |
| 21 | import type { Pull } from "../core/pulls.ts"; | |
| 22 | import type { Repo } from "../core/repos.ts"; | |
| 23 | import type { User } from "../core/users.ts"; | |
| 24 | import { classify, reproduce, type Diagnosis } from "./doctor_classify.ts"; | |
| 25 | import type { Red } from "./queue.ts"; | |
| 26 | ||
| 27 | export const DUTY = "batch-doctor"; | |
| 28 | ||
| 29 | /** What every doctor hold's reason begins with: how a person and `releaseOnGreen` tell it from the autonomy user's own. */ | |
| 30 | export const HOLD_PREFIX = "batch doctor:"; | |
| 31 | ||
| 32 | export const isDoctorHold = (why: string): boolean => why.startsWith(HOLD_PREFIX); | |
| 33 | ||
| 34 | export type DoctorAction = "held" | "recheck" | "kept"; | |
| 35 | ||
| 36 | /** The most `maintenance_run` rows the doctor keeps per repository; older ones are pruned on each write. */ | |
| 37 | export const RUNS_KEPT = 200; | |
| 38 | ||
| 39 | export interface DoctorRun { | |
| 40 | /** `base:head`, blank for a sealed pull. */ | |
| 41 | readonly batch: string; | |
| 42 | /** The pull's number, null for a sealed pull. */ | |
| 43 | readonly culprit: number | null; | |
| 44 | readonly class: string; | |
| 45 | readonly action: string; | |
| 46 | readonly probe: string; | |
| 47 | readonly at: string; | |
| 48 | /** The culprit is a sealed pull: its number, batch and probe are not told. */ | |
| 49 | readonly sealed: boolean; | |
| 50 | } | |
| 51 | ||
| 52 | /** Text a pull's author controls, made safe inside a markdown span: no backtick, `@`, newline or control character. */ | |
| 53 | export function plain(text: string): string { | |
| 54 | return text.replace(/[`@\u0000-\u001f\u007f]+/g, " ").replace(/ {2,}/g, " ").trim(); | |
| 55 | } | |
| 56 | ||
| 57 | function cleaned(d: Diagnosis): Diagnosis { | |
| 58 | return { | |
| 59 | ...d, | |
| 60 | probe: plain(d.probe), | |
| 61 | ...(d.test === undefined | |
| 62 | ? {} | |
| 63 | : { test: { ...d.test, name: plain(d.test.name), ...(d.test.file === undefined ? {} : { file: plain(d.test.file) }) } }), | |
| 64 | }; | |
| 65 | } | |
| 66 | ||
| 67 | /** Why a held pull says it is held: one line, the test first. */ | |
| 68 | function whyLine(d: Diagnosis): string { | |
| 69 | const name = d.test?.name ?? "the check"; | |
| 70 | return `${HOLD_PREFIX} ${name} fails alone`; | |
| 71 | } | |
| 72 | ||
| 73 | /** The comment a held pull gets: test, file, error line, command, what lifts it. */ | |
| 74 | export function holdComment(d: Diagnosis, builder: User | undefined): string { | |
| 75 | const lines = ["The batch doctor held this pull: it is red on its own merge with main, in a functional test."]; | |
| 76 | if (d.test !== undefined) { | |
| 77 | lines.push("", `- test: \`${d.test.name}\``); | |
| 78 | if (d.test.file !== undefined) lines.push(`- file: \`${d.test.file}\``); | |
| 79 | } | |
| 80 | lines.push(`- error: \`${d.probe}\``); | |
| 81 | const cmd = reproduce(d.test); | |
| 82 | if (cmd !== undefined) lines.push(`- reproduce: \`${cmd}\``); | |
| 83 | lines.push( | |
| 84 | "", | |
| 85 | `Rebase on main and fix it, then push: the hold lifts when the new head passes its own check.${builder ? ` Back to @${builder.username}.` : ""}`, | |
| 86 | ); | |
| 87 | return lines.join("\n"); | |
| 88 | } | |
| 89 | ||
| 90 | function record(app: App, repo: Repo, pull: Pull, batch: string, d: Diagnosis, action: DoctorAction): void { | |
| 91 | app.db | |
| 92 | .query( | |
| 93 | `INSERT INTO maintenance_run (duty, started, finished, findings, actions, probes, outcome, repo_id, issue_id, batch, culprit, class, action, probe) | |
| 94 | VALUES (?1, ?2, ?2, ?3, ?4, ?5, 'ok', ?6, ?7, ?8, ?9, ?10, ?11, ?12)`, | |
| 95 | ) | |
| 96 | .run( | |
| 97 | DUTY, | |
| 98 | new Date().toISOString(), | |
| 99 | JSON.stringify([`${d.class} red on pull #${pull.number}`]), | |
| 100 | JSON.stringify([action]), | |
| 101 | JSON.stringify([d.probe]), | |
| 102 | repo.id, | |
| 103 | pull.id, | |
| 104 | batch, | |
| 105 | pull.number, | |
| 106 | d.class, | |
| 107 | action, | |
| 108 | d.probe, | |
| 109 | ); | |
| 110 | app.db | |
| 111 | .query( | |
| 112 | `DELETE FROM maintenance_run WHERE duty = ?1 AND repo_id = ?2 AND id NOT IN | |
| 113 | (SELECT id FROM maintenance_run WHERE duty = ?1 AND repo_id = ?2 ORDER BY id DESC LIMIT ?3)`, | |
| 114 | ) | |
| 115 | .run(DUTY, repo.id, RUNS_KEPT); | |
| 116 | } | |
| 117 | ||
| 118 | /** Treat one red the queue's re-gate handed back, the pull alone on its merge. `by` acts. */ | |
| 119 | export function treatRed(app: App, repo: Repo, by: User, pull: Pull, red: Red, baseOid: string): DoctorAction { | |
| 120 | const batch = `${baseOid.slice(0, 12)}:${pull.headOid.slice(0, 12)}`; | |
| 121 | const d = cleaned(classify(red.reason, red.tests)); | |
| 122 | let action: DoctorAction; | |
| 123 | if (d.class !== "functional") action = "recheck"; | |
| 124 | else if (getHold(app.db, pull.id) !== undefined) action = "kept"; | |
| 125 | else { | |
| 126 | action = "held"; | |
| 127 | const issue = getIssueById(app.db, pull.id); | |
| 128 | const hold = setHold(app.db, pull.id, by.id, whyLine(d).slice(0, 200), "green"); | |
| 129 | app.bus.append({ | |
| 130 | kind: "pull.held", | |
| 131 | repoId: repo.id, | |
| 132 | actorId: by.id, | |
| 133 | payload: { number: pull.number, why: hold.why }, | |
| 134 | }); | |
| 135 | if (issue !== undefined) { | |
| 136 | const builder = builderOf(app.db, repo, issue); | |
| 137 | addComment(app, by, repo, issue, holdComment(d, builder)); | |
| 138 | if (builder && setAssignee(app.db, issue.id, builder.id)) | |
| 139 | app.bus.append({ | |
| 140 | kind: "pull.assigned", | |
| 141 | repoId: repo.id, | |
| 142 | actorId: by.id, | |
| 143 | payload: { number: pull.number, assignee: builder.username, agent: true }, | |
| 144 | }); | |
| 145 | } | |
| 146 | } | |
| 147 | record(app, repo, pull, batch, d, action); | |
| 148 | return action; | |
| 149 | } | |
| 150 | ||
| 151 | /** What a reader is told of one run: a sealed pull's number, batch and probe are blanked. */ | |
| 152 | const RUN_COLUMNS = `CASE WHEN i.sealed = 1 THEN '' ELSE m.batch END AS batch, | |
| 153 | CASE WHEN i.sealed = 1 THEN NULL ELSE m.culprit END AS culprit, m.class, m.action, | |
| 154 | CASE WHEN i.sealed = 1 THEN '' ELSE m.probe END AS probe, m.started AS at, COALESCE(i.sealed, 0) AS sealed`; | |
| 155 | ||
| 156 | type RunRow = Omit<DoctorRun, "sealed"> & { sealed: number }; | |
| 157 | const toRun = ({ sealed, ...r }: RunRow): DoctorRun => ({ ...r, sealed: sealed === 1 }); | |
| 158 | ||
| 159 | /** The doctor's last runs on a repository, newest first; a sealed pull is not named. */ | |
| 160 | export function doctorRuns(db: Database, repoId: string, limit = 20): DoctorRun[] { | |
| 161 | return db | |
| 162 | .query<RunRow, [string, string, number]>( | |
| 163 | `SELECT ${RUN_COLUMNS} | |
| 164 | FROM maintenance_run m LEFT JOIN issue i ON i.id = m.issue_id | |
| 165 | WHERE m.duty = ?1 AND m.repo_id = ?2 ORDER BY m.id DESC LIMIT ?3`, | |
| 166 | ) | |
| 167 | .all(DUTY, repoId, limit) | |
| 168 | .map(toRun); | |
| 169 | } | |
| 170 | ||
| 171 | /** The doctor's newest runs across repositories, in one read: every repository for `all`, public ones otherwise. */ | |
| 172 | export function recentDoctorRuns(db: Database, all: boolean, limit: number): { repo: string; run: DoctorRun }[] { | |
| 173 | return db | |
| 174 | .query<RunRow & { repo: string }, [string, number]>( | |
| 175 | `SELECT ${RUN_COLUMNS}, u.username || '/' || r.name AS repo | |
| 176 | FROM maintenance_run m | |
| 177 | JOIN repo r ON r.id = m.repo_id JOIN user u ON u.id = r.owner_id | |
| 178 | LEFT JOIN issue i ON i.id = m.issue_id | |
| 179 | WHERE m.duty = ?1${all ? "" : " AND r.visibility = 'public'"} | |
| 180 | ORDER BY m.id DESC LIMIT ?2`, | |
| 181 | ) | |
| 182 | .all(DUTY, limit) | |
| 183 | .map(({ repo, ...r }) => ({ repo, run: toRun(r) })); | |
| 184 | } |
src/checks/doctor_classify.ts added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | /** | |
| 2 | * The batch doctor's first question about a red (#7555): was it the load, | |
| 3 | * the machine, or the code? Read off the verdict the gate already carries | |
| 4 | * (`reason`, `tests`), never off a log. | |
| 5 | * | |
| 6 | * - `budget`: every failing test threw `expectBudget` — the gate's own load | |
| 7 | * (the queue re-gates a red pull alone later); not held. | |
| 8 | * - `infra`: the gate stopped before tests, or a test died on the machine | |
| 9 | * (extract, ENOENT, tarball, disk); not held. | |
| 10 | * - `functional`: a test the code made fail; held when alone. | |
| 11 | * | |
| 12 | * Plain data in, plain data out: no I/O. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { GateReason } from "./gate_reason.ts"; | |
| 16 | import type { FailedTest, GateTests } from "./gate_tests.ts"; | |
| 17 | ||
| 18 | /** What a test's `expectBudget` throws: the mark of a timing red, not a logic one. */ | |
| 19 | const BUDGET_MARK = "over budget (expectBudget)"; | |
| 20 | ||
| 21 | export type RedClass = "budget" | "infra" | "functional"; | |
| 22 | ||
| 23 | export interface Diagnosis { | |
| 24 | readonly class: RedClass; | |
| 25 | /** The first failing test, when the gate named one. */ | |
| 26 | readonly test?: FailedTest; | |
| 27 | /** The line the class went on: the first error line, else the reason. */ | |
| 28 | readonly probe: string; | |
| 29 | } | |
| 30 | ||
| 31 | // Specific shapes only: a bare word like `extract` is as likely a function under test. | |
| 32 | const INFRA = /\b(ENOENT|ENOSPC|EMFILE|ETIMEDOUT|ECONNRESET)\b|no space left|disk quota|(^|\s)tar: /i; | |
| 33 | const PROBE_MAX = 300; | |
| 34 | ||
| 35 | const clip = (s: string): string => (s.length > PROBE_MAX ? `${s.slice(0, PROBE_MAX - 1)}…` : s); | |
| 36 | ||
| 37 | /** Class a red from its reason and the tests it named. */ | |
| 38 | export function classify(reason: GateReason | undefined, tests: GateTests | undefined): Diagnosis { | |
| 39 | if (reason !== undefined && (reason.kind === "install" || reason.kind === "setup" || reason.kind === "timeout")) | |
| 40 | return { class: "infra", probe: reason.kind }; | |
| 41 | const failing = tests?.failing ?? []; | |
| 42 | const first = failing[0]; | |
| 43 | const probe = clip(first?.errors[0] ?? (reason?.kind ?? "check")); | |
| 44 | // No test named: nothing to hold it by, so it is the queue's to re-gate. | |
| 45 | if (first === undefined) return { class: "infra", probe }; | |
| 46 | const errors = (t: FailedTest): string => t.errors.join("\n"); | |
| 47 | if (failing.every((t) => t.errors[0]?.includes(BUDGET_MARK))) return { class: "budget", test: first, probe }; | |
| 48 | if (failing.every((t) => INFRA.test(errors(t)))) return { class: "infra", test: first, probe }; | |
| 49 | return { class: "functional", test: first, probe }; | |
| 50 | } | |
| 51 | ||
| 52 | const SAFE = /^[\w@./+-]{1,200}$/; | |
| 53 | ||
| 54 | /** The command that reproduces a failing test, or undefined when its file is not a plain path. */ | |
| 55 | export function reproduce(test: FailedTest | undefined): string | undefined { | |
| 56 | if (test?.file === undefined || !SAFE.test(test.file)) return undefined; | |
| 57 | const path = test.file.startsWith("./") || test.file.startsWith("/") ? test.file : `./${test.file}`; | |
| 58 | return `bun test ${path}`; | |
| 59 | } |
src/core/migration-pins/20261111110000-maintenance-run-doctor.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | e88ce9739bfc9660667668ab3ca760a26d48aa98064a8a4cb79908e44b5d9c77 |
src/core/migrations/20261111110000-maintenance-run-doctor.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | // The batch doctor's acts (#7555) are `maintenance_run` rows (duty `batch-doctor`): | |
| 2 | // the repository and pull they concern, the batch, the culprit pull, how the red | |
| 3 | // was classed, what was done and the test output line it went on. Additive: columns only. | |
| 4 | export default ` | |
| 5 | ALTER TABLE maintenance_run ADD COLUMN repo_id TEXT; | |
| 6 | ALTER TABLE maintenance_run ADD COLUMN issue_id TEXT; | |
No cascade on these new columns: rows outlive their repository. Delete a repository's |
||
| 7 | ALTER TABLE maintenance_run ADD COLUMN batch TEXT NOT NULL DEFAULT ''; | |
| 8 | ALTER TABLE maintenance_run ADD COLUMN culprit INTEGER; | |
| 9 | ALTER TABLE maintenance_run ADD COLUMN class TEXT NOT NULL DEFAULT ''; | |
| 10 | ALTER TABLE maintenance_run ADD COLUMN action TEXT NOT NULL DEFAULT ''; | |
| 11 | ALTER TABLE maintenance_run ADD COLUMN probe TEXT NOT NULL DEFAULT ''; | |
| 12 | CREATE INDEX maintenance_run_repo ON maintenance_run (duty, repo_id, id); | |
| 13 | `; |
src/core/repos.ts +2
| @@ -1050,6 +1050,8 @@ export async function deleteRepo( | ||
| 1050 | 1050 | // still reported. |
| 1051 | 1051 | const removed = await deleteRepoDir(dir); |
| 1052 | 1052 | if (!removed.ok && present) return err("delete_failed"); |
| 1053 | // The doctor's rows carry `repo_id` without a foreign key (added columns on `maintenance_run`). | |
| 1054 | db.query("DELETE FROM maintenance_run WHERE duty = 'batch-doctor' AND repo_id = ?1").run(repo.id); | |
| 1053 | 1055 | db.query("DELETE FROM repo WHERE id = ?1").run(repo.id); |
| 1054 | 1056 | forgetRepoCursors(db, repo.id); |
| 1055 | 1057 | forgetListings(db); |
src/crew/autonomy.ts +3
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | * repository's origin. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | import { treatRed } from "../checks/doctor.ts"; | |
| 15 | 16 | import { tickBets } from "../actions/bets.ts"; |
| 16 | 17 | import { betsInUse } from "../core/bets.ts"; |
| 17 | 18 | import { onlyListDocs, resolveListConflicts } from "../actions/list-conflicts.ts"; |
| @@ -2196,6 +2197,8 @@ async function admit( | ||
| 2196 | 2197 | }); |
| 2197 | 2198 | addLearned(app.config.dataDir, pull.id, red.learn); |
| 2198 | 2199 | if (!again) addComment(app, by, repo, getIssueById(app.db, pull.id) ?? pull, clashSaid(red.comment, together)); |
| 2200 | // A red that reproduces on the re-gate is the code's: only then is it the doctor's to hold. | |
| 2201 | if (again) treatRed(app, repo, by, pull, red, baseOid); | |
| 2199 | 2202 | }, |
| 2200 | 2203 | }); |
| 2201 | 2204 | } |
src/http/mcp/tools/queue_gates.ts +3 −2
| @@ -1,9 +1,10 @@ | ||
| 1 | import { doctorRuns } from "../../../checks/doctor.ts"; | |
| 1 | 2 | import { recentGates } from "../../../checks/gate_result.ts"; |
| 2 | 3 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 3 | 4 | |
| 4 | 5 | export const tool: Tool = { |
| 5 | 6 | name: "queue_gates", |
| 6 | doc: "`{ repo, limit? }`: the merge queue's recent gates, newest first (`limit` 1–100, default 20), as `GET …/queue/gates` answers: each gate's pulls, state, reason, counts and failing tests by name with their first error lines, a test red in two gates that share no pull flagged `flaky`", | |
| 7 | doc: "`{ repo, limit? }`: the merge queue's recent gates, newest first (`limit` 1–100, default 20), as `GET …/queue/gates` answers: each gate's pulls, state, reason, counts and failing tests by name with their first error lines, a test red in two gates that share no pull flagged `flaky`; `doctor`: the batch doctor's last acts on the repository (`culprit`, `class`, `action` held/recheck/kept, `probe`; a sealed pull's `culprit` is null and its `batch` and `probe` blank)", | |
| 7 | 8 | description: "Recent merge-queue gates: pulls, verdicts, failing tests.", |
| 8 | 9 | inputSchema: { |
| 9 | 10 | type: "object", |
| @@ -22,6 +23,6 @@ export const tool: Tool = { | ||
| 22 | 23 | return bad("invalid", "`limit` is a whole number from 1 to 100"); |
| 23 | 24 | const gates = recentGates(ctx.app.db, repo.id, limit); |
| 24 | 25 | const red = gates.filter((g) => g.state === "red").length; |
| 25 | return good({ says: `${gates.length} gate(s), ${red} red.`, gates }); | |
| 26 | return good({ says: `${gates.length} gate(s), ${red} red.`, gates, doctor: doctorRuns(ctx.app.db, repo.id, limit) }); | |
| 26 | 27 | }, |
| 27 | 28 | }; |
src/tui/screens/flow.ts +13
| @@ -21,6 +21,7 @@ | ||
| 21 | 21 | */ |
| 22 | 22 | |
| 23 | 23 | import type { Database } from "bun:sqlite"; |
| 24 | import { doctorRuns, type DoctorRun } from "../../checks/doctor.ts"; | |
| 24 | 25 | import { recentGates, type KeptResult } from "../../checks/gate_result.ts"; |
| 25 | 26 | import { reportLine } from "../../checks/report.ts"; |
| 26 | 27 | import { HOLDS } from "../../crew/held.ts"; |
| @@ -133,6 +134,7 @@ export function flowLines( | ||
| 133 | 134 | return [ |
| 134 | 135 | `${pad(truncate(q.repo, 15), 15)} ${pad(String(q.queued), 6, "right")} ${pad(String(q.gating), 6, "right")} ${pad(String(q.gates), 5, "right")} ${pad(String(q.landedHour), 9, "right")} ${pad(q.approvedToLanded === null ? "—" : String(q.approvedToLanded), 12, "right")}`, |
| 135 | 136 | ...gateLines(id === undefined ? [] : recentGates(db, id, GATES_SHOWN), now.getTime(), inner), |
| 137 | ...doctorLines(id === undefined ? [] : doctorRuns(db, id, DOCTOR_SHOWN), now.getTime(), inner), | |
| 136 | 138 | ]; |
| 137 | 139 | })), |
| 138 | 140 | ...(red.length === 0 |
| @@ -216,6 +218,17 @@ export function gateLines(gates: readonly KeptResult[], now: number, inner: numb | ||
| 216 | 218 | }); |
| 217 | 219 | } |
| 218 | 220 | |
| 221 | /** The doctor's acts shown under a repository's gates. */ | |
| 222 | const DOCTOR_SHOWN = 3; | |
| 223 | ||
| 224 | /** One indented line per batch-doctor act (#7555), newest first: `doctor #14 held · functional · how long ago · the error line`; none when it has not acted. */ | |
| 225 | export function doctorLines(runs: readonly DoctorRun[], now: number, inner: number): string[] { | |
| 226 | return runs.map((r) => { | |
| 227 | const pull = r.sealed || r.culprit === null ? "a sealed pull" : `#${r.culprit}`; | |
| 228 | return truncate(` doctor ${pull} ${r.action} · ${r.class} · ${ago(Date.parse(r.at), now)}${r.probe === "" ? "" : ` · ${r.probe}`}`, inner); | |
| 229 | }); | |
| 230 | } | |
| 231 | ||
| 219 | 232 | /** Idle pulls shown before `+N more`. */ |
| 220 | 233 | const IDLE_SHOWN = 5; |
| 221 | 234 |
src/web/pages/boards-doctor.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * The batch doctor's last acts (#7555), for the flow panel: the newest ten | |
| 3 | * across the repositories the reader may see, one row each with when, the | |
| 4 | * culprit pull, what the doctor did and how it classed the red. A sealed | |
| 5 | * pull is named only as `a sealed pull`, with no probe; one read | |
| 6 | * (`recentDoctorRuns`). | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { recentDoctorRuns, type DoctorRun } from "../../checks/doctor.ts"; | |
| 11 | import { span } from "../../flow/pipeline.ts"; | |
| 12 | import { html, type Html } from "../html.ts"; | |
| 13 | import { badge, card, row } from "../ui.ts"; | |
| 14 | ||
| 15 | /** The most runs the card lists. */ | |
| 16 | export const DOCTOR_LISTED = 10; | |
| 17 | ||
| 18 | function runRow(repo: string, run: DoctorRun, now: number): Html { | |
| 19 | const tone = run.action === "held" ? "danger" : "default"; | |
| 20 | return row({ | |
| 21 | data: { doctor: run.action }, | |
| 22 | title: html`${run.sealed || run.culprit === null ? html`<span class="muted">a sealed pull</span>` : html`<a href="/${repo}/pulls/${String(run.culprit)}">#${String(run.culprit)}</a>`} ${badge(run.action, { tone })}`, | |
| 23 | meta: html`${span(Math.max(0, now - Date.parse(run.at)))} ago · ${repo} · ${run.class}`, | |
| 24 | ...(run.probe === "" ? {} : { facts: [{ label: "error", value: html`<span class="break">${run.probe}</span>` }] }), | |
| 25 | }); | |
| 26 | } | |
| 27 | ||
outdated
Principle 1: say in one plain sentence what this card is for. Principle 7: the dotted meta line mixes repo, class and probe, so give the probe its own row. |
||
| 28 | /** The "Batch doctor" card: nothing until the doctor has treated a red (an empty card is one more note on a page held to a count). */ | |
| 29 | export function doctorCard(db: Database, owner: boolean, now: number): Html { | |
| 30 | const top = recentDoctorRuns(db, owner, DOCTOR_LISTED); | |
| 31 | if (top.length === 0) return html``; | |
| 32 | return card(html`<h2>Batch doctor</h2><p class="muted">When a merge gate finds a pull red on its own, the doctor names the failing test and holds the pull until it passes.</p><ul class="list">${top.map((l) => runRow(l.repo, l.run, now))}</ul>`, { id: "doctor" }); | |
| 33 | } |
src/web/pages/flow-panel.ts +2
| @@ -51,6 +51,7 @@ import { readHeld } from "../../flow/held.ts"; | ||
| 51 | 51 | import { readVerified, verifiedSays, type VerifiedRow } from "../../flow/verified.ts"; |
| 52 | 52 | import type { App } from "../../app.ts"; |
| 53 | 53 | import { html, type Html } from "../html.ts"; |
| 54 | import { doctorCard } from "./boards-doctor.ts"; | |
| 54 | 55 | import { keeperRow } from "../studio/keeper_row.ts"; |
| 55 | 56 | import { gatesSection } from "./boards-gates.ts"; |
| 56 | 57 | import { heldCard } from "./boards-held.ts"; |
| @@ -233,6 +234,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 233 | 234 | ${heldCard(readHeld(app, reader.kind === "owner", now))} |
| 234 | 235 | ${queueCard(queuesFor(app, reader, new Date(now), { batches: true }))} |
| 235 | 236 | ${gatesSection(app.db, reader.kind === "owner", now)} |
| 237 | ${doctorCard(app.db, reader.kind === "owner", now)} | |
| 236 | 238 | ${url.searchParams.get("idle") === "all" && idle.length > 0 ? idleList(idle) : null} |
| 237 | 239 | ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`; |
| 238 | 240 | } |
test/doctor-classify.test.ts added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { classify, reproduce } from "../src/checks/doctor_classify.ts"; | |
| 3 | import { redFor } from "./helpers/doctor.ts"; | |
| 4 | ||
| 5 | const of = (error: string, reason?: Parameters<typeof redFor>[3]) => { | |
| 6 | const red = redFor("t", "test/a.test.ts", error, reason); | |
| 7 | return classify(red.reason, red.tests); | |
| 8 | }; | |
| 9 | ||
| 10 | describe("the batch doctor classes a red", () => { | |
| 11 | test("every test over its budget is a budget red", () => { | |
| 12 | const d = of("error: over budget (expectBudget): GET / took 90ms, budget 50ms"); | |
| 13 | expect(d.class).toBe("budget"); | |
| 14 | expect(d.probe).toContain("over budget"); | |
| 15 | }); | |
| 16 | ||
| 17 | test("ENOENT, tarball and disk errors are infra", () => { | |
| 18 | expect(of("error: ENOENT: no such file or directory, open 'x'").class).toBe("infra"); | |
| 19 | expect(of("error: tar: extract failed: unexpected end of tarball").class).toBe("infra"); | |
| 20 | expect(of("error: ENOSPC: no space left on device").class).toBe("infra"); | |
| 21 | }); | |
| 22 | ||
| 23 | test("a functional error that merely says extract or tarball is not infra", () => { | |
| 24 | expect(of("error: expected extract() to return 3").class).toBe("functional"); | |
| 25 | expect(of("error: tarball is not a function").class).toBe("functional"); | |
| 26 | }); | |
| 27 | ||
| 28 | test("a gate that stopped before its tests is infra", () => { | |
| 29 | expect(of("", { kind: "install" }).class).toBe("infra"); | |
| 30 | expect(of("", { kind: "timeout" }).class).toBe("infra"); | |
| 31 | }); | |
| 32 | ||
| 33 | test("anything else is functional, with the test and its error line as the probe", () => { | |
| 34 | const d = of("error: expect(received).toBe(expected)"); | |
| 35 | expect(d.class).toBe("functional"); | |
| 36 | expect(d.test?.name).toBe("t"); | |
| 37 | expect(d.probe).toBe("error: expect(received).toBe(expected)"); | |
| 38 | }); | |
| 39 | ||
| 40 | test("a mix of budget and functional reds is functional", () => { | |
| 41 | const red = redFor("a", "test/a.test.ts", "error: over budget (expectBudget): slow"); | |
| 42 | const tests = { ...red.tests!, failed: 2, failing: [...red.tests!.failing, { name: "b", file: "test/b.test.ts", errors: ["error: boom"] }] }; | |
| 43 | expect(classify(red.reason, tests).class).toBe("functional"); | |
| 44 | }); | |
| 45 | ||
| 46 | test("the reproduce command refuses a file that is not a plain path", () => { | |
| 47 | expect(reproduce({ name: "t", file: "test/a.test.ts", errors: [] })).toBe("bun test ./test/a.test.ts"); | |
| 48 | expect(reproduce({ name: "t", file: "a; rm -rf /", errors: [] })).toBeUndefined(); | |
| 49 | expect(reproduce(undefined)).toBeUndefined(); | |
| 50 | }); | |
| 51 | }); |
test/doctor-culprit.test.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { deleteRepo } from "../src/actions/repos.ts"; | |
| 3 | import { doctorRuns, HOLD_PREFIX, isDoctorHold, RUNS_KEPT, treatRed } from "../src/checks/doctor.ts"; | |
| 4 | import { getHold } from "../src/core/holds.ts"; | |
| 5 | import { listComments } from "../src/core/issues.ts"; | |
| 6 | import { doctorWorld, redFor, type Doctor } from "./helpers/doctor.ts"; | |
| 7 | ||
| 8 | describe("the batch doctor and a red culprit", () => { | |
| 9 | let w: Doctor; | |
| 10 | beforeAll(async () => { | |
| 11 | w = await doctorWorld("culprit"); | |
| 12 | }); | |
| 13 | afterAll(() => w.close()); | |
| 14 | ||
| 15 | test("a functional red alone is held with a comment naming test, file, error and command; the batch is recorded", async () => { | |
| 16 | const pull = await w.pull("bad.ts"); | |
| 17 | const red = redFor("adds two numbers", "test/add.test.ts", "error: expect(received).toBe(expected)"); | |
| 18 | expect(treatRed(w.app, w.repo, w.alice, pull, red, "b".repeat(40))).toBe("held"); | |
| 19 | const hold = getHold(w.app.db, pull.id)!; | |
| 20 | expect(hold.until).toBe("green"); | |
| 21 | expect(hold.why).toContain("adds two numbers"); | |
| 22 | expect(hold.why.startsWith(HOLD_PREFIX)).toBe(true); | |
| 23 | expect(isDoctorHold(hold.why)).toBe(true); | |
| 24 | expect(isDoctorHold("waiting on a person")).toBe(false); | |
| 25 | const said = listComments(w.app.db, pull.id).map((c) => c.body).join("\n"); | |
| 26 | expect(said).toContain("adds two numbers"); | |
| 27 | expect(said).toContain("test/add.test.ts"); | |
| 28 | expect(said).toContain("expect(received).toBe(expected)"); | |
| 29 | expect(said).toContain("bun test ./test/add.test.ts"); | |
| 30 | const [run] = doctorRuns(w.app.db, w.repo.id); | |
| 31 | expect(run).toMatchObject({ culprit: pull.number, class: "functional", action: "held" }); | |
| 32 | expect(run!.batch).toStartWith("bbbbbbbbbbbb:"); | |
| 33 | expect(run!.probe).toContain("expect(received)"); | |
| 34 | }); | |
| 35 | ||
| 36 | test("a budget-only red is rechecked, not held", async () => { | |
| 37 | const pull = await w.pull("slow.ts"); | |
| 38 | const red = redFor("serves /", "test/slow.test.ts", "error: over budget (expectBudget): 90ms"); | |
| 39 | expect(treatRed(w.app, w.repo, w.alice, pull, red, "c".repeat(40))).toBe("recheck"); | |
| 40 | expect(getHold(w.app.db, pull.id)).toBeUndefined(); | |
| 41 | expect(doctorRuns(w.app.db, w.repo.id)[0]).toMatchObject({ class: "budget", action: "recheck" }); | |
| 42 | }); | |
| 43 | ||
| 44 | test("the doctor keeps only its newest RUNS_KEPT runs per repository", async () => { | |
| 45 | const pull = await w.pull("many.ts"); | |
| 46 | const red = redFor("serves /", "test/slow.test.ts", "error: over budget (expectBudget): 90ms"); | |
| 47 | for (let i = 0; i < RUNS_KEPT + 5; i++) treatRed(w.app, w.repo, w.alice, pull, red, "e".repeat(40)); | |
| 48 | const kept = w.app.db | |
| 49 | .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM maintenance_run WHERE repo_id = ?1") | |
| 50 | .get(w.repo.id)!.n; | |
| 51 | expect(kept).toBe(RUNS_KEPT); | |
| 52 | }); | |
| 53 | ||
| 54 | test("a test name with a backtick and @admin cannot break out of the span in the comment or the hold", async () => { | |
| 55 | const pull = await w.pull("evil.ts"); | |
| 56 | const red = redFor("x` @admin [a](http://e.test) `", "test/e`@bob.test.ts", "error: boom` @carol"); | |
| 57 | expect(treatRed(w.app, w.repo, w.alice, pull, red, "f".repeat(40))).toBe("held"); | |
| 58 | const said = listComments(w.app.db, pull.id).map((c) => c.body).join("\n"); | |
| 59 | expect(said).not.toContain("@admin"); | |
| 60 | expect(said).not.toContain("@bob"); | |
| 61 | expect(said).not.toContain("@carol"); | |
| 62 | expect(getHold(w.app.db, pull.id)!.why).not.toContain("@"); | |
| 63 | expect(getHold(w.app.db, pull.id)!.why).not.toContain("`"); | |
| 64 | expect(said.split("`").length % 2).toBe(1); | |
| 65 | expect(said.match(/`[^`\n]*`/g)!.join("")).not.toContain("@"); | |
| 66 | }); | |
| 67 | ||
| 68 | test("deleting the repository deletes its doctor rows", async () => { | |
| 69 | const other = await doctorWorld("culprit-gone"); | |
| 70 | try { | |
| 71 | const pull = await other.pull("g.ts"); | |
| 72 | treatRed(other.app, other.repo, other.alice, pull, redFor("t", "test/t.test.ts", "error: x"), "a".repeat(40)); | |
| 73 | expect(doctorRuns(other.app.db, other.repo.id).length).toBe(1); | |
| 74 | expect((await deleteRepo(other.app, other.alice, other.repo)).ok).toBe(true); | |
| 75 | expect(doctorRuns(other.app.db, other.repo.id).length).toBe(0); | |
| 76 | } finally { | |
| 77 | await other.close(); | |
| 78 | } | |
| 79 | }); | |
| 80 | }); |
test/doctor-doors.test.ts added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | /** | |
| 2 | * The batch doctor's runs are reachable (#7555): the flow panel, the terminal Flow dialog and MCP `queue_gates` | |
| 3 | * each show them, and none tells a guest or a sealed pull's probe. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { treatRed } from "../src/checks/doctor.ts"; | |
| 7 | import { createToken } from "../src/core/tokens.ts"; | |
| 8 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 9 | import { doctorLines, flowLines } from "../src/tui/screens/flow.ts"; | |
| 10 | import { doctorWorld, redFor, type Doctor } from "./helpers/doctor.ts"; | |
| 11 | ||
| 12 | describe("the batch doctor's runs are reachable", () => { | |
| 13 | let w: Doctor; | |
| 14 | let servers: Servers; | |
| 15 | let pullNumber = 0; | |
| 16 | beforeAll(async () => { | |
| 17 | w = await doctorWorld("doors"); | |
| 18 | servers = await serve(w.app); | |
| 19 | const pull = await w.pull("bad.ts"); | |
| 20 | pullNumber = pull.number; | |
| 21 | treatRed(w.app, w.repo, w.alice, pull, redFor("adds two", "test/add.test.ts", "error: expected 3 got 4"), "c".repeat(40)); | |
| 22 | }); | |
| 23 | afterAll(async () => { | |
| 24 | servers.stop(); | |
| 25 | await w.close(); | |
| 26 | }); | |
| 27 | ||
| 28 | const login = async (): Promise<string> => { | |
| 29 | const res = await fetch(`${servers.url}/signin`, { | |
| 30 | method: "POST", | |
| 31 | redirect: "manual", | |
| 32 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 33 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 34 | }); | |
| 35 | return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 36 | }; | |
| 37 | const mcp = async () => { | |
| 38 | const res = await fetch(`${servers.url}/mcp`, { | |
| 39 | method: "POST", | |
| 40 | headers: { | |
| 41 | authorization: `Bearer ${createToken(w.app.db, w.alice.id, "t", "ro").token}`, | |
| 42 | "content-type": "application/json", | |
| 43 | accept: "application/json, text/event-stream", | |
| 44 | }, | |
| 45 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "queue_gates", arguments: { repo: "alice/doors" } } }), | |
| 46 | }); | |
| 47 | const body = (await res.json()) as { result: { isError?: boolean; content: { text: string }[] } }; | |
| 48 | expect(body.result.isError).not.toBe(true); | |
| 49 | return JSON.parse(body.result.content[0]!.text) as { doctor: { culprit: number | null; batch: string; action: string; class: string; probe: string; sealed: boolean }[] }; | |
| 50 | }; | |
| 51 | const seal = (on: number) => w.app.db.query("UPDATE issue SET sealed = ?2 WHERE repo_id = ?1 AND number = ?3").run(w.repo.id, on, pullNumber); | |
| 52 | ||
| 53 | test("the flow panel lists it for the owner and not for a guest", async () => { | |
| 54 | const page = await (await fetch(`${servers.url}/boards`, { headers: { cookie: await login() } })).text(); | |
| 55 | expect(page).toContain("Batch doctor"); | |
| 56 | expect(page).toContain(`/alice/doors/pulls/${pullNumber}`); | |
| 57 | expect(page).toContain("expected 3 got 4"); | |
| 58 | const guest = await (await fetch(`${servers.url}/boards`)).text(); | |
| 59 | expect(guest).not.toContain("alice/doors"); | |
| 60 | expect(guest).not.toContain("expected 3 got 4"); | |
| 61 | }); | |
| 62 | ||
| 63 | test("the terminal Flow dialog lists it under the repository's queue line", () => { | |
| 64 | const queues = [{ repo: "alice/doors", queued: 1, gating: 0, gates: 0, landedHour: 0, approvedToLanded: null }]; | |
| 65 | const lines = flowLines(w.app.db, new Date(), [], { queues }, []); | |
| 66 | expect(lines.some((l) => l.startsWith(` doctor #${pullNumber} held · functional`))).toBe(true); | |
| 67 | }); | |
| 68 | ||
| 69 | test("MCP queue_gates carries it, and a read-only token may read it", async () => { | |
| 70 | const { doctor } = await mcp(); | |
| 71 | expect(doctor[0]).toMatchObject({ culprit: pullNumber, action: "held", class: "functional", sealed: false }); | |
| 72 | expect(doctor[0]!.probe).toContain("expected 3 got 4"); | |
| 73 | }); | |
| 74 | ||
| 75 | test("a sealed culprit is named by no door and its probe is blank", async () => { | |
| 76 | seal(1); | |
| 77 | try { | |
| 78 | const { doctor } = await mcp(); | |
| 79 | expect(doctor[0]).toMatchObject({ sealed: true, probe: "", culprit: null, batch: "" }); | |
| 80 | expect(JSON.stringify(doctor)).not.toContain(`"culprit":${pullNumber}`); | |
| 81 | const page = await (await fetch(`${servers.url}/boards`, { headers: { cookie: await login() } })).text(); | |
| 82 | expect(page).toContain("a sealed pull"); | |
| 83 | expect(page).not.toContain("expected 3 got 4"); | |
| 84 | const lines = doctorLines(doctor.map((d) => ({ ...d, at: new Date().toISOString() })), Date.now(), 70); | |
| 85 | expect(lines[0]).toContain("a sealed pull"); | |
| 86 | expect(lines[0]).not.toContain("expected 3 got 4"); | |
| 87 | } finally { | |
| 88 | seal(0); | |
| 89 | } | |
| 90 | }); | |
| 91 | }); |
test/doctor-hold-lift.test.ts added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { releaseOnGreen } from "../src/actions/holds.ts"; | |
| 3 | import { treatRed } from "../src/checks/doctor.ts"; | |
| 4 | import { getHold } from "../src/core/holds.ts"; | |
| 5 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 6 | import { doctorWorld, redFor, type Doctor } from "./helpers/doctor.ts"; | |
| 7 | ||
| 8 | describe("a hold the doctor set lifts on a green new head", () => { | |
| 9 | let w: Doctor; | |
| 10 | beforeAll(async () => { | |
| 11 | w = await doctorWorld("lift"); | |
| 12 | }); | |
| 13 | afterAll(() => w.close()); | |
| 14 | ||
| 15 | const check = (pullId: string, number: number, commitOid: string, state: "ok" | "failed") => { | |
| 16 | const run = createRun(w.app.db, { repoId: w.repo.id, kind: "check", issueId: pullId, env: `pull-${number}`, commitOid, reason: "pull" }); | |
| 17 | claimRun(w.app.db, run.id); | |
| 18 | return finishRun(w.app.db, run.id, { state, summary: state })!; | |
| 19 | }; | |
| 20 | ||
| 21 | test("a red check keeps it, a green check of the head lets it go", async () => { | |
| 22 | const pull = await w.pull("lift.ts"); | |
| 23 | treatRed(w.app, w.repo, w.alice, pull, redFor("t", "test/t.test.ts", "error: boom"), "e".repeat(40)); | |
| 24 | expect(getHold(w.app.db, pull.id)).toBeDefined(); | |
| 25 | expect(releaseOnGreen(w.app, check(pull.id, pull.number, pull.headOid, "failed"))).toBe(false); | |
| 26 | expect(getHold(w.app.db, pull.id)).toBeDefined(); | |
| 27 | expect(releaseOnGreen(w.app, check(pull.id, pull.number, pull.headOid, "ok"))).toBe(true); | |
| 28 | expect(getHold(w.app.db, pull.id)).toBeUndefined(); | |
| 29 | }); | |
| 30 | }); |
test/doctor-person-hold.test.ts added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { releaseOnGreen } from "../src/actions/holds.ts"; | |
| 3 | import { doctorRuns, treatRed } from "../src/checks/doctor.ts"; | |
| 4 | import { getHold, setHold } from "../src/core/holds.ts"; | |
| 5 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 6 | import { doctorWorld, redFor, type Doctor } from "./helpers/doctor.ts"; | |
| 7 | ||
| 8 | describe("the doctor never touches a hold a person set", () => { | |
| 9 | let w: Doctor; | |
| 10 | beforeAll(async () => { | |
| 11 | w = await doctorWorld("person"); | |
| 12 | }); | |
| 13 | afterAll(() => w.close()); | |
| 14 | ||
| 15 | test("a person's hold stands through a functional red: kept, not overwritten", async () => { | |
| 16 | const pull = await w.pull("mine.ts"); | |
| 17 | setHold(w.app.db, pull.id, w.alice.id, "waiting on design"); | |
| 18 | const action = treatRed(w.app, w.repo, w.alice, pull, redFor("t", "test/t.test.ts", "error: boom"), "f".repeat(40)); | |
| 19 | expect(action).toBe("kept"); | |
| 20 | expect(getHold(w.app.db, pull.id)).toMatchObject({ why: "waiting on design", heldByName: "alice" }); | |
| 21 | expect(doctorRuns(w.app.db, w.repo.id)[0]).toMatchObject({ action: "kept" }); | |
| 22 | }); | |
| 23 | ||
| 24 | test("a green check does not lift a person's plain hold", async () => { | |
| 25 | const pull = await w.pull("mine2.ts"); | |
| 26 | setHold(w.app.db, pull.id, w.alice.id, "waiting on design"); | |
| 27 | const run = createRun(w.app.db, { repoId: w.repo.id, kind: "check", issueId: pull.id, env: `pull-${pull.number}`, commitOid: pull.headOid, reason: "pull" }); | |
| 28 | claimRun(w.app.db, run.id); | |
| 29 | expect(releaseOnGreen(w.app, finishRun(w.app.db, run.id, { state: "ok", summary: "ok" })!)).toBe(false); | |
| 30 | expect(getHold(w.app.db, pull.id)).toBeDefined(); | |
| 31 | }); | |
| 32 | }); |
test/helpers/doctor.ts added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 2 | import { tmpdir } from "node:os"; | |
| 3 | import { join } from "node:path"; | |
| 4 | import { createApp, type App } from "../../src/app.ts"; | |
| 5 | import { openPull } from "../../src/actions/pulls.ts"; | |
| 6 | import { commitToBranch, createRepo } from "../../src/actions/repos.ts"; | |
| 7 | import type { Red } from "../../src/checks/queue.ts"; | |
| 8 | import { getPull, type Pull } from "../../src/core/pulls.ts"; | |
| 9 | import { getRepoById, type Repo } from "../../src/core/repos.ts"; | |
| 10 | import { createUser, type User } from "../../src/core/users.ts"; | |
| 11 | ||
| 12 | export interface Doctor { | |
| 13 | readonly app: App; | |
| 14 | readonly alice: User; | |
| 15 | readonly repo: Repo; | |
| 16 | /** Open one more pull from a fresh branch. */ | |
| 17 | pull(path: string): Promise<Pull>; | |
| 18 | close(): Promise<void>; | |
| 19 | } | |
| 20 | ||
| 21 | /** A forge with one person, one repository, and a way to open pulls: what the doctor treats. */ | |
| 22 | export async function doctorWorld(name: string): Promise<Doctor> { | |
| 23 | const dataDir = await mkdtemp(join(tmpdir(), `forge-doctor-${name}-`)); | |
| 24 | const app = createApp({ dataDir, port: 0 }); | |
| 25 | const a = await createUser(app.db, { | |
| 26 | origin: "seed", | |
| 27 | trusted: true, | |
| 28 | username: "alice", | |
| 29 | email: "[email protected]", | |
| 30 | password: "a-good-passphrase", | |
| 31 | adminIfFirst: true, | |
| 32 | }); | |
| 33 | if (!a.ok) throw new Error(a.error); | |
| 34 | const alice = a.value; | |
| 35 | const made = await createRepo(app, alice, { name, visibility: "private" }); | |
| 36 | if (!made.ok) throw new Error(String(made.error)); | |
| 37 | const seeded = await commitToBranch(app, alice, made.value, { | |
| 38 | message: "Start", | |
| 39 | files: [{ path: "README.md", content: `# ${name}\n` }], | |
| 40 | }); | |
| 41 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 42 | const repo = getRepoById(app.db, made.value.id)!; | |
| 43 | return { | |
| 44 | app, | |
| 45 | alice, | |
| 46 | repo, | |
| 47 | async pull(path) { | |
| 48 | const pushed = await commitToBranch(app, alice, repo, { | |
| 49 | branch: `feature/${path}`, | |
| 50 | base: "main", | |
| 51 | message: path, | |
| 52 | files: [{ path, content: `export const n = "${path}";\n` }], | |
| 53 | }); | |
| 54 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); | |
| 55 | const opened = await openPull(app, alice, repo, { title: path, baseBranch: "main", headBranch: `feature/${path}` }); | |
| 56 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 57 | return getPull(app.db, repo.id, opened.value.number)!; | |
| 58 | }, | |
| 59 | async close() { | |
| 60 | await app.close?.(); | |
| 61 | await rm(dataDir, { recursive: true, force: true }); | |
| 62 | }, | |
| 63 | }; | |
| 64 | } | |
| 65 | ||
| 66 | /** A red verdict naming one failing test with this error line. */ | |
| 67 | export function redFor(name: string, file: string, error: string, reason: Red["reason"] = { kind: "tests", failed: 1 }): Red { | |
| 68 | return { | |
| 69 | lands: false, | |
| 70 | reopen: true, | |
| 71 | comment: "red", | |
| 72 | learn: [file], | |
| 73 | ...(reason === undefined ? {} : { reason }), | |
| 74 | tests: { passed: 10, failed: 1, failing: [{ name, file, errors: [error] }] }, | |
| 75 | }; | |
| 76 | } |
test/merge-queue.test.ts +41 −1
| @@ -23,6 +23,8 @@ import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | ||
| 23 | 23 | import { createUser, type User } from "../src/core/users.ts"; |
| 24 | 24 | import { landIfGreen, REGATE_RED_MS, regateDue, sweepGreen } from "../src/crew/autonomy.ts"; |
| 25 | 25 | import { listComments } from "../src/core/issues.ts"; |
| 26 | import { doctorRuns, isDoctorHold } from "../src/checks/doctor.ts"; | |
| 27 | import { getHold } from "../src/core/holds.ts"; | |
| 26 | 28 | import { readQueues } from "../src/flow/queue.ts"; |
| 27 | 29 | import { repoDir } from "../src/git/repos.ts"; |
| 28 | 30 | import { logLevel, setLogLevel } from "../src/kernel/log.ts"; |
| @@ -681,6 +683,39 @@ describe("the merge queue", () => { | ||
| 681 | 683 | expect(await sweepGreen(app, repo)).toBe(1); |
| 682 | 684 | expect(gatesOf(repo)).toHaveLength(before + 1); |
| 683 | 685 | expect(merged(repo, pull)).toBe(true); |
| 686 | // A red that went green on the re-gate was never the doctor's. | |
| 687 | expect(getHold(app.db, pull.id)).toBeUndefined(); | |
| 688 | expect(doctorRuns(app.db, repo.id)).toEqual([]); | |
| 689 | }); | |
| 690 | ||
| 691 | test("the batch doctor holds a red only once it reproduces on the re-gate, with its own comment (#7555)", async () => { | |
| 692 | const repo = await autonomousRepo("doctor-wiring"); | |
| 693 | const pull = await greenPull(repo, "red-a.ts"); | |
| 694 | expect(await sweepGreen(app, repo)).toBe(0); | |
| 695 | // First red: told, not held, nothing recorded. | |
| 696 | expect(getHold(app.db, pull.id)).toBeUndefined(); | |
| 697 | expect(doctorRuns(app.db, repo.id)).toEqual([]); | |
| 698 | age(pull); | |
| 699 | expect(await sweepGreen(app, repo)).toBe(0); | |
| 700 | // The same head and base red again: held, marked as the doctor's, recorded. | |
| 701 | const hold = getHold(app.db, pull.id); | |
| 702 | expect(hold?.until).toBe("green"); | |
| 703 | expect(isDoctorHold(hold!.why)).toBe(true); | |
| 704 | expect(hold!.why).toContain("goes red"); | |
| 705 | const [run] = doctorRuns(app.db, repo.id); | |
| 706 | expect(run).toMatchObject({ culprit: pull.number, class: "functional", action: "held" }); | |
| 707 | expect(listComments(app.db, pull.id).at(-1)!.body).toContain("bun test ./test/red.test.ts"); | |
| 708 | }); | |
| 709 | ||
| 710 | test("a red handed back while others landed ahead of it is not held by the doctor (#7555)", async () => { | |
| 711 | const repo = await autonomousRepo("doctor-on-top"); | |
| 712 | const pulls = []; | |
| 713 | for (const name of ["a.ts", "b.ts", "red-c.ts"]) pulls.push(await greenPull(repo, name)); | |
| 714 | expect(await sweepGreen(app, repo)).toBe(2); | |
| 715 | const red = pulls[2]!; | |
| 716 | expect(latestDecision(app.db, red.id, "land")?.answer).toBe("red"); | |
| 717 | expect(getHold(app.db, red.id)).toBeUndefined(); | |
| 718 | expect(doctorRuns(app.db, repo.id)).toEqual([]); | |
| 684 | 719 | }); |
| 685 | 720 | |
| 686 | 721 | test("a red that reproduces on the re-gate stays red, told once", async () => { |
| @@ -695,7 +730,12 @@ describe("the merge queue", () => { | ||
| 695 | 730 | expect(again?.answer).toBe("red"); |
| 696 | 731 | expect((again?.state as { again?: boolean }).again).toBe(true); |
| 697 | 732 | expect(regateDue(again!, Date.now() + 10 * REGATE_RED_MS)).toBe(false); |
| 698 | expect(listComments(app.db, pull.id)).toHaveLength(comments); | |
| 733 | // The red is not told twice. The one comment added is the doctor's hold | |
| 734 | // notice: it says the red reproduced, which the first comment could not. | |
| 735 | const added = listComments(app.db, pull.id).slice(comments); | |
| 736 | expect(added).toHaveLength(1); | |
| 737 | expect(added[0]!.body).toContain("The batch doctor held this pull"); | |
| 738 | expect(added[0]!.body).toContain("goes red"); | |
| 699 | 739 | // However long it waits now, it is not gated again until a side moves. |
| 700 | 740 | age(pull); |
| 701 | 741 | const before = gatesOf(repo).length; |
Untrusted test name, file and error text go into backtick spans unescaped. Sanitise them (backticks, newlines,
@), same forwhyLineand the storedhold.why.