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
1 change: 1 addition & 0 deletions packages/loopover-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ loopover-mcp completion fish
loopover-mcp completion powershell
loopover-mcp decision-pack --login jsonbored --json
loopover-mcp repo-decision --login jsonbored --repo we-promise/sure --json
loopover-mcp monitor-open-prs --login jsonbored --json
loopover-mcp analyze-branch --login jsonbored --json
loopover-mcp preflight --login jsonbored --json
loopover-mcp review-pr --login jsonbored --commit "feat(mcp): add doctor grouping" --body "Fixes #160. Validated with npm test." --linked-issue 160 --json
Expand Down
69 changes: 68 additions & 1 deletion packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const CLI_COMMAND_SPEC = {
"init-client": [],
"decision-pack": [],
"repo-decision": [],
"monitor-open-prs": [],
"analyze-branch": [],
preflight: [],
"review-pr": [],
Expand Down Expand Up @@ -924,6 +925,12 @@ const STDIO_TOOL_DESCRIPTORS = [
category: "discovery",
description: "Return the go/raise/avoid decision for one specific contributor-and-repo pair, drawn from that contributor's decision pack — narrower than loopover_get_decision_pack, which returns the whole pack. Takes login (GitHub username), owner, and repo.",
},
{
name: "loopover_monitor_open_prs",
category: "discovery",
description:
"Inspect a contributor's open PRs on registered repos, classify queue state, and return public-safe next-step packets from cached metadata.",
},
{
name: "loopover_compare_pr_variants",
category: "branch",
Expand Down Expand Up @@ -1655,6 +1662,18 @@ registerStdioTool(
},
);

registerStdioTool(
"loopover_monitor_open_prs",
{
description: stdioToolDescription("loopover_monitor_open_prs"),
inputSchema: loginShape,
},
async ({ login }) => {
const payload = await getOpenPrMonitor(login);
return toolResult(openPrMonitorToolSummary(login, payload), payload);
},
);

registerStdioTool(
"loopover_compare_pr_variants",
{
Expand Down Expand Up @@ -2826,6 +2845,7 @@ async function runCli(args) {
if (command === "issue-slop") return issueSlopCli(args.slice(1));
if (command === "decision-pack") return decisionPackCli(options);
if (command === "repo-decision") return repoDecisionCli(options);
if (command === "monitor-open-prs") return monitorOpenPrsCli(options);
if (command === "review-pr") return reviewPrCli(options);
if (command !== "analyze-branch" && command !== "preflight") {
const suggestion = suggestCommand(command);
Expand Down Expand Up @@ -3189,6 +3209,40 @@ async function decisionPackCli(options) {
if (payload.cache?.rerunGuidance) process.stdout.write(`Rerun when: ${sanitizePlainTextTerminalOutput(payload.cache.rerunGuidance)}\n`);
}

function printMonitorOpenPrsHelp() {
process.stdout.write(
[
"Usage: loopover-mcp monitor-open-prs --login <github-login> [--json]",
"",
"Review your open PRs across registered repos: queue classification and next steps per PR.",
"Mirrors the loopover_monitor_open_prs MCP tool and GET /v1/contributors/{login}/open-pr-monitor. No source upload.",
"",
"Pass --json for machine-readable output.",
].join("\n") + "\n",
);
}

async function monitorOpenPrsCli(options) {
if (options.help === true) return printMonitorOpenPrsHelp();
const login = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN;
if (!login) throw new Error("Pass --login <github-login> or set LOOPOVER_LOGIN.");
const payload = await getOpenPrMonitor(login);
if (options.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
return;
}
// #6261: every value below is the API's to choose -- the summary/guidance it composes, and PR titles it echoes
// back from third-party repos -- so all of it is sanitized before it reaches the terminal. `login` is the user's
// own --login/env value and needs no sanitizing, but it only reaches stdout via the literal fallback branch.
process.stdout.write(`${sanitizePlainTextTerminalOutput(openPrMonitorToolSummary(login, payload))}\n`);
for (const line of payload?.guidance ?? []) process.stdout.write(`${sanitizePlainTextTerminalOutput(line)}\n`);
for (const pr of payload?.pullRequests ?? []) {
const heading = `${pr.repoFullName}#${pr.number} [${pr.classification}] ${pr.title}`;
process.stdout.write(`${sanitizePlainTextTerminalOutput(heading)}\n`);
for (const step of pr.nextSteps ?? []) process.stdout.write(` - ${sanitizePlainTextTerminalOutput(step)}\n`);
}
}

function printRepoDecisionHelp() {
process.stdout.write(
[
Expand Down Expand Up @@ -3667,6 +3721,7 @@ function printHelp() {
loopover-mcp init-client --print codex|claude|cursor|mcp|vscode [--agent-profile miner-planner|maintainer-triage|repo-owner-intake] [--json]
loopover-mcp decision-pack --login <github-login> [--json]
loopover-mcp repo-decision --login <github-login> --repo owner/repo [--json]
loopover-mcp monitor-open-prs --login <github-login> [--json]
loopover-mcp analyze-branch --login <github-login> [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--scenario-note "..."] [--validation "passed|npm test|summary"] [--format table] [--json]
loopover-mcp preflight --login <github-login> [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--validation "passed|npm test|summary"] [--format table] [--json]
loopover-mcp review-pr --login <github-login> [--repo owner/repo] [--base origin/main] [--commit <message>]... [--body <text>] [--body-file <path>] [--linked-issue <number>] [--json]
Expand All @@ -3684,7 +3739,7 @@ function printHelp() {
LOOPOVER_PROFILE
LOOPOVER_CONFIG_PATH or LOOPOVER_CONFIG_DIR
LOOPOVER_API_TOKEN, LOOPOVER_MCP_TOKEN, LOOPOVER_TOKEN, or a session from loopover-mcp login
LOOPOVER_LOGIN or GITHUB_LOGIN (default --login for analyze-branch, preflight, review-pr, decision-pack, repo-decision, and agent plan/packet)
LOOPOVER_LOGIN or GITHUB_LOGIN (default --login for analyze-branch, preflight, review-pr, decision-pack, repo-decision, monitor-open-prs, and agent plan/packet)
GITHUB_TOKEN for non-interactive login bootstrap
GITTENSOR_SCORE_PREVIEW_CMD
GITTENSOR_ROOT
Expand Down Expand Up @@ -4795,6 +4850,18 @@ function repoDecisionToolSummary(login, repoFullName, payload) {
return `LoopOver repo decision for ${login} in ${repoFullName}.`;
}

function getOpenPrMonitor(login) {
return apiGet(`/v1/contributors/${encodeURIComponent(login)}/open-pr-monitor`);
}

// Mirror the API's own `summary` when it sends one, so the CLI and the loopover_monitor_open_prs MCP
// tool (which returns monitor.summary verbatim) never drift into two different sentences for one payload.
function openPrMonitorToolSummary(login, payload) {
const summary = typeof payload?.summary === "string" ? payload.summary.trim() : "";
if (summary) return summary;
return `LoopOver open-PR monitor for ${login}.`;
}

function isCacheableDecisionPack(payload, login) {
return payload?.status === "ready" && typeof payload.login === "string" && payload.login.toLowerCase() === login.toLowerCase();
}
Expand Down
133 changes: 133 additions & 0 deletions test/unit/mcp-cli-monitor-open-prs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// #6732: the CLI mirror for loopover_monitor_open_prs. The MCP tool and GET
// /v1/contributors/:login/open-pr-monitor already served this; only the stdio/CLI surface was missing.
// These pin the three things that can silently rot: the tool is registered, both surfaces hit the same
// route, and `monitor-open-prs --json` stays byte-identical to what the tool returns for one input.
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";
// Any CLI command that calls the API must go through runAsync: the fixture server lives in this process,
// so run()'s execFileSync would block the event loop and the child's fetch would abort before a response.
import { closeFixtureServer, openPrMonitorFixture, run, runAsync, runExpectingFailure, startFixtureServer } from "./support/mcp-cli-harness";

const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");

let client: Client;
let transport: StdioClientTransport;
let configDir: string;
let apiUrl: string;
let capturedRequests: Array<{ url: string; method: string }>;

async function connect() {
configDir = mkdtempSync(join(tmpdir(), "loopover-monitor-open-prs-"));
capturedRequests = [];
apiUrl = await startFixtureServer({
onApiRequest: (request) => {
if (request.url && request.url.includes("/open-pr-monitor")) {
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
}
},
});
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: "monitor-open-prs-test", version: "0.0.1" });
await client.connect(transport);
}

async function disconnect() {
await client.close().catch(() => undefined);
await closeFixtureServer();
if (configDir) rmSync(configDir, { recursive: true, force: true });
}

describe("loopover_monitor_open_prs stdio proxy", () => {
beforeEach(connect);
afterEach(disconnect);

it("registers the tool in the stdio server tool list", async () => {
const { tools } = await client.listTools();
expect(tools.map((t) => t.name)).toContain("loopover_monitor_open_prs");
});

it("proxies login to /v1/contributors/:login/open-pr-monitor via apiGet and returns the monitor", async () => {
const result = await client.callTool({ name: "loopover_monitor_open_prs", arguments: { login: "JSONbored" } });
expect(capturedRequests.length).toBe(1);
const captured = capturedRequests[0]!;
expect(captured.url).toContain("/v1/contributors/JSONbored/open-pr-monitor");
expect(captured.method).toBe("GET");
expect(result.isError).toBeFalsy();
const text = JSON.stringify(result);
expect(text).toContain("JSONbored/gittensory");
expect(text).toContain("failing_checks");
// The tool summary is the API's own sentence, not a second one invented client-side.
expect(text).toContain(openPrMonitorFixture().summary);
});
});

describe("loopover-mcp monitor-open-prs CLI", () => {
beforeEach(connect);
afterEach(disconnect);

it("--json emits exactly the payload the MCP tool surfaces for the same login (mirror parity)", async () => {
const viaTool = await client.callTool({ name: "loopover_monitor_open_prs", arguments: { login: "JSONbored" } });
const toolData = (viaTool as { structuredContent?: unknown }).structuredContent;
const viaCli = JSON.parse(await runAsync(["monitor-open-prs", "--login", "JSONbored", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" }));
expect(viaCli).toEqual(openPrMonitorFixture());
if (toolData !== undefined) expect(viaCli).toEqual(toolData);
});

it("prints the API summary, guidance, and a next-step line per open PR", async () => {
const out = await runAsync(["monitor-open-prs", "--login", "JSONbored"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" });
const fixture = openPrMonitorFixture();
expect(out).toContain(fixture.summary);
expect(out).toContain(fixture.guidance[0]!);
expect(out).toContain("JSONbored/gittensory#42 [failing_checks] fix(queue): drain stale entries");
expect(out).toContain(" - Fix the failing check, then push.");
});

it("resolves the login from LOOPOVER_LOGIN, then GITHUB_LOGIN, the way decision-pack does", async () => {
const viaLoopoverLogin = await runAsync(["monitor-open-prs", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token", LOOPOVER_LOGIN: "JSONbored" });
expect(JSON.parse(viaLoopoverLogin)).toEqual(openPrMonitorFixture());
const viaGithubLogin = await runAsync(["monitor-open-prs", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token", GITHUB_LOGIN: "JSONbored" });
expect(JSON.parse(viaGithubLogin)).toEqual(openPrMonitorFixture());
});

it("fails with the shared login-required message when no login is resolvable", () => {
const failure = runExpectingFailure(["monitor-open-prs"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token", LOOPOVER_LOGIN: "", GITHUB_LOGIN: "" });
expect(failure.status).toBe(1);
expect(`${failure.stdout}${failure.stderr}`).toMatch(/Pass --login <github-login> or set LOOPOVER_LOGIN\./);
});

// #6261: the API composes the summary/guidance and echoes PR titles back from third-party repos, so a hostile
// string must not be able to repaint the terminal. --json stays raw on purpose: JSON.stringify escapes U+001B.
it("strips ANSI escapes from API-chosen text on the plain-text path but not from --json", async () => {
await closeFixtureServer();
const hostileUrl = await startFixtureServer({ openPrMonitor: { summary: "FAKE PASS", guidance: ["rewritten"] } });
const env = { LOOPOVER_API_URL: hostileUrl, LOOPOVER_TOKEN: "session-token" };

const plain = await runAsync(["monitor-open-prs", "--login", "JSONbored"], env);
expect(plain).not.toContain("");
expect(plain).toContain("FAKE PASS");
expect(plain).toContain("rewritten");

const asJson = await runAsync(["monitor-open-prs", "--login", "JSONbored", "--json"], env);
expect(JSON.parse(asJson).summary).toBe("FAKE PASS");
});

it("documents itself in --help and in the shell-completion command list", () => {
expect(run(["--help"])).toContain("loopover-mcp monitor-open-prs --login <github-login> [--json]");
expect(run(["monitor-open-prs", "--help"])).toContain("Mirrors the loopover_monitor_open_prs MCP tool");
expect(run(["completion", "bash"])).toContain("monitor-open-prs");
});
});
2 changes: 1 addition & 1 deletion test/unit/mcp-cli-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ describe("loopover-mcp CLI — tools", () => {
it("documents LOOPOVER_LOGIN / GITHUB_LOGIN in the --help Environment block (#5930)", () => {
const help = run(["--help"]);
expect(help).toContain("Environment:");
// Six subcommands resolve the login from LOOPOVER_LOGIN (then GITHUB_LOGIN); help must list it.
// Seven subcommands resolve the login from LOOPOVER_LOGIN (then GITHUB_LOGIN); help must list it.
expect(help).toMatch(/LOOPOVER_LOGIN or GITHUB_LOGIN/);
});
});
11 changes: 6 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// (#6619 registered the pr-ai-review-findings CLI mirror, taking the count from 60 to 61.)
// (#6621 registered the loopover_get_eligibility_plan REST/CLI mirror, taking the count from 61 to 62.)
// (#6615 registered the loopover_close_pr write-tool — 9th of the 9 buildXSpec builders — taking the count from 62 to 63.)
// (#6732 registered the loopover_monitor_open_prs CLI mirror, taking the count from 63 to 64.)
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
Expand Down Expand Up @@ -51,14 +52,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

it("lists exactly 63 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
it("lists exactly 64 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(63);
expect(primary.length).toBe(64);
expect(legacy.length).toBe(0);
expect(names.length).toBe(63);
expect(names.length).toBe(64);
});

it("no loopover_ tool's description carries a stale deprecation notice", async () => {
Expand All @@ -68,11 +69,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
}
});

it("`loopover-mcp tools --json` reports the same 63-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 64-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(63);
expect(payload.count).toBe(64);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort());
});
});
Expand Down
30 changes: 30 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export async function startFixtureServer(
onPacketRequest?: (body: unknown) => void;
onApiRequest?: (request: IncomingMessage) => void;
validateConfigWarnings?: string[];
openPrMonitor?: Record<string, unknown>;
intakeStatus?: number;
localBranchAnalysisStatus?: number;
} = {},
Expand Down Expand Up @@ -249,6 +250,10 @@ export async function startFixtureServer(
);
return;
}
if (request.url === "/v1/contributors/JSONbored/open-pr-monitor" && request.method === "GET") {
response.end(JSON.stringify({ ...openPrMonitorFixture(), ...(options.openPrMonitor ?? {}) }));
return;
}
if (request.url === "/v1/contributors/JSONbored/repos/JSONbored/gittensory/decision" && request.method === "GET") {
if (options.repoDecisionStatus && options.repoDecisionStatus >= 400) {
response.statusCode = options.repoDecisionStatus;
Expand Down Expand Up @@ -625,6 +630,31 @@ export function localBranchAnalysisFixture() {
};
}

/** Mirrors the ContributorOpenPrMonitor shape src/signals/contributor-open-pr-monitor.ts returns. */
export function openPrMonitorFixture() {
return {
login: "JSONbored",
generatedAt: "2026-06-01T00:00:00.000Z",
openPrCount: 1,
registeredRepoCount: 2,
cleanupFirst: true,
summary: "1 open PR needs attention before starting new work.",
guidance: ["Clear the failing check on JSONbored/gittensory#42 before opening anything new."],
pendingScenarios: [],
pullRequests: [
{
repoFullName: "JSONbored/gittensory",
number: 42,
title: "fix(queue): drain stale entries",
classification: "failing_checks",
summary: "CI is red on this PR.",
reasons: ["1 check is failing."],
nextSteps: ["Fix the failing check, then push."],
},
],
};
}

export function decisionPackFixture() {
return {
status: "ready",
Expand Down