Skip to content
Closed
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
51 changes: 51 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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) {
Expand Down
143 changes: 143 additions & 0 deletions test/unit/mcp-cli-watch-issues-tool.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> };
};

let tempDir = "";
const captured: Array<{ method: string; url: string; body?: unknown }> = [];
const loaded = new Map<string, BinModule>();

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);
}
});
});
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 @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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(),
);
Expand Down