From 1c98be193925a53d538298d72693f83c0a18e46a Mon Sep 17 00:00:00 2001 From: davion-knight <298846663+davion-knight@users.noreply.github.com> Date: Fri, 17 Jul 2026 05:40:08 -0500 Subject: [PATCH] feat(api): add a REST route + CLI mirror for loopover_simulate_open_pr_pressure loopover_simulate_open_pr_pressure had neither a REST route nor a CLI mirror, though it is deterministic, public-safe, and read-only -- no repo access, no GitHub writes -- the same tier as the lint routes that already have full REST/CLI parity. Adds POST /v1/lint/open-pr-pressure beside them. Rather than hand-copying the tool's input contract, the route parses with the tool's OWN simulateOpenPrPressureShape, now exported from src/mcp/server.ts (a one-word change): the two surfaces cannot diverge on accepted input because there is only one schema. It then delegates to the same pure simulateOpenPrPressure and adds no logic of its own. Adds the loopover_simulate_open_pr_pressure stdio tool. Like the boundary-tests mirror it PROXIES to the route rather than computing in-process: simulateOpenPrPressure lives app-side in src/services/open-pr-pressure-scenarios.ts, not in @loopover/engine. The bin cannot import from src/ (package boundary), so its zod shape is the one hand-mirrored copy here -- reproduced VERBATIM, including the nested signals object, findings, and the nullable queueHealth -- and its own tests pin that the fields the real schema requires are enforced there too rather than waved through to a 400. Route tests assert output parity with the pure simulator across every queue-health level and both role lanes, the optional contributorOpenPrCount, the nullable queueHealth arm, and 400s for each rejected field plus an unparseable body. The CLI test asserts the proxied POST, the null-queueHealth arm, and that zod rejects bad input before any API call. 100% line and branch coverage on every changed line. Closes #6751 --- packages/loopover-mcp/bin/loopover-mcp.js | 59 ++++++++++++ src/api/routes.ts | 13 +++ src/mcp/server.ts | 4 +- .../mcp-cli-open-pr-pressure-tool.test.ts | 95 ++++++++++++++++++ test/unit/mcp-tool-rename-aliases.test.ts | 10 +- test/unit/routes-open-pr-pressure.test.ts | 96 +++++++++++++++++++ test/unit/support/mcp-cli-harness.ts | 5 + 7 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 test/unit/mcp-cli-open-pr-pressure-tool.test.ts create mode 100644 test/unit/routes-open-pr-pressure.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 93c03b6bb..18f755201 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -563,6 +563,46 @@ const suggestBoundaryTestsShape = { testFiles: z.array(z.string().max(400)).max(2000).optional(), }; +// #6751: mirrors simulateOpenPrPressureShape in src/mcp/server.ts VERBATIM. The bin cannot import from src/ +// (package boundary), so this copy is the one place parity is by convention rather than construction — the +// route parses with the tool's own exported shape, and mcp-cli-open-pr-pressure-tool.test.ts pins that a +// payload this shape accepts is one the route accepts too. +const simulateOpenPrPressureCount = z.number().int().min(0).max(1000000); +const simulateOpenPrPressureShape = { + repoFullName: z.string().min(3).max(200), + generatedAt: z.string().min(1).max(100), + queueHealth: z + .object({ + repoFullName: z.string().min(3).max(200), + generatedAt: z.string().min(1).max(100), + burdenScore: z.number().finite(), + level: z.enum(["low", "medium", "high", "critical"]), + summary: z.string().max(1000), + signals: z + .object({ + openIssues: simulateOpenPrPressureCount, + openPullRequests: simulateOpenPrPressureCount, + unlinkedPullRequests: simulateOpenPrPressureCount, + stalePullRequests: simulateOpenPrPressureCount, + draftPullRequests: simulateOpenPrPressureCount, + maintainerAuthoredPullRequests: simulateOpenPrPressureCount, + collisionClusters: simulateOpenPrPressureCount, + ageBuckets: z + .object({ under7Days: simulateOpenPrPressureCount, days7To30: simulateOpenPrPressureCount, over30Days: simulateOpenPrPressureCount }) + .passthrough(), + likelyReviewablePullRequests: simulateOpenPrPressureCount, + cachedOpenPullRequests: simulateOpenPrPressureCount.optional(), + likelyReviewablePullRequestsSource: z.enum(["cache", "sampled_cache", "authoritative"]).optional(), + }) + .passthrough(), + findings: z.array(z.unknown()).max(100), + }) + .passthrough() + .nullable(), + roleContext: z.object({ maintainerLane: z.boolean() }).passthrough(), + contributorOpenPrCount: simulateOpenPrPressureCount.optional(), +}; + const checkSlopRiskShape = { changedFiles: z .array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() })) @@ -872,6 +912,12 @@ const STDIO_TOOL_DESCRIPTORS = [ category: "review", description: "Assess the deterministic slop risk of a planned change from local diff metadata (paths + line counts) + the PR description — an agent-native, source-free quality self-check. Returns slopRisk (0-100), band, findings, and the rubric. Computed in-process; no repo data and no API round-trip.", }, + { + name: "loopover_simulate_open_pr_pressure", + category: "discovery", + description: + "Rank what-if scenarios for easing a repo's open-PR pressure from already-computed queue-health metadata — deterministic, public-safe, and read-only. Needs no repo access and performs no GitHub writes.", + }, { name: "loopover_suggest_boundary_tests", category: "review", @@ -1450,6 +1496,19 @@ registerStdioTool( (input) => toolResult("LoopOver slop-risk self-check.", { ...buildSlopAssessment(input), rubric: SLOP_RUBRIC_MARKDOWN }), ); +// #6751: CLI mirror of the remote server's loopover_simulate_open_pr_pressure. Proxies rather than computing +// in-process (like the boundary-tests mirror, #6750): simulateOpenPrPressure lives app-side in +// src/services/open-pr-pressure-scenarios.ts, not in @loopover/engine, so POST /v1/lint/open-pr-pressure stays +// the single source of truth for the ranking. +registerStdioTool( + "loopover_simulate_open_pr_pressure", + { + description: stdioToolDescription("loopover_simulate_open_pr_pressure"), + inputSchema: simulateOpenPrPressureShape, + }, + async (input) => toolResult("LoopOver open-PR pressure simulation.", await apiPost("/v1/lint/open-pr-pressure", input)), +); + // #6750: CLI mirror of the remote server's loopover_suggest_boundary_tests. Unlike its check_slop_risk sibling // this one PROXIES rather than computing in-process: the builders live app-side (src/signals/ // boundary-test-generation.ts, which depends on the app's AdvisoryFinding type), not in @loopover/engine, so diff --git a/src/api/routes.ts b/src/api/routes.ts index c6bc539ba..7d67b4391 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -155,6 +155,8 @@ import { } from "../orb/relay"; import { computeFleetAnalytics } from "../orb/analytics"; import { handleMcpRequest } from "../mcp/server"; +import { simulateOpenPrPressureShape } from "../mcp/server"; +import { simulateOpenPrPressure, type OpenPrPressureInput } from "../services/open-pr-pressure-scenarios"; import { buildOpenApiSpec } from "../openapi/spec"; import { COMMAND_RATE_LIMIT_EVENT_TYPE, generateSignalSnapshots } from "../queue/processors"; import { generateChatQaAnswer } from "../services/ai-chat-qa"; @@ -3257,6 +3259,17 @@ export function createApp() { return c.json({ ...buildSlopAssessment(parsed.data), rubric: SLOP_RUBRIC_MARKDOWN }); }); + // #6751: REST mirror of the loopover_simulate_open_pr_pressure MCP tool — deterministic, public-safe, and + // read-only (no repo access, no GitHub writes), the same tier as the lint routes it sits with. Parses with the + // tool's OWN exported simulateOpenPrPressureShape so the two surfaces cannot diverge on accepted input, then + // delegates to the same pure simulateOpenPrPressure. No logic of its own. + app.post("/v1/lint/open-pr-pressure", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = z.object(simulateOpenPrPressureShape).safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_open_pr_pressure_request", issues: parsed.error.issues }, 400); + return c.json(simulateOpenPrPressure(parsed.data as unknown as OpenPrPressureInput)); + }); + // #6750: REST mirror of the loopover_suggest_boundary_tests MCP tool, bringing it to the same parity its // same-tier advisory-lint sibling /v1/lint/slop-risk (directly above) already has. Reproduces the tool's // handler exactly: keep only touches whose path is actually in the changed set, build the finding, and build diff --git a/src/mcp/server.ts b/src/mcp/server.ts index c567ffd8b..531050425 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1546,7 +1546,9 @@ const simulateOpenPrPressureQueueHealthSchema = z // #2224 - pure, read-only open-PR pressure simulator surfaced over MCP. The simulator only reads // bounded queue counts and maintainer-lane state, so validate those fields at the MCP boundary. -const simulateOpenPrPressureShape = { +// #6751: exported so POST /v1/lint/open-pr-pressure parses with this EXACT shape rather than a second, +// drifting copy — the REST mirror and this tool can never diverge on what they accept. +export const simulateOpenPrPressureShape = { repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), generatedAt: z.string().min(1).max(100), queueHealth: simulateOpenPrPressureQueueHealthSchema, diff --git a/test/unit/mcp-cli-open-pr-pressure-tool.test.ts b/test/unit/mcp-cli-open-pr-pressure-tool.test.ts new file mode 100644 index 000000000..5c6b3348a --- /dev/null +++ b/test/unit/mcp-cli-open-pr-pressure-tool.test.ts @@ -0,0 +1,95 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness"; + +// #6751: the CLI mirror of loopover_simulate_open_pr_pressure. It PROXIES to POST /v1/lint/open-pr-pressure +// (simulateOpenPrPressure lives app-side in src/services/, not @loopover/engine), so the route is the single +// source of truth for the ranking. The bin cannot import from src/, so its zod shape is a hand-mirror of the +// tool's — these tests pin that mirror: a payload the shape accepts reaches the route, and the fields the real +// schema requires (nested signals, findings) are enforced here too rather than being waved through. +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); +let client: Client; +let transport: StdioClientTransport; +let configDir: string; +let captured: Array<{ url: string; method: string }>; + +const SIGNALS = { + openIssues: 12, + openPullRequests: 9, + unlinkedPullRequests: 3, + stalePullRequests: 2, + draftPullRequests: 1, + maintainerAuthoredPullRequests: 1, + collisionClusters: 1, + ageBuckets: { under7Days: 4, days7To30: 3, over30Days: 2 }, + likelyReviewablePullRequests: 5, +}; +const VALID = { + repoFullName: "acme/widgets", + generatedAt: "2026-07-17T00:00:00.000Z", + queueHealth: { repoFullName: "acme/widgets", generatedAt: "2026-07-17T00:00:00.000Z", burdenScore: 42.5, level: "high", summary: "Backed up.", signals: SIGNALS, findings: [] }, + roleContext: { maintainerLane: false }, + contributorOpenPrCount: 3, +}; + +beforeEach(async () => { + configDir = mkdtempSync(join(tmpdir(), "loopover-pr-pressure-")); + captured = []; + const apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url?.includes("/lint/open-pr-pressure")) captured.push({ url: request.url ?? "", method: request.method ?? "" }); + }, + }); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { ...process.env, LOOPOVER_CONFIG_DIR: configDir, LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token", LOOPOVER_API_TIMEOUT_MS: "5000" }, + }); + client = new Client({ name: "pr-pressure-tool-test", version: "0.0.1" }); + await client.connect(transport); +}); + +afterEach(async () => { + await client?.close().catch(() => undefined); + await closeFixtureServer(); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +}); + +describe("loopover_simulate_open_pr_pressure stdio mirror (#6751)", () => { + it("registers the tool on the stdio server", async () => { + expect((await client.listTools()).tools.map((t) => t.name)).toContain("loopover_simulate_open_pr_pressure"); + }); + + it("proxies a valid payload to POST /v1/lint/open-pr-pressure and returns the ranking", async () => { + const result = await client.callTool({ name: "loopover_simulate_open_pr_pressure", arguments: VALID }); + expect(result.isError).toBeFalsy(); + expect(captured).toHaveLength(1); + expect(captured[0]!.method).toBe("POST"); + expect(JSON.stringify(result)).toContain("close_stale"); + }); + + it("accepts a null queueHealth, exactly like the tool's shape", async () => { + const result = await client.callTool({ name: "loopover_simulate_open_pr_pressure", arguments: { ...VALID, queueHealth: null } }); + expect(result.isError).toBeFalsy(); + expect(captured).toHaveLength(1); + }); + + it("rejects input the real schema rejects — before any API call", async () => { + for (const args of [ + {}, + { ...VALID, repoFullName: "ab" }, + { ...VALID, queueHealth: { ...VALID.queueHealth, level: "bogus" } }, + { ...VALID, queueHealth: { ...VALID.queueHealth, signals: undefined } }, // nested signals are required + { ...VALID, roleContext: {} }, + { ...VALID, contributorOpenPrCount: -1 }, + ]) { + const rejected = await client.callTool({ name: "loopover_simulate_open_pr_pressure", arguments: args }).then((r) => Boolean(r.isError), () => true); + expect(rejected, JSON.stringify(args).slice(0, 50)).toBe(true); + } + expect(captured).toHaveLength(0); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 79c718bbf..173077c20 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -53,14 +53,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 67 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 68 loopover_ tools and zero gittensory_-prefixed aliases", async () => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name); const primary = names.filter((n) => n.startsWith("loopover_")); const legacy = names.filter((n) => n.startsWith("gittensory_")); - expect(primary.length).toBe(67); + expect(primary.length).toBe(68); expect(legacy.length).toBe(0); - expect(names.length).toBe(67); + expect(names.length).toBe(68); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -70,11 +70,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 67-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 68-tool count the live server registers", async () => { const { tools } = await client.listTools(); const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }> }; expect(payload.count).toBe(tools.length); - expect(payload.count).toBe(67); + expect(payload.count).toBe(68); expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort()); }); }); diff --git a/test/unit/routes-open-pr-pressure.test.ts b/test/unit/routes-open-pr-pressure.test.ts new file mode 100644 index 000000000..7227875f9 --- /dev/null +++ b/test/unit/routes-open-pr-pressure.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { simulateOpenPrPressure, type OpenPrPressureInput } from "../../src/services/open-pr-pressure-scenarios"; +import { createTestEnv } from "../helpers/d1"; + +// #6751: POST /v1/lint/open-pr-pressure — the REST mirror of loopover_simulate_open_pr_pressure. The route +// parses with the tool's OWN exported simulateOpenPrPressureShape and delegates to the same pure +// simulateOpenPrPressure (whose ranking is covered by its own tests), so these pin the ROUTE contract: +// the simulation returned unmodified, and the shape's rejections. +const apiHeaders = (env: Env) => ({ authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, "content-type": "application/json" }); +const PATH = "/v1/lint/open-pr-pressure"; +const post = (env: Env, body: unknown) => createApp().request(PATH, { method: "POST", headers: apiHeaders(env), body: JSON.stringify(body) }, env); + +const SIGNALS = { + openIssues: 12, + openPullRequests: 9, + unlinkedPullRequests: 3, + stalePullRequests: 2, + draftPullRequests: 1, + maintainerAuthoredPullRequests: 1, + collisionClusters: 1, + ageBuckets: { under7Days: 4, days7To30: 3, over30Days: 2 }, + likelyReviewablePullRequests: 5, +}; +const QUEUE_HEALTH = { + repoFullName: "acme/widgets", + generatedAt: "2026-07-17T00:00:00.000Z", + burdenScore: 42.5, + level: "high" as const, + summary: "Queue is backed up.", + signals: SIGNALS, + findings: [], +}; +const VALID = { + repoFullName: "acme/widgets", + generatedAt: "2026-07-17T00:00:00.000Z", + queueHealth: QUEUE_HEALTH, + roleContext: { maintainerLane: false }, + contributorOpenPrCount: 3, +}; + +describe("POST /v1/lint/open-pr-pressure (#6751)", () => { + it("returns exactly what the pure simulator returns (parity)", async () => { + const env = createTestEnv(); + const response = await post(env, VALID); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual(JSON.parse(JSON.stringify(simulateOpenPrPressure(VALID as unknown as OpenPrPressureInput)))); + }); + + it("covers each queue-health level and both role lanes", async () => { + const env = createTestEnv(); + for (const level of ["low", "medium", "high", "critical"] as const) { + for (const maintainerLane of [true, false]) { + const body = { ...VALID, queueHealth: { ...QUEUE_HEALTH, level }, roleContext: { maintainerLane } }; + const response = await post(env, body); + expect(response.status, `${level}/${maintainerLane}`).toBe(200); + await expect(response.json()).resolves.toEqual(JSON.parse(JSON.stringify(simulateOpenPrPressure(body as unknown as OpenPrPressureInput)))); + } + } + }); + + it("treats contributorOpenPrCount as optional and queueHealth as nullable, exactly like the tool's shape", async () => { + const env = createTestEnv(); + const { contributorOpenPrCount: _omitted, ...withoutCount } = VALID; + expect((await post(env, withoutCount)).status).toBe(200); + // The shape declares queueHealth .nullable() — a repo with no computed health must still simulate. + const nullHealth = { ...VALID, queueHealth: null }; + const response = await post(env, nullHealth); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual(JSON.parse(JSON.stringify(simulateOpenPrPressure(nullHealth as unknown as OpenPrPressureInput)))); + }); + + it("rejects an invalid or unparseable body with 400", async () => { + const env = createTestEnv(); + for (const body of [ + {}, + { ...VALID, repoFullName: "ab" }, + { ...VALID, queueHealth: { ...QUEUE_HEALTH, level: "bogus" } }, + { ...VALID, queueHealth: { ...QUEUE_HEALTH, burdenScore: Number.POSITIVE_INFINITY } }, + { ...VALID, roleContext: {} }, + { ...VALID, contributorOpenPrCount: -1 }, + ]) { + const response = await post(env, body); + expect(response.status, JSON.stringify(body).slice(0, 60)).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_open_pr_pressure_request" }); + } + const malformed = await createApp().request(PATH, { method: "POST", headers: apiHeaders(createTestEnv()), body: "{not json" }, createTestEnv()); + expect(malformed.status).toBe(400); + }); + + it("is public-safe: no wallet/hotkey/trust-score terms in the simulation", async () => { + const env = createTestEnv(); + const text = JSON.stringify(await (await post(env, VALID)).json()); + expect(text).not.toMatch(/wallet|hotkey|coldkey|trust score|reward estimate/i); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index fa2eec366..6f8dfa1d8 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -549,6 +549,11 @@ export async function startFixtureServer( ); return; } + // #6751: the open-PR-pressure mirror proxies here. + if (request.url === "/v1/lint/open-pr-pressure" && request.method === "POST") { + response.end(JSON.stringify({ repoFullName: "acme/widgets", summary: "Ranked 2 scenarios.", scenarios: [{ id: "close_stale", rank: 1 }] })); + return; + } if (request.url === "/v1/repos/owner/repo/pulls/7/reviewability" && request.method === "GET") { response.end( JSON.stringify({