diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index bd6783535..11592c4ed 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -16134,6 +16134,31 @@ ] } } + }, + "SelftuneOverrideAuditResponse": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "audit": { + "type": "array", + "items": { + "nullable": true + } + } + } + }, + "ClearSelftuneOverrideResponse": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "cleared": { + "type": "boolean" + } + } } }, "parameters": {}, @@ -21768,6 +21793,132 @@ } ] } + }, + "/v1/repos/{owner}/{repo}/selftune/overrides/audit": { + "get": { + "summary": "Self-tune gate override audit trail for a repo (#9303)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + }, + { + "schema": { + "type": "string", + "example": "50" + }, + "required": false, + "description": "Optional row cap for the returned audit history (positive integer).", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Newest-first override_audit rows recording why the self-tune loop promoted, shadowed, or cleared a live gate override", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelftuneOverrideAuditResponse" + } + } + } + }, + "403": { + "description": "Insufficient role" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/selftune/overrides": { + "delete": { + "summary": "Clear the live self-tune gate override for a repo (#9303)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "confirm": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "confirm" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Live override cleared; the automatic self-tune promote path is untouched", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClearSelftuneOverrideResponse" + } + } + } + }, + "400": { + "description": "Malformed confirmation body" + }, + "403": { + "description": "Insufficient role" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 5ed13dd4b..47937224a 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1982,6 +1982,23 @@ export const GateConfigEffectiveResponseSchema = z }) .openapi("GateConfigEffectiveResponse"); +/** #9303: mirrors selftuneOverrideAuditOutputSchema (src/mcp/server.ts) — audit rows stay z.unknown(), + * listOverrideAudit is the single source of truth for the event fields. */ +export const SelftuneOverrideAuditResponseSchema = z + .object({ + repoFullName: z.string().optional(), + audit: z.array(z.unknown()).optional(), + }) + .openapi("SelftuneOverrideAuditResponse"); + +/** #9303: mirrors clearSelftuneOverrideOutputSchema (src/mcp/server.ts) — the write-side confirmation shape. */ +export const ClearSelftuneOverrideResponseSchema = z + .object({ + repoFullName: z.string().optional(), + cleared: z.boolean().optional(), + }) + .openapi("ClearSelftuneOverrideResponse"); + /** * Response body for POST /v1/scoring/eligibility-plan. Field-level parity with `eligibilityPlanOutputSchema` * (the `loopover_get_eligibility_plan` MCP tool `outputSchema`) in src/mcp/server.ts — #9301. diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 1b0df5732..616305102 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -117,6 +117,8 @@ import { RewardRiskActionSchema, ScorePreviewSchema, ScoringModelSnapshotSchema, + SelftuneOverrideAuditResponseSchema, + ClearSelftuneOverrideResponseSchema, SignalFidelitySchema, SkippedPrAuditExportSchema, SyncStatusSchema, @@ -203,6 +205,8 @@ export function buildOpenApiSpec() { registry.register("IssueQualityReport", IssueQualityReportSchema); registry.register("IssueQualityResponse", IssueQualityResponseSchema); registry.register("GateConfigEffectiveResponse", GateConfigEffectiveResponseSchema); + registry.register("SelftuneOverrideAuditResponse", SelftuneOverrideAuditResponseSchema); + registry.register("ClearSelftuneOverrideResponse", ClearSelftuneOverrideResponseSchema); registry.register("EligibilityPlanResponse", EligibilityPlanResponseSchema); registry.register("ScoreBreakdownResponse", ScoreBreakdownResponseSchema); registry.register("EvaluateEscalationRequest", EvaluateEscalationRequestSchema); @@ -564,6 +568,52 @@ export function buildOpenApiSpec() { 403: { description: "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/repos/{owner}/{repo}/selftune/overrides/audit", + summary: "Self-tune gate override audit trail for a repo (#9303)", + request: { + params: z.object({ owner: z.string(), repo: z.string() }), + query: z.object({ + limit: z.string().optional().openapi({ + param: { description: "Optional row cap for the returned audit history (positive integer)." }, + example: "50", + }), + }), + }, + responses: { + 200: { + description: + "Newest-first override_audit rows recording why the self-tune loop promoted, shadowed, or cleared a live gate override", + content: { "application/json": { schema: SelftuneOverrideAuditResponseSchema } }, + }, + 403: { description: "Insufficient role" }, + }, + }); + registry.registerPath({ + method: "delete", + path: "/v1/repos/{owner}/{repo}/selftune/overrides", + summary: "Clear the live self-tune gate override for a repo (#9303)", + request: { + params: z.object({ owner: z.string(), repo: z.string() }), + body: { + required: false, + content: { + "application/json": { + schema: z.object({ confirm: z.literal(true) }), + }, + }, + }, + }, + responses: { + 200: { + description: "Live override cleared; the automatic self-tune promote path is untouched", + content: { "application/json": { schema: ClearSelftuneOverrideResponseSchema } }, + }, + 400: { description: "Malformed confirmation body" }, + 403: { description: "Insufficient role" }, + }, + }); registry.registerPath({ method: "post", path: "/v1/loop/evaluate-escalation", diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index f3d000bb2..3f8c1d597 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -39,6 +39,8 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/repos/{owner}/{repo}/issue-quality"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/outcome-patterns"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/gate-config/effective"]).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/selftune/overrides/audit"]).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/selftune/overrides"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/maintainer-noise"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/ams-miner-cohort"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/gate-precision"]).toBeDefined(); @@ -171,6 +173,35 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/auth/github/token"]?.post?.security).toEqual([{ LoopOverBearer: [] }, { LoopOverSessionCookie: [] }]); }); + // #9303: selftune-override routes were live but undocumented; assert both paths, their HTTP methods, and the + // response schema keys matching each route's MCP tool output shape (selftuneOverrideAuditOutputSchema / + // clearSelftuneOverrideOutputSchema in src/mcp/server.ts). + it("documents the selftune-override audit and clear routes with matching MCP output shapes (#9303)", () => { + const spec = buildOpenApiSpec(); + + const auditPath = spec.paths["/v1/repos/{owner}/{repo}/selftune/overrides/audit"]; + expect(auditPath?.get).toBeDefined(); + expect(auditPath?.get?.responses?.["200"]?.content?.["application/json"]?.schema).toEqual({ + $ref: "#/components/schemas/SelftuneOverrideAuditResponse", + }); + + const clearPath = spec.paths["/v1/repos/{owner}/{repo}/selftune/overrides"]; + expect(clearPath?.delete).toBeDefined(); + expect(clearPath?.delete?.responses?.["200"]?.content?.["application/json"]?.schema).toEqual({ + $ref: "#/components/schemas/ClearSelftuneOverrideResponse", + }); + + const auditSchema = spec.components?.schemas?.SelftuneOverrideAuditResponse as + | { properties?: Record } + | undefined; + expect(Object.keys(auditSchema?.properties ?? {}).sort()).toEqual(["audit", "repoFullName"]); + + const clearSchema = spec.components?.schemas?.ClearSelftuneOverrideResponse as + | { properties?: Record } + | undefined; + expect(Object.keys(clearSchema?.properties ?? {}).sort()).toEqual(["cleared", "repoFullName"]); + }); + // #9307: the three agent/pending-actions approval-queue routes were undocumented while their agent/audit-feed // neighbor was. Assert each is registered with a response schema whose keys match the MCP tool output shape it // mirrors, so the OpenAPI contract can never drift from what loopover_{list,propose,decide}_pending_action validate.