Skip to content
Closed
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
104 changes: 104 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
Expand Down Expand Up @@ -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": [
Expand Down
78 changes: 77 additions & 1 deletion packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ const CLI_COMMAND_SPEC = {
"repo-decision": [],
"contributor-profile": [],
"monitor-open-prs": [],
"pr-outcomes": [],
"analyze-branch": [],
preflight: [],
"review-pr": [],
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -3838,6 +3861,45 @@ async function monitorOpenPrsCli(options) {
}
}

function printPrOutcomesHelp() {
process.stdout.write(
[
"Usage: loopover-mcp pr-outcomes --login <github-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 <github-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(
[
Expand Down Expand Up @@ -4317,6 +4379,7 @@ function printHelp() {
loopover-mcp decision-pack --login <github-login> [--json]
loopover-mcp repo-decision --login <github-login> --repo owner/repo [--json]
loopover-mcp monitor-open-prs --login <github-login> [--json]
loopover-mcp pr-outcomes --login <github-login> [--limit N] [--json]
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]
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]
loopover-mcp review-pr --login <github-login> [--repo owner/repo] [--base origin/main] [--commit <message>]... [--body <text>] [--body-file <path>] [--linked-issue <number>] [--json]
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
}
Expand Down
18 changes: 18 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 4 additions & 11 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -3750,18 +3751,10 @@ export class LoopoverMcp {

private async prOutcomes(login: string, limit?: number): Promise<ToolPayload> {
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<string, unknown>,
summary: payload.summary,
data: payload as unknown as Record<string, unknown>,
};
}

Expand Down
18 changes: 18 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
16 changes: 16 additions & 0 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
ContributorPatternReportSchema,
ContributorDecisionPackSchema,
ContributorOpenPrMonitorSchema,
ContributorPrOutcomesSchema,
ContributorRewardRiskStrategySchema,
ContributorProfileSchema,
ContributorScoringProfileSchema,
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading