diff --git a/src/api/routes.ts b/src/api/routes.ts index caa3a0c854..1efb667f11 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -264,6 +264,7 @@ import { buildMaintainerActivationPreview, recommendedAdvisoryActivationSettings 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 { computeParityReadiness, isParityAuditEnabled } from "../review/parity-wire"; import { computePredictedGateAgreement } from "../review/predicted-gate-agreement"; import { isRagEnabled } from "../review/rag-wire"; @@ -2700,6 +2701,36 @@ export function createApp() { return c.json(await loadMaintainerNoiseReport(c.env, fullName)); }); + // #6168 self-tune override admin: the operator-facing read side of the self-tune override store. The + // LOOPOVER_REVIEW_SELFTUNE loop only ever writes override_audit rows automatically (via the cron's promote + // path); this exposes the audit trail so a self-host operator can inspect it without direct D1 access. + // Maintainer-scoped + read-only, mirroring the gate-precision route above. + app.get("/v1/repos/:owner/:repo/selftune/overrides/audit", async (c) => { + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const gate = await requireRepoMaintainer(c, fullName); + if (gate instanceof Response) return gate; + const limitRaw = Number(c.req.query("limit")); + const limit = limitRaw > 0 ? limitRaw : undefined; + const audit = await listOverrideAudit(c.env as unknown as StorageEnv, fullName, limit); + return c.json({ repoFullName: fullName, audit }); + }); + + // #6168 self-tune override admin: clear the LIVE override for a repo (the operator's "reset to config base" + // control). An optional JSON body is treated as a confirmation of the override being cleared and is run + // through the same sanitizer the apply path uses — a malformed payload is rejected (400) rather than + // silently ignored. Maintainer-scoped; the automatic promote path is untouched. + app.delete("/v1/repos/:owner/:repo/selftune/overrides", async (c) => { + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const gate = await requireRepoMaintainer(c, fullName); + if (gate instanceof Response) return gate; + const body = await c.req.json().catch(() => null); + if (body !== null && sanitizeOverridePayload(body) === null) { + return c.json({ error: "invalid_override_payload" }, 400); + } + await deleteLiveOverride(c.env as unknown as StorageEnv, fullName); + return c.json({ repoFullName: fullName, cleared: true }); + }); + // One-click "enable advisory mode" — turns on the gate + deterministic rules in advisory (non-blocking) // mode. Merges onto current settings so unrelated fields are preserved. app.post("/v1/repos/:owner/:repo/activation", async (c) => { @@ -5526,6 +5557,7 @@ function canSessionAccessPath(env: Env, identity: Extract { expect(updated.status).toBe(200); await expect(updated.json()).resolves.toMatchObject({ commentMode: "all_prs", checkRunDetailLevel: "standard", backfillEnabled: false }); }); + + it("exposes and clears self-tune overrides for operators, rejecting unauthorized callers (#6168)", async () => { + const app = createApp(); + const env = createTestEnv(); + const storage = env as unknown as StorageEnv; + const fullName = "JSONbored/gittensory"; + + // Seed a live override + two audit rows for the repo the operator will inspect/clear. + await writeLiveOverride(storage, fullName, { confidenceFloor: 0.95, scopeCap: { files: 3, lines: 120 } }); + await recordOverrideAudit(storage, fullName, "override.promoted", { floor: 0.95 }); + await recordOverrideAudit(storage, fullName, "override.applied", { cap: "3f/120l" }); + + // ── Audit read ──────────────────────────────────────────────────────────────────────────────── + // Unauthenticated → 401 (rejected by the auth middleware before the handler). + const auditUnauthenticated = await app.request(`/v1/repos/${fullName}/selftune/overrides/audit`, {}, env); + expect(auditUnauthenticated.status).toBe(401); + + // A non-maintainer session reaches the handler (allowlisted path) but is rejected per-repo → 403. + const { token: contributorToken } = await createSessionForGitHubUser(env, { login: "plain-contributor", id: 7788 }); + const auditForbidden = await app.request(`/v1/repos/${fullName}/selftune/overrides/audit`, { headers: { authorization: `Bearer ${contributorToken}` } }, env); + expect(auditForbidden.status).toBe(403); + + // Operator (static token), no ?limit → the full trail. + const audit = await app.request(`/v1/repos/${fullName}/selftune/overrides/audit`, { headers: apiHeaders(env) }, env); + expect(audit.status).toBe(200); + const auditBody = (await audit.json()) as { repoFullName: string; audit: { eventType: string; detail: string | null; createdAt: string }[] }; + expect(auditBody.repoFullName).toBe(fullName); + expect(auditBody.audit.map((row) => row.eventType).sort()).toEqual(["override.applied", "override.promoted"]); + + // ?limit trims the trail (covers the limit>0 branch vs the absent-param default above). + const auditLimited = await app.request(`/v1/repos/${fullName}/selftune/overrides/audit?limit=1`, { headers: apiHeaders(env) }, env); + expect(auditLimited.status).toBe(200); + const limitedBody = (await auditLimited.json()) as { audit: unknown[] }; + expect(limitedBody.audit).toHaveLength(1); + + // ── Live-override clear ─────────────────────────────────────────────────────────────────────── + const clearUnauthenticated = await app.request(`/v1/repos/${fullName}/selftune/overrides`, { method: "DELETE" }, env); + expect(clearUnauthenticated.status).toBe(401); + + const clearForbidden = await app.request(`/v1/repos/${fullName}/selftune/overrides`, { method: "DELETE", headers: { authorization: `Bearer ${contributorToken}` } }, env); + expect(clearForbidden.status).toBe(403); + + // A malformed confirmation body is rejected before anything is cleared (400) — the override survives. + const clearInvalid = await app.request(`/v1/repos/${fullName}/selftune/overrides`, { method: "DELETE", headers: apiHeaders(env), body: JSON.stringify({ confidenceFloor: 2 }) }, env); + expect(clearInvalid.status).toBe(400); + await expect(clearInvalid.json()).resolves.toMatchObject({ error: "invalid_override_payload" }); + expect(await loadOverride(storage, fullName)).not.toBeNull(); + + // A valid confirmation body clears the live override. + const clearWithBody = await app.request(`/v1/repos/${fullName}/selftune/overrides`, { method: "DELETE", headers: apiHeaders(env), body: JSON.stringify({ confidenceFloor: 0.95 }) }, env); + expect(clearWithBody.status).toBe(200); + await expect(clearWithBody.json()).resolves.toMatchObject({ repoFullName: fullName, cleared: true }); + expect(await loadOverride(storage, fullName)).toBeNull(); + + // Clearing again with no body is an idempotent no-op (covers the empty-body → null branch). + const clearNoBody = await app.request(`/v1/repos/${fullName}/selftune/overrides`, { method: "DELETE", headers: apiHeaders(env) }, env); + expect(clearNoBody.status).toBe(200); + await expect(clearNoBody.json()).resolves.toMatchObject({ cleared: true }); + }); }); async function seedRegisteredInstalledRepo(env: Env, installationId: number, owner: string, name: string): Promise {