diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 4d2d28e96e..265fa56ed3 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -454,6 +454,14 @@ const loginShape = { login: z.string().min(1), }; +// #7763: mirrors remote watchIssuesShape (src/mcp/server.ts) for the stdio twin of loopover_watch_issues. +const watchIssuesShape = { + login: z.string().min(1), + 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), @@ -1235,6 +1243,12 @@ const STDIO_TOOL_DESCRIPTORS = [ 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_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_pr_outcome", category: "review", @@ -2303,6 +2317,22 @@ registerStdioTool( }, ); +// #7763: stdio twin of the remote loopover_watch_issues tool / watch CLI — same /v1/contributors/{login}/watches +// routes; action vocab stays watch|unwatch|list (CLI uses add|remove for the same POST/DELETE). +registerStdioTool( + "loopover_watch_issues", + { + description: stdioToolDescription("loopover_watch_issues"), + inputSchema: watchIssuesShape, + }, + async (input: any) => { + const payload = await manageIssueWatches(input); + const n = (payload.watching ?? []).length; + const changed = payload.changed ? ` (${payload.changed})` : ""; + return toolResult(`Watching ${n} repo(s) for new grabbable issues${changed}.`, payload); + }, +); + registerStdioTool( "loopover_pr_outcome", { @@ -6067,6 +6097,27 @@ function getPrOutcomes(login: any, limit: any) { return apiGet(`/v1/contributors/${encodeURIComponent(login)}/pr-outcomes${suffix}`); } +// #7763 / #6746: shared HTTP for loopover_watch_issues stdio + `watch` CLI. MCP actions watch|unwatch|list +// map to POST|DELETE|GET on /v1/contributors/{login}/watches. +async function manageIssueWatches(input: { + login: string; + action?: "watch" | "unwatch" | "list"; + repoFullName?: string; + labels?: string[]; +}) { + const action = input.action ?? "list"; + const base = `/v1/contributors/${encodeURIComponent(input.login)}/watches`; + if (action === "list") return apiGet(base); + if (!input.repoFullName) throw new Error(`${action} requires repoFullName.`); + if (action === "watch") { + return apiPost(base, { + repoFullName: input.repoFullName, + ...(input.labels?.length ? { labels: input.labels } : {}), + }); + } + return apiDelete(base, { repoFullName: input.repoFullName }); +} + // #6745: contributor notification feed + mark-read. `postMarkNotificationsRead` sends no ids to mark all // delivered notifications read, mirroring markNotificationsReadShape's optional ids. function getNotifications(login: any) { diff --git a/test/unit/mcp-cli-watch-issues-tool.test.ts b/test/unit/mcp-cli-watch-issues-tool.test.ts new file mode 100644 index 0000000000..c5b263f36b --- /dev/null +++ b/test/unit/mcp-cli-watch-issues-tool.test.ts @@ -0,0 +1,143 @@ +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"; + +// #7763: in-process coverage for the loopover_watch_issues stdio tool. +// Same entrypoint-guard pattern as mcp-cli-repo-focus-manifest — import .ts, hold exported `server`, +// connect InMemoryTransport so v8/Codecov attributes registerStdioTool + manageIssueWatches. +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + server: { connect: (transport: unknown) => Promise }; +}; + +let tempDir = ""; +const captured: Array<{ method: string; url: string; body?: unknown }> = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-watch-issues-")); + const apiUrl = await startFixtureServer({ + onWatchRequest: ({ method, body }) => { + captured.push({ method, url: "/v1/contributors/miner/watches", body }); + }, + onApiRequest: (request) => { + const url = request.url ?? ""; + if (url.includes("/watches") && request.method === "GET") { + captured.push({ method: "GET", url }); + } + }, + }); + 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_watch_issues stdio tool (in-process, #7763)", () => { + it.each(MODULES)("registers and proxies list/watch/unwatch — %s", async (specifier) => { + captured.length = 0; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "watch-issues-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + const { tools } = await client.listTools(); + const tool = tools.find((entry) => entry.name === "loopover_watch_issues"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/watch repos|grabbable/i); + + const listResult = await client.callTool({ + name: "loopover_watch_issues", + arguments: { login: "miner", action: "list" }, + }); + expect(listResult.isError).toBeFalsy(); + expect(JSON.stringify(listResult)).toContain("acme/widgets"); + expect(captured.some((row) => row.method === "GET")).toBe(true); + + captured.length = 0; + const defaultList = await client.callTool({ + name: "loopover_watch_issues", + arguments: { login: "miner" }, + }); + expect(defaultList.isError).toBeFalsy(); + expect(captured.some((row) => row.method === "GET")).toBe(true); + + captured.length = 0; + const watchResult = await client.callTool({ + name: "loopover_watch_issues", + arguments: { + login: "miner", + action: "watch", + repoFullName: "acme/widgets", + labels: ["bug"], + }, + }); + expect(watchResult.isError).toBeFalsy(); + expect(JSON.stringify(watchResult)).toMatch(/watching|Watching/); + expect(captured).toEqual([ + { + method: "POST", + url: "/v1/contributors/miner/watches", + body: { repoFullName: "acme/widgets", labels: ["bug"] }, + }, + ]); + + captured.length = 0; + const watchNoLabels = await client.callTool({ + name: "loopover_watch_issues", + arguments: { login: "miner", action: "watch", repoFullName: "acme/gadgets" }, + }); + expect(watchNoLabels.isError).toBeFalsy(); + expect(captured).toEqual([ + { + method: "POST", + url: "/v1/contributors/miner/watches", + body: { repoFullName: "acme/gadgets" }, + }, + ]); + + captured.length = 0; + const missingRepo = await client.callTool({ + name: "loopover_watch_issues", + arguments: { login: "miner", action: "watch" }, + }); + expect(missingRepo.isError).toBeTruthy(); + + captured.length = 0; + const unwatchResult = await client.callTool({ + name: "loopover_watch_issues", + arguments: { login: "miner", action: "unwatch", repoFullName: "acme/widgets" }, + }); + expect(unwatchResult.isError).toBeFalsy(); + expect(JSON.stringify(unwatchResult)).toMatch(/unwatched|Watching/); + expect(captured).toEqual([ + { + method: "DELETE", + url: "/v1/contributors/miner/watches", + body: { repoFullName: "acme/widgets" }, + }, + ]); + } 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 cc89b6f112..9205b306b8 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -31,6 +31,7 @@ // (#7800 registered the loopover_get_gate_config_effective remote+stdio tool, taking the count from 86 to 87.) // (#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.) +// (#7763 registered the loopover_watch_issues stdio tool, taking the count from 89 to 90.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -77,14 +78,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 89 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 90 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(89); + expect(primary.length).toBe(90); expect(legacy.length).toBe(0); - expect(names.length).toBe(89); + expect(names.length).toBe(90); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -96,14 +97,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 89-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 90-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(89); + expect(payload.count).toBe(90); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), );