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
68 changes: 68 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -14490,6 +14490,43 @@
"login",
"marked"
]
},
"ReviewRiskExplanation": {
"type": "object",
"properties": {
"preflight": {
"$ref": "#/components/schemas/PreflightResult"
},
"roleContext": {
"allOf": [
{
"$ref": "#/components/schemas/RoleContext"
},
{
"nullable": true
}
]
},
"recommendation": {
"type": "string",
"enum": [
"likely_duplicate",
"maintainer_lane",
"needs_author",
"review",
"watch"
]
},
"summary": {
"type": "string"
}
},
"required": [
"preflight",
"roleContext",
"recommendation",
"summary"
]
}
},
"parameters": {},
Expand Down Expand Up @@ -18869,6 +18906,37 @@
}
]
}
},
"/v1/preflight/review-risk": {
"post": {
"summary": "Explain review risk for a planned pull request",
"responses": {
"200": {
"description": "Review-risk explanation with preflight, role context, and recommendation",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReviewRiskExplanation"
}
}
}
},
"400": {
"description": "Invalid preflight input"
},
"403": {
"description": "Forbidden when contributorLogin does not match the authenticated session"
}
},
"security": [
{
"LoopOverBearer": []
},
{
"LoopOverSessionCookie": []
}
]
}
}
},
"servers": [
Expand Down
72 changes: 72 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ const CLI_COMMAND_SPEC = {
"contributor-profile": [],
"monitor-open-prs": [],
"pr-outcomes": [],
"explain-review-risk": [],
notifications: [],
"notifications-read": [],
"analyze-branch": [],
Expand Down Expand Up @@ -936,6 +937,11 @@ const STDIO_TOOL_DESCRIPTORS = [
category: "discovery",
description: "Preflight planned PR metadata against lane, duplicate, linked issue, test, and queue signals.",
},
{
name: "loopover_explain_review_risk",
category: "review",
description: "Explain review risk for a planned PR using preflight, lane, duplicate, and role context.",
},
{
name: "loopover_validate_linked_issue",
category: "discovery",
Expand Down Expand Up @@ -1516,6 +1522,19 @@ registerStdioTool(
async (input) => toolResult("LoopOver PR preflight.", await apiPost("/v1/preflight/pr", input)),
);

// #6980: CLI stdio mirror of loopover_explain_review_risk — proxies POST /v1/preflight/review-risk.
registerStdioTool(
"loopover_explain_review_risk",
{
description: stdioToolDescription("loopover_explain_review_risk"),
inputSchema: preflightShape,
},
async (input) => {
const payload = await apiPost("/v1/preflight/review-risk", input);
return toolResult(payload.summary ?? `LoopOver review-risk explanation for ${input.repoFullName}.`, payload);
},
);

registerStdioTool(
"loopover_validate_linked_issue",
{
Expand Down Expand Up @@ -3401,6 +3420,7 @@ async function runCli(args) {
if (command === "contributor-profile") return contributorProfileCli(options);
if (command === "monitor-open-prs") return monitorOpenPrsCli(options);
if (command === "pr-outcomes") return prOutcomesCli(options);
if (command === "explain-review-risk") return explainReviewRiskCli(options);
if (command === "notifications") return notificationsCli(options);
if (command === "notifications-read") return notificationsReadCli(options);
if (command === "review-pr") return reviewPrCli(options);
Expand Down Expand Up @@ -3904,6 +3924,57 @@ async function prOutcomesCli(options) {
}
}

function printExplainReviewRiskHelp() {
process.stdout.write(
[
"Usage: loopover-mcp explain-review-risk --repo owner/repo --title <text> [--login <github-login>] [--body <text>] [--json]",
"",
"Explain review risk for a planned PR (preflight + optional role context + recommendation).",
"Mirrors the loopover_explain_review_risk MCP tool and POST /v1/preflight/review-risk. No source upload.",
"",
"Pass --repo or --repoFullName, --title, and optionally --login as contributorLogin.",
"Pass --json for machine-readable output.",
].join("\n") + "\n",
);
}

async function explainReviewRiskCli(options) {
if (options.help === true) return printExplainReviewRiskHelp();
const repoFullName = options.repoFullName ?? options.repo;
if (!repoFullName || !String(repoFullName).includes("/")) throw new Error("Pass --repo owner/repo or --repoFullName owner/repo.");
if (!options.title) throw new Error("Pass --title <text>.");
const contributorLogin = options.login ?? options.contributorLogin;
const labels = Array.isArray(options.label) ? options.label : options.label ? [options.label] : undefined;
const changedFiles = Array.isArray(options.changedFile) ? options.changedFile : options.changedFile ? [options.changedFile] : undefined;
const linkedIssues = Array.isArray(options.issue)
? options.issue.map((value) => Number(value)).filter((value) => Number.isInteger(value) && value > 0)
: options.issue
? [Number(options.issue)].filter((value) => Number.isInteger(value) && value > 0)
: undefined;
const tests = Array.isArray(options.test) ? options.test : options.test ? [options.test] : undefined;
const payload = await apiPost(
"/v1/preflight/review-risk",
stripUndefined({
repoFullName,
title: options.title,
contributorLogin,
body: options.body,
labels,
changedFiles,
linkedIssues: linkedIssues && linkedIssues.length > 0 ? linkedIssues : undefined,
tests,
authorAssociation: options.authorAssociation,
}),
);
if (options.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
return;
}
process.stdout.write(`${sanitizePlainTextTerminalOutput(payload.summary ?? `LoopOver review-risk explanation for ${repoFullName}.`)}\n`);
if (payload.recommendation) process.stdout.write(`Recommendation: ${sanitizePlainTextTerminalOutput(payload.recommendation)}\n`);
if (payload.preflight?.status) process.stdout.write(`Preflight status: ${sanitizePlainTextTerminalOutput(payload.preflight.status)}\n`);
}

function printNotificationsHelp() {
process.stdout.write(
[
Expand Down Expand Up @@ -4444,6 +4515,7 @@ function printHelp() {
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 explain-review-risk --repo owner/repo --title <text> [--login <github-login>] [--body <text>] [--json]
loopover-mcp notifications --login <github-login> [--json]
loopover-mcp notifications-read --login <github-login> [--id <delivery-id>]... [--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]
Expand Down
20 changes: 20 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ import {
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 { buildReviewRiskExplanation } from "../signals/review-risk";
import { buildNotificationFeed } from "../notifications/service";
import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk";
import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch";
Expand Down Expand Up @@ -3598,6 +3599,25 @@ export function createApp() {
return c.json(buildPreflightResult(parsed.data, repo, issues, pullRequests, bounties, issueQuality?.report));
});

// #6980: REST mirror of loopover_explain_review_risk — same preflightSchema as /v1/preflight/pr, richer
// payload (preflight + optional roleContext + recommendation + summary). Does NOT pass issueQuality.
app.post("/v1/preflight/review-risk", async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = preflightSchema.safeParse(body);
if (!parsed.success) return c.json({ error: "invalid_preflight_request", issues: parsed.error.issues }, 400);
if (parsed.data.contributorLogin) {
const unauthorized = await requireContributorAccess(c, parsed.data.contributorLogin);
if (unauthorized) return unauthorized;
}
const [repo, issues, pullRequests, bounties] = await Promise.all([
getRepository(c.env, parsed.data.repoFullName),
listIssues(c.env, parsed.data.repoFullName),
listPullRequests(c.env, parsed.data.repoFullName),
listBountiesByRepo(c.env, parsed.data.repoFullName),
]);
return c.json(buildReviewRiskExplanation({ input: parsed.data, repo, issues, pullRequests, bounties }));
});

app.post("/v1/preflight/local-diff", async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = localDiffPreflightSchema.safeParse(body);
Expand Down
23 changes: 6 additions & 17 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,11 @@ import {
buildPrTextLint,
buildQueueHealth,
buildRegistryChangeReport,
buildRoleContext,
} 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 { buildReviewRiskExplanation } from "../signals/review-risk";
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 @@ -4209,24 +4209,13 @@ export class LoopoverMcp {
listPullRequests(this.env, input.repoFullName),
listBountiesByRepo(this.env, input.repoFullName),
]);
const preflight = buildPreflightResult(input, repo, issues, pullRequests, bounties);
const roleContext = input.contributorLogin
? buildRoleContext({ login: input.contributorLogin, repo, repoFullName: input.repoFullName, pullRequests, issues })
: null;
const explanation = buildReviewRiskExplanation({ input, repo, issues, pullRequests, bounties });
return {
summary: `LoopOver review-risk explanation for ${input.repoFullName}.`,
summary: explanation.summary,
data: {
preflight,
roleContext,
recommendation: preflight.collisions.some((cluster) => cluster.risk === "high")
? "likely_duplicate"
: roleContext?.maintainerLane
? "maintainer_lane"
: preflight.status === "needs_work"
? "needs_author"
: preflight.status === "ready"
? "review"
: "watch",
preflight: explanation.preflight,
roleContext: explanation.roleContext,
recommendation: explanation.recommendation,
},
};
}
Expand Down
9 changes: 9 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1908,6 +1908,15 @@ export const RoleContextSchema = z
})
.openapi("RoleContext");

export const ReviewRiskExplanationSchema = z
.object({
preflight: PreflightResultSchema,
roleContext: RoleContextSchema.nullable(),
recommendation: z.enum(["likely_duplicate", "maintainer_lane", "needs_author", "review", "watch"]),
summary: z.string(),
})
.openapi("ReviewRiskExplanation");

const ContributorOutcomeCountsSchema = z.object({
pullRequests: z.number(),
mergedPullRequests: z.number(),
Expand Down
12 changes: 12 additions & 0 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {
RepositorySettingsSchema,
RepoDocRefreshResultSchema,
RoleContextSchema,
ReviewRiskExplanationSchema,
RewardRiskActionSchema,
ScorePreviewSchema,
ScoringModelSnapshotSchema,
Expand Down Expand Up @@ -123,6 +124,7 @@ export function buildOpenApiSpec() {
registry.register("RepoFitRecommendation", RepoFitRecommendationSchema);
registry.register("PreflightResult", PreflightResultSchema);
registry.register("LocalDiffPreflightResult", LocalDiffPreflightResultSchema);
registry.register("ReviewRiskExplanation", ReviewRiskExplanationSchema);
registry.register("LocalBranchAnalysis", LocalBranchAnalysisSchema);
registry.register("MaintainerPacket", MaintainerPacketSchema);
registry.register("MaintainerLaneReport", MaintainerLaneReportSchema);
Expand Down Expand Up @@ -847,6 +849,16 @@ export function buildOpenApiSpec() {
400: { description: "Invalid preflight input" },
},
});
registry.registerPath({
method: "post",
path: "/v1/preflight/review-risk",
summary: "Explain review risk for a planned pull request",
responses: {
200: { description: "Review-risk explanation with preflight, role context, and recommendation", content: { "application/json": { schema: ReviewRiskExplanationSchema } } },
400: { description: "Invalid preflight input" },
403: { description: "Forbidden when contributorLogin does not match the authenticated session" },
},
});
registry.registerPath({
method: "post",
path: "/v1/preflight/local-diff",
Expand Down
56 changes: 56 additions & 0 deletions src/signals/review-risk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { BountyRecord, IssueRecord, PullRequestRecord, RepositoryRecord } from "../types";
import {
buildPreflightResult,
buildRoleContext,
type PreflightInput,
type PreflightResult,
type RoleContext,
} from "./engine";

export type ReviewRiskRecommendation =
| "likely_duplicate"
| "maintainer_lane"
| "needs_author"
| "review"
| "watch";

export type ReviewRiskExplanation = {
preflight: PreflightResult;
roleContext: RoleContext | null;
recommendation: ReviewRiskRecommendation;
summary: string;
};

/**
* Review-risk explanation for a planned PR — shared by `loopover_explain_review_risk`
* and `POST /v1/preflight/review-risk`. Uses the same `buildPreflightResult` core as
* PR preflight (without issueQuality) plus optional per-contributor role context.
*/
export function buildReviewRiskExplanation(args: {
input: PreflightInput;
repo: RepositoryRecord | null;
issues: IssueRecord[];
pullRequests: PullRequestRecord[];
bounties?: BountyRecord[];
}): ReviewRiskExplanation {
const { input, repo, issues, pullRequests, bounties = [] } = args;
const preflight = buildPreflightResult(input, repo, issues, pullRequests, bounties);
const roleContext = input.contributorLogin
? buildRoleContext({ login: input.contributorLogin, repo, repoFullName: input.repoFullName, pullRequests, issues })
: null;
const recommendation: ReviewRiskRecommendation = preflight.collisions.some((cluster) => cluster.risk === "high")
? "likely_duplicate"
: roleContext?.maintainerLane
? "maintainer_lane"
: preflight.status === "needs_work"
? "needs_author"
: preflight.status === "ready"
? "review"
: "watch";
return {
preflight,
roleContext,
recommendation,
summary: `LoopOver review-risk explanation for ${input.repoFullName}.`,
};
}
Loading
Loading