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
28 changes: 28 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1070,6 +1070,14 @@ const selftuneOverrideAuditShape = {
limit: z.number().int().positive().optional(),
};

// #9300: write-side sibling of selftuneOverrideAuditShape — mirrors src/mcp/server.ts's
// clearSelftuneOverrideShape (`confirm` must be the literal true; omitted/false is schema-rejected).
const clearSelftuneOverrideShape = {
owner: z.string().min(1),
repo: z.string().min(1),
confirm: z.literal(true),
};

// #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 @@ -1583,6 +1591,12 @@ const STDIO_TOOL_DESCRIPTORS = [
description:
"Return the self-tune override audit trail for a repo — why the self-tune loop promoted, shadowed, or cleared a live gate override, newest first. Optionally capped by limit. Maintainer-authenticated; read-only measurement.",
},
{
name: "loopover_clear_selftune_override",
category: "maintainer",
description:
"Clear a repo's LIVE self-tune gate override (the operator's \"reset to config base\" control), mirroring DELETE /v1/repos/:owner/:repo/selftune/overrides. Requires confirm:true; the automatic self-tune promote path is untouched. Maintainer access required.",
},
{
name: "loopover_get_automation_state",
category: "agent",
Expand Down Expand Up @@ -3192,6 +3206,20 @@ registerStdioTool(
},
);

// #9300: write-side sibling of loopover_get_selftune_override_audit — DELETE {repoBase}/selftune/overrides
// with confirm:true (schema-enforced; never silently defaulted). Same apiDelete helper the unwatch action uses.
registerStdioTool(
"loopover_clear_selftune_override",
{
description: stdioToolDescription("loopover_clear_selftune_override"),
inputSchema: clearSelftuneOverrideShape,
},
async ({ owner, repo, confirm }: any) => {
const payload = await apiDelete(`${toolRepoBase(owner, repo)}/selftune/overrides`, { confirm });
return toolResult(`Cleared the live self-tune gate override for ${owner}/${repo}.`, payload);
},
);

// #7752: read-side counterpart to the pause/resume/set-level write tools above. Proxies the same
// GET {repoBase}/automation-state the `maintain automation-state` CLI already calls — no duplicated HTTP path.
// Summary is intentionally branch-free (no ?? / ?. / ternaries) so codecov/patch stays at 100%; the full
Expand Down
121 changes: 121 additions & 0 deletions test/unit/mcp-cli-clear-selftune-override.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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";

// #9300: in-process coverage for loopover_clear_selftune_override in packages/loopover-mcp/bin/loopover-mcp.ts.
// Same entrypoint-guard pattern as mcp-cli-selftune-audit — import the committed .ts so v8/Codecov
// attributes the new registerStdioTool + apiDelete lines.
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;

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

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

beforeAll(async () => {
tempDir = mkdtempSync(join(tmpdir(), "loopover-clear-selftune-"));
const apiUrl = await startFixtureServer({
onClearSelftuneOverride: (body) => {
capturedDeletes.push({
url: "/v1/repos/owner/repo/selftune/overrides",
method: "DELETE",
body,
});
},
});
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_clear_selftune_override stdio tool (in-process, #9300)", () => {
it.each(MODULES)("registers and proxies DELETE .../selftune/overrides with confirm:true — %s", async (specifier) => {
capturedDeletes.length = 0;
const mod = loaded.get(specifier)!;
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mod.server.connect(serverTransport);
const client = new Client({ name: "clear-selftune-test", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
try {
const { tools } = await client.listTools();
const tool = tools.find((entry) => entry.name === "loopover_clear_selftune_override");
expect(tool).toBeDefined();
expect(tool?.description).toMatch(/clear.*self-tune.*override/i);

const result = await client.callTool({
name: "loopover_clear_selftune_override",
arguments: { owner: "owner", repo: "repo", confirm: true },
});
expect(result.isError).toBeFalsy();
expect(capturedDeletes).toEqual([
{
url: "/v1/repos/owner/repo/selftune/overrides",
method: "DELETE",
body: { confirm: true },
},
]);
expect(JSON.stringify(result)).toMatch(/Cleared the live self-tune gate override for owner\/repo/);
expect(JSON.stringify(result)).toContain('"cleared":true');
} finally {
await client.close().catch(() => undefined);
}
});

it.each(MODULES)("rejects missing or false confirm without calling DELETE — %s", async (specifier) => {
capturedDeletes.length = 0;
const mod = loaded.get(specifier)!;
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mod.server.connect(serverTransport);
const client = new Client({ name: "clear-selftune-reject-test", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
try {
const missing = await client
.callTool({
name: "loopover_clear_selftune_override",
arguments: { owner: "owner", repo: "repo" },
})
.then(
(r) => ({ isError: Boolean(r.isError), text: JSON.stringify(r) }),
(e: unknown) => ({ isError: true, text: String(e) }),
);
expect(missing.isError).toBe(true);

const falsy = await client
.callTool({
name: "loopover_clear_selftune_override",
arguments: { owner: "owner", repo: "repo", confirm: false },
})
.then(
(r) => ({ isError: Boolean(r.isError), text: JSON.stringify(r) }),
(e: unknown) => ({ isError: true, text: String(e) }),
);
expect(falsy.isError).toBe(true);

// Schema rejection must never reach the REST route.
expect(capturedDeletes).toEqual([]);
} finally {
await client.close().catch(() => undefined);
}
});
});
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 @@ -43,6 +43,7 @@
// (#7755 registered the loopover_generate_contributor_issue_drafts stdio tool, taking the count from 98 to 99.)
// (#7753 registered the loopover_propose_action stdio tool, taking the count from 99 to 100.)
// (#7798 registered the loopover_get_selftune_override_audit remote+stdio tool, taking the count from 100 to 101.)
// (#9300 registered the loopover_clear_selftune_override CLI mirror, taking the count from 101 to 102.)
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 @@ -89,14 +90,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

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

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

it("`loopover-mcp tools --json` reports the same 101-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 102-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(101);
expect(payload.count).toBe(102);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
[...tools.map((t) => t.name)].sort(),
);
Expand Down
9 changes: 9 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ export async function startFixtureServer(
onPlanIssuesRequest?: (body: { goal?: string; dryRun?: boolean; create?: boolean; limit?: number }) => void;
onWatchRequest?: (req: { method: string; body: { repoFullName?: string; labels?: string[] } }) => void;
onApiRequest?: (request: IncomingMessage) => void;
/** #9300: captures DELETE /v1/repos/:owner/:repo/selftune/overrides body ({ confirm }). */
onClearSelftuneOverride?: (body: { confirm?: boolean }) => void;
validateConfigWarnings?: string[];
openPrMonitor?: Record<string, unknown>;
prOutcomes?: Record<string, unknown>;
Expand Down Expand Up @@ -748,6 +750,13 @@ export async function startFixtureServer(
response.end(JSON.stringify({ repoFullName: "owner/bare" }));
return;
}
// #9300: clear live self-tune override (write-side sibling of the audit GET above).
if (request.url?.startsWith("/v1/repos/owner/repo/selftune/overrides") && !request.url.includes("/audit") && request.method === "DELETE") {
const body = (await readJsonRequest(request)) as { confirm?: boolean };
options.onClearSelftuneOverride?.(body);
response.end(JSON.stringify({ repoFullName: "owner/repo", cleared: true }));
return;
}
if (request.url?.startsWith("/v1/repos/owner/repo/outcome-calibration") && request.method === "GET") {
const windowDays = new URL(request.url, "http://localhost").searchParams.get("windowDays");
response.end(
Expand Down