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
39 changes: 39 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -5526,6 +5557,7 @@ function canSessionAccessPath(env: Env, identity: Extract<AuthIdentity, { kind:
if (isRepoOutcomeCalibrationPath(path)) return true;
if (isRepoGatePrecisionPath(path)) return true;
if (isRepoMaintainerNoisePath(path)) return true;
if (isRepoSelftuneOverridesPath(path)) return true;
if (isRepoSettingsPreviewPath(path)) return true;
if (isRepoOnboardingPackPreviewPath(path)) return true;
if (isRepoFocusManifestPath(path)) return true;
Expand Down Expand Up @@ -5567,6 +5599,13 @@ function isRepoMaintainerNoisePath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/maintainer-noise$/.test(path);
}

// #6168: let a browser (session) maintainer reach the self-tune override admin routes; the route's own
// requireRepoMaintainer then enforces per-repo authority (a non-maintainer session → 403). Matches the
// gate-precision allowlist entry above. Covers both the audit read and the live-override delete.
function isRepoSelftuneOverridesPath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/selftune\/overrides(?:\/audit)?$/.test(path);
}

function isRepoSettingsPreviewPath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/settings-preview$/.test(path);
}
Expand Down
60 changes: 60 additions & 0 deletions test/integration/routes-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createApp } from "../../src/api/routes";
import { RateLimiter } from "../../src/auth/rate-limit";
import { createSessionForGitHubUser } from "../../src/auth/security";
import { persistSignalSnapshot, upsertInstallation, upsertRepositoryFromGitHub } from "../../src/db/repositories";
import { loadOverride, recordOverrideAudit, type StorageEnv, writeLiveOverride } from "../../src/review/auto-apply";
import { handleMcpRequest } from "../../src/mcp/server";
import { normalizeRegistryPayload } from "../../src/registry/normalize";
import { persistRegistrySnapshot } from "../../src/registry/sync";
Expand Down Expand Up @@ -1179,6 +1180,65 @@ describe("api route guards and error branches", () => {
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<void> {
Expand Down