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 000000000..1b2eb8069 Binary files /dev/null and b/src/services/maintainer-recap-routing.ts differ diff --git a/src/services/maintainer-recap.ts b/src/services/maintainer-recap.ts index 111c99c10..e0d000943 100644 --- a/src/services/maintainer-recap.ts +++ b/src/services/maintainer-recap.ts @@ -14,6 +14,8 @@ import { PUBLIC_LOCAL_PATH_SCRUB_PATTERN, PUBLIC_UNSAFE_PATTERN } from "../signa import { deliverRecapToDiscord, deliverRecapToSlack } from "./notify-discord"; import type { GatePrecisionReport } from "./gate-precision"; import type { DriftRecapSection } from "./maintainer-recap-drift"; +import { buildRoutingRecapSection } from "./maintainer-recap-routing"; +import { REVIEWER_ROUTING_SHADOW_EVENT_TYPE, type RoutingShadowDecision } from "./reviewer-routing"; import type { OutcomeCalibration } from "./outcome-calibration"; import type { MaintainerRecapCohortCounts, MaintainerRecapRepo, RecapReport } from "../types"; import { nowIso } from "../utils/json"; @@ -162,7 +164,7 @@ function recapSectionLines(items: string[], fallback: string): string[] { * (Summary, Totals, Per-repo), mirroring formatWeeklyValueReportMarkdown at weekly-value-report.ts. PURE * string function — no delivery, no I/O. Every free-text value is routed through {@link redactRecapLine} so no * reward/trust/score/path term can leak into the digest even if the input report was hand-built. (#2240) */ -export function formatMaintainerRecap(report: RecapReport, options: { configDrift?: DriftRecapSection } = {}): string { +export function formatMaintainerRecap(report: RecapReport, options: { configDrift?: DriftRecapSection; routingShadow?: { title: string; lines: string[] } } = {}): string { const { totals } = report; const rate = totals.gateFalsePositiveRate !== null ? `${Math.round(totals.gateFalsePositiveRate * 100)}%` : "n/a"; const perRepoLines = report.repos.map( @@ -194,6 +196,9 @@ export function formatMaintainerRecap(report: RecapReport, options: { configDrif ...(options.configDrift ? ["", `## ${redactRecapLine(options.configDrift.title)}`, ...recapSectionLines(options.configDrift.lines, "_No drift lines for this window._")] : []), + ...(options.routingShadow + ? ["", `## ${redactRecapLine(options.routingShadow.title)}`, ...recapSectionLines(options.routingShadow.lines, "_No routing-shadow lines for this window._")] + : []), ]; return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`; } @@ -217,6 +222,31 @@ export type RunMaintainerRecapResult = * never throws, so a single-channel outage does not abort the other. When `enabled === false`, short-circuits * before any I/O (the flag-OFF arm mirrored by the cron/job processor). */ +/** Read the window's reviewer_routing_shadow decisions back off the audit trail and build the recap + * section. Null — the section is simply absent — on any read error (fail-safe, like every recap input). */ +async function loadRoutingRecapSection(env: Env, windowDays: number, generatedAt: string): Promise<{ title: string; lines: string[] } | null> { + 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); + }); +});