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
54 changes: 49 additions & 5 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -1261,6 +1271,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",
Expand Down Expand Up @@ -2371,6 +2387,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",
{
Expand Down Expand Up @@ -4394,16 +4427,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 <github-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) =>
Expand All @@ -4420,7 +4464,7 @@ async function watchCli(args: any) {
};

if (subcommand === "list") {
emit(await apiGet(base));
emit(await watchIssuesRequest(login, "list"));
return;
}
if (subcommand === "add" || subcommand === "remove") {
Expand All @@ -4430,9 +4474,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;
}
Expand Down
161 changes: 161 additions & 0 deletions test/unit/mcp-cli-watch-issues.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> };
watchCli: (args: string[]) => Promise<void>;
};

async function captureStdout(fn: () => Promise<void>): Promise<string> {
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<string, BinModule>();

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" } });
});
});
11 changes: 6 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
// (#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.)
// (#7760 registered the loopover_get_contributor_profile stdio tool, taking the count from 90 to 91.)
// (#7763 registered the loopover_watch_issues stdio tool, taking the count from 91 to 92.)
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
Expand Down Expand Up @@ -79,14 +80,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

it("lists exactly 91 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
it("lists exactly 92 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(91);
expect(primary.length).toBe(92);
expect(legacy.length).toBe(0);
expect(names.length).toBe(91);
expect(names.length).toBe(92);
});

it("no loopover_ tool's description carries a stale deprecation notice", async () => {
Expand All @@ -98,14 +99,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
}
});

it("`loopover-mcp tools --json` reports the same 91-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 92-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(91);
expect(payload.count).toBe(92);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
[...tools.map((t) => t.name)].sort(),
);
Expand Down