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
54 changes: 50 additions & 4 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ const CLI_COMMAND_SPEC = {
profile: ["list", "create", "switch", "remove"],
cache: ["status", "clear", "list"],
agent: ["plan", "status", "explain", "packet"],
maintain: ["status", "queue", "propose", "approve", "reject", "pause", "resume", "set-level", "precision", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs", "generate-issue-drafts", "plan-issues"],
maintain: ["status", "queue", "propose", "approve", "reject", "pause", "resume", "set-level", "precision", "selftune-audit", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs", "generate-issue-drafts", "plan-issues"],
};
const COMPLETION_SHELLS = ["bash", "zsh", "fish", "powershell"];
const AGENT_PROFILE_IDS = ["miner-planner", "miner-auto-dev", "maintainer-triage", "repo-owner-intake"];
Expand Down Expand Up @@ -943,6 +943,13 @@ const gatePrecisionShape = {
windowDays: z.number().int().positive().optional(),
};

// #7798: mirrors remote loopover_get_selftune_override_audit — optional positive limit for ?limit=.
const selftuneOverrideAuditShape = {
owner: z.string().min(1),
repo: z.string().min(1),
limit: z.number().int().positive().optional(),
};

// #7764: mirrors the remote loopover_plan_repo_issues tool's input (src/mcp/server.ts's planRepoIssuesShape),
// minus the create-only `milestone` which this proxy (and the `maintain plan-issues` CLI) does not expose --
// forwarded to POST /v1/repos/:owner/:repo/issue-plan-drafts/generate. `goal` is the required maintainer
Expand Down Expand Up @@ -1385,6 +1392,12 @@ const STDIO_TOOL_DESCRIPTORS = [
category: "maintainer",
description: "Return per-gate-type false-positive precision for a repo's recorded gate blocks — blocked / blocked-then-merged counts and false-positive rates with low-sample guards. Optionally bounded by windowDays. Maintainer-authenticated; measurement only.",
},
{
name: "loopover_get_selftune_override_audit",
category: "maintainer",
description:
"Return the self-tune override audit trail for a repo: why LOOPOVER_REVIEW_SELFTUNE promoted or applied a live override. Optionally capped by limit. Same as `loopover-mcp maintain selftune-audit`. Maintainer-authenticated; read-only.",
},
{
name: "loopover_plan_repo_issues",
category: "maintainer",
Expand Down Expand Up @@ -2809,7 +2822,22 @@ registerStdioTool(
const payload = await apiGet(`${toolRepoBase(owner, repo)}/gate-precision${query}`);
return toolResult(`Gate precision for ${owner}/${repo}.`, payload);
},
);
);

registerStdioTool(
"loopover_get_selftune_override_audit",
{
description: stdioToolDescription("loopover_get_selftune_override_audit"),
inputSchema: selftuneOverrideAuditShape,
},
async ({ owner, repo, limit }: any) => {
// #7798: proxies GET {repoBase}/selftune/overrides/audit. Schema rejects non-positive limit; omit ?limit
// when absent so the route applies its own default (service default 50).
const query = limit ? `?limit=${encodeURIComponent(limit)}` : "";
const payload = await apiGet(`${toolRepoBase(owner, repo)}/selftune/overrides/audit${query}`);
return toolResult(`Self-tune override audit for ${owner}/${repo}.`, payload);
},
);

registerStdioTool(
"loopover_plan_repo_issues",
Expand Down Expand Up @@ -3426,6 +3454,7 @@ function printMaintainHelp() {
` actions: ${MAINTAIN_ACTION_CLASSES.join(", ")}`,
` levels: ${MAINTAIN_AUTONOMY_LEVELS.join(", ")}`,
" precision [--window-days N] Show gate false-positive telemetry (blocked-then-merged per gate type).",
" selftune-audit [--limit N] Show the self-tune override audit trail (promotions/applies).",
" outcome-calibration Show slop-band merge rates and recommendation-outcome calibration.",
" [--window-days N] Bound the recommendation window (default: full history).",
" onboarding-pack [--refresh] Preview the repo's contributor onboarding pack.",
Expand Down Expand Up @@ -3566,6 +3595,23 @@ export async function maintainCli(args: any) {
emit(payload, lines.join("\n"));
return;
}
if (subcommand === "selftune-audit") {
// #7798: read-only mirror of GET {repoBase}/selftune/overrides/audit (same surface as the remote
// loopover_get_selftune_override_audit tool). Optional --limit mirrors the route's ?limit (a non-positive
// value falls through to the service default server-side). Same emit/--json handling as precision.
const limit = Number(options.limit);
const query = limit > 0 ? `?limit=${encodeURIComponent(limit)}` : "";
const payload = await apiGet(`${repoBase}/selftune/overrides/audit${query}`);
const audit = payload.audit ?? [];
const lines = [
`Self-tune override audit for ${repoFullName}: ${audit.length} event(s).`,
...audit.map((entry: any) =>
sanitizePlainTextTerminalOutput([entry.createdAt, entry.eventType, entry.detail].filter(Boolean).join(" ")),
),
];
emit(payload, lines.join("\n"));
return;
}
if (subcommand === "outcome-calibration") {
// #6735 outcome calibration: read-only measurement of whether higher-slop bands merge less often and how
// agent recommendations panned out. Same --window-days handling the sibling precision command uses (a
Expand Down Expand Up @@ -3702,7 +3748,7 @@ export async function maintainCli(args: any) {
return;
}
throw new Error(
`Unknown maintain subcommand: ${subcommand}. Use status | queue | propose <action-class> <pull-number> | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs | generate-issue-drafts | plan-issues.`,
`Unknown maintain subcommand: ${subcommand}. Use status | queue | propose <action-class> <pull-number> | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | selftune-audit | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs | generate-issue-drafts | plan-issues.`,
);
}

Expand Down Expand Up @@ -4904,7 +4950,7 @@ function printHelp() {
loopover-mcp doctor [--profile name] [--cwd path] [--exit-code] [--json]
loopover-mcp cache status|list|clear [--json]
loopover-mcp init-client --print codex|claude|cursor|mcp|vscode [--agent-profile miner-planner|maintainer-triage|repo-owner-intake] [--json]
loopover-mcp maintain status|queue|approve|reject|pause|resume|set-level|precision|outcome-calibration|onboarding-pack|audit-feed|automation-state|refresh-docs|generate-issue-drafts --repo owner/repo [--json] (see \`loopover-mcp maintain --help\`)
loopover-mcp maintain status|queue|approve|reject|pause|resume|set-level|precision|selftune-audit|outcome-calibration|onboarding-pack|audit-feed|automation-state|refresh-docs|generate-issue-drafts --repo owner/repo [--json] (see \`loopover-mcp maintain --help\`)
loopover-mcp decision-pack --login <github-login> [--json]
loopover-mcp repo-decision --login <github-login> --repo owner/repo [--json]
loopover-mcp contributor-profile [--login <github-login>] [--json]
Expand Down
40 changes: 40 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ import { loadMaintainerLaneReport, maintainerLaneSummary } from "../services/mai
import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack";
import { buildRegistrationReadinessResponse, buildGittensorConfigRecommendationResponse } from "../api/routes";
import { loadGatePrecisionReport } from "../services/gate-precision";
import { listOverrideAudit, type StorageEnv as AutoApplyStorageEnv } from "../review/auto-apply";
import { buildUnavailableQueueTrendReport } from "../services/queue-trends";
import {
applyMcpPlanningChoices,
Expand Down Expand Up @@ -230,6 +231,13 @@ const ownerRepoWindowShape = {
windowDays: z.number().int().positive().optional(),
};

// #7798 - optional `limit` matches GET .../selftune/overrides/audit?limit= (positive only; omitted → service default).
const ownerRepoLimitShape = {
owner: z.string().min(1),
repo: z.string().min(1),
limit: z.number().int().positive().optional(),
};

const windowOnlyShape = {
windowDays: z.number().int().positive().optional(),
};
Expand Down Expand Up @@ -1052,6 +1060,12 @@ const gatePrecisionOutputSchema = {
signals: z.array(z.string()).optional(),
};

// #7798 - self-tune override audit trail (mirrors GET .../selftune/overrides/audit).
const selftuneOverrideAuditOutputSchema = {
repoFullName: z.string().optional(),
audit: z.array(z.unknown()).optional(),
};

// #5825 - maintainer-authenticated skipped-PR audit trail, mirroring GET /v1/app/skipped-pr-audit's
// filters (all optional: a bare call returns the caller's own repo-scoped feed). No owner/repo shape
// here on purpose: unlike ownerRepoShape tools this report can legitimately span every repo the caller
Expand Down Expand Up @@ -1897,6 +1911,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
loopover_get_repo_outcome_patterns: "maintainer",
loopover_get_outcome_calibration: "maintainer",
loopover_get_gate_precision: "maintainer",
loopover_get_selftune_override_audit: "maintainer",
loopover_get_skipped_pr_audit: "maintainer",
loopover_get_fleet_analytics: "maintainer",
loopover_get_recommendation_quality: "maintainer",
Expand Down Expand Up @@ -2152,6 +2167,17 @@ export class LoopoverMcp {
async (input) => this.toolResult(await this.getGatePrecision(input)),
);

register(
"loopover_get_selftune_override_audit",
{
description:
"Return the self-tune override audit trail for a repo: why LOOPOVER_REVIEW_SELFTUNE promoted or applied a live override (event type, detail, timestamp). Optionally capped by limit. Maintainer-authenticated; read-only measurement.",
inputSchema: ownerRepoLimitShape,
outputSchema: selftuneOverrideAuditOutputSchema,
},
async (input) => this.toolResult(await this.getSelftuneOverrideAudit(input)),
);

register(
"loopover_get_skipped_pr_audit",
{
Expand Down Expand Up @@ -3834,6 +3860,20 @@ export class LoopoverMcp {
};
}

// #7798 - surface GET .../selftune/overrides/audit over MCP. Same per-repo read gate as getGatePrecision
// (requireRepoAccess); listOverrideAudit is the same service the REST route calls. Optional limit is
// forwarded when present; when omitted, listOverrideAudit's default (50) applies — matching the route
// when ?limit is missing.
private async getSelftuneOverrideAudit(input: { owner: string; repo: string; limit?: number | undefined }): Promise<ToolPayload> {
const fullName = `${input.owner}/${input.repo}`;
await this.requireRepoAccess(fullName);
const audit = await listOverrideAudit(this.env as unknown as AutoApplyStorageEnv, fullName, input.limit);
return {
summary: `LoopOver self-tune override audit for ${fullName}: ${audit.length} event(s).`,
data: { repoFullName: fullName, audit },
};
}

// #5825 - repo-scope resolution for the skipped-PR audit tool. Mirrors skippedPrAuditRepoScope in
// src/api/routes.ts (same underlying loadControlPanelRoleSummary/loadControlPanelAccessScope calls,
// same maintainer/owner/operator role gate, same "no filter -> caller's own scoped repos" fallback),
Expand Down
2 changes: 2 additions & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5532,6 +5532,7 @@ describe("api routes", () => {
expect(toolNames).toContain("loopover_get_gate_config_effective");
expect(toolNames).toContain("loopover_get_ams_miner_cohort");
expect(toolNames).toContain("loopover_get_repo_focus_manifest");
expect(toolNames).toContain("loopover_get_selftune_override_audit");
expect(toolNames).toContain("loopover_get_pr_maintainer_packet");
expect(toolNames).toContain("loopover_explain_review_risk");
expect(toolNames).toContain("loopover_compare_pr_variants");
Expand Down Expand Up @@ -5811,6 +5812,7 @@ describe("api routes", () => {
["loopover_get_gate_config_effective", { owner: "entrius", repo: "allways-ui" }],
["loopover_get_ams_miner_cohort", { owner: "entrius", repo: "allways-ui" }],
["loopover_get_repo_focus_manifest", { owner: "entrius", repo: "allways-ui" }],
["loopover_get_selftune_override_audit", { owner: "entrius", repo: "allways-ui" }],
["loopover_get_pr_maintainer_packet", { owner: "entrius", repo: "allways-ui", number: 12 }],
[
"loopover_preview_local_pr_score",
Expand Down
2 changes: 1 addition & 1 deletion test/unit/mcp-cli-basics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ describe("loopover-mcp CLI — basics", () => {
expect(ps).toContain("[System.Management.Automation.CompletionResult]::new");
expect(ps).toContain("$commands = @('login', 'logout'");
expect(ps).toContain(
"'maintain' = @('status', 'queue', 'propose', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision', 'outcome-calibration', 'onboarding-pack', 'audit-feed', 'automation-state', 'refresh-docs', 'generate-issue-drafts', 'plan-issues')",
"'maintain' = @('status', 'queue', 'propose', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision', 'selftune-audit', 'outcome-calibration', 'onboarding-pack', 'audit-feed', 'automation-state', 'refresh-docs', 'generate-issue-drafts', 'plan-issues')",
);
});

Expand Down
14 changes: 14 additions & 0 deletions test/unit/mcp-cli-maintain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ describe("loopover-mcp CLI — maintain (#784)", () => {
expect(scoped).toMatch(/Gate precision for owner\/repo \(last 30d\)/);
});

it("selftune-audit reports the override audit trail (plain + json), passing --limit through (#7798)", async () => {
const e = await env();
const out = await runAsync(["maintain", "selftune-audit", "--repo", "owner/repo"], e);
expect(out).toMatch(/Self-tune override audit for owner\/repo: 3 event\(s\)\./);
expect(out).toMatch(/override_promoted/);
expect(out).toMatch(/override_shadowed/);
const json = JSON.parse(await runAsync(["maintain", "selftune-audit", "--repo", "owner/repo", "--json"], e)) as {
audit: Array<{ eventType: string }>;
};
expect(json.audit).toHaveLength(3);
const limited = await runAsync(["maintain", "selftune-audit", "--repo", "owner/repo", "--limit", "1"], e);
expect(limited).toMatch(/Self-tune override audit for owner\/repo: 1 event\(s\)\./);
});

it("generate-issue-drafts dry-runs by default and never forwards create (#6757)", async () => {
const bodies: Array<{ dryRun?: boolean; create?: boolean; limit?: number }> = [];
const e = await env({ onIssueDraftRequest: (b) => bodies.push(b) });
Expand Down
87 changes: 87 additions & 0 deletions test/unit/mcp-cli-selftune-override-audit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";

// #7798: in-process coverage for the loopover_get_selftune_override_audit stdio tool.
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;

type BinModule = {
server: { connect: (transport: unknown) => Promise<void> };
};

let tempDir = "";
const capturedRequests: Array<{ url: string; method: string }> = [];
const loaded = new Map<string, BinModule>();

beforeAll(async () => {
tempDir = mkdtempSync(join(tmpdir(), "loopover-selftune-audit-"));
const apiUrl = await startFixtureServer({
onApiRequest: (request) => {
if (request.url && request.url.includes("/selftune/overrides/audit")) {
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
}
},
});
process.env.LOOPOVER_API_URL = apiUrl;
process.env.LOOPOVER_API_TOKEN = "in-process-token";
process.env.LOOPOVER_API_TIMEOUT_MS = "2000";
process.env.LOOPOVER_CONFIG_DIR = tempDir;
process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1";
for (const specifier of MODULES) {
loaded.set(specifier, (await import(specifier)) as unknown as BinModule);
}
}, 120_000);

afterAll(async () => {
await closeFixtureServer();
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
delete process.env.LOOPOVER_API_URL;
delete process.env.LOOPOVER_API_TOKEN;
delete process.env.LOOPOVER_CONFIG_DIR;
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
});

describe("bin loopover_get_selftune_override_audit stdio tool (in-process, #7798)", () => {
it.each(MODULES)("registers and proxies GET .../selftune/overrides/audit — %s", async (specifier) => {
capturedRequests.length = 0;
const mod = loaded.get(specifier)!;
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mod.server.connect(serverTransport);
const client = new Client({ name: "selftune-audit-test", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
try {
const { tools } = await client.listTools();
const tool = tools.find((entry) => entry.name === "loopover_get_selftune_override_audit");
expect(tool).toBeDefined();
expect(tool?.description).toMatch(/self-tune override audit/i);

const unlimited = await client.callTool({
name: "loopover_get_selftune_override_audit",
arguments: { owner: "owner", repo: "repo" },
});
expect(capturedRequests.length).toBe(1);
expect(capturedRequests[0]!.url).toContain("/v1/repos/owner/repo/selftune/overrides/audit");
expect(capturedRequests[0]!.url).not.toContain("limit=");
expect(capturedRequests[0]!.method).toBe("GET");
expect(unlimited.isError).toBeFalsy();
expect(JSON.stringify(unlimited)).toContain("override_promoted");

capturedRequests.length = 0;
const limited = await client.callTool({
name: "loopover_get_selftune_override_audit",
arguments: { owner: "owner", repo: "repo", limit: 1 },
});
expect(capturedRequests.length).toBe(1);
expect(capturedRequests[0]!.url).toContain("limit=1");
expect(limited.isError).toBeFalsy();
const data = limited.structuredContent as { audit: unknown[] };
expect(data.audit).toHaveLength(1);
} finally {
await client.close().catch(() => undefined);
}
});
});
1 change: 1 addition & 0 deletions test/unit/mcp-output-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [
"loopover_get_eligibility_plan",
"loopover_simulate_open_pr_pressure",
"loopover_get_gate_precision",
"loopover_get_selftune_override_audit",
"loopover_get_skipped_pr_audit",
];

Expand Down
Loading