From 3598872a8d388c9bb52fd058c8e72fabcd92edae Mon Sep 17 00:00:00 2001 From: hurryup52 Date: Sun, 26 Jul 2026 15:29:44 +0200 Subject: [PATCH] feat(api): surface computeRuleGateEval's per-(project, ruleCode) rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeRuleGateEval (rule-gate-eval.ts) is the finer-grained "which rule is broken on which repo" sibling of computeBlendedRuleGateEval, but had zero consumers outside its own unit test — the blended, pooled-across- projects view is the only one wired into the auto-tune breaker cache (outcomes-wire.ts) and the operator-dashboard tile. Add GET /v1/internal/rule-gate-eval, mirroring the existing unflagged /v1/internal/fleet/analytics route (bearer-gated by the shared /v1/internal/* middleware, ?days= window defaulting to 90). ruleCode carries no privacy concern (unlike a contributor login), so unlike the fairness/contributors detail-route-plus-summary-tile split, this returns the full per-project report directly. Test seeds two different projects sharing the same ruleCode via real review_audit gate_decision/pr_outcome pairs and asserts the response contains two distinct per-project rows (not one pooled row), plus the ?days= default/override and 401-without-token cases. Closes #8906 --- src/api/routes.ts | 13 +++++++++ test/integration/api.test.ts | 56 ++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) 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" });