diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index bdb2032d4..50708ebb5 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -14856,6 +14856,108 @@ "login", "marked" ] + }, + "GatePrecisionResponse": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "generatedAt": { + "type": "string" + }, + "windowDays": { + "type": "number", + "nullable": true + }, + "perGateType": { + "type": "array", + "items": { + "nullable": true + } + }, + "overall": { + "nullable": true + }, + "signals": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "OutcomeCalibrationResponse": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "generatedAt": { + "type": "string" + }, + "windowDays": { + "type": "number", + "nullable": true + }, + "slop": { + "nullable": true + }, + "recommendations": { + "nullable": true + }, + "signals": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + } + } + }, + "ActivationPreviewResponse": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "generatedAt": { + "type": "string" + }, + "currentReviewCheckMode": { + "type": "string" + }, + "aiReviewConfigured": { + "type": "boolean" + }, + "evaluatedCount": { + "type": "number" + }, + "withFindingsCount": { + "type": "number" + }, + "findingCodeCounts": { + "type": "array", + "items": { + "nullable": true + } + }, + "samples": { + "type": "array", + "items": { + "nullable": true + } + }, + "recommendedAction": { + "type": "string", + "nullable": true + }, + "summary": { + "type": "string" + } + } } }, "parameters": {}, @@ -19437,6 +19539,271 @@ } ] } + }, + "/v1/repos/{owner}/{repo}/maintainer-noise": { + "get": { + "summary": "Maintainer queue-noise triage report for a repo (#2228)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Read-only queue-noise triage report — noise score/level, sources, recommended maintainer actions, and queue health (mirrors loopover_get_maintainer_noise)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintainerNoiseReport" + } + } + } + }, + "401": { + "description": "Missing or invalid credential" + }, + "403": { + "description": "Caller is not a maintainer of this repo" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/ams-miner-cohort": { + "get": { + "summary": "AMS-vs-human contributor-mix cohort comparison for a repo (#6488)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "AMS-miner vs human cohort metrics over the comparison window; `present: false` (never a 404) when the AMS reputation bridge is off or the repo has no submitter activity (mirrors loopover_get_ams_miner_cohort)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AmsMinerCohortComparison" + } + } + } + }, + "401": { + "description": "Missing or invalid credential" + }, + "403": { + "description": "Caller is not a maintainer of this repo" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/gate-precision": { + "get": { + "summary": "Per-gate-type false-positive precision report for a repo (#554)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "required": false, + "name": "windowDays", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Read-only per-gate-type precision measurement — blocked / blocked-then-merged / overridden counts and false-positive rates with low-sample guards; never adjusts a gate (mirrors loopover_get_gate_precision)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatePrecisionResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid credential" + }, + "403": { + "description": "Caller is not a maintainer of this repo" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/outcome-calibration": { + "get": { + "summary": "Slop-band and recommendation outcome calibration for a repo (#543)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "required": false, + "name": "windowDays", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Read-only measurement of whether higher slop bands merge less often and how agent recommendations are panning out over the optional window (mirrors loopover_get_outcome_calibration)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OutcomeCalibrationResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid credential" + }, + "403": { + "description": "Caller is not a maintainer of this repo" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/activation-preview": { + "get": { + "summary": "Maintainer activation preview over recent PRs for a repo (#701)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Deterministic \"here's what LoopOver would have surfaced\" preview — evaluated/with-findings counts, distinct finding codes, per-PR samples, current review-check mode, and the single recommended next action (mirrors loopover_get_activation_preview)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivationPreviewResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid credential" + }, + "403": { + "description": "Caller is not a maintainer of this repo" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 06a6285e9..15a826296 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -967,7 +967,7 @@ const repoContextOutputSchema = { dataQuality: z.unknown().optional(), }; -const maintainerNoiseOutputSchema = { +export const maintainerNoiseOutputSchema = { repoFullName: z.string().optional(), generatedAt: z.string().optional(), score: z.number().optional(), @@ -978,7 +978,7 @@ const maintainerNoiseOutputSchema = { summary: z.string().optional(), }; -const amsMinerCohortOutputSchema = { +export const amsMinerCohortOutputSchema = { present: z.boolean().optional(), windowDays: z.number().optional(), totalSubmitterCount: z.number().optional(), @@ -996,7 +996,7 @@ const repoFocusManifestOutputSchema = { // (#7799) Repo-specific "here's what LoopOver would have surfaced" activation preview over recent PRs. // Mirrors buildMaintainerActivationPreview's shape; deterministic, maintainer-authenticated, advisory only. -const activationPreviewOutputSchema = { +export const activationPreviewOutputSchema = { repoFullName: z.string().optional(), generatedAt: z.string().optional(), currentReviewCheckMode: z.string().optional(), @@ -1114,7 +1114,7 @@ const repoSettingsOutputSchema = { slopGateMode: z.string().optional(), }; -const maintainerMeasurementReportOutputSchema = { +export const maintainerMeasurementReportOutputSchema = { repoFullName: z.string().optional(), generatedAt: z.string().optional(), windowDays: z.number().nullable().optional(), @@ -1127,7 +1127,7 @@ const maintainerMeasurementReportOutputSchema = { // #2220 - gate-precision measurement surfaced over MCP. Mirrors the // maintainerMeasurementReportOutputSchema pattern: report fields optional, structured sub-reports as // z.unknown() (buildGatePrecisionReport is the single source of truth for their shape). -const gatePrecisionOutputSchema = { +export const gatePrecisionOutputSchema = { repoFullName: z.string().optional(), generatedAt: z.string().optional(), windowDays: z.number().nullable().optional(), diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 79085ab1e..34c6dee69 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1926,6 +1926,60 @@ export const GateConfigEffectiveResponseSchema = z }) .openapi("GateConfigEffectiveResponse"); +/** + * #554 gate false-positive precision report (GET /v1/repos/:owner/:repo/gate-precision). Mirrors the + * loopover_get_gate_precision MCP tool's gatePrecisionOutputSchema field-for-field. Structured sub-reports + * (perGateType rows, overall) stay permissive — loadGatePrecisionReport is the single source of truth for + * their shape — matching how the MCP tool leaves them as z.unknown(). + */ +export const GatePrecisionResponseSchema = z + .object({ + repoFullName: z.string().optional(), + generatedAt: z.string().optional(), + windowDays: z.number().nullable().optional(), + perGateType: z.array(z.unknown()).optional(), + overall: z.unknown().optional(), + signals: z.array(z.string()).optional(), + }) + .openapi("GatePrecisionResponse"); + +/** + * #543 slop-band + recommendation outcome calibration (GET /v1/repos/:owner/:repo/outcome-calibration). + * Mirrors the loopover_get_outcome_calibration MCP tool's maintainerMeasurementReportOutputSchema + * field-for-field; buildRepoOutcomeCalibration owns the slop/recommendations sub-report shapes. + */ +export const OutcomeCalibrationResponseSchema = z + .object({ + repoFullName: z.string().optional(), + generatedAt: z.string().optional(), + windowDays: z.number().nullable().optional(), + slop: z.unknown().optional(), + recommendations: z.unknown().optional(), + signals: z.array(z.string()).optional(), + status: z.string().optional(), + }) + .openapi("OutcomeCalibrationResponse"); + +/** + * #701 maintainer activation preview over recent PRs (GET /v1/repos/:owner/:repo/activation-preview). + * Mirrors the loopover_get_activation_preview MCP tool's activationPreviewOutputSchema field-for-field; + * buildMaintainerActivationPreview owns the findingCodeCounts / samples row shapes. + */ +export const ActivationPreviewResponseSchema = z + .object({ + repoFullName: z.string().optional(), + generatedAt: z.string().optional(), + currentReviewCheckMode: z.string().optional(), + aiReviewConfigured: z.boolean().optional(), + evaluatedCount: z.number().optional(), + withFindingsCount: z.number().optional(), + findingCodeCounts: z.array(z.unknown()).optional(), + samples: z.array(z.unknown()).optional(), + recommendedAction: z.string().nullable().optional(), + summary: z.string().optional(), + }) + .openapi("ActivationPreviewResponse"); + export const BurdenForecastSchema = z .object({ repoFullName: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 67f085e8c..406ede994 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -47,6 +47,9 @@ import { MaintainerLaneReportSchema, MaintainerNoiseReportSchema, AmsMinerCohortComparisonSchema, + GatePrecisionResponseSchema, + OutcomeCalibrationResponseSchema, + ActivationPreviewResponseSchema, McpCompatibilitySchema, PullRequestMaintainerPacketSchema, PullRequestReviewIntelligenceSchema, @@ -172,6 +175,9 @@ export function buildOpenApiSpec() { registry.register("ContributorRewardRiskStrategy", ContributorRewardRiskStrategySchema); registry.register("MaintainerNoiseReport", MaintainerNoiseReportSchema); registry.register("AmsMinerCohortComparison", AmsMinerCohortComparisonSchema); + registry.register("GatePrecisionResponse", GatePrecisionResponseSchema); + registry.register("OutcomeCalibrationResponse", OutcomeCalibrationResponseSchema); + registry.register("ActivationPreviewResponse", ActivationPreviewResponseSchema); registry.register("PullRequestReviewability", PullRequestReviewabilitySchema); registry.registerPath({ @@ -480,6 +486,82 @@ export function buildOpenApiSpec() { 404: { description: "No live or shadow gate override is active for this repo" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/repos/{owner}/{repo}/maintainer-noise", + summary: "Maintainer queue-noise triage report for a repo (#2228)", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, + responses: { + 200: { + description: "Read-only queue-noise triage report — noise score/level, sources, recommended maintainer actions, and queue health (mirrors loopover_get_maintainer_noise)", + content: { "application/json": { schema: MaintainerNoiseReportSchema } }, + }, + 401: { description: "Missing or invalid credential" }, + 403: { description: "Caller is not a maintainer of this repo" }, + }, + }); + registry.registerPath({ + method: "get", + path: "/v1/repos/{owner}/{repo}/ams-miner-cohort", + summary: "AMS-vs-human contributor-mix cohort comparison for a repo (#6488)", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, + responses: { + 200: { + description: "AMS-miner vs human cohort metrics over the comparison window; `present: false` (never a 404) when the AMS reputation bridge is off or the repo has no submitter activity (mirrors loopover_get_ams_miner_cohort)", + content: { "application/json": { schema: AmsMinerCohortComparisonSchema } }, + }, + 401: { description: "Missing or invalid credential" }, + 403: { description: "Caller is not a maintainer of this repo" }, + }, + }); + registry.registerPath({ + method: "get", + path: "/v1/repos/{owner}/{repo}/gate-precision", + summary: "Per-gate-type false-positive precision report for a repo (#554)", + request: { + params: z.object({ owner: z.string(), repo: z.string() }), + query: z.object({ windowDays: z.coerce.number().int().positive().optional() }), + }, + responses: { + 200: { + description: "Read-only per-gate-type precision measurement — blocked / blocked-then-merged / overridden counts and false-positive rates with low-sample guards; never adjusts a gate (mirrors loopover_get_gate_precision)", + content: { "application/json": { schema: GatePrecisionResponseSchema } }, + }, + 401: { description: "Missing or invalid credential" }, + 403: { description: "Caller is not a maintainer of this repo" }, + }, + }); + registry.registerPath({ + method: "get", + path: "/v1/repos/{owner}/{repo}/outcome-calibration", + summary: "Slop-band and recommendation outcome calibration for a repo (#543)", + request: { + params: z.object({ owner: z.string(), repo: z.string() }), + query: z.object({ windowDays: z.coerce.number().int().positive().optional() }), + }, + responses: { + 200: { + description: "Read-only measurement of whether higher slop bands merge less often and how agent recommendations are panning out over the optional window (mirrors loopover_get_outcome_calibration)", + content: { "application/json": { schema: OutcomeCalibrationResponseSchema } }, + }, + 401: { description: "Missing or invalid credential" }, + 403: { description: "Caller is not a maintainer of this repo" }, + }, + }); + registry.registerPath({ + method: "get", + path: "/v1/repos/{owner}/{repo}/activation-preview", + summary: "Maintainer activation preview over recent PRs for a repo (#701)", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, + responses: { + 200: { + description: "Deterministic \"here's what LoopOver would have surfaced\" preview — evaluated/with-findings counts, distinct finding codes, per-PR samples, current review-check mode, and the single recommended next action (mirrors loopover_get_activation_preview)", + content: { "application/json": { schema: ActivationPreviewResponseSchema } }, + }, + 401: { description: "Missing or invalid credential" }, + 403: { description: "Caller is not a maintainer of this repo" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/outcome-patterns", diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index f4afc434c..f9a166c7e 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; import { buildOpenApiSpec } from "../../src/openapi/spec"; +import { + maintainerNoiseOutputSchema, + amsMinerCohortOutputSchema, + gatePrecisionOutputSchema, + maintainerMeasurementReportOutputSchema, + activationPreviewOutputSchema, +} from "../../src/mcp/server"; describe("OpenAPI contract", () => { it("exports the modern private-beta backend contract only", () => { @@ -78,7 +85,6 @@ describe("OpenAPI contract", () => { "/v1/repos/{owner}/{repo}/burden-forecast", "/v1/repos/{owner}/{repo}/registry-drift", "/v1/repos/{owner}/{repo}/maintainer-lane", - "/v1/repos/{owner}/{repo}/maintainer-noise", "/v1/repos/{owner}/{repo}/pulls/{number}/review-intelligence", "/v1/repos/{owner}/{repo}/pulls/{number}/scoring-preview", "/v1/internal/jobs/generate-signal-snapshots/run", @@ -135,6 +141,29 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/auth/github/token"]?.post?.security).toEqual([{ LoopOverBearer: [] }, { LoopOverSessionCookie: [] }]); }); + // #9302: the five repo maintainer-report routes (maintainer-noise, ams-miner-cohort, gate-precision, + // outcome-calibration, activation-preview) must be documented as GETs whose 200 response references a + // component schema whose keys match, field-for-field, their MCP tool's output shape — closing the drift + // for the whole "repo maintainer advisory report" route family (mirrors #6611's gate-config/effective fix). + it("documents the five maintainer-report routes with response schemas matching their MCP tool shapes", () => { + const spec = buildOpenApiSpec(); + const cases: Array<{ path: string; component: string; shape: Record }> = [ + { path: "/v1/repos/{owner}/{repo}/maintainer-noise", component: "MaintainerNoiseReport", shape: maintainerNoiseOutputSchema }, + { path: "/v1/repos/{owner}/{repo}/ams-miner-cohort", component: "AmsMinerCohortComparison", shape: amsMinerCohortOutputSchema }, + { path: "/v1/repos/{owner}/{repo}/gate-precision", component: "GatePrecisionResponse", shape: gatePrecisionOutputSchema }, + { path: "/v1/repos/{owner}/{repo}/outcome-calibration", component: "OutcomeCalibrationResponse", shape: maintainerMeasurementReportOutputSchema }, + { path: "/v1/repos/{owner}/{repo}/activation-preview", component: "ActivationPreviewResponse", shape: activationPreviewOutputSchema }, + ]; + for (const { path, component, shape } of cases) { + const operation = (spec.paths[path] as { get?: { responses?: Record }> } } | undefined)?.get; + expect(operation, `${path} GET operation should be documented`).toBeDefined(); + const responseSchema = operation?.responses?.["200"]?.content?.["application/json"]?.schema; + expect(responseSchema?.$ref, `${path} 200 response should reference ${component}`).toBe(`#/components/schemas/${component}`); + const componentSchema = spec.components?.schemas?.[component] as { properties?: Record } | undefined; + expect(Object.keys(componentSchema?.properties ?? {}).sort(), `${component} keys should match ${path}'s MCP tool output shape`).toEqual(Object.keys(shape).sort()); + } + }); + // #5810: every operation needs a title in the generated spec and the rendered API browser. Iterating the built // document (rather than counting `summary:` lines in the source) also covers the paths registered from a loop, // and fails loudly when a future route is added without one.