From 73a91a255457a000d8b8080b94e8f4243ce4d0bc Mon Sep 17 00:00:00 2001 From: davion-knight <298846663+davion-knight@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:01:58 -0500 Subject: [PATCH] feat(api): add REST mirrors for the finding-taxonomy and enrichment-analyzers MCP resources buildFindingTaxonomyDocument() and buildEnrichmentAnalyzersTaxonomyDocument() were reachable only as MCP resources (loopover://finding-taxonomy, gittensory://enrichment-analyzers). A caller without MCP access -- a plain HTTP client, a dashboard, a non-MCP integration -- had no way to fetch either, even though routes.ts otherwise exposes essentially every other piece of review/registry/scoring data over /v1/*. Adds GET /v1/finding-taxonomy and GET /v1/enrichment-analyzers, each delegating to its existing pure, argument-free builder with the same plain-c.json() handler shape as the /v1/scoring/model route they sit beside. Both are additive: the MCP resource registrations are untouched, and the URIs stay MCP-only identifiers. Registers both paths in src/openapi/spec.ts with response schemas, and regenerates apps/loopover-ui/public/openapi.json so ui:openapi:check stays green. The two schemas are deliberately permissive on member strings -- the taxonomies are open-ended (FINDING_CATEGORIES, the committed analyzer-metadata.json), so the shape is the contract, not the enum membership. Tests assert each route returns its builder's document byte-identically, that neither leaks PR/user/private data, and that both are gated exactly like the sibling /v1/scoring/model route -- pinned against that sibling rather than a hard-coded status, so the assertion stays honest if the shared middleware changes. Extends openapi.test.ts's path list with both new paths. Closes #6593 --- apps/loopover-ui/public/openapi.json | 112 ++++++++++++++++++++++ src/api/routes.ts | 11 +++ src/openapi/schemas.ts | 25 +++++ src/openapi/spec.ts | 18 ++++ test/unit/openapi.test.ts | 2 + test/unit/routes-taxonomy-mirrors.test.ts | 59 ++++++++++++ 6 files changed, 227 insertions(+) create mode 100644 test/unit/routes-taxonomy-mirrors.test.ts diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index a1e6f52e35..47faaafdb1 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -14102,6 +14102,68 @@ "effective", "shadowPending" ] + }, + "FindingTaxonomyDocument": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "severities": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "categories", + "severities" + ] + }, + "EnrichmentAnalyzersTaxonomyDocument": { + "type": "object", + "properties": { + "defaultProfile": { + "type": "string" + }, + "analyzers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "category": { + "type": "string" + }, + "costClass": { + "type": "string" + }, + "profiles": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "category", + "costClass", + "profiles" + ] + } + } + }, + "required": [ + "defaultProfile", + "analyzers" + ] } }, "parameters": {}, @@ -18209,6 +18271,56 @@ } ] } + }, + "/v1/finding-taxonomy": { + "get": { + "summary": "Canonical AI-review finding taxonomy", + "responses": { + "200": { + "description": "Finding categories and the severity ladder", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindingTaxonomyDocument" + } + } + } + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/enrichment-analyzers": { + "get": { + "summary": "REES enrichment analyzer taxonomy", + "responses": { + "200": { + "description": "Default profile and the registered enrichment analyzers", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrichmentAnalyzersTaxonomyDocument" + } + } + } + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/api/routes.ts b/src/api/routes.ts index 9c8991e9f3..4dbb256beb 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -192,6 +192,8 @@ import { } from "../services/control-panel-roles"; import { runFindOpportunities, validateFindOpportunitiesInput, type FindOpportunitiesInput } from "../mcp/find-opportunities"; import { runIssueRagRetrieval, validateIssueRagInput, type IssueRagInput } from "../mcp/issue-rag"; +import { buildFindingTaxonomyDocument } from "../review/finding-taxonomy"; +import { buildEnrichmentAnalyzersTaxonomyDocument } from "../review/enrichment-analyzers-taxonomy"; import { loadPrAiReviewFindings } from "../mcp/pr-ai-review-findings"; import { buildMcpCompatibilityMetadata, @@ -2057,6 +2059,15 @@ export function createApp() { app.get("/v1/scoring/model", async (c) => c.json(await getOrCreateScoringModelSnapshot(c.env))); + // #6593: REST mirrors of the `loopover://finding-taxonomy` / `gittensory://enrichment-analyzers` MCP + // resources, so a plain HTTP client (a dashboard, a non-MCP integration) can discover the same static + // documents. Both builders are pure, argument-free, and return no PR/user/private data — the same class of + // public static discovery data as /v1/scoring/model and /v1/upstream/ruleset alongside them, so they carry no + // extra auth. The MCP resource registrations stay exactly as they are; this is additive, not a replacement. + app.get("/v1/finding-taxonomy", (c) => c.json(buildFindingTaxonomyDocument())); + + app.get("/v1/enrichment-analyzers", (c) => c.json(buildEnrichmentAnalyzersTaxonomyDocument())); + app.get("/v1/upstream/status", async (c) => c.json(await loadUpstreamStatus(c.env))); app.get("/v1/upstream/ruleset", async (c) => { diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 9d9bc62882..d8a3f9c63f 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1532,6 +1532,31 @@ export const ReadinessSchema = z }) .openapi("Readiness"); +// #6593: the two static discovery documents the finding-taxonomy / enrichment-analyzers REST mirrors return. +// Deliberately permissive on the member strings (they are open-ended taxonomies sourced from +// FINDING_CATEGORIES / the committed analyzer-metadata.json) so adding a category or analyzer never breaks the +// spec — the SHAPE is the contract here, not the enum membership. +export const FindingTaxonomyDocumentSchema = z + .object({ + categories: z.array(z.string()), + severities: z.array(z.string()), + }) + .openapi("FindingTaxonomyDocument"); + +export const EnrichmentAnalyzersTaxonomyDocumentSchema = z + .object({ + defaultProfile: z.string(), + analyzers: z.array( + z.object({ + name: z.string(), + category: z.string(), + costClass: z.string(), + profiles: z.array(z.string()), + }), + ), + }) + .openapi("EnrichmentAnalyzersTaxonomyDocument"); + export const ScoringModelSnapshotSchema = z .object({ id: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 30354b50c7..047cd6552e 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -2,6 +2,8 @@ import { OpenApiGeneratorV3, OpenAPIRegistry } from "@asteasolutions/zod-to-open import { z } from "zod"; import { AdvisorySchema, + EnrichmentAnalyzersTaxonomyDocumentSchema, + FindingTaxonomyDocumentSchema, ActionPortfolioSchema, AgentActionSchema, AgentContextSnapshotSchema, @@ -239,6 +241,22 @@ export function buildOpenApiSpec() { 200: { description: "Latest private scoring model snapshot", content: { "application/json": { schema: ScoringModelSnapshotSchema } } }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/finding-taxonomy", + summary: "Canonical AI-review finding taxonomy", + responses: { + 200: { description: "Finding categories and the severity ladder", content: { "application/json": { schema: FindingTaxonomyDocumentSchema } } }, + }, + }); + registry.registerPath({ + method: "get", + path: "/v1/enrichment-analyzers", + summary: "REES enrichment analyzer taxonomy", + responses: { + 200: { description: "Default profile and the registered enrichment analyzers", content: { "application/json": { schema: EnrichmentAnalyzersTaxonomyDocumentSchema } } }, + }, + }); registry.registerPath({ method: "get", path: "/v1/upstream/status", diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index 54d6c520e8..c5db56974d 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -8,6 +8,8 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/mcp/compatibility"]).toBeDefined(); expect(spec.paths["/v1/public/github/repos/{owner}/{repo}/stats"]).toBeDefined(); expect(spec.paths["/v1/registry/snapshot"]).toBeDefined(); + expect(spec.paths["/v1/finding-taxonomy"]).toBeDefined(); + expect(spec.paths["/v1/enrichment-analyzers"]).toBeDefined(); expect(spec.paths["/v1/registry/changes"]).toBeDefined(); expect(spec.paths["/v1/readiness"]).toBeDefined(); expect(spec.paths["/v1/sync/status"]).toBeDefined(); diff --git a/test/unit/routes-taxonomy-mirrors.test.ts b/test/unit/routes-taxonomy-mirrors.test.ts new file mode 100644 index 0000000000..39d4e592cc --- /dev/null +++ b/test/unit/routes-taxonomy-mirrors.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { buildEnrichmentAnalyzersTaxonomyDocument } from "../../src/review/enrichment-analyzers-taxonomy"; +import { buildFindingTaxonomyDocument } from "../../src/review/finding-taxonomy"; +import { createTestEnv } from "../helpers/d1"; + +// #6593: REST mirrors of the `loopover://finding-taxonomy` / `gittensory://enrichment-analyzers` MCP resources. +// Both delegate to a pure, argument-free builder, so these tests pin the ROUTE contract — served byte-identical +// to the document the MCP resource already returns, and gated exactly like the sibling static-data routes it +// sits with (no new auth middleware of its own) — rather than re-testing the builders themselves. +const apiHeaders = (env: Env) => ({ authorization: `Bearer ${env.LOOPOVER_API_TOKEN}` }); +const PATHS = ["/v1/finding-taxonomy", "/v1/enrichment-analyzers"] as const; + +describe("static taxonomy REST mirrors (#6593)", () => { + it("GET /v1/finding-taxonomy returns the finding taxonomy document", async () => { + const env = createTestEnv(); + const response = await createApp().request("/v1/finding-taxonomy", { headers: apiHeaders(env) }, env); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual(JSON.parse(JSON.stringify(buildFindingTaxonomyDocument()))); + const doc = body as { categories: unknown[]; severities: unknown[] }; + expect(doc.categories.length).toBeGreaterThan(0); + expect(doc.severities.length).toBeGreaterThan(0); + }); + + it("GET /v1/enrichment-analyzers returns the enrichment analyzer taxonomy document", async () => { + const env = createTestEnv(); + const response = await createApp().request("/v1/enrichment-analyzers", { headers: apiHeaders(env) }, env); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual(JSON.parse(JSON.stringify(buildEnrichmentAnalyzersTaxonomyDocument()))); + const doc = body as { defaultProfile: unknown; analyzers: unknown[] }; + expect(typeof doc.defaultProfile).toBe("string"); + expect(doc.analyzers.length).toBeGreaterThan(0); + expect(doc.analyzers[0]).toMatchObject({ name: expect.any(String), category: expect.any(String), costClass: expect.any(String), profiles: expect.any(Array) }); + }); + + it("is gated exactly like the sibling static-data routes — no new auth middleware, no new public hole", async () => { + const app = createApp(); + const env = createTestEnv(); + // /v1/scoring/model is the route these two are modelled on; whatever it answers unauthenticated, they must + // answer too. Pinning it against the sibling (rather than a hard-coded status) keeps this honest if the + // shared middleware ever changes. + const sibling = await app.request("/v1/scoring/model", {}, env); + for (const path of PATHS) { + const response = await app.request(path, {}, env); + expect(response.status, `${path} must match /v1/scoring/model's unauthenticated behavior`).toBe(sibling.status); + } + }); + + it("exposes no PR/user/private data in either document", async () => { + const app = createApp(); + const env = createTestEnv(); + for (const path of PATHS) { + const text = JSON.stringify(await (await app.request(path, { headers: apiHeaders(env) }, env)).json()); + expect(text, path).not.toMatch(/wallet|hotkey|coldkey|trust score|reward|pullNumber|authorLogin/i); + } + }); +});