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
22 changes: 22 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const CLI_COMMAND_SPEC = {
"init-client": [],
"decision-pack": [],
"repo-decision": [],
"contributor-profile": [],
"monitor-open-prs": [],
"analyze-branch": [],
preflight: [],
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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 <github-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;
Expand Down
52 changes: 52 additions & 0 deletions test/unit/mcp-cli-contributor-profile.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
11 changes: 11 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down