diff --git a/src/api/routes.ts b/src/api/routes.ts index 827ed51fdc..bbf1525293 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -300,6 +300,7 @@ import { handleInternalCalibration, handleInternalDecision, type OpsAgentConfig import { computeParityReadiness, isParityAuditEnabled } from "../review/parity-wire"; import { computePredictedGateAgreement } from "../review/predicted-gate-agreement"; import { computeContributorGateEval, contributorFairnessFlags, computeBlendedContributorGateEval, contributorGlobalFairnessFlags } from "../review/contributor-gate-eval"; +import { computeRuleGateEval } from "../review/rule-gate-eval"; import { getContributorTrustProfile } from "../review/contributor-trust-profile"; import { backfillContributorGateHistory } from "../review/contributor-gate-history-backfill"; import { isFairnessAnalyticsEnabled, resolveFairnessAnalyticsManifestOverride } from "../review/contributor-trust-profile-wire"; @@ -4404,6 +4405,18 @@ export function createApp() { return c.json(await computeFleetAnalytics(c.env, { windowDays: days })); }); + // Per-(project, ruleCode) gate-decision accuracy (#8906): the finer-grained sibling of the blended + // view already wired into the operator-dashboard tile (rulesBelowClosePrecisionFloor) and the + // concrete-evidence breaker exemption cache (outcomes-wire.ts). No privacy concern — ruleCode isn't a + // contributor identity — so unlike the fairness/contributors split, this returns the full + // per-project report directly rather than a detail route + summary-only dashboard tile. Owner-only: + // bearer-gated by the `/v1/internal/*` middleware (INTERNAL_JOB_TOKEN). `?days=` windows the lookback + // (default 90, matching BREAKER_EVAL_WINDOW_DAYS and the fairness routes' hardcoded window). + app.get("/v1/internal/rule-gate-eval", async (c) => { + const days = parsePositiveInt(c.req.query("days")) ?? 90; + return c.json(await computeRuleGateEval(c.env, { days, nowMs: Date.now() })); + }); + // Orb instance registry — the fleet trust gate. Every self-host instance that ingests is recorded here, // but only REGISTERED ones count toward fleet calibration (computeFleetAnalytics). Bearer-gated by the // `/v1/internal/*` middleware (INTERNAL_JOB_TOKEN). List shows pending + registered instances with their diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 4dd61c511e..46830ff10e 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -4273,6 +4273,62 @@ describe("api routes", () => { expect((await app.request("/v1/internal/fairness/contributors", { headers }, offEnv)).status).toBe(404); }); + it("GET /v1/internal/rule-gate-eval returns the per-(project, ruleCode) breakdown, not the blended view (#8906)", async () => { + const app = createApp(); + const env = createTestEnv(); + const now = new Date().toISOString(); + // Same ruleCode ("surface_lane_reject"), two DIFFERENT projects — proves the response is + // genuinely per-project, not pooled/blended (which would collapse this to one row with + // projectCount: 2 instead of two rows each carrying its own `project`). + for (const [id, project] of [ + ["gd-1", "owner/repo-a"], + ["gd-2", "owner/repo-b"], + ] as const) { + await env.DB.prepare( + `INSERT INTO review_audit (id, project, target_id, event_type, decision, summary, source, created_at) VALUES (?, ?, ?, 'gate_decision', 'close', 'surface_lane_reject', 'gittensory-native', ?)`, + ) + .bind(id, project, `${project}#1`, now) + .run(); + await env.DB.prepare( + `INSERT INTO review_audit (id, project, target_id, event_type, decision, source, created_at) VALUES (?, ?, ?, 'pr_outcome', 'merged', 'github', ?)`, + ) + .bind(`po-${id}`, project, `${project}#1`, now) + .run(); + } + + const headers = { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}` }; + const res = await app.request("/v1/internal/rule-gate-eval", { headers }, env); + expect(res.status).toBe(200); + const body = (await res.json()) as { + rows: Array<{ project: string; ruleCode: string; wouldClose: number; closeFalse: number }>; + hasSignal: boolean; + }; + expect(body.rows).toHaveLength(2); + expect(body.rows.map((r) => r.project).sort()).toEqual(["owner/repo-a", "owner/repo-b"]); + expect(body.rows.every((r) => r.ruleCode === "surface_lane_reject" && r.wouldClose === 1 && r.closeFalse === 1)).toBe(true); + expect(body.hasSignal).toBe(false); // each row's `decided` is 1, below the signal floor + }); + + it("GET /v1/internal/rule-gate-eval honors ?days and defaults to a 90-day window when omitted", async () => { + const app = createApp(); + const env = createTestEnv(); + const headers = { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}` }; + + const withDays = await app.request("/v1/internal/rule-gate-eval?days=30", { headers }, env); + expect(withDays.status).toBe(200); + expect(await withDays.json()).toEqual({ rows: [], hasSignal: false }); + + const withoutDays = await app.request("/v1/internal/rule-gate-eval", { headers }, env); + expect(withoutDays.status).toBe(200); + expect(await withoutDays.json()).toEqual({ rows: [], hasSignal: false }); + }); + + it("GET /v1/internal/rule-gate-eval 401s without the internal bearer token", async () => { + const app = createApp(); + const res = await app.request("/v1/internal/rule-gate-eval", {}, createTestEnv()); + expect(res.status).toBe(401); + }); + it("POST /v1/internal/jobs/backfill-contributor-gate-history/run backfills synchronously and honors `limit`; 404 when off (#fairness-analytics)", async () => { const app = createApp(); const env = createTestEnv({ LOOPOVER_FAIRNESS_ANALYTICS: "true" });