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
112 changes: 112 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
Expand Down Expand Up @@ -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": [
Expand Down
11 changes: 11 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
25 changes: 25 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
18 changes: 18 additions & 0 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { OpenApiGeneratorV3, OpenAPIRegistry } from "@asteasolutions/zod-to-open
import { z } from "zod";
import {
AdvisorySchema,
EnrichmentAnalyzersTaxonomyDocumentSchema,
FindingTaxonomyDocumentSchema,
ActionPortfolioSchema,
AgentActionSchema,
AgentContextSnapshotSchema,
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions test/unit/openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
59 changes: 59 additions & 0 deletions test/unit/routes-taxonomy-mirrors.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Loading