Skip to content

Commit 484c9c8

Browse files
authored
feat(mcp): add a contributor-profile CLI command (#6961)
1 parent a4310f1 commit 484c9c8

3 files changed

Lines changed: 85 additions & 0 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ const CLI_COMMAND_SPEC = {
8787
"init-client": [],
8888
"decision-pack": [],
8989
"repo-decision": [],
90+
"contributor-profile": [],
9091
"monitor-open-prs": [],
9192
"analyze-branch": [],
9293
preflight: [],
@@ -3251,6 +3252,7 @@ async function runCli(args) {
32513252
if (command === "issue-slop") return issueSlopCli(args.slice(1));
32523253
if (command === "decision-pack") return decisionPackCli(options);
32533254
if (command === "repo-decision") return repoDecisionCli(options);
3255+
if (command === "contributor-profile") return contributorProfileCli(options);
32543256
if (command === "monitor-open-prs") return monitorOpenPrsCli(options);
32553257
if (command === "review-pr") return reviewPrCli(options);
32563258
if (command !== "analyze-branch" && command !== "preflight") {
@@ -3643,6 +3645,26 @@ function printDecisionPackHelp() {
36433645
);
36443646
}
36453647

3648+
// #6737: CLI mirror of the loopover_get_contributor_profile MCP tool and GET /v1/contributors/{login}/profile
3649+
// (requireContributorAccess-gated -- the same gate decision-pack/repo-decision already satisfy). Login resolves
3650+
// from --login / the active session / LOOPOVER_LOGIN / GITHUB_LOGIN, exactly like the sibling contributor
3651+
// commands, so an already-logged-in contributor never retypes their own login. Named `contributor-profile`
3652+
// because the top-level `profile` command already manages MCP client profiles.
3653+
async function contributorProfileCli(options) {
3654+
const login = options.login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN;
3655+
if (!login) throw new Error("Pass --login <github-login>, log in with `loopover-mcp login`, or set LOOPOVER_LOGIN.");
3656+
const payload = await apiGet(`/v1/contributors/${encodeURIComponent(login)}/profile`);
3657+
if (options.json) {
3658+
process.stdout.write(`${JSON.stringify(payload, null, 2)}
3659+
`);
3660+
return;
3661+
}
3662+
process.stdout.write(`LoopOver contributor profile for ${login}.
3663+
`);
3664+
if (payload.summary) process.stdout.write(`${sanitizePlainTextTerminalOutput(payload.summary)}
3665+
`);
3666+
}
3667+
36463668
async function decisionPackCli(options) {
36473669
if (options.help === true) return printDecisionPackHelp();
36483670
const login = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { mkdtempSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import { closeFixtureServer, runAsync, runExpectingFailure, startFixtureServer } from "./support/mcp-cli-harness";
6+
7+
describe("loopover-mcp CLI — contributor-profile (#6737)", () => {
8+
let tempDir: string | null = null;
9+
10+
afterEach(async () => {
11+
await closeFixtureServer();
12+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
13+
tempDir = null;
14+
});
15+
16+
async function env(onApiRequest?: (request: import("node:http").IncomingMessage) => void) {
17+
tempDir = mkdtempSync(join(tmpdir(), "loopover-cli-"));
18+
const url = await startFixtureServer(onApiRequest ? { onApiRequest } : {});
19+
return { LOOPOVER_API_URL: url, LOOPOVER_TOKEN: "session-token", LOOPOVER_CONFIG_DIR: tempDir, LOOPOVER_API_TIMEOUT_MS: "1000" };
20+
}
21+
22+
it("mirrors GET /v1/contributors/:login/profile for an explicit --login (plain + json)", async () => {
23+
const requests: string[] = [];
24+
const e = await env((request) => requests.push(request.url ?? ""));
25+
26+
const plain = await runAsync(["contributor-profile", "--login", "octocat"], e);
27+
expect(plain).toMatch(/LoopOver contributor profile for octocat\./);
28+
expect(plain).toMatch(/3 registered repos; 12 merged PRs; strongest in review-tooling\./);
29+
expect(requests.at(-1)).toBe("/v1/contributors/octocat/profile");
30+
31+
const json = JSON.parse(await runAsync(["contributor-profile", "--login", "octocat", "--json"], e)) as { login: string; summary: string };
32+
// Parity: the --json surface re-serializes the same payload the plain summary was built from.
33+
expect(json).toMatchObject({ login: "octocat", summary: "3 registered repos; 12 merged PRs; strongest in review-tooling." });
34+
});
35+
36+
it("resolves the login from LOOPOVER_LOGIN when --login is omitted, and url-encodes it", async () => {
37+
const requests: string[] = [];
38+
const e = await env((request) => requests.push(request.url ?? ""));
39+
40+
await runAsync(["contributor-profile"], { ...e, LOOPOVER_LOGIN: "a b/c" });
41+
expect(requests.at(-1)).toBe("/v1/contributors/a%20b%2Fc/profile");
42+
});
43+
44+
it("errors (never issuing a request) when no login can be resolved", async () => {
45+
const requests: string[] = [];
46+
const e = await env((request) => requests.push(request.url ?? ""));
47+
// Clear every login fallback the CLI would otherwise read from the ambient process env.
48+
const failure = runExpectingFailure(["contributor-profile"], { ...e, LOOPOVER_LOGIN: "", GITHUB_LOGIN: "" });
49+
expect(`${failure.stdout}${failure.stderr}`).toMatch(/Pass --login/);
50+
expect(requests.filter((url) => url.includes("/profile"))).toHaveLength(0);
51+
});
52+
});

test/unit/support/mcp-cli-harness.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,17 @@ export async function startFixtureServer(
293293
);
294294
return;
295295
}
296+
const contributorProfileMatch = /^\/v1\/contributors\/([^/]+)\/profile$/.exec(new URL(request.url ?? "/", "http://localhost").pathname);
297+
if (contributorProfileMatch && request.method === "GET") {
298+
response.end(
299+
JSON.stringify({
300+
login: decodeURIComponent(contributorProfileMatch[1]!),
301+
generatedAt: "2026-05-30T00:00:00.000Z",
302+
summary: "3 registered repos; 12 merged PRs; strongest in review-tooling.",
303+
}),
304+
);
305+
return;
306+
}
296307
if (request.url === "/v1/contributors/JSONbored/open-pr-monitor" && request.method === "GET") {
297308
response.end(JSON.stringify({ ...openPrMonitorFixture(), ...(options.openPrMonitor ?? {}) }));
298309
return;

0 commit comments

Comments
 (0)