diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 98c0d58a59..bca7e13b10 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -14347,6 +14347,64 @@ ] } ] + }, + "ContributorPrOutcomes": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "count": { + "type": "number" + }, + "summary": { + "type": "string" + }, + "outcomes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "pullNumber": { + "type": "number", + "nullable": true + }, + "outcome": { + "type": "string", + "enum": [ + "merged" + ] + }, + "attribution": { + "type": "string" + }, + "deeplink": { + "type": "string" + }, + "recordedAt": { + "type": "string" + } + }, + "required": [ + "repoFullName", + "pullNumber", + "outcome", + "attribution", + "deeplink", + "recordedAt" + ] + } + } + }, + "required": [ + "login", + "count", + "summary", + "outcomes" + ] } }, "parameters": {}, @@ -18590,6 +18648,52 @@ } ] } + }, + "/v1/contributors/{login}/pr-outcomes": { + "get": { + "summary": "Contributor post-merge PR outcome history", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "maximum": 100 + }, + "required": false, + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Self-scoped post-merge outcome records with public-safe attribution (mirrors loopover_pr_outcome).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContributorPrOutcomes" + } + } + } + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 029405f4f2..4a8d130bac 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -89,6 +89,7 @@ const CLI_COMMAND_SPEC = { "repo-decision": [], "contributor-profile": [], "monitor-open-prs": [], + "pr-outcomes": [], "analyze-branch": [], preflight: [], "review-pr": [], @@ -1130,6 +1131,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_pr_outcome", + category: "review", + description: + "Return a contributor's own post-merge outcome records — for each merged PR, a public-safe attribution of what it did for their standing on the repo. Self-scoped: only the authenticated login's outcomes.", + }, { name: "loopover_compare_pr_variants", category: "branch", @@ -2070,6 +2077,21 @@ registerStdioTool( }, ); +registerStdioTool( + "loopover_pr_outcome", + { + description: stdioToolDescription("loopover_pr_outcome"), + inputSchema: { + login: z.string().min(1), + limit: z.number().int().positive().max(100).optional(), + }, + }, + async ({ login, limit }) => { + const payload = await getPrOutcomes(login, limit); + return toolResult(prOutcomesToolSummary(login, payload), payload); + }, +); + registerStdioTool( "loopover_compare_pr_variants", { @@ -3376,6 +3398,7 @@ async function runCli(args) { if (command === "repo-decision") return repoDecisionCli(options); if (command === "contributor-profile") return contributorProfileCli(options); if (command === "monitor-open-prs") return monitorOpenPrsCli(options); + if (command === "pr-outcomes") return prOutcomesCli(options); if (command === "review-pr") return reviewPrCli(options); if (command !== "analyze-branch" && command !== "preflight") { const suggestion = suggestCommand(command); @@ -3838,6 +3861,45 @@ async function monitorOpenPrsCli(options) { } } +function printPrOutcomesHelp() { + process.stdout.write( + [ + "Usage: loopover-mcp pr-outcomes --login [--limit N] [--json]", + "", + "List your post-merge PR outcome history (public-safe attribution per merged PR).", + "Mirrors the loopover_pr_outcome MCP tool and GET /v1/contributors/{login}/pr-outcomes. No source upload.", + "", + "Pass --json for machine-readable output.", + ].join("\n") + "\n", + ); +} + +async function prOutcomesCli(options) { + if (options.help === true) return printPrOutcomesHelp(); + const login = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + if (!login) throw new Error("Pass --login or set LOOPOVER_LOGIN."); + const limitRaw = options.limit; + let limit; + if (limitRaw !== undefined && limitRaw !== true) { + const parsed = Number(limitRaw); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) { + throw new Error("Pass --limit as an integer between 1 and 100."); + } + limit = parsed; + } + const payload = await getPrOutcomes(login, limit); + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + process.stdout.write(`${sanitizePlainTextTerminalOutput(prOutcomesToolSummary(login, payload))}\n`); + for (const outcome of payload?.outcomes ?? []) { + const heading = `${outcome.repoFullName}#${outcome.pullNumber ?? "?"} [${outcome.outcome}]`; + process.stdout.write(`${sanitizePlainTextTerminalOutput(heading)}\n`); + if (outcome.attribution) process.stdout.write(` ${sanitizePlainTextTerminalOutput(outcome.attribution)}\n`); + } +} + function printRepoDecisionHelp() { process.stdout.write( [ @@ -4317,6 +4379,7 @@ function printHelp() { loopover-mcp decision-pack --login [--json] loopover-mcp repo-decision --login --repo owner/repo [--json] loopover-mcp monitor-open-prs --login [--json] + loopover-mcp pr-outcomes --login [--limit N] [--json] loopover-mcp analyze-branch --login [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--scenario-note "..."] [--validation "passed|npm test|summary"] [--format table] [--json] loopover-mcp preflight --login [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--validation "passed|npm test|summary"] [--format table] [--json] loopover-mcp review-pr --login [--repo owner/repo] [--base origin/main] [--commit ]... [--body ] [--body-file ] [--linked-issue ] [--json] @@ -4335,7 +4398,7 @@ function printHelp() { LOOPOVER_PROFILE LOOPOVER_CONFIG_PATH or LOOPOVER_CONFIG_DIR LOOPOVER_API_TOKEN, LOOPOVER_MCP_TOKEN, LOOPOVER_TOKEN, or a session from loopover-mcp login - LOOPOVER_LOGIN or GITHUB_LOGIN (default --login for analyze-branch, preflight, review-pr, decision-pack, repo-decision, monitor-open-prs, and agent plan/packet) + LOOPOVER_LOGIN or GITHUB_LOGIN (default --login for analyze-branch, preflight, review-pr, decision-pack, repo-decision, monitor-open-prs, pr-outcomes, and agent plan/packet) GITHUB_TOKEN for non-interactive login bootstrap GITTENSOR_SCORE_PREVIEW_CMD GITTENSOR_ROOT @@ -5463,6 +5526,13 @@ function getOpenPrMonitor(login) { return apiGet(`/v1/contributors/${encodeURIComponent(login)}/open-pr-monitor`); } +function getPrOutcomes(login, limit) { + const query = new URLSearchParams(); + if (limit != null) query.set("limit", String(limit)); + const suffix = query.size > 0 ? `?${query}` : ""; + return apiGet(`/v1/contributors/${encodeURIComponent(login)}/pr-outcomes${suffix}`); +} + // Mirror the API's own `summary` when it sends one, so the CLI and the loopover_monitor_open_prs MCP // tool (which returns monitor.summary verbatim) never drift into two different sentences for one payload. function openPrMonitorToolSummary(login, payload) { @@ -5471,6 +5541,12 @@ function openPrMonitorToolSummary(login, payload) { return `LoopOver open-PR monitor for ${login}.`; } +function prOutcomesToolSummary(login, payload) { + const summary = typeof payload?.summary === "string" ? payload.summary.trim() : ""; + if (summary) return summary; + return `LoopOver post-merge outcomes for ${login}.`; +} + function isCacheableDecisionPack(payload, login) { return payload?.status === "ready" && typeof payload.login === "string" && payload.login.toLowerCase() === login.toLowerCase(); } diff --git a/src/api/routes.ts b/src/api/routes.ts index a570e054d1..121428092c 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -270,6 +270,7 @@ import { } from "../signals/extension-contributor-context"; import { attachDataQuality, buildCoreSignalFidelity, buildFreshnessSloReport, buildRepoDataQuality, buildSignalFidelity } from "../signals/data-quality"; import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor"; +import { buildContributorPrOutcomes } from "../signals/contributor-pr-outcomes"; import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; import { buildIssueSlopAssessment, ISSUE_SLOP_RUBRIC_MARKDOWN } from "../signals/issue-slop"; @@ -3325,6 +3326,23 @@ export function createApp() { return c.json(await buildContributorOpenPrMonitor(c.env, login)); }); + // #6747: REST mirror of loopover_pr_outcome — same requireContributorAccess gate + notification-delivery source. + app.get("/v1/contributors/:login/pr-outcomes", async (c) => { + const login = c.req.param("login"); + const unauthorized = await requireContributorAccess(c, login); + if (unauthorized) return unauthorized; + const limitParam = c.req.query("limit"); + let limit: number | undefined; + if (limitParam !== undefined) { + const parsed = Number(limitParam); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) { + return c.json({ error: "invalid_limit", detail: "limit must be an integer between 1 and 100" }, 400); + } + limit = parsed; + } + return c.json(await buildContributorPrOutcomes(c.env, login, limit)); + }); + app.get("/v1/contributors/:login/repos/:owner/:repo/decision", async (c) => { const login = c.req.param("login"); const unauthorized = await requireContributorAccess(c, login); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f9e65e8deb..d77180447b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -142,6 +142,7 @@ import { } from "../signals/engine"; import { PUBLIC_SURFACE_SKIP_REASONS, skippedPrAuditRemediation, type PublicSurfaceSkipReason } from "../signals/settings-preview"; import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor"; +import { buildContributorPrOutcomes } from "../signals/contributor-pr-outcomes"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; import { computeLocalScorerTokens } from "../signals/local-scorer"; import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk"; @@ -3750,18 +3751,10 @@ export class LoopoverMcp { private async prOutcomes(login: string, limit?: number): Promise { this.requireContributorAccess(login); - const deliveries = await listNotificationDeliveriesForRecipient(this.env, login, { eventType: "pull_request_merged", limit: limit ?? 50 }); - const outcomes = deliveries.map((delivery) => ({ - repoFullName: delivery.repoFullName, - pullNumber: delivery.pullNumber, - outcome: "merged" as const, - attribution: delivery.body, - deeplink: delivery.deeplink, - recordedAt: delivery.createdAt, - })); + const payload = await buildContributorPrOutcomes(this.env, login, limit); return { - summary: `LoopOver post-merge outcomes for ${login}: ${outcomes.length} merged PR(s).`, - data: { login: login.toLowerCase(), count: outcomes.length, outcomes } as unknown as Record, + summary: payload.summary, + data: payload as unknown as Record, }; } diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 6334e69e06..20234bf962 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -463,6 +463,24 @@ export const ContributorOpenPrMonitorSchema = z }) .openapi("ContributorOpenPrMonitor"); +export const ContributorPrOutcomesSchema = z + .object({ + login: z.string(), + count: z.number(), + summary: z.string(), + outcomes: z.array( + z.object({ + repoFullName: z.string(), + pullNumber: z.number().nullable(), + outcome: z.literal("merged"), + attribution: z.string(), + deeplink: z.string(), + recordedAt: z.string(), + }), + ), + }) + .openapi("ContributorPrOutcomes"); + export const ContributorOpportunitySchema = z .object({ repoFullName: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 5b43aaace3..6dca754355 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -24,6 +24,7 @@ import { ContributorPatternReportSchema, ContributorDecisionPackSchema, ContributorOpenPrMonitorSchema, + ContributorPrOutcomesSchema, ContributorRewardRiskStrategySchema, ContributorProfileSchema, ContributorScoringProfileSchema, @@ -776,6 +777,21 @@ export function buildOpenApiSpec() { }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/contributors/{login}/pr-outcomes", + summary: "Contributor post-merge PR outcome history", + request: { + params: z.object({ login: z.string() }), + query: z.object({ limit: z.coerce.number().int().positive().max(100).optional() }), + }, + responses: { + 200: { + description: "Self-scoped post-merge outcome records with public-safe attribution (mirrors loopover_pr_outcome).", + content: { "application/json": { schema: ContributorPrOutcomesSchema } }, + }, + }, + }); registry.registerPath({ method: "get", path: "/v1/contributors/{login}/repos/{owner}/{repo}/decision", diff --git a/src/signals/contributor-pr-outcomes.ts b/src/signals/contributor-pr-outcomes.ts new file mode 100644 index 0000000000..24914713bc --- /dev/null +++ b/src/signals/contributor-pr-outcomes.ts @@ -0,0 +1,44 @@ +import { listNotificationDeliveriesForRecipient } from "../db/repositories"; + +export type ContributorPrOutcome = { + repoFullName: string; + pullNumber: number | null; + outcome: "merged"; + attribution: string; + deeplink: string; + recordedAt: string; +}; + +export type ContributorPrOutcomes = { + login: string; + count: number; + summary: string; + outcomes: ContributorPrOutcome[]; +}; + +/** + * Post-merge outcome history for a contributor — the payload behind `loopover_pr_outcome` + * and `GET /v1/contributors/:login/pr-outcomes`. Sourced from notification deliveries with + * `eventType: "pull_request_merged"` (public-safe attribution only; no reward/wallet fields). + */ +export async function buildContributorPrOutcomes(env: Env, login: string, limit?: number): Promise { + const deliveries = await listNotificationDeliveriesForRecipient(env, login, { + eventType: "pull_request_merged", + limit: limit ?? 50, + }); + const outcomes: ContributorPrOutcome[] = deliveries.map((delivery) => ({ + repoFullName: delivery.repoFullName, + pullNumber: delivery.pullNumber, + outcome: "merged" as const, + attribution: delivery.body, + deeplink: delivery.deeplink, + recordedAt: delivery.createdAt, + })); + const normalizedLogin = login.toLowerCase(); + return { + login: normalizedLogin, + count: outcomes.length, + summary: `LoopOver post-merge outcomes for ${login}: ${outcomes.length} merged PR(s).`, + outcomes, + }; +} diff --git a/test/integration/routes-errors.test.ts b/test/integration/routes-errors.test.ts index ea1c670857..abcbb2fce5 100644 --- a/test/integration/routes-errors.test.ts +++ b/test/integration/routes-errors.test.ts @@ -225,6 +225,14 @@ describe("api route guards and error branches", () => { expect(staticTokenOpenPrMonitor.status).toBe(200); await expect(staticTokenOpenPrMonitor.json()).resolves.toMatchObject({ login: "victim" }); + const victimPrOutcomes = await app.request("/v1/contributors/victim/pr-outcomes", { headers: sessionHeaders }, env); + expect(victimPrOutcomes.status).toBe(403); + await expect(victimPrOutcomes.json()).resolves.toMatchObject({ error: "forbidden_contributor" }); + + const ownPrOutcomes = await app.request("/v1/contributors/attacker/pr-outcomes", { headers: sessionHeaders }, env); + expect(ownPrOutcomes.status).toBe(200); + await expect(ownPrOutcomes.json()).resolves.toMatchObject({ login: "attacker", outcomes: expect.any(Array) }); + const victimRepoDecision = await app.request("/v1/contributors/victim/repos/owner/private-repo/decision", { headers: sessionHeaders }, env); expect(victimRepoDecision.status).toBe(403); await expect(victimRepoDecision.json()).resolves.toMatchObject({ error: "forbidden_contributor" }); diff --git a/test/unit/mcp-cli-pr-outcomes.test.ts b/test/unit/mcp-cli-pr-outcomes.test.ts new file mode 100644 index 0000000000..35c91251ac --- /dev/null +++ b/test/unit/mcp-cli-pr-outcomes.test.ts @@ -0,0 +1,150 @@ +// #6747: CLI + stdio mirrors for loopover_pr_outcome. The host MCP tool already existed; this pins the +// REST-backed stdio proxy and shell CLI against the same fixture payload. +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { closeFixtureServer, prOutcomesFixture, run, runAsync, runExpectingFailure, startFixtureServer } from "./support/mcp-cli-harness"; + +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); + +let client: Client; +let transport: StdioClientTransport; +let configDir: string; +let apiUrl: string; +let capturedRequests: Array<{ url: string; method: string }>; + +async function connect() { + configDir = mkdtempSync(join(tmpdir(), "loopover-pr-outcomes-")); + capturedRequests = []; + apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/pr-outcomes")) { + capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" }); + } + }, + }); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...process.env, + LOOPOVER_CONFIG_DIR: configDir, + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + LOOPOVER_API_TIMEOUT_MS: "5000", + }, + }); + client = new Client({ name: "pr-outcomes-test", version: "0.0.1" }); + await client.connect(transport); +} + +async function disconnect() { + await client.close().catch(() => undefined); + await closeFixtureServer(); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +} + +describe("loopover_pr_outcome stdio proxy (#6747)", () => { + beforeEach(connect); + afterEach(disconnect); + + it("registers the tool in the stdio server tool list", async () => { + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toContain("loopover_pr_outcome"); + }); + + it("proxies login (+ optional limit) to GET /v1/contributors/:login/pr-outcomes", async () => { + const result = await client.callTool({ name: "loopover_pr_outcome", arguments: { login: "JSONbored", limit: 10 } }); + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toContain("/v1/contributors/JSONbored/pr-outcomes"); + expect(captured.url).toContain("limit=10"); + expect(captured.method).toBe("GET"); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).toContain("JSONbored/loopover"); + expect(text).toContain(prOutcomesFixture().summary); + }); +}); + +describe("loopover-mcp pr-outcomes CLI (#6747)", () => { + beforeEach(connect); + afterEach(disconnect); + + it("--json emits exactly the payload the MCP tool surfaces for the same login (mirror parity)", async () => { + const viaTool = await client.callTool({ name: "loopover_pr_outcome", arguments: { login: "JSONbored" } }); + const toolData = (viaTool as { structuredContent?: unknown }).structuredContent; + const viaCli = JSON.parse( + await runAsync(["pr-outcomes", "--login", "JSONbored", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" }), + ); + expect(viaCli).toEqual(prOutcomesFixture()); + if (toolData !== undefined) expect(viaCli).toEqual(toolData); + }); + + it("prints the API summary and one line per outcome", async () => { + const out = await runAsync(["pr-outcomes", "--login", "JSONbored"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" }); + const fixture = prOutcomesFixture(); + expect(out).toContain(fixture.summary); + expect(out).toContain("JSONbored/loopover#42 [merged]"); + expect(out).toContain(fixture.outcomes[0]!.attribution); + }); + + it("forwards --limit and resolves login from LOOPOVER_LOGIN / GITHUB_LOGIN", async () => { + await runAsync(["pr-outcomes", "--json", "--limit", "5"], { + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + LOOPOVER_LOGIN: "JSONbored", + }); + expect(capturedRequests.at(-1)?.url).toContain("limit=5"); + + const viaGithubLogin = await runAsync(["pr-outcomes", "--json"], { + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + GITHUB_LOGIN: "JSONbored", + }); + expect(JSON.parse(viaGithubLogin)).toEqual(prOutcomesFixture()); + }); + + it("fails when no login is resolvable or --limit is out of range", () => { + const noLogin = runExpectingFailure(["pr-outcomes"], { + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + LOOPOVER_LOGIN: "", + GITHUB_LOGIN: "", + }); + expect(noLogin.status).toBe(1); + expect(`${noLogin.stdout}${noLogin.stderr}`).toMatch(/Pass --login or set LOOPOVER_LOGIN\./); + + const badLimit = runExpectingFailure(["pr-outcomes", "--login", "JSONbored", "--limit", "0"], { + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + }); + expect(badLimit.status).toBe(1); + expect(`${badLimit.stdout}${badLimit.stderr}`).toMatch(/integer between 1 and 100/); + }); + + it("strips ANSI escapes from API-chosen text on the plain-text path but not from --json", async () => { + await closeFixtureServer(); + const hostileUrl = await startFixtureServer({ + prOutcomes: { summary: "\u001b[31mFAKE PASS\u001b[0m", outcomes: [{ repoFullName: "a/b", pullNumber: 1, outcome: "merged", attribution: "\u001b[2Krewritten", deeplink: "https://x", recordedAt: "t" }] }, + }); + const env = { LOOPOVER_API_URL: hostileUrl, LOOPOVER_TOKEN: "session-token" }; + + const plain = await runAsync(["pr-outcomes", "--login", "JSONbored"], env); + expect(plain).not.toContain("\u001b"); + expect(plain).toContain("FAKE PASS"); + expect(plain).toContain("rewritten"); + + const asJson = await runAsync(["pr-outcomes", "--login", "JSONbored", "--json"], env); + expect(JSON.parse(asJson).summary).toBe("\u001b[31mFAKE PASS\u001b[0m"); + }); + + it("documents itself in --help and in the shell-completion command list", () => { + expect(run(["--help"])).toContain("loopover-mcp pr-outcomes --login [--limit N] [--json]"); + expect(run(["pr-outcomes", "--help"])).toContain("Mirrors the loopover_pr_outcome MCP tool"); + expect(run(["completion", "bash"])).toContain("pr-outcomes"); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index ef51afdb1f..8e8be0c06f 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -19,6 +19,7 @@ // (#6734 registered the loopover_get_repo_outcome_patterns CLI mirror, taking the count from 74 to 75.) // (#6740 registered the loopover_explain_gate_disposition CLI mirror, taking the count from 75 to 76.) // (#6741 registered the loopover_draft_pr_body CLI mirror, taking the count from 76 to 77.) +// (#6747 registered the loopover_pr_outcome CLI mirror, taking the count from 77 to 78.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -66,14 +67,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 77 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 78 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(77); + expect(primary.length).toBe(78); expect(legacy.length).toBe(0); - expect(names.length).toBe(77); + expect(names.length).toBe(78); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -85,14 +86,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 77-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 78-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(77); + expect(payload.count).toBe(78); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), ); diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index c5db56974d..2892ad63c0 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -24,6 +24,7 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/contributors/{login}/profile"]).toBeDefined(); expect(spec.paths["/v1/contributors/{login}/decision-pack"]).toBeDefined(); expect(spec.paths["/v1/contributors/{login}/open-pr-monitor"]).toBeDefined(); + expect(spec.paths["/v1/contributors/{login}/pr-outcomes"]).toBeDefined(); expect(spec.paths["/v1/contributors/{login}/repos/{owner}/{repo}/decision"]).toBeDefined(); expect(spec.paths["/v1/preflight/pr"]).toBeDefined(); expect(spec.paths["/v1/preflight/local-diff"]).toBeDefined(); diff --git a/test/unit/routes-pr-outcomes.test.ts b/test/unit/routes-pr-outcomes.test.ts new file mode 100644 index 0000000000..a2c7f8846e --- /dev/null +++ b/test/unit/routes-pr-outcomes.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { LoopoverMcp } from "../../src/mcp/server"; +import { createSessionForGitHubUser } from "../../src/auth/security"; +import { insertNotificationDeliveryIfAbsent } from "../../src/db/repositories"; +import { buildContributorPrOutcomes } from "../../src/signals/contributor-pr-outcomes"; +import { createTestEnv } from "../helpers/d1"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; + +const apiHeaders = (env: Env) => ({ authorization: `Bearer ${env.LOOPOVER_API_TOKEN}` }); + +async function seedMergedOutcome(env: Env, recipientLogin: string, pullNumber: number, dedupKey: string) { + await insertNotificationDeliveryIfAbsent(env, { + dedupKey, + channel: "badge", + recipientLogin, + eventType: "pull_request_merged", + repoFullName: "owner/repo", + pullNumber, + title: `Merged: owner/repo#${pullNumber}`, + body: `Your pull request owner/repo#${pullNumber} merged. Merged contributions strengthen your standing on owner/repo.`, + deeplink: `https://github.com/owner/repo/pull/${pullNumber}`, + actorLogin: recipientLogin, + }); +} + +describe("GET /v1/contributors/:login/pr-outcomes (#6747)", () => { + it("returns post-merge outcomes for the authenticated contributor", async () => { + const app = createApp(); + const env = createTestEnv(); + await seedMergedOutcome(env, "miner", 7, "pull_request_merged:owner/repo#7:m1"); + + const response = await app.request("/v1/contributors/miner/pr-outcomes", { headers: apiHeaders(env) }, env); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + login: "miner", + count: 1, + summary: "LoopOver post-merge outcomes for miner: 1 merged PR(s).", + outcomes: [{ repoFullName: "owner/repo", pullNumber: 7, outcome: "merged" }], + }); + }); + + it("honors ?limit and rejects out-of-range limits", async () => { + const app = createApp(); + const env = createTestEnv(); + await seedMergedOutcome(env, "miner", 1, "pull_request_merged:owner/repo#1:a"); + await seedMergedOutcome(env, "miner", 2, "pull_request_merged:owner/repo#2:b"); + await seedMergedOutcome(env, "miner", 3, "pull_request_merged:owner/repo#3:c"); + + const limited = await app.request("/v1/contributors/miner/pr-outcomes?limit=2", { headers: apiHeaders(env) }, env); + expect(limited.status).toBe(200); + const limitedBody = (await limited.json()) as { count: number }; + expect(limitedBody.count).toBe(2); + + const bad = await app.request("/v1/contributors/miner/pr-outcomes?limit=0", { headers: apiHeaders(env) }, env); + expect(bad.status).toBe(400); + await expect(bad.json()).resolves.toMatchObject({ error: "invalid_limit" }); + + const tooHigh = await app.request("/v1/contributors/miner/pr-outcomes?limit=101", { headers: apiHeaders(env) }, env); + expect(tooHigh.status).toBe(400); + + const notInt = await app.request("/v1/contributors/miner/pr-outcomes?limit=1.5", { headers: apiHeaders(env) }, env); + expect(notInt.status).toBe(400); + }); + + it("rejects unauthenticated callers", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request("/v1/contributors/miner/pr-outcomes", {}, env); + expect(response.status).toBeGreaterThanOrEqual(401); + }); + + it("forbids a session from reading another login's outcomes", async () => { + const app = createApp(); + // Session callers reach /v1/contributors/* only when ADMIN_GITHUB_LOGINS (or a path allowlist) opens the + // coarse gate; requireContributorAccess then enforces actor === login (same as open-pr-monitor). + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "miner" }); + const { token } = await createSessionForGitHubUser(env, { login: "miner", id: 1 }); + const response = await app.request("/v1/contributors/other/pr-outcomes", { + headers: { authorization: `Bearer ${token}` }, + }, env); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ error: "forbidden_contributor" }); + }); + + it("matches the host MCP tool payload for the same login (mirror parity)", async () => { + const env = createTestEnv(); + await seedMergedOutcome(env, "miner", 7, "pull_request_merged:owner/repo#7:parity"); + + const viaBuilder = await buildContributorPrOutcomes(env, "miner"); + + const app = createApp(); + const viaRest = await (await app.request("/v1/contributors/miner/pr-outcomes", { headers: apiHeaders(env) }, env)).json(); + + const server = new LoopoverMcp(env).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "pr-outcomes-parity", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + const viaMcp = await client.callTool({ name: "loopover_pr_outcome", arguments: { login: "miner" } }); + const mcpData = (viaMcp as { structuredContent?: unknown }).structuredContent; + + expect(viaRest).toEqual(viaBuilder); + expect(mcpData).toEqual(viaBuilder); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 7bf0396ef9..b58c1bccf9 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -176,6 +176,7 @@ export async function startFixtureServer( onApiRequest?: (request: IncomingMessage) => void; validateConfigWarnings?: string[]; openPrMonitor?: Record; + prOutcomes?: Record; intakeStatus?: number; localBranchAnalysisStatus?: number; /** #6743: overrides the repo-doc refresh route's default "opened a new PR" response, e.g. to exercise @@ -311,6 +312,12 @@ export async function startFixtureServer( response.end(JSON.stringify({ ...openPrMonitorFixture(), ...(options.openPrMonitor ?? {}) })); return; } + const prOutcomesMatch = request.url?.match(/^\/v1\/contributors\/([^/]+)\/pr-outcomes(?:\?(.*))?$/); + if (prOutcomesMatch && request.method === "GET") { + const login = decodeURIComponent(prOutcomesMatch[1]!); + response.end(JSON.stringify({ ...prOutcomesFixture(login), ...(options.prOutcomes ?? {}) })); + return; + } if (request.url === "/v1/contributors/JSONbored/repos/JSONbored/loopover/decision" && request.method === "GET") { if (options.repoDecisionStatus && options.repoDecisionStatus >= 400) { response.statusCode = options.repoDecisionStatus; @@ -875,6 +882,25 @@ export function openPrMonitorFixture() { }; } +/** Mirrors GET /v1/contributors/:login/pr-outcomes / buildContributorPrOutcomes. */ +export function prOutcomesFixture(login = "JSONbored") { + return { + login: login.toLowerCase(), + count: 1, + summary: `LoopOver post-merge outcomes for ${login}: 1 merged PR(s).`, + outcomes: [ + { + repoFullName: "JSONbored/loopover", + pullNumber: 42, + outcome: "merged" as const, + attribution: "Your pull request JSONbored/loopover#42 merged. Merged contributions strengthen your standing.", + deeplink: "https://github.com/JSONbored/loopover/pull/42", + recordedAt: "2026-06-01T00:00:00.000Z", + }, + ], + }; +} + export function decisionPackFixture() { return { status: "ready",