diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 369c3fb86..d338d450b 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -578,6 +578,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() })) @@ -887,6 +927,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", @@ -1471,6 +1517,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 9dd253083..a7a9baab6 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"; @@ -3271,6 +3273,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 dce95ced2..39384c643 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -54,14 +54,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 68 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 69 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(68); + expect(primary.length).toBe(69); expect(legacy.length).toBe(0); - expect(names.length).toBe(68); + expect(names.length).toBe(69); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -71,11 +71,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 68-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 69-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(68); + expect(payload.count).toBe(69); 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 9c6b601c9..4a7e04b16 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -579,6 +579,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({