diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index c961df147f..7fba4567f7 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -1030,6 +1030,17 @@ const planRepoIssuesShape = { limit: z.number().int().min(1).max(10).optional().default(5), }; +// #7755: mirrors the remote loopover_generate_contributor_issue_drafts input (src/mcp/server.ts's +// generateContributorIssueDraftsShape) -- dryRun/create carry the route's create-safety (create alone is +// rejected there); `limit` is capped at 20, matching the route. +const generateContributorIssueDraftsShape = { + owner: z.string().min(1), + repo: z.string().min(1), + dryRun: z.boolean().optional().default(true), + create: z.boolean().optional().default(false), + limit: z.number().int().min(1).max(20).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 = [ @@ -1518,6 +1529,12 @@ const STDIO_TOOL_DESCRIPTORS = [ 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_generate_contributor_issue_drafts", + category: "maintainer", + description: + "Generate contributor-facing issue drafts for one repo from its lane/config/queue signals. Dry-run BY DEFAULT: it only PREVIEWS drafts unless the caller passes BOTH create:true and dryRun:false, so it can never silently open issues; the write path additionally requires repo write access and is suppressed while the agent is globally paused/frozen. Maintainer access required.", + }, { name: "loopover_open_pr", category: "agent", @@ -3102,6 +3119,24 @@ registerStdioTool( ); }, ); + +// #7755: stdio mirror of the remote loopover_generate_contributor_issue_drafts + the `maintain +// generate-issue-drafts` CLI. Proxies POST {repoBase}/contributor-issue-drafts/generate (the same route the +// CLI hits). The route re-applies its own explicit_create_requires_dry_run_false guard, so forwarding the +// schema-defaulted dryRun/create verbatim keeps create-safety exact: `create` alone (dryRun still true) is +// rejected; only an explicit {create:true, dryRun:false} reaches the write path. +registerStdioTool( + "loopover_generate_contributor_issue_drafts", + { + description: stdioToolDescription("loopover_generate_contributor_issue_drafts"), + inputSchema: generateContributorIssueDraftsShape, + }, + async ({ owner, repo, dryRun, create, limit }: any) => { + const payload = await apiPost(`${toolRepoBase(owner, repo)}/contributor-issue-drafts/generate`, { dryRun, create, limit }); + return toolResult(`Contributor issue drafts for ${owner}/${repo}.`, 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. diff --git a/test/unit/mcp-cli-generate-contributor-issue-drafts.test.ts b/test/unit/mcp-cli-generate-contributor-issue-drafts.test.ts new file mode 100644 index 0000000000..481e7c90cd --- /dev/null +++ b/test/unit/mcp-cli-generate-contributor-issue-drafts.test.ts @@ -0,0 +1,75 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness"; + +// #7755: in-process coverage for the loopover_generate_contributor_issue_drafts stdio tool. Same #7764 +// entrypoint-guard pattern as mcp-cli-repo-focus-manifest -- import the .ts, hold the exported `server`, +// connect an InMemoryTransport so v8/Codecov attributes the registerStdioTool block (a subprocess spawn can't +// be instrumented). Verifies the create-safety forwarding: dry-run by default, explicit create only on request. +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + server: { connect: (transport: unknown) => Promise }; +}; + +let tempDir = ""; +const draftBodies: Array<{ dryRun?: boolean; create?: boolean; limit?: number }> = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-generate-issue-drafts-")); + const apiUrl = await startFixtureServer({ onIssueDraftRequest: (body) => draftBodies.push(body) }); + process.env.LOOPOVER_API_URL = apiUrl; + process.env.LOOPOVER_API_TOKEN = "in-process-token"; + process.env.LOOPOVER_API_TIMEOUT_MS = "2000"; + process.env.LOOPOVER_CONFIG_DIR = tempDir; + process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1"; + for (const specifier of MODULES) { + loaded.set(specifier, (await import(specifier)) as unknown as BinModule); + } +}, 120_000); + +afterAll(async () => { + await closeFixtureServer(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + delete process.env.LOOPOVER_API_URL; + delete process.env.LOOPOVER_API_TOKEN; + delete process.env.LOOPOVER_CONFIG_DIR; + delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK; +}); + +describe("bin loopover_generate_contributor_issue_drafts stdio tool (in-process, #7755)", () => { + it.each(MODULES)("dry-runs by default and only writes on explicit create+dryRun=false — %s", async (specifier) => { + draftBodies.length = 0; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "generate-issue-drafts-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + const tool = (await client.listTools()).tools.find((entry) => entry.name === "loopover_generate_contributor_issue_drafts"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/issue drafts|dry-run/i); + + // Defaults: schema fills dryRun=true, create=false, limit=5 -> a safe preview. + const preview = await client.callTool({ name: "loopover_generate_contributor_issue_drafts", arguments: { owner: "owner", repo: "repo" } }); + expect(preview.isError).toBeFalsy(); + expect(draftBodies.at(-1)).toEqual({ dryRun: true, create: false, limit: 5 }); + expect(JSON.stringify(preview)).toContain("Contributor issue drafts for owner/repo."); + + // Explicit write: only {create:true, dryRun:false} reaches the write path. + const write = await client.callTool({ + name: "loopover_generate_contributor_issue_drafts", + arguments: { owner: "owner", repo: "repo", create: true, dryRun: false, limit: 3 }, + }); + expect(write.isError).toBeFalsy(); + expect(draftBodies.at(-1)).toEqual({ dryRun: false, create: true, limit: 3 }); + } finally { + await client.close().catch(() => undefined); + } + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 3c6885c2e0..bdbff8def7 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -40,6 +40,7 @@ // (#7757 registered the loopover_get_agent_audit_feed stdio tool, taking the count from 95 to 96.) // (#7754 registered the loopover_refresh_repo_docs stdio tool, taking the count from 96 to 97.) // (#7756 registered the loopover_get_repo_onboarding_pack stdio tool, taking the count from 97 to 98.) +// (#7755 registered the loopover_generate_contributor_issue_drafts stdio tool, taking the count from 98 to 99.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -86,14 +87,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 98 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 99 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(98); + expect(primary.length).toBe(99); expect(legacy.length).toBe(0); - expect(names.length).toBe(98); + expect(names.length).toBe(99); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -105,14 +106,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 98-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 99-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(98); + expect(payload.count).toBe(99); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), );