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
95 changes: 61 additions & 34 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,11 @@ const STDIO_TOOL_DESCRIPTORS = [
category: "review",
description: "Explain a private score preview multiplier-by-multiplier with plain-English levers and the highest-impact improvement.",
},
{
name: "loopover_get_eligibility_plan",
category: "discovery",
description: "Derive a structured eligibility plan from local score-preview metadata: whether the branch/PR is eligible now, public-safe blockers, and cleanup paths. Advisory dry-run only — no GitHub writes.",
},
{
name: "loopover_get_decision_pack",
category: "discovery",
Expand Down Expand Up @@ -1509,6 +1514,46 @@ registerStdioTool(
async (input) => toolResult("LoopOver private local PR scoring preview.", await previewLocalScore(await withClientWorkspaceRoots(input))),
);

// Shared by loopover_explain_score_breakdown and loopover_get_eligibility_plan (#6621): both resolve the same
// local branch/diff metadata into the /v1/scoring request body — only the endpoint they POST it to differs, so
// the assembly lives here once rather than in two drifting copies.
function buildLocalScoreRequestBody(workspaceInput, contributorLogin) {
const workspace = resolveWorkspaceCwd(workspaceInput);
const diff = collectLocalDiff(workspace.cwd, workspaceInput.baseRef, workspaceInput.workspaceRoots);
const branchPayload = buildBranchAnalysisPayload({
...workspaceInput,
login: contributorLogin,
cwd: workspace.cwd,
repoFullName: workspaceInput.repoFullName,
baseRef: workspaceInput.baseRef,
});
const upstreamPreview = branchPayload.localScorerStatus;
const estimatedSourceLines = workspaceInput.sourceLines ?? Math.max(1, diff.changedLineCount - diff.testFiles.length);
return {
repoFullName: workspaceInput.repoFullName,
targetType: "local_diff",
targetKey: workspaceInput.targetKey ?? localDiffTargetKey(branchPayload, workspaceInput.baseRef),
contributorLogin,
labels: workspaceInput.labels,
linkedIssueMode: workspaceInput.linkedIssueMode,
sourceTokenScore: workspaceInput.sourceTokenScore ?? estimatedSourceLines,
sourceLines: estimatedSourceLines,
totalTokenScore: workspaceInput.totalTokenScore ?? diff.changedLineCount,
testTokenScore: diff.testFiles.length,
openPrCount: workspaceInput.openPrCount,
credibility: workspaceInput.credibility,
changesRequestedCount: workspaceInput.changesRequestedCount,
pendingMergedPrCount: workspaceInput.pendingMergedPrCount,
pendingClosedPrCount: workspaceInput.pendingClosedPrCount,
approvedPrCount: workspaceInput.approvedPrCount,
expectedOpenPrCountAfterMerge: workspaceInput.expectedOpenPrCountAfterMerge,
projectedCredibility: workspaceInput.projectedCredibility,
scenarioNotes: workspaceInput.scenarioNotes,
branchEligibility: workspaceInput.branchEligibility,
metadataOnly: !upstreamPreview.ok,
};
}

registerStdioTool(
"loopover_explain_score_breakdown",
{
Expand All @@ -1519,44 +1564,26 @@ registerStdioTool(
const workspaceInput = await withClientWorkspaceRoots(input);
const contributorLogin = workspaceInput.contributorLogin ?? activeProfile.session?.login;
if (!contributorLogin) throw new Error("contributorLogin is required for score breakdown.");
const workspace = resolveWorkspaceCwd(workspaceInput);
const diff = collectLocalDiff(workspace.cwd, workspaceInput.baseRef, workspaceInput.workspaceRoots);
const branchPayload = buildBranchAnalysisPayload({
...workspaceInput,
login: contributorLogin,
cwd: workspace.cwd,
repoFullName: workspaceInput.repoFullName,
baseRef: workspaceInput.baseRef,
});
const upstreamPreview = branchPayload.localScorerStatus;
const estimatedSourceLines = workspaceInput.sourceLines ?? Math.max(1, diff.changedLineCount - diff.testFiles.length);
const body = {
repoFullName: workspaceInput.repoFullName,
targetType: "local_diff",
targetKey: workspaceInput.targetKey ?? localDiffTargetKey(branchPayload, workspaceInput.baseRef),
contributorLogin,
labels: workspaceInput.labels,
linkedIssueMode: workspaceInput.linkedIssueMode,
sourceTokenScore: workspaceInput.sourceTokenScore ?? estimatedSourceLines,
sourceLines: estimatedSourceLines,
totalTokenScore: workspaceInput.totalTokenScore ?? diff.changedLineCount,
testTokenScore: diff.testFiles.length,
openPrCount: workspaceInput.openPrCount,
credibility: workspaceInput.credibility,
changesRequestedCount: workspaceInput.changesRequestedCount,
pendingMergedPrCount: workspaceInput.pendingMergedPrCount,
pendingClosedPrCount: workspaceInput.pendingClosedPrCount,
approvedPrCount: workspaceInput.approvedPrCount,
expectedOpenPrCountAfterMerge: workspaceInput.expectedOpenPrCountAfterMerge,
projectedCredibility: workspaceInput.projectedCredibility,
scenarioNotes: workspaceInput.scenarioNotes,
branchEligibility: workspaceInput.branchEligibility,
metadataOnly: !upstreamPreview.ok,
};
const body = buildLocalScoreRequestBody(workspaceInput, contributorLogin);
return toolResult("LoopOver private score breakdown.", await apiPost("/v1/scoring/explain-breakdown", body));
},
);

registerStdioTool(
"loopover_get_eligibility_plan",
{
description: stdioToolDescription("loopover_get_eligibility_plan"),
inputSchema: localScoreShape,
},
async (input) => {
const workspaceInput = await withClientWorkspaceRoots(input);
const contributorLogin = workspaceInput.contributorLogin ?? activeProfile.session?.login;
if (!contributorLogin) throw new Error("contributorLogin is required for the eligibility plan.");
const body = buildLocalScoreRequestBody(workspaceInput, contributorLogin);
return toolResult("LoopOver private eligibility plan.", await apiPost("/v1/scoring/eligibility-plan", body));
},
);

registerStdioTool(
"loopover_get_decision_pack",
{
Expand Down
24 changes: 24 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ import { buildRemediationPlan } from "../services/remediation-plan";
import { handleDraftCreate, handleDraftOAuthCallback, handleDraftStatus } from "../services/draft";
import { decidePendingAgentAction } from "../services/agent-approval-queue";
import { explainScoreBreakdown } from "../services/score-breakdown";
import { deriveEligibilityPlan } from "../services/eligibility-plan";
import { buildMcpClientTelemetry } from "../services/client-telemetry";
import {
authoritativeContributorRepoStats,
Expand Down Expand Up @@ -2113,6 +2114,29 @@ export function createApp() {
return c.json(explainScoreBreakdown(preview));
});

app.post("/v1/scoring/eligibility-plan", async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = scorePreviewSchema.safeParse(body);
if (!parsed.success) return c.json({ error: "invalid_scoring_preview_request", issues: parsed.error.issues }, 400);
// Like /v1/scoring/preview (and loopover_get_eligibility_plan's own MCP handler), the contributor gate is
// conditional on contributorLogin being supplied — not unconditionally required as in explain-breakdown.
if (parsed.data.contributorLogin) {
const unauthorized = await requireContributorAccess(c, parsed.data.contributorLogin);
if (unauthorized) return unauthorized;
}
const [repo, snapshot, evidence, contributorIssues] = await Promise.all([
getRepository(c.env, parsed.data.repoFullName),
getOrCreateScoringModelSnapshot(c.env),
parsed.data.contributorLogin ? getContributorEvidence(c.env, parsed.data.contributorLogin) : Promise.resolve(null),
parsed.data.contributorLogin ? listContributorIssues(c.env, parsed.data.contributorLogin) : Promise.resolve([]),
]);
const openIssueCount = contributorOpenIssueCount(contributorIssues, parsed.data.repoFullName);
// Time-decay (#703) is an owner-gated global, injected server-side (not caller-controllable).
const input = { ...parsed.data, openIssueCount, applyTimeDecay: isTimeDecayEnabled(c.env) };
const preview = buildScorePreview({ input, repo, snapshot, contributorEvidence: evidence });
return c.json(deriveEligibilityPlan(preview));
});

app.get("/v1/sync/status", async (c) => {
const [snapshot, scoringSnapshot, repositories, segments, totals, detailStates, installations, rateLimits, signalSnapshots, bounties, upstreamDrift] = await Promise.all([
getLatestRegistrySnapshot(c.env),
Expand Down
32 changes: 32 additions & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2012,6 +2012,37 @@ describe("api routes", () => {
expect(missingContributorBreakdown.status).toBe(400);
await expect(missingContributorBreakdown.json()).resolves.toMatchObject({ error: "contributor_login_required" });

// #6621: /v1/scoring/eligibility-plan reuses the same fetch/build as explain-breakdown but returns a
// deriveEligibilityPlan verdict, and — like /v1/scoring/preview — treats contributorLogin as optional.
const eligibilityPlan = await app.request(
"/v1/scoring/eligibility-plan",
{ method: "POST", headers: apiHeaders(env), body: JSON.stringify(agedScoreInput) },
env,
);
expect(eligibilityPlan.status).toBe(200);
const eligibilityPlanBody = (await eligibilityPlan.json()) as {
eligible: boolean;
branchEligibilityStatus: string;
blockers: string[];
cleanupPaths: string[];
};
expect(eligibilityPlanBody).toMatchObject({
eligible: expect.any(Boolean),
branchEligibilityStatus: expect.any(String),
blockers: expect.any(Array),
cleanupPaths: expect.any(Array),
});

// Unlike explain-breakdown (which 400s without a contributorLogin), the eligibility plan omits the
// contributor gate when no login is supplied — the conditional path shared with /v1/scoring/preview.
const anonymousEligibilityPlan = await app.request(
"/v1/scoring/eligibility-plan",
{ method: "POST", headers: apiHeaders(env), body: JSON.stringify({ repoFullName: "entrius/allways-ui", sourceTokenScore: 42 }) },
env,
);
expect(anonymousEligibilityPlan.status).toBe(200);
await expect(anonymousEligibilityPlan.json()).resolves.toMatchObject({ eligible: expect.any(Boolean), blockers: expect.any(Array) });

for (const [signalType, payload] of [
["queue-health", { repoFullName: "entrius/allways-ui", signals: { openPullRequests: 2 } }],
["config-quality", { repoFullName: "entrius/allways-ui", notObservedConfiguredLabels: ["refactor"] }],
Expand Down Expand Up @@ -4543,6 +4574,7 @@ describe("api routes", () => {

for (const [path, error] of [
["/v1/scoring/preview", "invalid_scoring_preview_request"],
["/v1/scoring/eligibility-plan", "invalid_scoring_preview_request"],
["/v1/agent/runs", "invalid_agent_run_request"],
["/v1/agent/plan-next-work", "invalid_agent_plan_request"],
["/v1/agent/preflight-branch", "invalid_agent_preflight_branch_request"],
Expand Down
13 changes: 13 additions & 0 deletions test/integration/routes-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,19 @@ describe("api route guards and error branches", () => {
expect(victimScorePreview.status).toBe(403);
await expect(victimScorePreview.json()).resolves.toMatchObject({ error: "forbidden_contributor" });

// #6621: /v1/scoring/eligibility-plan applies the same contributor gate as /v1/scoring/preview.
const victimEligibilityPlan = await app.request(
"/v1/scoring/eligibility-plan",
{
method: "POST",
headers: sessionHeaders,
body: JSON.stringify({ repoFullName: "owner/private-repo", contributorLogin: "victim", metadataOnly: true }),
},
env,
);
expect(victimEligibilityPlan.status).toBe(403);
await expect(victimEligibilityPlan.json()).resolves.toMatchObject({ error: "forbidden_contributor" });

const victimBranchPayload = {
login: "victim",
repoFullName: "owner/private-repo",
Expand Down
11 changes: 6 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// `tools --json` listing stays in lockstep with what the live server actually registers.
// (#6152 registered the 5 maintain-surface tools, taking the count from 42 to 47.)
// (#6150 registered the local-scorer and plan-DAG/predict-gate tools, taking the count from 55 to 60.)
// (#6621 registered the loopover_get_eligibility_plan REST/CLI mirror, taking the count from 60 to 61.)
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
Expand Down Expand Up @@ -48,14 +49,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

it("lists exactly 60 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
it("lists exactly 61 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(60);
expect(primary.length).toBe(61);
expect(legacy.length).toBe(0);
expect(names.length).toBe(60);
expect(names.length).toBe(61);
});

it("no loopover_ tool's description carries a stale deprecation notice", async () => {
Expand All @@ -65,11 +66,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
}
});

it("`loopover-mcp tools --json` reports the same 60-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 61-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(60);
expect(payload.count).toBe(61);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort());
});
});
Expand Down