Skip to content

Commit 748acee

Browse files
authored
Merge pull request #6996 from RealDiligent/feat/pr-outcomes-rest-cli-6747-v2
feat(api): REST + CLI mirror for loopover_pr_outcome (#6747)
2 parents 75e6f42 + 631eecc commit 748acee

14 files changed

Lines changed: 622 additions & 18 deletions

File tree

apps/loopover-ui/public/openapi.json

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14347,6 +14347,64 @@
1434714347
]
1434814348
}
1434914349
]
14350+
},
14351+
"ContributorPrOutcomes": {
14352+
"type": "object",
14353+
"properties": {
14354+
"login": {
14355+
"type": "string"
14356+
},
14357+
"count": {
14358+
"type": "number"
14359+
},
14360+
"summary": {
14361+
"type": "string"
14362+
},
14363+
"outcomes": {
14364+
"type": "array",
14365+
"items": {
14366+
"type": "object",
14367+
"properties": {
14368+
"repoFullName": {
14369+
"type": "string"
14370+
},
14371+
"pullNumber": {
14372+
"type": "number",
14373+
"nullable": true
14374+
},
14375+
"outcome": {
14376+
"type": "string",
14377+
"enum": [
14378+
"merged"
14379+
]
14380+
},
14381+
"attribution": {
14382+
"type": "string"
14383+
},
14384+
"deeplink": {
14385+
"type": "string"
14386+
},
14387+
"recordedAt": {
14388+
"type": "string"
14389+
}
14390+
},
14391+
"required": [
14392+
"repoFullName",
14393+
"pullNumber",
14394+
"outcome",
14395+
"attribution",
14396+
"deeplink",
14397+
"recordedAt"
14398+
]
14399+
}
14400+
}
14401+
},
14402+
"required": [
14403+
"login",
14404+
"count",
14405+
"summary",
14406+
"outcomes"
14407+
]
1435014408
}
1435114409
},
1435214410
"parameters": {},
@@ -18590,6 +18648,52 @@
1859018648
}
1859118649
]
1859218650
}
18651+
},
18652+
"/v1/contributors/{login}/pr-outcomes": {
18653+
"get": {
18654+
"summary": "Contributor post-merge PR outcome history",
18655+
"parameters": [
18656+
{
18657+
"schema": {
18658+
"type": "string"
18659+
},
18660+
"required": true,
18661+
"name": "login",
18662+
"in": "path"
18663+
},
18664+
{
18665+
"schema": {
18666+
"type": "integer",
18667+
"minimum": 0,
18668+
"exclusiveMinimum": true,
18669+
"maximum": 100
18670+
},
18671+
"required": false,
18672+
"name": "limit",
18673+
"in": "query"
18674+
}
18675+
],
18676+
"responses": {
18677+
"200": {
18678+
"description": "Self-scoped post-merge outcome records with public-safe attribution (mirrors loopover_pr_outcome).",
18679+
"content": {
18680+
"application/json": {
18681+
"schema": {
18682+
"$ref": "#/components/schemas/ContributorPrOutcomes"
18683+
}
18684+
}
18685+
}
18686+
}
18687+
},
18688+
"security": [
18689+
{
18690+
"LoopOverBearer": []
18691+
},
18692+
{
18693+
"LoopOverSessionCookie": []
18694+
}
18695+
]
18696+
}
1859318697
}
1859418698
},
1859518699
"servers": [

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

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ const CLI_COMMAND_SPEC = {
8989
"repo-decision": [],
9090
"contributor-profile": [],
9191
"monitor-open-prs": [],
92+
"pr-outcomes": [],
9293
"analyze-branch": [],
9394
preflight: [],
9495
"review-pr": [],
@@ -1130,6 +1131,12 @@ const STDIO_TOOL_DESCRIPTORS = [
11301131
description:
11311132
"Inspect a contributor's open PRs on registered repos, classify queue state, and return public-safe next-step packets from cached metadata.",
11321133
},
1134+
{
1135+
name: "loopover_pr_outcome",
1136+
category: "review",
1137+
description:
1138+
"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.",
1139+
},
11331140
{
11341141
name: "loopover_compare_pr_variants",
11351142
category: "branch",
@@ -2070,6 +2077,21 @@ registerStdioTool(
20702077
},
20712078
);
20722079

2080+
registerStdioTool(
2081+
"loopover_pr_outcome",
2082+
{
2083+
description: stdioToolDescription("loopover_pr_outcome"),
2084+
inputSchema: {
2085+
login: z.string().min(1),
2086+
limit: z.number().int().positive().max(100).optional(),
2087+
},
2088+
},
2089+
async ({ login, limit }) => {
2090+
const payload = await getPrOutcomes(login, limit);
2091+
return toolResult(prOutcomesToolSummary(login, payload), payload);
2092+
},
2093+
);
2094+
20732095
registerStdioTool(
20742096
"loopover_compare_pr_variants",
20752097
{
@@ -3376,6 +3398,7 @@ async function runCli(args) {
33763398
if (command === "repo-decision") return repoDecisionCli(options);
33773399
if (command === "contributor-profile") return contributorProfileCli(options);
33783400
if (command === "monitor-open-prs") return monitorOpenPrsCli(options);
3401+
if (command === "pr-outcomes") return prOutcomesCli(options);
33793402
if (command === "review-pr") return reviewPrCli(options);
33803403
if (command !== "analyze-branch" && command !== "preflight") {
33813404
const suggestion = suggestCommand(command);
@@ -3838,6 +3861,45 @@ async function monitorOpenPrsCli(options) {
38383861
}
38393862
}
38403863

3864+
function printPrOutcomesHelp() {
3865+
process.stdout.write(
3866+
[
3867+
"Usage: loopover-mcp pr-outcomes --login <github-login> [--limit N] [--json]",
3868+
"",
3869+
"List your post-merge PR outcome history (public-safe attribution per merged PR).",
3870+
"Mirrors the loopover_pr_outcome MCP tool and GET /v1/contributors/{login}/pr-outcomes. No source upload.",
3871+
"",
3872+
"Pass --json for machine-readable output.",
3873+
].join("\n") + "\n",
3874+
);
3875+
}
3876+
3877+
async function prOutcomesCli(options) {
3878+
if (options.help === true) return printPrOutcomesHelp();
3879+
const login = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN;
3880+
if (!login) throw new Error("Pass --login <github-login> or set LOOPOVER_LOGIN.");
3881+
const limitRaw = options.limit;
3882+
let limit;
3883+
if (limitRaw !== undefined && limitRaw !== true) {
3884+
const parsed = Number(limitRaw);
3885+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
3886+
throw new Error("Pass --limit as an integer between 1 and 100.");
3887+
}
3888+
limit = parsed;
3889+
}
3890+
const payload = await getPrOutcomes(login, limit);
3891+
if (options.json) {
3892+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
3893+
return;
3894+
}
3895+
process.stdout.write(`${sanitizePlainTextTerminalOutput(prOutcomesToolSummary(login, payload))}\n`);
3896+
for (const outcome of payload?.outcomes ?? []) {
3897+
const heading = `${outcome.repoFullName}#${outcome.pullNumber ?? "?"} [${outcome.outcome}]`;
3898+
process.stdout.write(`${sanitizePlainTextTerminalOutput(heading)}\n`);
3899+
if (outcome.attribution) process.stdout.write(` ${sanitizePlainTextTerminalOutput(outcome.attribution)}\n`);
3900+
}
3901+
}
3902+
38413903
function printRepoDecisionHelp() {
38423904
process.stdout.write(
38433905
[
@@ -4317,6 +4379,7 @@ function printHelp() {
43174379
loopover-mcp decision-pack --login <github-login> [--json]
43184380
loopover-mcp repo-decision --login <github-login> --repo owner/repo [--json]
43194381
loopover-mcp monitor-open-prs --login <github-login> [--json]
4382+
loopover-mcp pr-outcomes --login <github-login> [--limit N] [--json]
43204383
loopover-mcp analyze-branch --login <github-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]
43214384
loopover-mcp preflight --login <github-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]
43224385
loopover-mcp review-pr --login <github-login> [--repo owner/repo] [--base origin/main] [--commit <message>]... [--body <text>] [--body-file <path>] [--linked-issue <number>] [--json]
@@ -4335,7 +4398,7 @@ function printHelp() {
43354398
LOOPOVER_PROFILE
43364399
LOOPOVER_CONFIG_PATH or LOOPOVER_CONFIG_DIR
43374400
LOOPOVER_API_TOKEN, LOOPOVER_MCP_TOKEN, LOOPOVER_TOKEN, or a session from loopover-mcp login
4338-
LOOPOVER_LOGIN or GITHUB_LOGIN (default --login for analyze-branch, preflight, review-pr, decision-pack, repo-decision, monitor-open-prs, and agent plan/packet)
4401+
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)
43394402
GITHUB_TOKEN for non-interactive login bootstrap
43404403
GITTENSOR_SCORE_PREVIEW_CMD
43414404
GITTENSOR_ROOT
@@ -5463,6 +5526,13 @@ function getOpenPrMonitor(login) {
54635526
return apiGet(`/v1/contributors/${encodeURIComponent(login)}/open-pr-monitor`);
54645527
}
54655528

5529+
function getPrOutcomes(login, limit) {
5530+
const query = new URLSearchParams();
5531+
if (limit != null) query.set("limit", String(limit));
5532+
const suffix = query.size > 0 ? `?${query}` : "";
5533+
return apiGet(`/v1/contributors/${encodeURIComponent(login)}/pr-outcomes${suffix}`);
5534+
}
5535+
54665536
// Mirror the API's own `summary` when it sends one, so the CLI and the loopover_monitor_open_prs MCP
54675537
// tool (which returns monitor.summary verbatim) never drift into two different sentences for one payload.
54685538
function openPrMonitorToolSummary(login, payload) {
@@ -5471,6 +5541,12 @@ function openPrMonitorToolSummary(login, payload) {
54715541
return `LoopOver open-PR monitor for ${login}.`;
54725542
}
54735543

5544+
function prOutcomesToolSummary(login, payload) {
5545+
const summary = typeof payload?.summary === "string" ? payload.summary.trim() : "";
5546+
if (summary) return summary;
5547+
return `LoopOver post-merge outcomes for ${login}.`;
5548+
}
5549+
54745550
function isCacheableDecisionPack(payload, login) {
54755551
return payload?.status === "ready" && typeof payload.login === "string" && payload.login.toLowerCase() === login.toLowerCase();
54765552
}

src/api/routes.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ import {
270270
} from "../signals/extension-contributor-context";
271271
import { attachDataQuality, buildCoreSignalFidelity, buildFreshnessSloReport, buildRepoDataQuality, buildSignalFidelity } from "../signals/data-quality";
272272
import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor";
273+
import { buildContributorPrOutcomes } from "../signals/contributor-pr-outcomes";
273274
import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk";
274275
import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch";
275276
import { buildIssueSlopAssessment, ISSUE_SLOP_RUBRIC_MARKDOWN } from "../signals/issue-slop";
@@ -3325,6 +3326,23 @@ export function createApp() {
33253326
return c.json(await buildContributorOpenPrMonitor(c.env, login));
33263327
});
33273328

3329+
// #6747: REST mirror of loopover_pr_outcome — same requireContributorAccess gate + notification-delivery source.
3330+
app.get("/v1/contributors/:login/pr-outcomes", async (c) => {
3331+
const login = c.req.param("login");
3332+
const unauthorized = await requireContributorAccess(c, login);
3333+
if (unauthorized) return unauthorized;
3334+
const limitParam = c.req.query("limit");
3335+
let limit: number | undefined;
3336+
if (limitParam !== undefined) {
3337+
const parsed = Number(limitParam);
3338+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
3339+
return c.json({ error: "invalid_limit", detail: "limit must be an integer between 1 and 100" }, 400);
3340+
}
3341+
limit = parsed;
3342+
}
3343+
return c.json(await buildContributorPrOutcomes(c.env, login, limit));
3344+
});
3345+
33283346
app.get("/v1/contributors/:login/repos/:owner/:repo/decision", async (c) => {
33293347
const login = c.req.param("login");
33303348
const unauthorized = await requireContributorAccess(c, login);

src/mcp/server.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ import {
142142
} from "../signals/engine";
143143
import { PUBLIC_SURFACE_SKIP_REASONS, skippedPrAuditRemediation, type PublicSurfaceSkipReason } from "../signals/settings-preview";
144144
import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor";
145+
import { buildContributorPrOutcomes } from "../signals/contributor-pr-outcomes";
145146
import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch";
146147
import { computeLocalScorerTokens } from "../signals/local-scorer";
147148
import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk";
@@ -3750,18 +3751,10 @@ export class LoopoverMcp {
37503751

37513752
private async prOutcomes(login: string, limit?: number): Promise<ToolPayload> {
37523753
this.requireContributorAccess(login);
3753-
const deliveries = await listNotificationDeliveriesForRecipient(this.env, login, { eventType: "pull_request_merged", limit: limit ?? 50 });
3754-
const outcomes = deliveries.map((delivery) => ({
3755-
repoFullName: delivery.repoFullName,
3756-
pullNumber: delivery.pullNumber,
3757-
outcome: "merged" as const,
3758-
attribution: delivery.body,
3759-
deeplink: delivery.deeplink,
3760-
recordedAt: delivery.createdAt,
3761-
}));
3754+
const payload = await buildContributorPrOutcomes(this.env, login, limit);
37623755
return {
3763-
summary: `LoopOver post-merge outcomes for ${login}: ${outcomes.length} merged PR(s).`,
3764-
data: { login: login.toLowerCase(), count: outcomes.length, outcomes } as unknown as Record<string, unknown>,
3756+
summary: payload.summary,
3757+
data: payload as unknown as Record<string, unknown>,
37653758
};
37663759
}
37673760

src/openapi/schemas.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,24 @@ export const ContributorOpenPrMonitorSchema = z
463463
})
464464
.openapi("ContributorOpenPrMonitor");
465465

466+
export const ContributorPrOutcomesSchema = z
467+
.object({
468+
login: z.string(),
469+
count: z.number(),
470+
summary: z.string(),
471+
outcomes: z.array(
472+
z.object({
473+
repoFullName: z.string(),
474+
pullNumber: z.number().nullable(),
475+
outcome: z.literal("merged"),
476+
attribution: z.string(),
477+
deeplink: z.string(),
478+
recordedAt: z.string(),
479+
}),
480+
),
481+
})
482+
.openapi("ContributorPrOutcomes");
483+
466484
export const ContributorOpportunitySchema = z
467485
.object({
468486
repoFullName: z.string(),

src/openapi/spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
ContributorPatternReportSchema,
2525
ContributorDecisionPackSchema,
2626
ContributorOpenPrMonitorSchema,
27+
ContributorPrOutcomesSchema,
2728
ContributorRewardRiskStrategySchema,
2829
ContributorProfileSchema,
2930
ContributorScoringProfileSchema,
@@ -776,6 +777,21 @@ export function buildOpenApiSpec() {
776777
},
777778
},
778779
});
780+
registry.registerPath({
781+
method: "get",
782+
path: "/v1/contributors/{login}/pr-outcomes",
783+
summary: "Contributor post-merge PR outcome history",
784+
request: {
785+
params: z.object({ login: z.string() }),
786+
query: z.object({ limit: z.coerce.number().int().positive().max(100).optional() }),
787+
},
788+
responses: {
789+
200: {
790+
description: "Self-scoped post-merge outcome records with public-safe attribution (mirrors loopover_pr_outcome).",
791+
content: { "application/json": { schema: ContributorPrOutcomesSchema } },
792+
},
793+
},
794+
});
779795
registry.registerPath({
780796
method: "get",
781797
path: "/v1/contributors/{login}/repos/{owner}/{repo}/decision",

0 commit comments

Comments
 (0)