From 3aeedfb799f1ff52aefc3a35e89c2c7842d791a9 Mon Sep 17 00:00:00 2001 From: Jeff <158072326+jeffrey701@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:53:48 -0400 Subject: [PATCH] feat(mcp): add a contributor-profile CLI command --- packages/loopover-mcp/bin/loopover-mcp.js | 22 ++++++++ test/unit/mcp-cli-contributor-profile.test.ts | 52 +++++++++++++++++++ test/unit/support/mcp-cli-harness.ts | 11 ++++ 3 files changed, 85 insertions(+) create mode 100644 test/unit/mcp-cli-contributor-profile.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 25d8d19bb..7ad75fa45 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -87,6 +87,7 @@ const CLI_COMMAND_SPEC = { "init-client": [], "decision-pack": [], "repo-decision": [], + "contributor-profile": [], "monitor-open-prs": [], "analyze-branch": [], preflight: [], @@ -3251,6 +3252,7 @@ async function runCli(args) { if (command === "issue-slop") return issueSlopCli(args.slice(1)); if (command === "decision-pack") return decisionPackCli(options); if (command === "repo-decision") return repoDecisionCli(options); + if (command === "contributor-profile") return contributorProfileCli(options); if (command === "monitor-open-prs") return monitorOpenPrsCli(options); if (command === "review-pr") return reviewPrCli(options); if (command !== "analyze-branch" && command !== "preflight") { @@ -3643,6 +3645,26 @@ function printDecisionPackHelp() { ); } +// #6737: CLI mirror of the loopover_get_contributor_profile MCP tool and GET /v1/contributors/{login}/profile +// (requireContributorAccess-gated -- the same gate decision-pack/repo-decision already satisfy). Login resolves +// 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) { + 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`); + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)} +`); + return; + } + process.stdout.write(`LoopOver contributor profile for ${login}. +`); + if (payload.summary) process.stdout.write(`${sanitizePlainTextTerminalOutput(payload.summary)} +`); +} + async function decisionPackCli(options) { if (options.help === true) return printDecisionPackHelp(); const login = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; diff --git a/test/unit/mcp-cli-contributor-profile.test.ts b/test/unit/mcp-cli-contributor-profile.test.ts new file mode 100644 index 000000000..7208086e0 --- /dev/null +++ b/test/unit/mcp-cli-contributor-profile.test.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { closeFixtureServer, runAsync, runExpectingFailure, startFixtureServer } from "./support/mcp-cli-harness"; + +describe("loopover-mcp CLI — contributor-profile (#6737)", () => { + let tempDir: string | null = null; + + afterEach(async () => { + await closeFixtureServer(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + }); + + async function env(onApiRequest?: (request: import("node:http").IncomingMessage) => void) { + tempDir = mkdtempSync(join(tmpdir(), "loopover-cli-")); + const url = await startFixtureServer(onApiRequest ? { onApiRequest } : {}); + return { LOOPOVER_API_URL: url, LOOPOVER_TOKEN: "session-token", LOOPOVER_CONFIG_DIR: tempDir, LOOPOVER_API_TIMEOUT_MS: "1000" }; + } + + it("mirrors GET /v1/contributors/:login/profile for an explicit --login (plain + json)", async () => { + const requests: string[] = []; + const e = await env((request) => requests.push(request.url ?? "")); + + const plain = await runAsync(["contributor-profile", "--login", "octocat"], e); + expect(plain).toMatch(/LoopOver contributor profile for octocat\./); + expect(plain).toMatch(/3 registered repos; 12 merged PRs; strongest in review-tooling\./); + expect(requests.at(-1)).toBe("/v1/contributors/octocat/profile"); + + const json = JSON.parse(await runAsync(["contributor-profile", "--login", "octocat", "--json"], e)) as { login: string; summary: string }; + // Parity: the --json surface re-serializes the same payload the plain summary was built from. + expect(json).toMatchObject({ login: "octocat", summary: "3 registered repos; 12 merged PRs; strongest in review-tooling." }); + }); + + it("resolves the login from LOOPOVER_LOGIN when --login is omitted, and url-encodes it", async () => { + const requests: string[] = []; + const e = await env((request) => requests.push(request.url ?? "")); + + await runAsync(["contributor-profile"], { ...e, LOOPOVER_LOGIN: "a b/c" }); + expect(requests.at(-1)).toBe("/v1/contributors/a%20b%2Fc/profile"); + }); + + it("errors (never issuing a request) when no login can be resolved", async () => { + const requests: string[] = []; + const e = await env((request) => requests.push(request.url ?? "")); + // Clear every login fallback the CLI would otherwise read from the ambient process env. + const failure = runExpectingFailure(["contributor-profile"], { ...e, LOOPOVER_LOGIN: "", GITHUB_LOGIN: "" }); + expect(`${failure.stdout}${failure.stderr}`).toMatch(/Pass --login/); + expect(requests.filter((url) => url.includes("/profile"))).toHaveLength(0); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 515e3da7e..eaf093f14 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -293,6 +293,17 @@ export async function startFixtureServer( ); return; } + const contributorProfileMatch = /^\/v1\/contributors\/([^/]+)\/profile$/.exec(new URL(request.url ?? "/", "http://localhost").pathname); + if (contributorProfileMatch && request.method === "GET") { + response.end( + JSON.stringify({ + login: decodeURIComponent(contributorProfileMatch[1]!), + generatedAt: "2026-05-30T00:00:00.000Z", + summary: "3 registered repos; 12 merged PRs; strongest in review-tooling.", + }), + ); + return; + } if (request.url === "/v1/contributors/JSONbored/open-pr-monitor" && request.method === "GET") { response.end(JSON.stringify({ ...openPrMonitorFixture(), ...(options.openPrMonitor ?? {}) })); return;