diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 4d2d28e96e..9dd9c7b0ee 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -1235,6 +1235,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_get_contributor_profile", + category: "discovery", + description: + "Return the evidence-backed LoopOver contributor profile for a GitHub login: registered repos, merged-PR history, and where the contributor is strongest. Takes login (the contributor's GitHub username). Same as `loopover-mcp contributor-profile`.", + }, { name: "loopover_pr_outcome", category: "review", @@ -2303,6 +2309,23 @@ registerStdioTool( }, ); +// #7760: local stdio mirror of the loopover_get_contributor_profile remote tool (src/mcp/server.ts). The remote +// tool + `contributor-profile` CLI (#6737) already served this endpoint; only the stdio surface was missing. Mirrors +// the loopover_monitor_open_prs block above -- loginShape + the shared getContributorProfile call (no duplicated HTTP +// path). The summary is the remote tool's own fixed sentence (server.ts uses the identical string), so the two +// surfaces never drift; the full API payload rides along as structuredContent. +registerStdioTool( + "loopover_get_contributor_profile", + { + description: stdioToolDescription("loopover_get_contributor_profile"), + inputSchema: loginShape, + }, + async ({ login }: any) => { + const payload = await getContributorProfile(login); + return toolResult(`LoopOver contributor profile for ${login}.`, payload); + }, +); + registerStdioTool( "loopover_pr_outcome", { @@ -4149,11 +4172,14 @@ function printContributorProfileHelp() { // from --login / the active session / LOOPOVER_LOGIN / GITHUB_LOGIN, exactly like the sibling contributor // commands, so an already-logged-in contributor never retypes their own login. Named `contributor-profile` // because the top-level `profile` command already manages MCP client profiles. -async function contributorProfileCli(options: any) { +// #7760: exported (like maintainCli) so an in-process test can drive it directly -- the subprocess CLI harness +// v8 can't instrument, so the shared getContributorProfile call below is graded through this in-process entry. +export async function contributorProfileCli(options: any) { if (options.help === true) return printContributorProfileHelp(); 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 payload = await apiGet(`/v1/contributors/${encodeURIComponent(login)}/profile`); + // #7760: shared with the loopover_get_contributor_profile stdio tool so the endpoint path lives in one place. + const payload = await getContributorProfile(login); if (options.json) { process.stdout.write(`${JSON.stringify(payload, null, 2)} `); @@ -6060,6 +6086,12 @@ function getOpenPrMonitor(login: any) { return apiGet(`/v1/contributors/${encodeURIComponent(login)}/open-pr-monitor`); } +// #7760: single source of truth for GET /v1/contributors/:login/profile, shared by the contributor-profile CLI +// and the loopover_get_contributor_profile stdio tool so neither duplicates the endpoint path. +function getContributorProfile(login: any) { + return apiGet(`/v1/contributors/${encodeURIComponent(login)}/profile`); +} + function getPrOutcomes(login: any, limit: any) { const query = new URLSearchParams(); if (limit != null) query.set("limit", String(limit)); diff --git a/test/unit/mcp-cli-contributor-profile-inprocess.test.ts b/test/unit/mcp-cli-contributor-profile-inprocess.test.ts new file mode 100644 index 0000000000..6f4316d1c9 --- /dev/null +++ b/test/unit/mcp-cli-contributor-profile-inprocess.test.ts @@ -0,0 +1,135 @@ +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"; + +// #7760: in-process coverage for the stdio loopover_get_contributor_profile tool AND the exported +// contributorProfileCli, both in packages/loopover-mcp/bin/loopover-mcp.ts. The bin is otherwise only exercised +// via subprocess spawn (the sibling mcp-cli-contributor-profile.test.ts), which v8 cannot instrument -- the +// isProcessEntrypoint guard is what lets a test import the module without it hijacking argv / binding stdin, so +// the shared getContributorProfile call + the new stdio handler get real Codecov-measured coverage. Same shape +// as mcp-cli-plan-issues.test.ts / mcp-cli-activation-preview.test.ts. Only the committed .ts source is imported. +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + contributorProfileCli: (options: { login?: string; json?: boolean }) => Promise; + server: { connect: (transport: unknown) => Promise }; +}; + +let tempDir = ""; +const capturedRequests: Array<{ url: string; method: string }> = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-contributor-profile-inprocess-")); + const apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/profile")) { + capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" }); + } + }, + }); + // The bin reads LOOPOVER_API_URL at module load, so set the env BEFORE importing (hence the dynamic import). + 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_API_TIMEOUT_MS; + delete process.env.LOOPOVER_CONFIG_DIR; + delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK; +}); + +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(""); +} + +describe("bin loopover_get_contributor_profile stdio tool (in-process, #7760)", () => { + it.each(MODULES)("registers and proxies GET /v1/contributors/:login/profile — %s", async (specifier) => { + capturedRequests.length = 0; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "contributor-profile-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + const { tools } = await client.listTools(); + const tool = tools.find((entry) => entry.name === "loopover_get_contributor_profile"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/contributor profile/i); + + const result = await client.callTool({ + name: "loopover_get_contributor_profile", + arguments: { login: "octocat" }, + }); + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toContain("/v1/contributors/octocat/profile"); + expect(captured.method).toBe("GET"); + expect(result.isError).toBeFalsy(); + // structuredContent is the raw API payload; the summary line is the remote tool's fixed sentence. + expect(result.structuredContent).toMatchObject({ login: "octocat" }); + const text = JSON.stringify(result); + expect(text).toContain("LoopOver contributor profile for octocat."); + expect(text).toContain("3 registered repos; 12 merged PRs; strongest in review-tooling."); + } finally { + await client.close().catch(() => undefined); + } + }); + + it.each(MODULES)("url-encodes the login in the proxied path — %s", async (specifier) => { + capturedRequests.length = 0; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "contributor-profile-encode-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + await client.callTool({ name: "loopover_get_contributor_profile", arguments: { login: "a b/c" } }); + expect(capturedRequests.at(-1)!.url).toContain("/v1/contributors/a%20b%2Fc/profile"); + } finally { + await client.close().catch(() => undefined); + } + }); +}); + +describe("bin contributor-profile CLI (in-process, #7760)", () => { + it.each(MODULES)("shares getContributorProfile with the stdio tool: prints the header + API summary — %s", async (specifier) => { + capturedRequests.length = 0; + const mod = loaded.get(specifier)!; + const out = await captureStdout(() => mod.contributorProfileCli({ login: "octocat" })); + expect(capturedRequests.at(-1)!.url).toBe("/v1/contributors/octocat/profile"); + expect(out).toMatch(/LoopOver contributor profile for octocat\./); + expect(out).toContain("3 registered repos; 12 merged PRs; strongest in review-tooling."); + }); + + it.each(MODULES)("--json re-serializes the same payload the shared call returned — %s", async (specifier) => { + const mod = loaded.get(specifier)!; + const out = await captureStdout(() => mod.contributorProfileCli({ login: "octocat", json: true })); + const payload = JSON.parse(out) as { login: string; summary: string }; + expect(payload).toMatchObject({ login: "octocat", summary: "3 registered repos; 12 merged PRs; strongest in review-tooling." }); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index cc89b6f112..9020dc2f7f 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.) +// (#7760 registered the loopover_get_contributor_profile 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(), );