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
59 changes: 59 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() }))
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
95 changes: 95 additions & 0 deletions test/unit/mcp-cli-open-pr-pressure-tool.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
10 changes: 5 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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());
});
});
Expand Down
96 changes: 96 additions & 0 deletions test/unit/routes-open-pr-pressure.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
5 changes: 5 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down