Skip to content

Commit efcb21a

Browse files
committed
feat(mcp): add REST route, CLI command, and stdio tool for loopover_plan_repo_issues
loopover_plan_repo_issues (generateIssuePlanDrafts) shipped only as a remote MCP tool and never got the REST + CLI + local-stdio mirror surfaces its repo-scoped, requireRepoManageAccess-gated siblings all have. Mirrors loopover_generate_contributor_issue_drafts exactly: a POST /v1/repos/:owner/:repo/issue-plan-drafts/generate route (same gate), a maintain plan-issues CLI command calling it, and a loopover_plan_repo_issues stdio tool. generateIssuePlanDrafts and the remote tool are unchanged. Closes #7764
1 parent a811c12 commit efcb21a

10 files changed

Lines changed: 744 additions & 15 deletions

File tree

apps/loopover-ui/public/openapi.json

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19030,6 +19030,58 @@
1903019030
}
1903119031
]
1903219032
}
19033+
},
19034+
"/v1/repos/{owner}/{repo}/issue-plan-drafts/generate": {
19035+
"post": {
19036+
"summary": "AI-plan repo issue drafts from a maintainer goal",
19037+
"parameters": [
19038+
{
19039+
"schema": {
19040+
"type": "string"
19041+
},
19042+
"required": true,
19043+
"name": "owner",
19044+
"in": "path"
19045+
},
19046+
{
19047+
"schema": {
19048+
"type": "string"
19049+
},
19050+
"required": true,
19051+
"name": "repo",
19052+
"in": "path"
19053+
}
19054+
],
19055+
"responses": {
19056+
"200": {
19057+
"description": "AI-plan a small set of GitHub issue drafts from a maintainer-supplied planning goal (dry-run by default)",
19058+
"content": {
19059+
"application/json": {
19060+
"schema": {
19061+
"type": "object",
19062+
"additionalProperties": {
19063+
"nullable": true
19064+
}
19065+
}
19066+
}
19067+
}
19068+
},
19069+
"400": {
19070+
"description": "Invalid request or explicit create without dryRun false"
19071+
},
19072+
"403": {
19073+
"description": "Insufficient role"
19074+
}
19075+
},
19076+
"security": [
19077+
{
19078+
"LoopOverBearer": []
19079+
},
19080+
{
19081+
"LoopOverSessionCookie": []
19082+
}
19083+
]
19084+
}
1903319085
}
1903419086
},
1903519087
"servers": [

packages/loopover-mcp/bin/loopover-mcp.js

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
// Phase 3 of #7291 / #7330 — design decision: single-file 1:1 JS→TS conversion (not a split).
33
// Seams exist, but splitting would be a separate refactor; this PR only routes the CLI through tsc.
44
import { createHash } from "node:crypto";
5-
import { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
5+
import { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
66
import { homedir } from "node:os";
77
import { delimiter, dirname, join } from "node:path";
8+
import { fileURLToPath } from "node:url";
89
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
910
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1011
import { buildFeasibilityVerdict, buildPrTextLint, buildGateDispositions, buildPublicPrBodyDraft } from "@loopover/engine";
@@ -60,6 +61,28 @@ const decisionPackCacheMaxBytes = 512 * 1024;
6061
const cliTextFileMaxBytes = 1024 * 1024;
6162
const changelogPath = new URL("../CHANGELOG.md", import.meta.url);
6263
const cliArgs = process.argv.slice(2);
64+
// #7764: true only when this file is the process entrypoint (`node .../loopover-mcp.js`, incl. via the npm
65+
// `.bin` symlink -- realpathSync resolves it), false when it is imported in-process (e.g. by a vitest unit
66+
// test that exercises the CLI dispatcher + stdio tools directly). The two top-level side effects below -- the
67+
// CLI dispatch and the stdio `server.connect()` -- are gated on it so an in-process importer neither hijacks
68+
// the test runner's argv into runCli() nor binds a StdioServerTransport to the shared stdin. This is the same
69+
// "testable-export refactor" path bin/loopover-miner-mcp.ts's createMinerMcpServer already took (see
70+
// codecov.yml's CLI-dispatcher note); subprocess invocation is unchanged (argv[1] is this file, so it stays
71+
// true) and every mcp-cli-*.test.ts harness run continues to hit the real dispatcher.
72+
function isProcessEntrypoint() {
73+
const entry = process.argv[1];
74+
/* v8 ignore next -- argv[1] is always populated for a spawned Node process; the guard is belt-and-suspenders */
75+
if (!entry)
76+
return false;
77+
try {
78+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
79+
}
80+
catch {
81+
/* v8 ignore next -- defensive: a realpath failure (renamed/removed entry) just means "not the launched CLI" */
82+
return false;
83+
}
84+
}
85+
const runAsCliEntrypoint = isProcessEntrypoint();
6386
const defaultProfileName = "default";
6487
// Single source of truth for shell-completion: top-level command -> its subcommands (if any).
6588
const CLI_COMMAND_SPEC = {
@@ -95,7 +118,7 @@ const CLI_COMMAND_SPEC = {
95118
profile: ["list", "create", "switch", "remove"],
96119
cache: ["status", "clear", "list"],
97120
agent: ["plan", "status", "explain", "packet"],
98-
maintain: ["status", "queue", "propose", "approve", "reject", "pause", "resume", "set-level", "precision", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs", "generate-issue-drafts"],
121+
maintain: ["status", "queue", "propose", "approve", "reject", "pause", "resume", "set-level", "precision", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs", "generate-issue-drafts", "plan-issues"],
99122
};
100123
const COMPLETION_SHELLS = ["bash", "zsh", "fish", "powershell"];
101124
const AGENT_PROFILE_IDS = ["miner-planner", "miner-auto-dev", "maintainer-triage", "repo-owner-intake"];
@@ -861,6 +884,19 @@ const gatePrecisionShape = {
861884
repo: z.string().min(1),
862885
windowDays: z.number().int().positive().optional(),
863886
};
887+
// #7764: mirrors the remote loopover_plan_repo_issues tool's input (src/mcp/server.ts's planRepoIssuesShape),
888+
// minus the create-only `milestone` which this proxy (and the `maintain plan-issues` CLI) does not expose --
889+
// forwarded to POST /v1/repos/:owner/:repo/issue-plan-drafts/generate. `goal` is the required maintainer
890+
// planning goal; dryRun/create carry the route's create-safety (create alone is rejected there). `limit` is
891+
// capped at 10, matching the route, because every draft costs real LLM spend.
892+
const planRepoIssuesShape = {
893+
owner: z.string().min(1),
894+
repo: z.string().min(1),
895+
goal: z.string().min(1).max(2000),
896+
dryRun: z.boolean().optional().default(true),
897+
create: z.boolean().optional().default(false),
898+
limit: z.number().int().min(1).max(10).optional().default(5),
899+
};
864900
// Single source of truth for stdio tool name + one-line description (#2233).
865901
// Registration and `loopover-mcp tools` both read this list.
866902
const STDIO_TOOL_DESCRIPTORS = [
@@ -1219,6 +1255,11 @@ const STDIO_TOOL_DESCRIPTORS = [
12191255
category: "maintainer",
12201256
description: "Return per-gate-type false-positive precision for a repo's recorded gate blocks — blocked / blocked-then-merged counts and false-positive rates with low-sample guards. Optionally bounded by windowDays. Maintainer-authenticated; measurement only.",
12211257
},
1258+
{
1259+
name: "loopover_plan_repo_issues",
1260+
category: "maintainer",
1261+
description: "AI-plan a small set of concrete GitHub issue drafts for a repo from a maintainer-supplied free-form goal, same as `loopover-mcp maintain plan-issues --goal ...`. Dry-run BY DEFAULT: only previews the drafted title/body/labels unless the caller passes BOTH create:true and dryRun:false, so it can never silently open issues. Maintainer access required.",
1262+
},
12221263
{
12231264
name: "loopover_open_pr",
12241265
category: "agent",
@@ -1282,7 +1323,9 @@ function stdioToolDescription(name) {
12821323
throw new Error(`Unknown stdio tool descriptor: ${name}`);
12831324
return tool.description;
12841325
}
1285-
if (cliArgs[0] && cliArgs[0] !== "--stdio") {
1326+
/* v8 ignore next 8 -- the CLI dispatch runs only in the launched process (runAsCliEntrypoint); an in-process
1327+
unit importer keeps it false and drives runCli/maintainCli directly instead (mcp-cli-plan-issues.test.ts). */
1328+
if (runAsCliEntrypoint && cliArgs[0] && cliArgs[0] !== "--stdio") {
12861329
try {
12871330
const exitCode = await runCli(cliArgs);
12881331
process.exit(typeof exitCode === "number" ? exitCode : 0);
@@ -1291,7 +1334,7 @@ if (cliArgs[0] && cliArgs[0] !== "--stdio") {
12911334
process.exit(reportCliFailure(argsWantJson(cliArgs), describeCliError(error), 1));
12921335
}
12931336
}
1294-
const server = new McpServer({
1337+
export const server = new McpServer({
12951338
name: "loopover-local",
12961339
version: packageVersion,
12971340
});
@@ -2143,6 +2186,17 @@ registerStdioTool("loopover_get_gate_precision", {
21432186
const payload = await apiGet(`${toolRepoBase(owner, repo)}/gate-precision${query}`);
21442187
return toolResult(`Gate precision for ${owner}/${repo}.`, payload);
21452188
});
2189+
registerStdioTool("loopover_plan_repo_issues", {
2190+
description: stdioToolDescription("loopover_plan_repo_issues"),
2191+
inputSchema: planRepoIssuesShape,
2192+
}, async ({ owner, repo, goal, dryRun, create, limit }) => {
2193+
// #7764: proxies POST {repoBase}/issue-plan-drafts/generate (the REST mirror of this same tool id). The
2194+
// route re-applies its own explicit_create_requires_dry_run_false guard, so forwarding the schema-defaulted
2195+
// dryRun/create verbatim keeps the create-safety exact: `create` alone (dryRun still true) is rejected;
2196+
// only an explicit {create:true, dryRun:false} reaches the write path.
2197+
const payload = await apiPost(`${toolRepoBase(owner, repo)}/issue-plan-drafts/generate`, { goal, dryRun, create, limit });
2198+
return toolResult(`Issue plan for ${owner}/${repo} (status=${payload.status}, dryRun=${payload.dryRun}): ${payload.proposed ?? 0} proposed, ${payload.created ?? 0} created.`, payload);
2199+
});
21462200
// ── Write-tools (#6149): pure LOCAL-execution spec builders. loopover NEVER performs the write -- each tool
21472201
// returns a spec the caller runs with its OWN gh creds. Brings the local stdio server to parity with the
21482202
// miner-auto-dev profile's recommendedTools, using the same @loopover/engine builders as the remote server.
@@ -2562,7 +2616,11 @@ server.registerPrompt("loopover_repo_owner_onboarding_pack", {
25622616
},
25632617
],
25642618
}));
2565-
await server.connect(new StdioServerTransport());
2619+
// #7764: only bind the shared stdin/stdout transport when actually launched as the CLI/stdio process. An
2620+
// in-process unit-test importer holds the exported `server` and connects it to an in-memory transport instead.
2621+
/* v8 ignore next -- only the launched stdio process binds the real transport; unit tests connect in-memory. */
2622+
if (runAsCliEntrypoint)
2623+
await server.connect(new StdioServerTransport());
25662624
async function withClientWorkspaceRoots(input) {
25672625
return withWorkspaceRoots(input, await clientWorkspaceRoots());
25682626
}
@@ -2619,13 +2677,16 @@ function printMaintainHelp() {
26192677
" generate-issue-drafts Preview contributor issue drafts (dry-run). Never creates without --create.",
26202678
" [--create] Actually open the drafted issues (requires repo write access).",
26212679
" [--limit N] Cap the drafts generated (1-20, default 5).",
2680+
' plan-issues --goal "..." AI-plan issue drafts from a free-form goal (dry-run). Never creates without --create.',
2681+
" [--create] Actually open the drafted issues (requires repo write access).",
2682+
" [--limit N] Cap the drafts generated (1-10, default 5).",
26222683
"",
26232684
"Pass --json for machine-readable output.",
26242685
].join("\n") + "\n");
26252686
}
26262687
// #784 maintainer CLI controls — thin proxies over the agent approval-queue API (#779) and the maintainer
26272688
// settings kill-switch (#130). The API enforces maintainer authorization; the CLI never decides locally.
2628-
async function maintainCli(args) {
2689+
export async function maintainCli(args) {
26292690
const subcommand = args[0];
26302691
if (!subcommand || subcommand === "--help" || subcommand === "help")
26312692
return printMaintainHelp();
@@ -2837,7 +2898,32 @@ async function maintainCli(args) {
28372898
emit(payload, lines.join("\n"));
28382899
return;
28392900
}
2840-
throw new Error(`Unknown maintain subcommand: ${subcommand}. Use status | queue | propose <action-class> <pull-number> | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs | generate-issue-drafts.`);
2901+
if (subcommand === "plan-issues") {
2902+
// #7764: session-authenticated mirror of POST {repoBase}/issue-plan-drafts/generate (and the remote
2903+
// loopover_plan_repo_issues tool). Requires --goal (the maintainer's free-form planning goal). Dry-run BY
2904+
// DEFAULT — only a bare `--create` opts into the write path, forwarded as {create:true, dryRun:false}, the
2905+
// exact shape the route's explicit_create_requires_dry_run_false guard demands. A plain `plan-issues` can
2906+
// never create.
2907+
const goal = typeof options.goal === "string" ? options.goal.trim() : "";
2908+
if (!goal)
2909+
throw new Error('Pass the planning goal: loopover-mcp maintain plan-issues --repo owner/repo --goal "...".');
2910+
const create = options.create === true;
2911+
const parsedLimit = Number(options.limit);
2912+
const body = { goal, create, dryRun: !create, ...(Number.isFinite(parsedLimit) ? { limit: parsedLimit } : {}) };
2913+
const payload = await apiPost(`${repoBase}/issue-plan-drafts/generate`, body);
2914+
const mode = payload.dryRun ? "dry-run" : "create";
2915+
const lines = [
2916+
`Issue plan for ${repoFullName} (${mode}, status=${sanitizePlainTextTerminalOutput(payload.status)}): ${payload.proposed ?? 0} proposed, ${payload.created ?? 0} created, ${payload.skippedDuplicate ?? 0} duplicate, ${payload.skippedDeclined ?? 0} declined, ${payload.skippedUnsafe ?? 0} unsafe, ${payload.skippedCreateFailed ?? 0} create-failed.`,
2917+
// draft.title/body are AI-generated free text, so the plain-text path is sanitized (#6261).
2918+
...(payload.drafts ?? []).map((draft) => {
2919+
const ref = draft.issue ? ` -> #${draft.issue.number} ${draft.issue.url}` : "";
2920+
return `- [${sanitizePlainTextTerminalOutput(draft.status)}] ${sanitizePlainTextTerminalOutput(draft.title)}${sanitizePlainTextTerminalOutput(ref)}`;
2921+
}),
2922+
];
2923+
emit(payload, lines.join("\n"));
2924+
return;
2925+
}
2926+
throw new Error(`Unknown maintain subcommand: ${subcommand}. Use status | queue | propose <action-class> <pull-number> | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs | generate-issue-drafts | plan-issues.`);
28412927
}
28422928
async function runCli(args) {
28432929
const command = args[0];

0 commit comments

Comments
 (0)