diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index dbe8ccd68f..55efe5f50b 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -15256,6 +15256,108 @@ "required": [ "ok" ] + }, + "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": {}, @@ -20022,6 +20124,271 @@ } ] } + }, + "/v1/repos/{owner}/{repo}/maintainer-noise": { + "get": { + "summary": "Maintainer queue-noise triage report for a repository (#9302)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Noise score/level, specific noise sources to clear first, and recommended maintainer actions — mirrors the loopover_get_maintainer_noise MCP tool. Maintainer-authenticated; advisory only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintainerNoiseReport" + } + } + } + }, + "401": { + "description": "Missing or invalid static protected API token" + }, + "403": { + "description": "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/ams-miner-cohort": { + "get": { + "summary": "AMS-vs-human contributor-mix cohort comparison for a repository (#9302)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Submitter counts, PR volume, acceptance rate, review-cycle, and time-to-merge metrics for AMS-tracked vs human submitters — mirrors the loopover_get_ams_miner_cohort MCP tool. Maintainer-authenticated; advisory only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AmsMinerCohortComparison" + } + } + } + }, + "401": { + "description": "Missing or invalid static protected API token" + }, + "403": { + "description": "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/gate-precision": { + "get": { + "summary": "Per-gate-type false-positive precision measurement for a repository (#9302)", + "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": "Blocked / blocked-then-merged / overridden counts and false-positive rates with low-sample guards — mirrors the loopover_get_gate_precision MCP tool. Maintainer-authenticated; measurement only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatePrecisionResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid static protected API token" + }, + "403": { + "description": "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/outcome-calibration": { + "get": { + "summary": "Slop-band and recommendation outcome calibration for a repository (#9302)", + "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": "Whether higher-slop bands merge less often and how agent recommendations are panning out — mirrors the loopover_get_outcome_calibration MCP tool. Maintainer-authenticated; measurement only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OutcomeCalibrationResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid static protected API token" + }, + "403": { + "description": "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/activation-preview": { + "get": { + "summary": "Deterministic maintainer activation preview for a repository (#9302)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "A deterministic \"here's what LoopOver would have surfaced\" run of the advisory engine over recent PRs — mirrors the loopover_get_activation_preview MCP tool. Maintainer-authenticated; advisory only, never runs AI", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivationPreviewResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid static protected API token" + }, + "403": { + "description": "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 84b39898fc..8cf5faca6b 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 94b40b299a..9fd8a0de2d 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -2970,6 +2970,59 @@ export const AmsMinerCohortComparisonSchema = z }) .openapi("AmsMinerCohortComparison"); +/** + * Response body for GET /v1/repos/{owner}/{repo}/gate-precision. Field-level parity with + * `gatePrecisionOutputSchema` (the `loopover_get_gate_precision` MCP tool `outputSchema`) in + * src/mcp/server.ts — #9302. + */ +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"); + +/** + * Response body for GET /v1/repos/{owner}/{repo}/outcome-calibration. Field-level parity with + * `maintainerMeasurementReportOutputSchema` (the `loopover_get_outcome_calibration` MCP tool + * `outputSchema`) in src/mcp/server.ts — #9302. + */ +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"); + +/** + * Response body for GET /v1/repos/{owner}/{repo}/activation-preview. Field-level parity with + * `activationPreviewOutputSchema` (the `loopover_get_activation_preview` MCP tool `outputSchema`) in + * src/mcp/server.ts — #9302. + */ +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 PullRequestReviewabilitySchema = z .object({ repoFullName: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index a8c3122fe3..4d4bfa83de 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -57,6 +57,9 @@ import { MaintainerLaneReportSchema, MaintainerNoiseReportSchema, AmsMinerCohortComparisonSchema, + GatePrecisionResponseSchema, + OutcomeCalibrationResponseSchema, + ActivationPreviewResponseSchema, McpCompatibilitySchema, PullRequestMaintainerPacketSchema, PullRequestReviewIntelligenceSchema, @@ -192,6 +195,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({ @@ -590,6 +596,87 @@ 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 repository (#9302)", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, + responses: { + 200: { + description: + "Noise score/level, specific noise sources to clear first, and recommended maintainer actions — mirrors the loopover_get_maintainer_noise MCP tool. Maintainer-authenticated; advisory only", + content: { "application/json": { schema: MaintainerNoiseReportSchema } }, + }, + 401: { description: "Missing or invalid static protected API token" }, + 403: { description: "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" }, + }, + }); + registry.registerPath({ + method: "get", + path: "/v1/repos/{owner}/{repo}/ams-miner-cohort", + summary: "AMS-vs-human contributor-mix cohort comparison for a repository (#9302)", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, + responses: { + 200: { + description: + "Submitter counts, PR volume, acceptance rate, review-cycle, and time-to-merge metrics for AMS-tracked vs human submitters — mirrors the loopover_get_ams_miner_cohort MCP tool. Maintainer-authenticated; advisory only", + content: { "application/json": { schema: AmsMinerCohortComparisonSchema } }, + }, + 401: { description: "Missing or invalid static protected API token" }, + 403: { description: "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" }, + }, + }); + registry.registerPath({ + method: "get", + path: "/v1/repos/{owner}/{repo}/gate-precision", + summary: "Per-gate-type false-positive precision measurement for a repository (#9302)", + request: { + params: z.object({ owner: z.string(), repo: z.string() }), + query: z.object({ windowDays: z.coerce.number().int().positive().optional() }), + }, + responses: { + 200: { + description: + "Blocked / blocked-then-merged / overridden counts and false-positive rates with low-sample guards — mirrors the loopover_get_gate_precision MCP tool. Maintainer-authenticated; measurement only", + content: { "application/json": { schema: GatePrecisionResponseSchema } }, + }, + 401: { description: "Missing or invalid static protected API token" }, + 403: { description: "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" }, + }, + }); + registry.registerPath({ + method: "get", + path: "/v1/repos/{owner}/{repo}/outcome-calibration", + summary: "Slop-band and recommendation outcome calibration for a repository (#9302)", + request: { + params: z.object({ owner: z.string(), repo: z.string() }), + query: z.object({ windowDays: z.coerce.number().int().positive().optional() }), + }, + responses: { + 200: { + description: + "Whether higher-slop bands merge less often and how agent recommendations are panning out — mirrors the loopover_get_outcome_calibration MCP tool. Maintainer-authenticated; measurement only", + content: { "application/json": { schema: OutcomeCalibrationResponseSchema } }, + }, + 401: { description: "Missing or invalid static protected API token" }, + 403: { description: "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" }, + }, + }); + registry.registerPath({ + method: "get", + path: "/v1/repos/{owner}/{repo}/activation-preview", + summary: "Deterministic maintainer activation preview for a repository (#9302)", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, + responses: { + 200: { + description: + "A deterministic \"here's what LoopOver would have surfaced\" run of the advisory engine over recent PRs — mirrors the loopover_get_activation_preview MCP tool. Maintainer-authenticated; advisory only, never runs AI", + content: { "application/json": { schema: ActivationPreviewResponseSchema } }, + }, + 401: { description: "Missing or invalid static protected API token" }, + 403: { description: "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for 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 627b631ab6..f6874799b7 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -10,6 +10,11 @@ import { intakeIdeaShape, intakeIdeaOutputSchema, planIdeaClaimsOutputSchema, + maintainerNoiseOutputSchema, + amsMinerCohortOutputSchema, + gatePrecisionOutputSchema, + maintainerMeasurementReportOutputSchema, + activationPreviewOutputSchema, } from "../../src/mcp/server"; describe("OpenAPI contract", () => { @@ -28,6 +33,11 @@ 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}/maintainer-noise"]).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/ams-miner-cohort"]).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/gate-precision"]).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/outcome-calibration"]).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/activation-preview"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/registration-readiness"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/gittensor-config-recommendation"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/pulls/{number}/maintainer-packet"]).toBeDefined(); @@ -89,7 +99,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", @@ -109,6 +118,11 @@ describe("OpenAPI contract", () => { expect(spec.components?.schemas?.RepoOutcomePatterns).toBeDefined(); expect(spec.components?.schemas?.RegistrationReadiness).toBeDefined(); expect(spec.components?.schemas?.GittensorConfigRecommendation).toBeDefined(); + expect(spec.components?.schemas?.GatePrecisionResponse).toBeDefined(); + expect(spec.components?.schemas?.OutcomeCalibrationResponse).toBeDefined(); + expect(spec.components?.schemas?.ActivationPreviewResponse).toBeDefined(); + expect(spec.components?.schemas?.MaintainerNoiseReport).toBeDefined(); + expect(spec.components?.schemas?.AmsMinerCohortComparison).toBeDefined(); expect(spec.components?.schemas?.PullRequestMaintainerPacket).toBeDefined(); expect(spec.components?.schemas?.PullRequestReviewability).toBeDefined(); expect(spec.components?.schemas?.LocalBranchAnalysis).toBeDefined(); @@ -226,6 +240,52 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/loop/request-apr-transfer"]).toBeUndefined(); }); + // #9302: five maintainer-report GET routes are each backed by an MCP tool whose Zod output shape already + // lives in src/mcp/server.ts. Assert every route is a documented GET path whose response component keys + // stay field-for-field in parity with those tool shapes. + it("documents the repo maintainer-report route family with tool-parity schemas (#9302)", () => { + const spec = buildOpenApiSpec(); + const schemas = spec.components?.schemas ?? {}; + + const propKeys = (name: string) => + Object.keys((schemas[name] as { properties?: Record }).properties ?? {}).sort(); + + const cases = [ + { + path: "/v1/repos/{owner}/{repo}/maintainer-noise", + response: "MaintainerNoiseReport", + outputShape: maintainerNoiseOutputSchema, + }, + { + path: "/v1/repos/{owner}/{repo}/ams-miner-cohort", + response: "AmsMinerCohortComparison", + outputShape: amsMinerCohortOutputSchema, + }, + { + path: "/v1/repos/{owner}/{repo}/gate-precision", + response: "GatePrecisionResponse", + outputShape: gatePrecisionOutputSchema, + }, + { + path: "/v1/repos/{owner}/{repo}/outcome-calibration", + response: "OutcomeCalibrationResponse", + outputShape: maintainerMeasurementReportOutputSchema, + }, + { + path: "/v1/repos/{owner}/{repo}/activation-preview", + response: "ActivationPreviewResponse", + outputShape: activationPreviewOutputSchema, + }, + ]; + + for (const { path, response, outputShape } of cases) { + const op = spec.paths[path]?.get; + expect(op, `${path} should be a documented GET path`).toBeDefined(); + expect(schemas[response], `${response} component should be registered`).toBeDefined(); + expect(propKeys(response)).toEqual(Object.keys(outputShape).sort()); + } + }); + it("declares an `in: path` parameter for every {templated} path segment (Cloudflare schema-validation warning 30046)", () => { const spec = buildOpenApiSpec(); for (const [path, methods] of Object.entries(spec.paths ?? {})) {