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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,17 @@ jobs:
# vitest.config.ts's own comment describes; Codecov's patch gate (changed-lines only) doesn't cover a
# whole-repo regression outside the diff, so this is what actually restores that backstop for CI, using
# vitest's own --mergeReports against each shard's uploaded blob report.
#
# That "merged = whole-suite" assumption breaks when scoped test selection (#ci-scoped-test-selection,
# see the shard job's own SCOPED_TEST_SELECTION comment above) was active for this run: all 3 shards then
# ran the SAME narrow `--changed=origin/main` subset (not a partition of the ~2,900-test full suite), so
# merging them still only reconstructs that narrow slice's coverage -- correctly high for the files it
# touches, but the threshold judges the WHOLE include set, so it false-fails even a fully-tested scoped
# PR (confirmed live: a 3-file packages/loopover-mcp-only PR with 100% coverage on its own two touched
# test files still reported 0%/80% and failed). This job disables the threshold in that same case, via
# the identical SCOPED_TEST_SELECTION condition -- Codecov's patch gate already enforces real per-line
# coverage on a scoped PR's actual diff regardless, so nothing is lost by skipping the whole-suite
# backstop specifically when it can't see the whole suite.
validate-tests-merge:
name: validate-tests-merge
needs: [changes, validate-tests]
Expand Down Expand Up @@ -1160,6 +1171,12 @@ jobs:
path: all-blob-reports
merge-multiple: true
- name: Merge shard coverage and check the global threshold
env:
# Mirrors validate-tests' own SCOPED_TEST_SELECTION condition exactly (this job already has
# `needs.changes` available). vitest.config.ts checks this var for TRUTHINESS, not `=== 'true'`,
# so the false branch must be an empty string (falsy), not the literal string "false" (which JS
# treats as truthy) -- that's why this is a `&& 'true' || ''` expression, not a bare boolean.
COVERAGE_NO_THRESHOLDS: ${{ (github.event_name == 'pull_request' && vars.SCOPED_TEST_SELECTION_ENABLED != 'false' && needs.changes.outputs.rees != 'true' && needs.changes.outputs.controlPlane != 'true' && needs.changes.outputs.engine != 'true' && needs.changes.outputs.backendConfig != 'true' && (needs.changes.outputs.backend == 'true' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.discoveryIndex == 'true')) && 'true' || '' }}
run: npx vitest run --coverage --mergeReports=all-blob-reports

# Diff-scoped security gate: fails only on vulnerabilities this PR introduces.
Expand Down
26 changes: 26 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,12 @@ const setActionAutonomyShape = {
level: z.enum(MAINTAIN_AUTONOMY_LEVELS),
};

const outcomeCalibrationShape = {
owner: z.string().min(1),
repo: z.string().min(1),
windowDays: z.number().int().positive().optional(),
};

const gatePrecisionShape = {
owner: z.string().min(1),
repo: z.string().min(1),
Expand Down Expand Up @@ -1286,6 +1292,11 @@ const STDIO_TOOL_DESCRIPTORS = [
category: "agent",
description: "Set the autonomy level for one action class via a read-merge-write, so the other classes are left untouched. Same as `loopover-mcp maintain set-level <action> <level>`. Maintainer access required.",
},
{
name: "loopover_get_outcome_calibration",
category: "maintainer",
description: "Return slop-band and recommendation outcome calibration for a repo: whether higher-slop bands merge less often and how agent recommendations are panning out. Optionally bounded by windowDays. Maintainer-authenticated; measurement only.",
},
{
name: "loopover_get_gate_precision",
category: "maintainer",
Expand Down Expand Up @@ -2583,6 +2594,21 @@ registerStdioTool(
},
);

registerStdioTool(
"loopover_get_outcome_calibration",
{
description: stdioToolDescription("loopover_get_outcome_calibration"),
inputSchema: outcomeCalibrationShape,
},
async ({ owner, repo, windowDays }: any) => {
// The schema already rejects a non-positive windowDays, so an omitted window is the only way to full history
// -- matching the route's own behaviour when ?windowDays is absent.
const query = windowDays ? `?windowDays=${encodeURIComponent(windowDays)}` : "";
const payload = await apiGet(`${toolRepoBase(owner, repo)}/outcome-calibration${query}`);
return toolResult(`Outcome calibration for ${owner}/${repo}.`, payload);
},
);

registerStdioTool(
"loopover_get_gate_precision",
{
Expand Down
10 changes: 6 additions & 4 deletions test/unit/mcp-cli-maintain-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ async function connect() {
const apiUrl = await startFixtureServer({
onApiRequest: (request) => {
const url = request.url ?? "";
if (/pending-actions|settings|gate-precision/.test(url)) capturedRequests.push({ url, method: request.method ?? "GET" });
if (/pending-actions|settings|gate-precision|outcome-calibration/.test(url)) capturedRequests.push({ url, method: request.method ?? "GET" });
},
});
transport = new StdioClientTransport({
Expand Down Expand Up @@ -51,23 +51,25 @@ afterEach(async () => {

const REPO = { owner: "owner", repo: "repo" };

/** Every #6152 tool, with an argument set the fixture serves and a field its real payload carries. */
/** Every #6152 tool (plus #7758's outcome-calibration sibling), with an argument set the fixture serves
* and a field its real payload carries. */
const MAINTAIN_TOOLS = [
{ name: "loopover_list_pending_actions", args: REPO, contains: "pa-1" },
{ name: "loopover_decide_pending_action", args: { ...REPO, id: "pa-1", decision: "accept" }, contains: "accepted" },
{ name: "loopover_set_agent_paused", args: { ...REPO, paused: true }, contains: "agentPaused" },
{ name: "loopover_set_action_autonomy", args: { ...REPO, action: "merge", level: "auto" }, contains: "autonomy" },
{ name: "loopover_get_gate_precision", args: REPO, contains: "falsePositiveRate" },
{ name: "loopover_get_outcome_calibration", args: REPO, contains: "positiveRate" },
] as const;

describe("loopover-mcp maintain stdio proxies (#6152)", () => {
it("registers all 5 maintain tools in the stdio server tool list", async () => {
it("registers all 6 maintain tools in the stdio server tool list", async () => {
await connect();
const names = (await client!.listTools()).tools.map((tool) => tool.name);
for (const tool of MAINTAIN_TOOLS) expect(names).toContain(tool.name);
});

it("lists all 5 maintain tools via `loopover-mcp tools --json` with non-empty descriptions", async () => {
it("lists all 6 maintain tools via `loopover-mcp tools --json` with non-empty descriptions", async () => {
await connect();
const payload = JSON.parse(run(["tools", "--json"])) as { tools: Array<{ name: string; description: string; category?: string }> };
for (const tool of MAINTAIN_TOOLS) {
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 @@ -21,6 +21,7 @@
// (#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.)
// (#6980 registered the loopover_explain_review_risk CLI mirror, taking the count from 78 to 79.)
// (#7758 registered the loopover_get_outcome_calibration stdio tool, taking the count from 79 to 80.)
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 @@ -68,14 +69,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

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

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

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