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
52 changes: 52 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -19030,6 +19030,58 @@
}
]
}
},
"/v1/repos/{owner}/{repo}/issue-plan-drafts/generate": {
"post": {
"summary": "AI-plan repo issue drafts from a maintainer goal",
"parameters": [
{
"schema": {
"type": "string"
},
"required": true,
"name": "owner",
"in": "path"
},
{
"schema": {
"type": "string"
},
"required": true,
"name": "repo",
"in": "path"
}
],
"responses": {
"200": {
"description": "AI-plan a small set of GitHub issue drafts from a maintainer-supplied planning goal (dry-run by default)",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": {
"nullable": true
}
}
}
}
},
"400": {
"description": "Invalid request or explicit create without dryRun false"
},
"403": {
"description": "Insufficient role"
}
},
"security": [
{
"LoopOverBearer": []
},
{
"LoopOverSessionCookie": []
}
]
}
}
},
"servers": [
Expand Down
107 changes: 100 additions & 7 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
// Phase 3 of #7291 / #7330 — design decision: single-file 1:1 JS→TS conversion (not a split).
// Seams exist, but splitting would be a separate refactor; this PR only routes the CLI through tsc.
import { createHash } from "node:crypto";
import { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
import { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { buildFeasibilityVerdict, buildPrTextLint, buildGateDispositions, buildPublicPrBodyDraft } from "@loopover/engine";
Expand Down Expand Up @@ -72,6 +73,27 @@ const decisionPackCacheMaxBytes = 512 * 1024;
const cliTextFileMaxBytes = 1024 * 1024;
const changelogPath = new URL("../CHANGELOG.md", import.meta.url);
const cliArgs = process.argv.slice(2);

// #7764: true only when this file is the process entrypoint (`node .../loopover-mcp.js`, incl. via the npm
// `.bin` symlink -- realpathSync resolves it), false when it is imported in-process (e.g. by a vitest unit
// test that exercises the CLI dispatcher + stdio tools directly). The two top-level side effects below -- the
// CLI dispatch and the stdio `server.connect()` -- are gated on it so an in-process importer neither hijacks
// the test runner's argv into runCli() nor binds a StdioServerTransport to the shared stdin. This is the same
// "testable-export refactor" path bin/loopover-miner-mcp.ts's createMinerMcpServer already took (see
// codecov.yml's CLI-dispatcher note); subprocess invocation is unchanged (argv[1] is this file, so it stays
// true) and every mcp-cli-*.test.ts harness run continues to hit the real dispatcher.
function isProcessEntrypoint() {
const entry = process.argv[1];
/* v8 ignore next -- argv[1] is always populated for a spawned Node process; the guard is belt-and-suspenders */
if (!entry) return false;
try {
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
} catch {
/* v8 ignore next -- defensive: a realpath failure (renamed/removed entry) just means "not the launched CLI" */
return false;
}
}
const runAsCliEntrypoint = isProcessEntrypoint();
const defaultProfileName = "default";
// Single source of truth for shell-completion: top-level command -> its subcommands (if any).
const CLI_COMMAND_SPEC = {
Expand Down Expand Up @@ -107,7 +129,7 @@ const CLI_COMMAND_SPEC = {
profile: ["list", "create", "switch", "remove"],
cache: ["status", "clear", "list"],
agent: ["plan", "status", "explain", "packet"],
maintain: ["status", "queue", "propose", "approve", "reject", "pause", "resume", "set-level", "precision", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs", "generate-issue-drafts"],
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"],
};
const COMPLETION_SHELLS = ["bash", "zsh", "fish", "powershell"];
const AGENT_PROFILE_IDS = ["miner-planner", "miner-auto-dev", "maintainer-triage", "repo-owner-intake"];
Expand Down Expand Up @@ -921,6 +943,20 @@ const gatePrecisionShape = {
windowDays: z.number().int().positive().optional(),
};

// #7764: mirrors the remote loopover_plan_repo_issues tool's input (src/mcp/server.ts's planRepoIssuesShape),
// minus the create-only `milestone` which this proxy (and the `maintain plan-issues` CLI) does not expose --
// forwarded to POST /v1/repos/:owner/:repo/issue-plan-drafts/generate. `goal` is the required maintainer
// planning goal; dryRun/create carry the route's create-safety (create alone is rejected there). `limit` is
// capped at 10, matching the route, because every draft costs real LLM spend.
const planRepoIssuesShape = {
owner: z.string().min(1),
repo: z.string().min(1),
goal: z.string().min(1).max(2000),
dryRun: z.boolean().optional().default(true),
create: z.boolean().optional().default(false),
limit: z.number().int().min(1).max(10).optional().default(5),
};

// Single source of truth for stdio tool name + one-line description (#2233).
// Registration and `loopover-mcp tools` both read this list.
const STDIO_TOOL_DESCRIPTORS = [
Expand Down Expand Up @@ -1302,6 +1338,12 @@ const STDIO_TOOL_DESCRIPTORS = [
category: "maintainer",
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.",
},
{
name: "loopover_plan_repo_issues",
category: "maintainer",
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.",
},
{
name: "loopover_open_pr",
category: "agent",
Expand Down Expand Up @@ -1373,7 +1415,9 @@ function stdioToolDescription(name: any) {
return tool.description;
}

if (cliArgs[0] && cliArgs[0] !== "--stdio") {
/* v8 ignore next 8 -- the CLI dispatch runs only in the launched process (runAsCliEntrypoint); an in-process
unit importer keeps it false and drives runCli/maintainCli directly instead (mcp-cli-plan-issues.test.ts). */
if (runAsCliEntrypoint && cliArgs[0] && cliArgs[0] !== "--stdio") {
try {
const exitCode = await runCli(cliArgs);
process.exit(typeof exitCode === "number" ? exitCode : 0);
Expand All @@ -1382,7 +1426,7 @@ if (cliArgs[0] && cliArgs[0] !== "--stdio") {
}
}

const server = new McpServer({
export const server = new McpServer({
name: "loopover-local",
version: packageVersion,
});
Expand Down Expand Up @@ -2623,6 +2667,25 @@ registerStdioTool(
return toolResult(`Gate precision for ${owner}/${repo}.`, payload);
},
);

registerStdioTool(
"loopover_plan_repo_issues",
{
description: stdioToolDescription("loopover_plan_repo_issues"),
inputSchema: planRepoIssuesShape,
},
async ({ owner, repo, goal, dryRun, create, limit }: any) => {
// #7764: proxies POST {repoBase}/issue-plan-drafts/generate (the REST mirror of this same tool id). The
// route re-applies its own explicit_create_requires_dry_run_false guard, so forwarding the schema-defaulted
// dryRun/create verbatim keeps the create-safety exact: `create` alone (dryRun still true) is rejected;
// only an explicit {create:true, dryRun:false} reaches the write path.
const payload = await apiPost(`${toolRepoBase(owner, repo)}/issue-plan-drafts/generate`, { goal, dryRun, create, limit });
return toolResult(
`Issue plan for ${owner}/${repo} (status=${payload.status}, dryRun=${payload.dryRun}): ${payload.proposed ?? 0} proposed, ${payload.created ?? 0} created.`,
payload,
);
},
);
// ── Write-tools (#6149): pure LOCAL-execution spec builders. loopover NEVER performs the write -- each tool
// returns a spec the caller runs with its OWN gh creds. Brings the local stdio server to parity with the
// miner-auto-dev profile's recommendedTools, using the same @loopover/engine builders as the remote server.
Expand Down Expand Up @@ -3167,7 +3230,10 @@ server.registerPrompt(
}),
);

await server.connect(new StdioServerTransport());
// #7764: only bind the shared stdin/stdout transport when actually launched as the CLI/stdio process. An
// in-process unit-test importer holds the exported `server` and connects it to an in-memory transport instead.
/* v8 ignore next -- only the launched stdio process binds the real transport; unit tests connect in-memory. */
if (runAsCliEntrypoint) await server.connect(new StdioServerTransport());

async function withClientWorkspaceRoots(input: any) {
return withWorkspaceRoots(input, await clientWorkspaceRoots());
Expand Down Expand Up @@ -3228,6 +3294,9 @@ function printMaintainHelp() {
" generate-issue-drafts Preview contributor issue drafts (dry-run). Never creates without --create.",
" [--create] Actually open the drafted issues (requires repo write access).",
" [--limit N] Cap the drafts generated (1-20, default 5).",
' plan-issues --goal "..." AI-plan issue drafts from a free-form goal (dry-run). Never creates without --create.',
" [--create] Actually open the drafted issues (requires repo write access).",
" [--limit N] Cap the drafts generated (1-10, default 5).",
"",
"Pass --json for machine-readable output.",
].join("\n") + "\n",
Expand All @@ -3236,7 +3305,7 @@ function printMaintainHelp() {

// #784 maintainer CLI controls — thin proxies over the agent approval-queue API (#779) and the maintainer
// settings kill-switch (#130). The API enforces maintainer authorization; the CLI never decides locally.
async function maintainCli(args: any) {
export async function maintainCli(args: any) {
const subcommand = args[0];
if (!subcommand || subcommand === "--help" || subcommand === "help") return printMaintainHelp();
const positional = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
Expand Down Expand Up @@ -3465,8 +3534,32 @@ async function maintainCli(args: any) {
emit(payload, lines.join("\n"));
return;
}
if (subcommand === "plan-issues") {
// #7764: session-authenticated mirror of POST {repoBase}/issue-plan-drafts/generate (and the remote
// loopover_plan_repo_issues tool). Requires --goal (the maintainer's free-form planning goal). Dry-run BY
// DEFAULT — only a bare `--create` opts into the write path, forwarded as {create:true, dryRun:false}, the
// exact shape the route's explicit_create_requires_dry_run_false guard demands. A plain `plan-issues` can
// never create.
const goal = typeof options.goal === "string" ? options.goal.trim() : "";
if (!goal) throw new Error('Pass the planning goal: loopover-mcp maintain plan-issues --repo owner/repo --goal "...".');
const create = options.create === true;
const parsedLimit = Number(options.limit);
const body = { goal, create, dryRun: !create, ...(Number.isFinite(parsedLimit) ? { limit: parsedLimit } : {}) };
const payload = await apiPost(`${repoBase}/issue-plan-drafts/generate`, body);
const mode = payload.dryRun ? "dry-run" : "create";
const lines = [
`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.`,
// draft.title/body are AI-generated free text, so the plain-text path is sanitized (#6261).
...(payload.drafts ?? []).map((draft: any) => {
const ref = draft.issue ? ` -> #${draft.issue.number} ${draft.issue.url}` : "";
return `- [${sanitizePlainTextTerminalOutput(draft.status)}] ${sanitizePlainTextTerminalOutput(draft.title)}${sanitizePlainTextTerminalOutput(ref)}`;
}),
];
emit(payload, lines.join("\n"));
return;
}
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.`,
`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.`,
);
}

Expand Down
56 changes: 56 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ import { resolveRepositorySettings } from "../settings/repository-settings";
import { loadPublicRepoFocusManifest, loadRepoFocusManifest, upsertRepoFocusManifest } from "../signals/focus-manifest-loader";
import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack";
import { generateContributorIssueDrafts } from "../services/contributor-issue-draft";
import { generateIssuePlanDrafts } from "../services/issue-plan-draft";
import { buildRepoSettingsPreview, PUBLIC_SURFACE_SKIP_REASONS, skippedPrAuditRemediation } from "../signals/settings-preview";
import {
buildGittensorConfigRecommendation,
Expand Down Expand Up @@ -954,6 +955,17 @@ const contributorIssueDraftGenerateSchema = z.object({
limit: z.number().int().min(1).max(20).optional().default(5),
});

// #7764: REST mirror of the loopover_plan_repo_issues MCP tool (src/mcp/server.ts's planRepoIssuesShape).
// Unlike the contributor-issue-draft schema above, `goal` is a REQUIRED maintainer-supplied free-form string
// and `limit` is capped lower (10, not 20): every draft here costs real LLM spend, unlike that tool's zero-cost
// static signals. dryRun/create keep the same create-safety contract (create alone is rejected below).
const issuePlanDraftGenerateSchema = z.object({
goal: z.string().trim().min(1).max(2000),
dryRun: z.boolean().optional().default(true),
create: z.boolean().optional().default(false),
limit: z.number().int().min(1).max(10).optional().default(5),
});

const settingsPreviewSchema = z.object({
sample: z
.object({
Expand Down Expand Up @@ -2793,6 +2805,43 @@ export function createApp() {
);
});

// #7764: REST mirror of the loopover_plan_repo_issues MCP tool (src/mcp/server.ts) and the
// `maintain plan-issues` CLI. Gated EXACTLY like the sibling contributor-issue-drafts route above
// (requireAppRole maintainer/owner/operator, then per-repo requireSessionRepoAccess for sessions), and it
// preserves the same create-safety: dry-run by default, and the write path is entered only when the caller
// passes BOTH create:true and dryRun:false -- which additionally requires live repo write access. The
// required `goal` is a maintainer-supplied free-form planning goal the service turns into issue drafts.
app.post("/v1/repos/:owner/:repo/issue-plan-drafts/generate", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const forbidden = await requireAppRole(c, ["maintainer", "owner", "operator"]);
if (forbidden) return forbidden;
const identity = await authenticateRequestIdentity(c);
const repo = await getRepository(c.env, fullName);
if (identity?.kind === "session") {
const repoForbidden = await requireSessionRepoAccess(c, identity, fullName, repo);
if (repoForbidden) return repoForbidden;
}
const body = await c.req.json().catch(() => null);
if (body === null) return c.json({ error: "invalid_json" }, 400);
const parsed = issuePlanDraftGenerateSchema.safeParse(body);
if (!parsed.success) return c.json({ error: "invalid_issue_plan_draft_request", issues: parsed.error.issues }, 400);
if (parsed.data.create && parsed.data.dryRun !== false) {
return c.json({ error: "explicit_create_requires_dry_run_false" }, 400);
}
if (parsed.data.create && parsed.data.dryRun === false) {
const writeForbidden = await requireRepoWriteAccess(c, fullName);
if (writeForbidden instanceof Response) return writeForbidden;
}
return c.json(
await generateIssuePlanDrafts(c.env, fullName, parsed.data.goal, {
dryRun: parsed.data.dryRun,
create: parsed.data.create,
limit: parsed.data.limit,
requestedBy: identity?.kind === "session" ? identity.actor : "api",
}),
);
});

// Repo loopover settings (gate config, AI-review mode/provider/model — NON-secret; the BYOK key is
// never here). Maintainer DATA: session callers must be a verified maintainer of THIS repo (per-repo
// scope), so a maintainer of repo A cannot read repo B's config. Server-to-server tokens are exempt.
Expand Down Expand Up @@ -6393,6 +6442,7 @@ function canSessionAccessPath(env: Env, identity: Extract<AuthIdentity, { kind:
if (isRepoAgentPendingActionsPath(path)) return true; // list (GET, requireRepoMaintainer) + propose (POST, requireRepoWriteAccess); decision POSTs on /:id/:decision require server tokens
if (isRepoIncidentReportsPath(path)) return true; // #5672: route's requireRepoMaintainer enforces per-repo authority (contributors → 403)
if (isRepoContributorIssueDraftGeneratePath(path)) return true;
if (isRepoIssuePlanDraftGeneratePath(path)) return true;
if (path === OPPORTUNITIES_FIND_PATH) return true;
if (path === ISSUE_RAG_RETRIEVE_PATH) return true;
if (path === LINT_PR_TEXT_PATH || path === VALIDATE_FOCUS_MANIFEST_PATH || path === LINT_SLOP_RISK_PATH || path === LINT_ISSUE_SLOP_PATH) return true;
Expand Down Expand Up @@ -6442,6 +6492,12 @@ function isRepoContributorIssueDraftGeneratePath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/contributor-issue-drafts\/generate$/.test(path);
}

// #7764: coarse path admission for the issue-plan-drafts generate route -- the route's own requireAppRole +
// requireSessionRepoAccess gate enforces real per-repo maintainer authority, exactly like the sibling above.
function isRepoIssuePlanDraftGeneratePath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/issue-plan-drafts\/generate$/.test(path);
}

function isRepoCheckBeforeStartPath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/check-before-start$/.test(path);
}
Expand Down
Loading
Loading