Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down