diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index b0054bc50a..967dd65481 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -462,6 +462,16 @@ const markNotificationsReadShape = { ids: z.array(z.string().min(1)).optional(), }; +// #7763: stdio mirror of the remote loopover_watch_issues shape (src/mcp/server.ts). login is optional here, +// resolved from `login` / the active session / LOOPOVER_LOGIN like the `watch` CLI; action defaults to `list`, +// and watch/unwatch need repoFullName. labels filter which issues a watch surfaces. +const watchIssuesShape = { + login: z.string().min(1).optional(), + action: z.enum(["watch", "unwatch", "list"]).default("list"), + repoFullName: z.string().min(3).max(200).optional(), + labels: z.array(z.string().min(1).max(100)).max(50).optional(), +}; + const loginRepoShape = { login: z.string().min(1), owner: z.string().min(1), @@ -1255,6 +1265,12 @@ const STDIO_TOOL_DESCRIPTORS = [ description: "Mark a contributor's own delivered notifications as read (clears the badge). Self-scoped; pass `ids` to clear specific notifications or omit to clear all.", }, + { + name: "loopover_watch_issues", + category: "utility", + description: + "Watch repos for NEW grabbable, high-multiplier issues (maintainer-created, not WIP). action=watch subscribes a repo (optional label filter), unwatch removes it, list (default) returns your watches. When a matching issue opens you're notified via loopover_list_notifications. Self-scoped to the authenticated login.", + }, { name: "loopover_compare_pr_variants", category: "branch", @@ -2348,6 +2364,23 @@ registerStdioTool( }, ); +// #7763: stdio mirror of the remote loopover_watch_issues + the `watch` CLI. Reuses the shared +// watchIssuesRequest helper (same /v1/contributors/:login/watches routes the CLI calls); login resolves the +// same way (arg / active session / LOOPOVER_LOGIN), action defaults to list, watch/unwatch need repoFullName. +registerStdioTool( + "loopover_watch_issues", + { + description: stdioToolDescription("loopover_watch_issues"), + inputSchema: watchIssuesShape, + }, + async ({ login, action, repoFullName, labels }: any) => { + const contributorLogin = login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + if (!contributorLogin) throw new Error("No GitHub login: pass `login`, log in with `loopover-mcp login`, or set LOOPOVER_LOGIN."); + if ((action === "watch" || action === "unwatch") && !repoFullName) throw new Error(`action "${action}" requires repoFullName.`); + return toolResult(`Issue-watch subscriptions for ${contributorLogin}.`, await watchIssuesRequest(contributorLogin, action, repoFullName, labels)); + }, +); + registerStdioTool( "loopover_compare_pr_variants", { @@ -4368,16 +4401,27 @@ async function notificationsCli(options: any) { } } +// #7763: shared REST dispatch for a contributor's issue-watch subscriptions, reused by the `watch` CLI and the +// loopover_watch_issues stdio tool so there is no duplicated HTTP logic. action maps list=GET, watch=POST, +// unwatch=DELETE on the /v1/contributors/:login/watches route family (the same routes the CLI already hit). +function watchIssuesRequest(login: any, action: any, repoFullName?: any, labels?: any) { + const base = `/v1/contributors/${encodeURIComponent(login)}/watches`; + if (action === "watch") return apiPost(base, { repoFullName, ...(labels && labels.length > 0 ? { labels } : {}) }); + if (action === "unwatch") return apiDelete(base, { repoFullName }); + return apiGet(base); +} + // #6746: contributor-scoped mirror of the loopover_watch_issues MCP tool and the /v1/contributors/{login}/watches // route family. The MCP tool's action enum maps to subcommands here: list=GET, add=POST, remove=DELETE. -async function watchCli(args: any) { +// Exported (like maintainCli, #7764) so an in-process test can cover the shared watchIssuesRequest call sites +// that a subprocess spawn can't instrument (#7763). +export async function watchCli(args: any) { const subcommand = args[0]; if (!subcommand || subcommand === "--help" || subcommand === "help") return printWatchHelp(); const positional = args[1] && !args[1].startsWith("--") ? args[1] : undefined; const options = parseOptions(args.slice(1)); const login = options.login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; if (!login) throw new Error("Pass --login , log in with `loopover-mcp login`, or set LOOPOVER_LOGIN."); - const base = `/v1/contributors/${encodeURIComponent(login)}/watches`; // The API chooses `changed` / repo / label text, so the plain-text path is sanitized (#6261); `login` is the // user's own value. const render = (payload: any) => @@ -4394,7 +4438,7 @@ async function watchCli(args: any) { }; if (subcommand === "list") { - emit(await apiGet(base)); + emit(await watchIssuesRequest(login, "list")); return; } if (subcommand === "add" || subcommand === "remove") { @@ -4404,9 +4448,9 @@ async function watchCli(args: any) { if (subcommand === "add") { const labels = typeof options.labels === "string" ? options.labels.split(",").map((label: any) => label.trim()).filter(Boolean) : []; - emit(await apiPost(base, { repoFullName: positional, ...(labels.length > 0 ? { labels } : {}) })); + emit(await watchIssuesRequest(login, "watch", positional, labels)); } else { - emit(await apiDelete(base, { repoFullName: positional })); + emit(await watchIssuesRequest(login, "unwatch", positional)); } return; } diff --git a/test/unit/mcp-cli-watch-issues.test.ts b/test/unit/mcp-cli-watch-issues.test.ts new file mode 100644 index 0000000000..03c2106a81 --- /dev/null +++ b/test/unit/mcp-cli-watch-issues.test.ts @@ -0,0 +1,161 @@ +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, vi } from "vitest"; +import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness"; + +// #7763: in-process coverage for the loopover_watch_issues 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 + the shared watchIssuesRequest helper (a subprocess spawn +// can't be instrumented). Drives all three actions (list=GET, watch=POST, unwatch=DELETE) end to end. +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + server: { connect: (transport: unknown) => Promise }; + watchCli: (args: string[]) => Promise; +}; + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array): boolean => { + chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }); + try { + await fn(); + } finally { + spy.mockRestore(); + } + return chunks.join(""); +} + +let tempDir = ""; +const watchGets: Array<{ method: string; url: string }> = []; +const watchWrites: Array<{ method: string; body: { repoFullName?: string; labels?: string[] } }> = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-watch-issues-")); + const apiUrl = await startFixtureServer({ + onApiRequest: (r) => { + if (r.method === "GET" && r.url && r.url.includes("/watches")) watchGets.push({ method: r.method ?? "", url: r.url ?? "" }); + }, + onWatchRequest: (req) => watchWrites.push(req), + }); + 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; +}); + +async function connectClient(specifier: (typeof MODULES)[number], name: string) { + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name, version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +describe("bin loopover_watch_issues stdio tool (in-process, #7763)", () => { + it.each(MODULES)("proxies list=GET, watch=POST (with/without labels), unwatch=DELETE — %s", async (specifier) => { + watchGets.length = 0; + watchWrites.length = 0; + const client = await connectClient(specifier, "watch-issues-test"); + try { + const tool = (await client.listTools()).tools.find((entry) => entry.name === "loopover_watch_issues"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/watch repos|grabbable/i); + + const list = await client.callTool({ name: "loopover_watch_issues", arguments: { login: "octocat", action: "list" } }); + expect(list.isError).toBeFalsy(); + expect(watchGets.at(-1)).toEqual({ method: "GET", url: "/v1/contributors/octocat/watches" }); + expect(JSON.stringify(list)).toContain("watching"); + + const watch = await client.callTool({ + name: "loopover_watch_issues", + arguments: { login: "octocat", action: "watch", repoFullName: "acme/widgets", labels: ["bug"] }, + }); + expect(watch.isError).toBeFalsy(); + expect(watchWrites.at(-1)).toEqual({ method: "POST", body: { repoFullName: "acme/widgets", labels: ["bug"] } }); + + // No labels -> the shared helper omits the labels key entirely. + await client.callTool({ name: "loopover_watch_issues", arguments: { login: "octocat", action: "watch", repoFullName: "acme/gadgets" } }); + expect(watchWrites.at(-1)).toEqual({ method: "POST", body: { repoFullName: "acme/gadgets" } }); + + const unwatch = await client.callTool({ + name: "loopover_watch_issues", + arguments: { login: "octocat", action: "unwatch", repoFullName: "acme/widgets" }, + }); + expect(unwatch.isError).toBeFalsy(); + expect(watchWrites.at(-1)).toEqual({ method: "DELETE", body: { repoFullName: "acme/widgets" } }); + } finally { + await client.close().catch(() => undefined); + } + }); + + it.each(MODULES)("errors (no request) when watch/unwatch is missing repoFullName — %s", async (specifier) => { + watchWrites.length = 0; + const client = await connectClient(specifier, "watch-issues-guard"); + try { + const result = await client.callTool({ name: "loopover_watch_issues", arguments: { login: "octocat", action: "watch" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toMatch(/requires repoFullName/i); + expect(watchWrites).toEqual([]); + } finally { + await client.close().catch(() => undefined); + } + }); + + it.each(MODULES)("errors when no login can be resolved from arg/session/env — %s", async (specifier) => { + const savedLogin = process.env.LOOPOVER_LOGIN; + const savedGh = process.env.GITHUB_LOGIN; + delete process.env.LOOPOVER_LOGIN; + delete process.env.GITHUB_LOGIN; + const client = await connectClient(specifier, "watch-issues-nologin"); + try { + const result = await client.callTool({ name: "loopover_watch_issues", arguments: { action: "list" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toMatch(/No GitHub login|LOOPOVER_LOGIN/i); + } finally { + await client.close().catch(() => undefined); + if (savedLogin !== undefined) process.env.LOOPOVER_LOGIN = savedLogin; + if (savedGh !== undefined) process.env.GITHUB_LOGIN = savedGh; + } + }); +}); + +// The `watch` CLI now routes through the same watchIssuesRequest helper. Drive it in-process (a subprocess +// spawn -- mcp-cli-watch.test.ts -- can't be v8-instrumented) so those shared call sites get real coverage. +describe("bin watch CLI reuses watchIssuesRequest (in-process, #7763)", () => { + it.each(MODULES)("list=GET, add=POST {repoFullName,labels}, remove=DELETE via the shared helper — %s", async (specifier) => { + watchGets.length = 0; + watchWrites.length = 0; + const mod = loaded.get(specifier)!; + + const listOut = await captureStdout(() => mod.watchCli(["list", "--login", "octocat"])); + expect(listOut).toMatch(/Watching \d+ repo\(s\) for octocat/); + expect(watchGets.at(-1)).toEqual({ method: "GET", url: "/v1/contributors/octocat/watches" }); + + await captureStdout(() => mod.watchCli(["add", "acme/widgets", "--labels", "bug,feature", "--login", "octocat"])); + expect(watchWrites.at(-1)).toEqual({ method: "POST", body: { repoFullName: "acme/widgets", labels: ["bug", "feature"] } }); + + await captureStdout(() => mod.watchCli(["remove", "acme/widgets", "--login", "octocat"])); + expect(watchWrites.at(-1)).toEqual({ method: "DELETE", body: { repoFullName: "acme/widgets" } }); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 7ec08b47c5..ace7aa5af1 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -32,6 +32,7 @@ // (#7797 registered the loopover_get_ams_miner_cohort remote+stdio tool, taking the count from 87 to 88.) // (#7808 registered the loopover_get_repo_focus_manifest remote+stdio tool, taking the count from 88 to 89.) // (#7762 registered the loopover_mark_notifications_read stdio tool, taking the count from 89 to 90.) +// (#7763 registered the loopover_watch_issues stdio tool, taking the count from 90 to 91.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -78,14 +79,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 90 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 91 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(90); + expect(primary.length).toBe(91); expect(legacy.length).toBe(0); - expect(names.length).toBe(90); + expect(names.length).toBe(91); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -97,14 +98,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 90-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 91-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(90); + expect(payload.count).toBe(91); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), );