From 999f9bc742f15c9c96c4c2e152d1bc5fec1228ba Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:27:02 -0700 Subject: [PATCH] =?UTF-8?q?feat(review):=20evidence-weighted=20reviewer=20?= =?UTF-8?q?routing,=20stage=201=20=E2=80=94=20the=20report-only=20shadow?= =?UTF-8?q?=20(#8229)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After each ok block-mode dual review, compute what routing WOULD have preferred for this repo from the live per-provider track records (#8228 over the stage-0 reviewer_vote rows) and record it — one audit event (reviewer_routing_shadow) plus a maintainer-recap section — changing NOTHING about the review: - computeWouldHaveRouted: repo-scoped rows only, ROUTING_MIN_DECIDED (10, the AUTOTUNE_MIN_DECIDED never-on-noise bar at reviewer grain) per (provider, repo), null on ties/lone reviewers/any missing density — an absent record MEANS no measurable preference, keeping stage 2's eventual evidence read undiluted - loadLiveProviderTrackRecords reads ONLY the live vote event type (the replay-derived campaign data never enters — the #8278 segregation rule from the consuming side); fail-safe empty on any store error - orchestration hook is best-effort end to end: zero added AI spend, a thrown read or a rejecting/throwing audit write reduces to no record - the weekly recap gains a routing-shadow section (grouped per repo + preferred provider with the mean precision edge, explicit empty line, report-only footer), read back off the audit trail at format time and fail-safe to an absent section Stage 2 (actual weighting behind a default-off flag with hard floors) ships only against this stage's recorded evidence, per the issue. --- src/queue/ai-review-orchestration.ts | 11 ++ src/services/maintainer-recap-routing.ts | Bin 0 -> 3075 bytes src/services/maintainer-recap.ts | 37 +++- src/services/reviewer-routing.ts | 130 +++++++++++++ test/unit/reviewer-routing.test.ts | 236 +++++++++++++++++++++++ test/unit/reviewer-vote-capture.test.ts | 23 +++ 6 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 src/services/maintainer-recap-routing.ts create mode 100644 src/services/reviewer-routing.ts create mode 100644 test/unit/reviewer-routing.test.ts diff --git a/src/queue/ai-review-orchestration.ts b/src/queue/ai-review-orchestration.ts index ad0c4ac20..cba2b0027 100644 --- a/src/queue/ai-review-orchestration.ts +++ b/src/queue/ai-review-orchestration.ts @@ -21,6 +21,7 @@ import { } from "./transient-locks"; import { buildPullRequestAdvisory } from "../rules/advisory"; import { recordAuditEvent, getDecryptedRepositoryAiKey, getRepository, listCheckSummaries, listPullRequestFiles } from "../db/repositories"; +import { recordRoutingShadow } from "../services/reviewer-routing"; import { createInstallationToken } from "../github/app"; import type { AgentActionMode } from "../settings/agent-execution"; import { buildAiReviewDiff } from "../review/review-diff"; @@ -734,6 +735,16 @@ export async function runAiReviewForAdvisory( metadata: { repoFullName: args.repoFullName, vote: vote.votedFail ? "fail" : "non_fail" }, }).catch(() => undefined); } + // #8229 stage 1: the report-only routing shadow — records what evidence-weighted routing WOULD have + // preferred for this repo (audit metadata only; the recap aggregates it). Same best-effort discipline + // as the votes above: internally fail-safe, zero AI spend, and a no-signal review records nothing. + if (result.reviewerVotes.length >= 2) { + await recordRoutingShadow(env, { + repoFullName: args.repoFullName, + prNumber: args.pr.number, + actualProviders: result.reviewerVotes.map((vote) => vote.reviewer), + }); + } const findings: AdvisoryFinding[] = []; if (result.consensusDefect) { findings.push({ diff --git a/src/services/maintainer-recap-routing.ts b/src/services/maintainer-recap-routing.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b2eb8069b7f1e85b5c5cf7e22657365d067b682 GIT binary patch literal 3075 zcmai0O>f&q5bfE&V(JDc=}MICURt?IkTyu$LzBR6k%M6*OL8bJLM@5ir4_*u&};Ag z3;kpHC4I9?QnC}YKpar}HE-T~GrO}H{id|>_+vY@T&R{VU;gp?uP=U{Uj6d)*~{1D zxbV6$lzjEw`T6(cyjn7yO^91v5XR2V$XivsqvvUMG@Iccz3MEF`|?iLC0lB4@t>l$ zMl5noXA7+Hwk!OE*4o;}YO|#E12ogp>dI%{5x-Z3ueWpZE0!RQEmn*JR6w_}+Owm^ zFj>CUykYysHl4?rZ`?|i&4yH0YEOL2##0L{aUE?|47Au&vMG4FVZB^=E~(!wp*Brd zm$Xv1Os(aLt;M_|*K(mNu+Z8@wp&Vu*wcQu0RQ;s-((OL6d3ZF-K77Sa}8KXhe{k& zaw)N@x@oLSC!|an<~Gdl>MhWUHrn-g{YptL5j5poG;5?E!>hbz!nC4gt&mejPnS!} zOXZo0rZe72AfW(w+h%f*)R0LEtOzR5hau9X3!r2V8HIR5B0ZBuW4w@?NJ(*~+qm3wM*??Ua=-~G)&V!JqnF?(V z-VFKFuy6pZawkesQ}v03Un5uR&Up|&b6hraQWf*18JLde+0BT|DGCn?3Zw@x{>*kdG(y`!6N&t5^#Qx;rA8ot)6?n00U4 zF-cfL6h@V*a*sB;8fBnsO4jUEsk{>NnsVx`ZiNQXBNT}J+7XUz86=;~5NiZ?fxsHzGcLey!JKErPjfx94AWQq&OfE&6j1jFvTrPQnW&(qusPk>VqhwdWb>aZ9XTUp5 zFXd3OXS>!$+*451*asupw4D#^_Ig*Hi48O~!h6C;nv&QIM+FW{kv4XZmek$cWp;}?jqq`U|+snEu)3>6GuBb2qH-93x$y#-w+%_v>Rzy!tbpE4~93m(ApoOw8DXk5^BmW^6vxpLawWHOl~YC?HRi^r5_d%I6b?SF+t&~s+=TCPt^ z+T1FumGN_EF={n{B~@?G18@=IA_(PMc&gEjE zWn4ZFrd;MC>Pb8x_+ejFp{RR{^;zDkx?`85H-U&xjTRAz$>P_C5BNOvcvd%ss`;m8 z-73pTes3~O zo^YSkHCcq!cr?Pgm0scFj R%QQO<5I) { + try { + const sinceIso = new Date(Date.parse(generatedAt) - windowDays * 24 * 60 * 60 * 1000).toISOString(); + const rows = await env.DB.prepare("SELECT metadata_json FROM audit_events WHERE event_type = ? AND created_at >= ?") + .bind(REVIEWER_ROUTING_SHADOW_EVENT_TYPE, sinceIso) + .all<{ metadata_json: string }>(); + const decisions: RoutingShadowDecision[] = []; + /* v8 ignore next -- defined-results guard, the loadKnobStatus convention */ + for (const row of rows.results ?? []) { + try { + const metadata = JSON.parse(row.metadata_json) as Partial; + if (typeof metadata.repoFullName !== "string" || typeof metadata.preferredProvider !== "string" || !Array.isArray(metadata.basis)) continue; + decisions.push(metadata as RoutingShadowDecision); + } catch { + /* a corrupt shadow row is not evidence */ + } + } + return buildRoutingRecapSection({ decisions, windowDays }); + } catch { + return null; + } +} + export async function runMaintainerRecap( env: Env, options: { @@ -238,7 +268,10 @@ export async function runMaintainerRecap( windowDays: options.windowDays, repos: options.repos ?? [], }); - const formatted = formatMaintainerRecap(report); + // #8229 stage 1: the routing-shadow section reads the window's recorded decisions straight from the + // audit trail — fail-safe to an absent section (the recap must never break on a read blip). + const routingShadow = await loadRoutingRecapSection(env, report.windowDays, options.generatedAt ?? nowIso()); + const formatted = formatMaintainerRecap(report, routingShadow ? { routingShadow } : {}); const [discord, slack] = await Promise.all([ deliverRecapToDiscord(env, report, formatted), deliverRecapToSlack(env, report, formatted), diff --git a/src/services/reviewer-routing.ts b/src/services/reviewer-routing.ts new file mode 100644 index 000000000..ce29a88d8 --- /dev/null +++ b/src/services/reviewer-routing.ts @@ -0,0 +1,130 @@ +// Evidence-weighted reviewer routing, STAGE 1 of #8229 (epic #8211 track F): the report-only shadow. +// After each ok block-mode dual review, compute what routing WOULD have preferred for this repo from the +// live per-provider track records (#8228 over the stage-0 reviewer_vote rows) and record it — one audit +// event plus a maintainer-recap section — while changing NOTHING about the review itself. Stage 2 (actual +// weighting behind a default-off flag, hard floors, instant restore) ships only against this stage's +// recorded evidence, per the issue's two-stage contract. +// +// Invariants the shadow holds (each pinned by a test): +// • ZERO behavior change and ZERO added AI spend — pure DB reads, one best-effort audit write; +// • fail-safe: any read/compute error ⇒ no record, review path byte-identical; +// • never a preference on noise — a per-(provider, repo) decided floor below which NOTHING records +// ({@link ROUTING_MIN_DECIDED}, the AUTOTUNE_MIN_DECIDED never-on-noise bar at reviewer grain); +// • a tie (or a lone provider) records nothing — absence of a record must mean "no measurable +// preference", so the eventual stage-2 evidence read is never diluted by no-signal rows. +import { buildBacktestCorpus, computeProviderTrackRecords, type ProviderReviewSignal, type ProviderTrackRecord } from "@loopover/engine"; +import { createSignalStore } from "../review/signal-tracking-wire"; +import { recordAuditEvent } from "../db/repositories"; + +/** Audit event type a shadow decision writes — ONE stable type forever (the #8159 event discipline). */ +export const REVIEWER_ROUTING_SHADOW_EVENT_TYPE = "reviewer_routing_shadow"; + +/** Minimum DECIDED votes per (provider, repo) before a preference may record — AUTOTUNE_MIN_DECIDED's + * never-on-noise bar (auto-tune.ts) applied at reviewer grain, as #8229's own floors clause requires. */ +export const ROUTING_MIN_DECIDED = 10; + +/** The trailing window the track-record read replays — mirrors the calibration corpus lookback. */ +const CORPUS_LOOKBACK_MS = 90 * 24 * 60 * 60 * 1000; + +export type RoutingShadowDecision = { + repoFullName: string; + preferredProvider: string; + actualProviders: string[]; + /** The evidence the preference rests on — repo-scoped decided/precision per actual provider. */ + basis: Array<{ provider: string; decided: number; precision: number }>; +}; + +/** + * PURE: what would evidence-weighted routing have preferred for this repo, given the current track + * records and the providers the review ACTUALLY used? Null — record nothing — unless EVERY actual + * provider has a repo-scoped row at/above the decided floor with a non-null precision, and exactly one + * provider strictly leads. Repo-scoped rows only: the per-(provider, repo) floor is the issue's own + * requirement, and a global rollup preference would smuggle cross-repo behavior into a per-repo call. + */ +export function computeWouldHaveRouted( + records: readonly ProviderTrackRecord[], + repoFullName: string, + actualProviders: readonly string[], +): RoutingShadowDecision | null { + if (actualProviders.length < 2) return null; // a lone reviewer has nothing to route between + const basis: Array<{ provider: string; decided: number; precision: number }> = []; + for (const provider of actualProviders) { + const row = records.find((record) => record.provider === provider && record.repoFullName === repoFullName); + if (!row || row.decided < ROUTING_MIN_DECIDED || row.precision === null) return null; + basis.push({ provider, decided: row.decided, precision: row.precision }); + } + const sorted = [...basis].sort((a, b) => b.precision - a.precision); + if (sorted[0]!.precision === sorted[1]!.precision) return null; // a tie is not a preference + return { + repoFullName, + preferredProvider: sorted[0]!.provider, + actualProviders: [...actualProviders], + basis, + }; +} + +/** + * Load the LIVE provider track records: stage-0 reviewer_vote rows joined to the labeled consensus corpus + * via the #8228 aggregation. Replay-derived signals never enter here — this reads only the live event + * type (the #8278 segregation rule from the consuming side). Fail-safe empty on any store error. + */ +export async function loadLiveProviderTrackRecords(env: Env, nowMs: number = Date.now()): Promise { + try { + const votes = await env.DB.prepare( + "SELECT actor, target_key, metadata_json FROM audit_events WHERE event_type = ? AND created_at >= ?", + ) + .bind(REVIEWER_VOTE_EVENT_TYPE, new Date(nowMs - CORPUS_LOOKBACK_MS).toISOString()) + .all<{ actor: string; target_key: string; metadata_json: string }>(); + const signals: ProviderReviewSignal[] = []; + /* v8 ignore next -- defined-results guard, the loadKnobStatus convention */ + for (const row of votes.results ?? []) { + let metadata: { repoFullName?: unknown; vote?: unknown } = {}; + try { + metadata = JSON.parse(row.metadata_json) as { repoFullName?: unknown; vote?: unknown }; + } catch { + continue; // a corrupt vote row is not evidence + } + if (typeof metadata.repoFullName !== "string" || (metadata.vote !== "fail" && metadata.vote !== "non_fail")) continue; + signals.push({ + provider: row.actor, + repoFullName: metadata.repoFullName, + targetKey: row.target_key, + vote: metadata.vote === "fail" ? "fail" : "pass", + }); + } + const { fired, overrides } = await createSignalStore(env).queryRuleHistory("ai_consensus_defect", nowMs - CORPUS_LOOKBACK_MS); + return computeProviderTrackRecords(signals, buildBacktestCorpus("ai_consensus_defect", fired, overrides)); + } catch { + return []; // fail-safe: no records ⇒ downstream records nothing ⇒ byte-identical behavior + } +} + +/** The stage-0 vote event type, mirrored here as the ONE consuming-side constant (the orchestration writer + * keeps its literal; the invariant test pins the two spellings together so they can never drift). */ +export const REVIEWER_VOTE_EVENT_TYPE = "reviewer_vote"; + +/** + * The orchestration hook: compute + record this review's shadow decision, best-effort end to end. Never + * throws, never adds an AI call; a null decision (no density / tie / lone reviewer / read error) writes + * NOTHING. Called after the stage-0 vote persistence with the same swap-proof reviewer identities. + */ +export async function recordRoutingShadow( + env: Env, + args: { repoFullName: string; prNumber: number; actualProviders: readonly string[] }, +): Promise { + try { + const decision = computeWouldHaveRouted(await loadLiveProviderTrackRecords(env), args.repoFullName, args.actualProviders); + if (!decision) return null; + await recordAuditEvent(env, { + eventType: REVIEWER_ROUTING_SHADOW_EVENT_TYPE, + actor: "loopover", + targetKey: `${args.repoFullName}#${args.prNumber}`, + outcome: "completed", + detail: `routing would have preferred ${decision.preferredProvider} (report-only shadow; review used ${decision.actualProviders.join(" + ")})`, + metadata: { ...decision }, + }).catch(() => undefined); + return decision; + } catch { + return null; // the shadow must never touch the review path + } +} diff --git a/test/unit/reviewer-routing.test.ts b/test/unit/reviewer-routing.test.ts new file mode 100644 index 000000000..da6461dd8 --- /dev/null +++ b/test/unit/reviewer-routing.test.ts @@ -0,0 +1,236 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ProviderTrackRecord } from "@loopover/engine"; +import * as repositories from "../../src/db/repositories"; +import { createSignalStore } from "../../src/review/signal-tracking-wire"; +import { + computeWouldHaveRouted, + loadLiveProviderTrackRecords, + recordRoutingShadow, + REVIEWER_ROUTING_SHADOW_EVENT_TYPE, + REVIEWER_VOTE_EVENT_TYPE, + ROUTING_MIN_DECIDED, +} from "../../src/services/reviewer-routing"; +import { buildRoutingRecapSection } from "../../src/services/maintainer-recap-routing"; +import { formatMaintainerRecap, runMaintainerRecap } from "../../src/services/maintainer-recap"; +import { createTestEnv } from "../helpers/d1"; + +// #8229 stage 1: the report-only routing shadow. The aggregation itself is #8228's (engine suite); these +// tests pin the preference rule's every refusal arm, the live-vote read path, the best-effort write, and +// the recap section. + +const REPO = "acme/widgets"; + +function record(provider: string, over: Partial = {}): ProviderTrackRecord { + return { + provider, + repoFullName: REPO, + signals: 12, + decided: 12, + confirmed: 8, + reversed: 4, + precision: 0.7, + agreementRate: 0.6, + consensusRate: 0.5, + splitRate: 0.5, + ...over, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("computeWouldHaveRouted (#8229 stage 1)", () => { + const PAIR = ["claude-code", "codex"]; + + it("prefers the strictly-leading provider with the full basis attached", () => { + const decision = computeWouldHaveRouted([record("claude-code", { precision: 0.9 }), record("codex", { precision: 0.6 })], REPO, PAIR); + expect(decision).toEqual({ + repoFullName: REPO, + preferredProvider: "claude-code", + actualProviders: PAIR, + basis: [ + { provider: "claude-code", decided: 12, precision: 0.9 }, + { provider: "codex", decided: 12, precision: 0.6 }, + ], + }); + }); + + it("refuses EVERY no-signal arm: lone reviewer, missing repo row, below the decided floor, null precision, tie", () => { + const dense = [record("claude-code", { precision: 0.9 }), record("codex", { precision: 0.6 })]; + expect(computeWouldHaveRouted(dense, REPO, ["claude-code"])).toBeNull(); + expect(computeWouldHaveRouted([dense[0]!], REPO, PAIR)).toBeNull(); // codex has no repo row + expect(computeWouldHaveRouted([dense[0]!, record("codex", { repoFullName: null })], REPO, PAIR)).toBeNull(); // overall rollup never counts + expect(computeWouldHaveRouted([dense[0]!, record("codex", { decided: ROUTING_MIN_DECIDED - 1 })], REPO, PAIR)).toBeNull(); + expect(computeWouldHaveRouted([dense[0]!, record("codex", { precision: null })], REPO, PAIR)).toBeNull(); + expect(computeWouldHaveRouted([record("claude-code"), record("codex")], REPO, PAIR)).toBeNull(); // 0.7 === 0.7 + }); +}); + +describe("loadLiveProviderTrackRecords (#8229 stage 1 read path)", () => { + it("joins live reviewer_vote rows to the labeled corpus; corrupt/malformed vote rows are never evidence", async () => { + const env = createTestEnv(); + const store = createSignalStore(env); + const now = Date.now(); + for (let i = 1; i <= 3; i += 1) { + const targetKey = `${REPO}#${i}`; + await store.recordRuleFired({ ruleId: "ai_consensus_defect", targetKey, outcome: "close", occurredAt: new Date(now - 10_000 - i).toISOString(), metadata: { confidence: 0.97 } }); + await store.recordHumanOverride({ ruleId: "ai_consensus_defect", targetKey, verdict: i === 3 ? "reversed" : "confirmed", occurredAt: new Date(now - i).toISOString() }); + await repositories.recordAuditEvent(env, { + eventType: REVIEWER_VOTE_EVENT_TYPE, + actor: "claude-code", + targetKey, + outcome: "completed", + detail: "vote", + metadata: { repoFullName: REPO, vote: i === 3 ? "non_fail" : "fail" }, + }); + } + // Malformed rows: missing repoFullName, foreign vote value, unparseable metadata. + await repositories.recordAuditEvent(env, { eventType: REVIEWER_VOTE_EVENT_TYPE, actor: "claude-code", targetKey: `${REPO}#9`, outcome: "completed", detail: "v", metadata: { vote: "fail" } }); + await repositories.recordAuditEvent(env, { eventType: REVIEWER_VOTE_EVENT_TYPE, actor: "claude-code", targetKey: `${REPO}#10`, outcome: "completed", detail: "v", metadata: { repoFullName: REPO, vote: "maybe" } }); + await env.DB.prepare("INSERT INTO audit_events (id, event_type, actor, target_key, outcome, detail, metadata_json, created_at) VALUES ('corrupt', ?, 'claude-code', ?, 'completed', 'v', 'not json', ?)") + .bind(REVIEWER_VOTE_EVENT_TYPE, `${REPO}#11`, new Date(now).toISOString()) + .run(); + + const records = await loadLiveProviderTrackRecords(env, now); + const repoRow = records.find((row) => row.provider === "claude-code" && row.repoFullName === REPO)!; + expect(repoRow.signals).toBe(3); // the three valid votes only + expect(repoRow.decided).toBe(3); + expect(repoRow.precision).toBe(1); // both fail votes landed on confirmed labels + }); + + it("fails safe to [] on a broken store", async () => { + const env = createTestEnv(); + env.DB = { prepare: () => { throw new Error("store down"); } } as never; + expect(await loadLiveProviderTrackRecords(env)).toEqual([]); + }); +}); + +describe("recordRoutingShadow (#8229 stage 1 write path)", () => { + async function seedDensePreference(env: Env): Promise { + const store = createSignalStore(env); + const now = Date.now(); + for (let i = 1; i <= ROUTING_MIN_DECIDED + 2; i += 1) { + const targetKey = `${REPO}#${i}`; + await store.recordRuleFired({ ruleId: "ai_consensus_defect", targetKey, outcome: "close", occurredAt: new Date(now - 10_000 - i).toISOString(), metadata: { confidence: 0.97 } }); + await store.recordHumanOverride({ ruleId: "ai_consensus_defect", targetKey, verdict: i % 2 === 0 ? "reversed" : "confirmed", occurredAt: new Date(now - i).toISOString() }); + // claude-code votes fail on CONFIRMED targets only (perfect precision); codex on REVERSED only (0). + await repositories.recordAuditEvent(env, { + eventType: REVIEWER_VOTE_EVENT_TYPE, + actor: "claude-code", + targetKey, + outcome: "completed", + detail: "vote", + metadata: { repoFullName: REPO, vote: i % 2 === 0 ? "non_fail" : "fail" }, + }); + await repositories.recordAuditEvent(env, { + eventType: REVIEWER_VOTE_EVENT_TYPE, + actor: "codex", + targetKey, + outcome: "completed", + detail: "vote", + metadata: { repoFullName: REPO, vote: i % 2 === 0 ? "fail" : "non_fail" }, + }); + } + } + + it("records ONE shadow event with the full decision metadata when a measurable preference exists", async () => { + const env = createTestEnv(); + await seedDensePreference(env); + const decision = await recordRoutingShadow(env, { repoFullName: REPO, prNumber: 77, actualProviders: ["claude-code", "codex"] }); + expect(decision?.preferredProvider).toBe("claude-code"); + const rows = await env.DB.prepare("SELECT target_key, metadata_json FROM audit_events WHERE event_type = ?") + .bind(REVIEWER_ROUTING_SHADOW_EVENT_TYPE) + .all<{ target_key: string; metadata_json: string }>(); + expect(rows.results).toHaveLength(1); + expect(rows.results![0]!.target_key).toBe(`${REPO}#77`); + const metadata = JSON.parse(rows.results![0]!.metadata_json) as { preferredProvider: string; basis: unknown[] }; + expect(metadata.preferredProvider).toBe("claude-code"); + expect(metadata.basis).toHaveLength(2); + }); + + it("records NOTHING without density, and stays fail-safe when the audit write rejects or the read throws", async () => { + const sparse = createTestEnv(); + expect(await recordRoutingShadow(sparse, { repoFullName: REPO, prNumber: 1, actualProviders: ["claude-code", "codex"] })).toBeNull(); + expect((await sparse.DB.prepare("SELECT COUNT(*) AS n FROM audit_events WHERE event_type = ?").bind(REVIEWER_ROUTING_SHADOW_EVENT_TYPE).first<{ n: number }>())?.n).toBe(0); + + const env = createTestEnv(); + await seedDensePreference(env); + vi.spyOn(repositories, "recordAuditEvent").mockRejectedValue(new Error("audit down")); + const decision = await recordRoutingShadow(env, { repoFullName: REPO, prNumber: 2, actualProviders: ["claude-code", "codex"] }); + expect(decision?.preferredProvider).toBe("claude-code"); // decision computed; the write's rejection was swallowed + vi.restoreAllMocks(); + + const broken = createTestEnv(); + broken.DB = { prepare: () => { throw new Error("boom"); } } as never; + expect(await recordRoutingShadow(broken, { repoFullName: REPO, prNumber: 3, actualProviders: ["claude-code", "codex"] })).toBeNull(); + + // The OUTER fail-safe: a synchronously-throwing audit writer (no promise to .catch) must also reduce + // to null, never into the review path. + const throwing = createTestEnv(); + await seedDensePreference(throwing); + vi.spyOn(repositories, "recordAuditEvent").mockImplementation(() => { + throw new Error("sync boom"); + }); + expect(await recordRoutingShadow(throwing, { repoFullName: REPO, prNumber: 4, actualProviders: ["claude-code", "codex"] })).toBeNull(); + }); +}); + +describe("routing recap section (#8229 stage 1 surfacing)", () => { + const decision = { + repoFullName: REPO, + preferredProvider: "claude-code", + actualProviders: ["claude-code", "codex"], + basis: [ + { provider: "claude-code", decided: 12, precision: 0.9 }, + { provider: "codex", decided: 12, precision: 0.6 }, + ], + }; + + it("renders the explicit empty line, and grouped lines with the mean edge + report-only footer otherwise", () => { + expect(buildRoutingRecapSection({ decisions: [], windowDays: 7 }).lines[0]).toContain("No would-have-routed decisions"); + const section = buildRoutingRecapSection({ decisions: [decision, decision, { ...decision, basis: [decision.basis[0]!] }], windowDays: 7 }); + expect(section.title).toContain("last 7d"); + expect(section.lines[0]).toContain("preferred claude-code on 3 review(s)"); + expect(section.lines[0]).toContain("0.200"); // mean edge (0.3 + 0.3 + 0-for-malformed) / 3 + expect(section.lines.at(-1)).toContain("Report-only"); + }); + + it("rides formatMaintainerRecap as an optional section and the recap job wires it from the audit trail", async () => { + const env = createTestEnv(); + await repositories.recordAuditEvent(env, { + eventType: REVIEWER_ROUTING_SHADOW_EVENT_TYPE, + actor: "loopover", + targetKey: `${REPO}#5`, + outcome: "completed", + detail: "shadow", + metadata: decision, + }); + const result = await runMaintainerRecap(env, { repos: [], generatedAt: new Date().toISOString() }); + expect(result.skipped).toBe(false); + if (result.skipped) throw new Error("unreachable"); + expect(result.formatted).toContain("Reviewer routing shadow"); + expect(result.formatted).toContain("preferred claude-code on 1 review(s)"); + + // The option is additive: no option ⇒ no section header. + expect(formatMaintainerRecap(result.report)).not.toContain("Reviewer routing shadow"); + }); + + it("the recap survives a broken audit store: the section is simply absent (fail-safe null arm)", async () => { + const env = createTestEnv(); + // Break ONLY the routing-shadow read — the rest of the recap's own store use must stay real, so the + // test proves the SECTION fails safe rather than the whole recap being down. + const realPrepare = env.DB.prepare.bind(env.DB); + env.DB = { + ...env.DB, + prepare: (sql: string) => { + if (sql.includes("SELECT metadata_json FROM audit_events")) throw new Error("store down"); + return realPrepare(sql); + }, + } as never; + const result = await runMaintainerRecap(env, { repos: [], generatedAt: new Date().toISOString() }); + expect(result.skipped).toBe(false); + if (result.skipped) throw new Error("unreachable"); + expect(result.formatted).not.toContain("Reviewer routing shadow"); + }); +}); diff --git a/test/unit/reviewer-vote-capture.test.ts b/test/unit/reviewer-vote-capture.test.ts index d29c4a73d..baccd3fbb 100644 --- a/test/unit/reviewer-vote-capture.test.ts +++ b/test/unit/reviewer-vote-capture.test.ts @@ -100,3 +100,26 @@ describe("reviewer-vote capture persistence (#8229 stage 0)", () => { vi.restoreAllMocks(); }); }); + +describe("routing shadow orchestration hook (#8229 stage 1)", () => { + it("a dual ok review invokes the shadow (recording nothing on a sparse corpus); the review outcome is untouched", async () => { + const seen: string[] = []; + const env = voteEnv(seen); + const result = await runAiReviewForAdvisory(env, { + mode: "live", + settings: { aiReviewMode: "block" } as RepositorySettings, + repoFullName: REPO, + pr: { number: 21, title: "Add helper", body: "Adds a helper." }, + author: "alice", + confirmedContributor: true, + advisory: advisory(21), + }); + expect(result).toBeDefined(); + // Sparse corpus ⇒ the shadow's no-signal arm: zero reviewer_routing_shadow rows, by design. + const shadows = await env.DB.prepare("SELECT COUNT(*) AS n FROM audit_events WHERE event_type = 'reviewer_routing_shadow'").first<{ n: number }>(); + expect(shadows?.n).toBe(0); + // The votes themselves persisted — the hook runs strictly after and independently of them. + const votes = await env.DB.prepare("SELECT COUNT(*) AS n FROM audit_events WHERE event_type = 'reviewer_vote' AND target_key = ?").bind(`${REPO}#21`).first<{ n: number }>(); + expect(votes?.n).toBe(2); + }); +});