Skip to content
Merged
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
22 changes: 22 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ import { buildRepoOutcomeCalibration } from "../services/outcome-calibration";
import { loadGatePrecisionReport } from "../services/gate-precision";
import { computeOpsStats, isOpsEnabled } from "../review/ops-wire";
import { deleteLiveOverride, listOverrideAudit, sanitizeOverridePayload, type StorageEnv } from "../review/auto-apply";
import { handleInternalCalibration, handleInternalDecision, type OpsAgentConfig } from "../review/ops";
import { computeParityReadiness, isParityAuditEnabled } from "../review/parity-wire";
import { computePredictedGateAgreement } from "../review/predicted-gate-agreement";
import { isRagEnabled } from "../review/rag-wire";
Expand Down Expand Up @@ -926,6 +927,16 @@ export function isCloudflareWorkerRuntime(): boolean {
return typeof navigator !== "undefined" && navigator.userAgent === "Cloudflare-Workers";
}

/** The {@link OpsAgentConfig} the `/v1/internal/decision` + `/v1/internal/calibration` operator read endpoints
* run under: the app slug (the `project` namespace the review agent records its `review_targets`/`review_audit`
* rows under — the same `GITHUB_APP_SLUG` fallback operator-dashboard's config uses) plus the
* `INTERNAL_JOB_TOKEN` secret name. The handlers' own `requireInternalAuth` re-checks that bearer, so they gate
* on the SAME `INTERNAL_JOB_TOKEN` the `/v1/internal/*` middleware already enforces — one logical gate. */
function internalOpsAgentConfig(env: Env): OpsAgentConfig {
const slug = env.GITHUB_APP_SLUG?.trim() || "loopover";
return { slug, secrets: { internalSecret: "INTERNAL_JOB_TOKEN" } };
}

export function createApp() {
const app = new Hono<AppBindings>();
// Registered FIRST/outermost (Sentry's own guidance) so it wraps every other middleware and route below,
Expand Down Expand Up @@ -3773,6 +3784,17 @@ export function createApp() {
return c.json(await computePredictedGateAgreement(c.env, { days: 90, nowMs: Date.now() }));
});

// Operator decision-trail: the full state + cached terminal decision + audit log for ONE review target, so any
// gate verdict is explainable on demand (?repo=<owner/repo>&number=<n>[&kind=pull_request|issue]). Bearer-gated
// by the `/v1/internal/*` middleware (INTERNAL_JOB_TOKEN); handleInternalDecision re-checks that same token and
// 400s a missing/invalid repo+number, 404s an unknown target. Aggregate review state only — no PR content.
app.get("/v1/internal/decision", (c) => handleInternalDecision(c.req.raw, c.env, internalOpsAgentConfig(c.env)));

// Operator calibration: confidence-vs-outcome curve + a recommended confidence floor for the review agent.
// Bearer-gated by the `/v1/internal/*` middleware (INTERNAL_JOB_TOKEN); handleInternalCalibration re-checks it.
// Fails safe to an empty-but-shaped report when there is no review signal yet. Aggregate counts only.
app.get("/v1/internal/calibration", (c) => handleInternalCalibration(c.req.raw, c.env, internalOpsAgentConfig(c.env)));

app.post("/v1/internal/jobs/refresh-registry", async (c) => {
const message: JobMessage = { type: "refresh-registry", requestedBy: "api" };
await c.env.JOBS.send(message);
Expand Down
28 changes: 0 additions & 28 deletions src/review/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,34 +481,6 @@ export async function computeStats(
};
}

/** GET /<slug>/internal/parity?days=90&shadow=loopover — bearer-gated, CORS-open cross-system gate
* parity feed (the per-repo cutover gate). Scoped to the agent's own project. Mirrors handleStats. */
export async function handleParity(
request: Request,
env: Env,
project: string,
deps: StatsEvalDeps = defaultStatsEvalDeps,
): Promise<Response> {
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS_HEADERS });
const expected = readSecret(env, STATS_TOKEN_SECRET);
const provided = request.headers.get("authorization") ?? "";
if (!expected || !timingSafeEqual(provided, `Bearer ${expected}`)) {
return new Response("unauthorized", { status: 401, headers: CORS_HEADERS });
}
const params = new URL(request.url).searchParams;
const authoritative = params.get("authoritative");
const shadow = params.get("shadow");
const parity = await deps.computeGateParity(env, {
days: Number(params.get("days") ?? 90),
nowMs: Date.now(),
project,
...(authoritative !== null ? { authoritative } : {}),
...(shadow !== null ? { shadow } : {}),
});
const cutoverReady = parity.rows.map((r) => ({ project: r.project, ready: isParityCutoverReady(r) }));
return Response.json({ ...parity, cutoverReady }, { headers: CORS_HEADERS });
}

/** GET /stats/data?days=90&bucket=day — bearer-gated, CORS-open aggregate feed for the local dashboard. */
export async function handleStats(request: Request, env: Env, deps: StatsEvalDeps = defaultStatsEvalDeps): Promise<Response> {
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS_HEADERS });
Expand Down
87 changes: 87 additions & 0 deletions test/unit/routes-internal-decision-calibration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { createApp } from "../../src/api/routes";
import { createTestEnv } from "../helpers/d1";

// The `/v1/internal/decision` + `/v1/internal/calibration` operator read endpoints wire the ops.ts handlers
// (handleInternalDecision / handleInternalCalibration) into real routes. Bearer-gated by the `/v1/internal/*`
// middleware (INTERNAL_JOB_TOKEN); the handlers re-check that same token via their own requireInternalAuth.
const bearer = (env: Env) => ({ authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}` });

describe("GET /v1/internal/decision — operator decision-trail endpoint", () => {
it("401s without the internal token (the /v1/internal/* middleware gate)", async () => {
const app = createApp();
const env = createTestEnv();
expect((await app.request("/v1/internal/decision?repo=owner/repo&number=5", {}, env)).status).toBe(401);
expect((await app.request("/v1/internal/decision?repo=owner/repo&number=5", { headers: { authorization: "Bearer nope" } }, env)).status).toBe(401);
});

it("400s on a missing/invalid repo+number", async () => {
const app = createApp();
const env = createTestEnv();
const res = await app.request("/v1/internal/decision?repo=bad", { headers: bearer(env) }, env);
expect(res.status).toBe(400);
});

it("404s when the target does not exist", async () => {
const app = createApp();
const env = createTestEnv();
const res = await app.request("/v1/internal/decision?repo=owner/repo&number=999", { headers: bearer(env) }, env);
expect(res.status).toBe(404);
});

it("200s with the decision trail for a seeded target, scoped to the app slug", async () => {
const app = createApp();
const env = createTestEnv(); // GITHUB_APP_SLUG defaults to "gittensory"
// review_targets is raw-SQL-only (migration 0050) — seed the row the endpoint reads back. Its id is the
// project-namespaced natural key `${slug}:${kind}:${repo}#${number}` (rowId).
await env.DB.prepare(
`INSERT INTO review_targets (id, project, kind, repo, number, status, verdict, head_sha, decided_sha, attempt_count, terminal_at, decision_json)
VALUES (?, ?, 'pull_request', ?, ?, 'merged', 'merge', 'abc123', 'abc123', 1, '2026-07-01T00:00:00Z', ?)`,
)
.bind("gittensory:pull_request:owner/repo#5", "gittensory", "owner/repo", 5, JSON.stringify({ action: "merge", confidence: 0.9 }))
.run();
await env.DB.prepare(
`INSERT INTO review_audit (id, project, target_id, event_type, decision, summary, created_at)
VALUES ('a1', 'gittensory', ?, 'reviewed', 'merge', 'looks good', '2026-07-01T00:00:00Z')`,
)
.bind("gittensory:pull_request:owner/repo#5")
.run();
const res = await app.request("/v1/internal/decision?repo=owner/repo&number=5", { headers: bearer(env) }, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { project: string; target: { number: number; status: string }; decision: unknown; audit: Array<{ event: string }> };
expect(body.project).toBe("gittensory");
expect(body.target.number).toBe(5);
expect(body.target.status).toBe("merged");
expect(body.decision).toEqual({ action: "merge", confidence: 0.9 });
expect(body.audit.map((a) => a.event)).toContain("reviewed");
// Privacy: aggregate review state only — never actor logins / trust internals.
expect(JSON.stringify(body)).not.toMatch(/login|actor|reward|payout|trust|wallet|hotkey/i);
});
});

describe("GET /v1/internal/calibration — operator calibration endpoint", () => {
it("401s without the internal token (the /v1/internal/* middleware gate)", async () => {
const app = createApp();
const env = createTestEnv();
expect((await app.request("/v1/internal/calibration", {}, env)).status).toBe(401);
});

it("200s with the calibration report (fail-safe empty), scoped to the app slug", async () => {
const app = createApp();
const env = createTestEnv();
const res = await app.request("/v1/internal/calibration", { headers: bearer(env) }, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { project: string; calibration: { currentFloor: number; note: string } };
expect(body.project).toBe("gittensory");
expect(typeof body.calibration.currentFloor).toBe("number");
expect(typeof body.calibration.note).toBe("string");
});

it("falls back to the 'loopover' project slug when GITHUB_APP_SLUG is unset (the || default branch)", async () => {
const app = createApp();
const env = createTestEnv({ GITHUB_APP_SLUG: "" });
const res = await app.request("/v1/internal/calibration", { headers: bearer(env) }, env);
expect(res.status).toBe(200);
expect(((await res.json()) as { project: string }).project).toBe("loopover");
});
});
88 changes: 0 additions & 88 deletions test/unit/stats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
cycleTimeMs,
EMPTY_CYCLE_TIME,
EMPTY_FINDING_ACCEPTANCE,
handleParity,
handleStats,
isParityCutoverReady,
MIN_PARITY_SAMPLE,
Expand Down Expand Up @@ -288,93 +287,6 @@ describe("computeStats — gate-decision read is fail-safe", () => {
});
});

describe("handleParity — bearer-gated, CORS-open cross-system parity feed", () => {
const PARITY_DEPS: StatsEvalDeps = {
computeGateEval: async () => ({ rows: [], hasSignal: false }),
computeTuningRecommendations: () => [],
computeGateParity: async () => ({
authoritative: "reviewbot",
shadow: "gittensory",
hasSignal: true,
rows: [
{ project: "gittensory", pairedSamples: 40, bothMerge: 40, bothClose: 0, bothHold: 0, disagree: 0, agreementRate: 1, unsafeDisagreements: 0, byReasonCode: [] },
{ project: "gittensory", pairedSamples: 5, bothMerge: 5, bothClose: 0, bothHold: 0, disagree: 0, agreementRate: 1, unsafeDisagreements: 0, byReasonCode: [] },
],
}),
};
const req = (headers: Record<string, string> = {}, method = "GET") =>
new Request("https://w.dev/gittensory/internal/parity?days=90&shadow=gittensory", { method, headers });

it("204s a CORS preflight with no auth", async () => {
const res = await handleParity(req({}, "OPTIONS"), stubEnv({ LOOPOVER_REVIEW_STATS_TOKEN: "s3cret" }), "gittensory");
expect(res.status).toBe(204);
expect(res.headers.get("access-control-allow-origin")).toBe("*");
});

it("401s when the token is unset or wrong", async () => {
expect((await handleParity(req({ authorization: "Bearer anything" }), stubEnv(), "gittensory")).status).toBe(401); // unset
const env = stubEnv({ LOOPOVER_REVIEW_STATS_TOKEN: "s3cret" });
expect((await handleParity(req(), env, "gittensory")).status).toBe(401); // no header
expect((await handleParity(req({ authorization: "Bearer nope" }), env, "gittensory")).status).toBe(401); // wrong
});

it("200s with the parity report + per-row cutoverReady for the correct token", async () => {
const res = await handleParity(req({ authorization: "Bearer s3cret" }), stubEnv({ LOOPOVER_REVIEW_STATS_TOKEN: "s3cret" }), "gittensory", PARITY_DEPS);
expect(res.status).toBe(200);
expect(res.headers.get("access-control-allow-origin")).toBe("*");
const body = (await res.json()) as { authoritative: string; shadow: string; cutoverReady: Array<{ project: string; ready: boolean }> };
expect(body.authoritative).toBe("reviewbot");
expect(body.shadow).toBe("gittensory");
// first row (40 paired, perfect agreement, 0 unsafe) is cutover-ready; the thin 5-sample row is not.
expect(body.cutoverReady).toEqual([{ project: "gittensory", ready: true }, { project: "gittensory", ready: false }]);
});

it("forwards the ?authoritative / ?shadow params (non-null branch) and uses ?days override", async () => {
let seen: { days: number; authoritative?: string; shadow?: string } | undefined;
const deps: StatsEvalDeps = {
computeGateEval: async () => ({ rows: [], hasSignal: false }),
computeTuningRecommendations: () => [],
computeGateParity: async (_env, o) => {
seen = { days: o.days, ...(o.authoritative !== undefined ? { authoritative: o.authoritative } : {}), ...(o.shadow !== undefined ? { shadow: o.shadow } : {}) };
return { authoritative: o.authoritative ?? "a", shadow: o.shadow ?? "s", hasSignal: false, rows: [] };
},
};
const r = new Request("https://w.dev/gittensory/internal/parity?days=14&authoritative=reviewbot&shadow=gittensory", {
method: "GET",
headers: { authorization: "Bearer s3cret" },
});
const res = await handleParity(r, stubEnv({ LOOPOVER_REVIEW_STATS_TOKEN: "s3cret" }), "gittensory", deps);
expect(res.status).toBe(200);
expect(seen).toEqual({ days: 14, authoritative: "reviewbot", shadow: "gittensory" });
});

it("omits authoritative/shadow (the {} branch) and defaults days to 90 when those params are absent", async () => {
let seen: { days: number; hasAuthoritative: boolean; hasShadow: boolean } | undefined;
const deps: StatsEvalDeps = {
computeGateEval: async () => ({ rows: [], hasSignal: false }),
computeTuningRecommendations: () => [],
computeGateParity: async (_env, o) => {
seen = { days: o.days, hasAuthoritative: "authoritative" in o, hasShadow: "shadow" in o };
return { authoritative: "reviewbot", shadow: "gittensory", hasSignal: false, rows: [] };
},
};
// No days / authoritative / shadow params → days defaults to 90, both spreads collapse to {}.
const r = new Request("https://w.dev/gittensory/internal/parity", { method: "GET", headers: { authorization: "Bearer s3cret" } });
const res = await handleParity(r, stubEnv({ LOOPOVER_REVIEW_STATS_TOKEN: "s3cret" }), "gittensory", deps);
expect(res.status).toBe(200);
expect(seen).toEqual({ days: 90, hasAuthoritative: false, hasShadow: false });
});

it("uses the default deps (defaultStatsEvalDeps) when none are injected — empty parity, no cutoverReady rows", async () => {
const res = await handleParity(req({ authorization: "Bearer s3cret" }), stubEnv({ LOOPOVER_REVIEW_STATS_TOKEN: "s3cret" }), "gittensory");
expect(res.status).toBe(200);
const body = (await res.json()) as { authoritative: string; cutoverReady: unknown[] };
// default emptyParity uses the URL's shadow=gittensory and a default authoritative=reviewbot.
expect(body.authoritative).toBe("reviewbot");
expect(body.cutoverReady).toEqual([]);
});
});

describe("handleStats — query-param default branches", () => {
it("defaults days→90 and bucket→day when those params are absent (the ?? fallbacks)", async () => {
let captured: { days: number; bucket: string } | undefined;
Expand Down