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
59 changes: 59 additions & 0 deletions packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const CLI_COMMAND_SPEC = {
profile: ["list", "create", "switch", "remove"],
cache: ["status", "clear"],
agent: ["plan", "status", "explain", "packet"],
maintain: ["status", "approve", "reject", "pause", "resume"],
};
const COMPLETION_SHELLS = ["bash", "zsh", "fish"];
const AGENT_PROFILE_IDS = ["miner-planner", "miner-auto-dev", "maintainer-triage", "repo-owner-intake"];
Expand Down Expand Up @@ -1307,13 +1308,71 @@ function workspaceRootStatus(roots) {
};
}

function printMaintainHelp() {
process.stdout.write(
[
"Usage: gittensory-mcp maintain <subcommand> --repo owner/repo",
"",
"Maintainer controls for the agent auto-maintain layer (requires maintainer access; run `gittensory-mcp login`).",
"",
"Subcommands:",
" status List the agent approval queue (auto_with_approval actions awaiting a decision).",
" approve <id> Approve a staged action -> execute it.",
" reject <id> Reject a staged action -> cancel it.",
" pause Pause ALL agent actions on the repo (kill-switch).",
" resume Resume agent actions on the repo.",
"",
"Pass --json for machine-readable output.",
].join("\n") + "\n",
);
}

// #784 maintainer CLI controls — thin proxies over the agent approval-queue API (#779) and the maintainer
// settings kill-switch (#130). The API enforces maintainer authorization; the CLI never decides locally.
async function maintainCli(args) {
const subcommand = args[0];
if (!subcommand || subcommand === "--help" || subcommand === "help") return printMaintainHelp();
const positional = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
const options = parseOptions(args.slice(1));
const repoFullName = options.repo;
if (!repoFullName || !repoFullName.includes("/")) throw new Error("Pass --repo owner/repo.");
const [owner, repo] = repoFullName.split("/", 2);
const repoBase = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
const queueBase = `${repoBase}/agent/pending-actions`;
const emit = (payload, line) => {
if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
else process.stdout.write(`${line}\n`);
};
if (subcommand === "status") {
const payload = await apiGet(queueBase);
const actions = payload.pendingActions ?? [];
emit(payload, [`Agent approval queue for ${repoFullName}: ${actions.length} pending.`, ...actions.map((action) => `- ${action.id} ${action.actionClass} on #${action.pullNumber} ${action.reason ?? ""}`)].join("\n"));
return;
}
if (subcommand === "approve" || subcommand === "reject") {
if (!positional) throw new Error(`Pass the pending-action id: gittensory-mcp maintain ${subcommand} <id> --repo owner/repo.`);
// The approval-queue route's decision verb is accept|reject (#779); the CLI exposes approve|reject.
const decision = subcommand === "approve" ? "accept" : "reject";
const payload = await apiPost(`${queueBase}/${encodeURIComponent(positional)}/${decision}`, {});
emit(payload, `${subcommand === "approve" ? "Accepted" : "Rejected"} ${positional}: ${payload.status ?? "ok"}${payload.executionOutcome ? ` (${payload.executionOutcome})` : ""}.`);
return;
}
if (subcommand === "pause" || subcommand === "resume") {
const payload = await apiFetch(`${repoBase}/settings`, { method: "PUT", body: JSON.stringify({ agentPaused: subcommand === "pause" }) });
emit(payload, `Agent actions ${subcommand === "pause" ? "paused" : "resumed"} for ${repoFullName}.`);
return;
}
throw new Error(`Unknown maintain subcommand: ${subcommand}. Use status | approve <id> | reject <id> | pause | resume.`);
}

async function runCli(args) {
const command = args[0];
if (command === "--help" || command === "help") return printHelp();
if (command === "--version" || command === "-v" || command === "version") return printVersion(parseOptions(args.slice(1)));
if (command === "completion") return completionCommand(args.slice(1));
if (command === "agent") return runAgentCli(args.slice(1));
if (command === "cache") return runCacheCli(args.slice(1));
if (command === "maintain") return maintainCli(args.slice(1));
const options = parseOptions(args.slice(1));
if (command === "login") return login(options);
if (command === "logout") return logout(options);
Expand Down
57 changes: 57 additions & 0 deletions test/unit/mcp-cli-maintain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { closeFixtureServer, runAsync, startFixtureServer } from "./support/mcp-cli-harness";

describe("gittensory-mcp CLI — maintain (#784)", () => {
let tempDir: string | null = null;

afterEach(async () => {
await closeFixtureServer();
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
tempDir = null;
});

async function env() {
tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-"));
const url = await startFixtureServer();
return { GITTENSORY_API_URL: url, GITTENSORY_TOKEN: "session-token", GITTENSORY_CONFIG_DIR: tempDir, GITTENSORY_API_TIMEOUT_MS: "1000" };
}

it("status lists the agent approval queue (plain + json)", async () => {
const e = await env();
const out = await runAsync(["maintain", "status", "--repo", "owner/repo"], e);
expect(out).toMatch(/Agent approval queue for owner\/repo: 1 pending/);
expect(out).toMatch(/pa-1\s+merge on #7\s+clean/);
const json = JSON.parse(await runAsync(["maintain", "status", "--repo", "owner/repo", "--json"], e)) as { pendingActions: Array<{ id: string; actionClass: string }> };
expect(json.pendingActions[0]).toMatchObject({ id: "pa-1", actionClass: "merge" });
});

it("approve executes a staged action; reject cancels one", async () => {
const e = await env();
expect(await runAsync(["maintain", "approve", "pa-1", "--repo", "owner/repo"], e)).toMatch(/Accepted pa-1: accepted \(completed\)/);
expect(await runAsync(["maintain", "reject", "pa-1", "--repo", "owner/repo"], e)).toMatch(/Rejected pa-1: rejected/);
});

it("pause and resume toggle the repo kill-switch", async () => {
const e = await env();
expect(await runAsync(["maintain", "pause", "--repo", "owner/repo"], e)).toMatch(/Agent actions paused for owner\/repo/);
expect(await runAsync(["maintain", "resume", "--repo", "owner/repo"], e)).toMatch(/Agent actions resumed for owner\/repo/);
});

it("validates inputs: --repo required, id required for approve, known subcommand", async () => {
const e = await env();
await expect(runAsync(["maintain", "status"], e)).rejects.toThrow(/Pass --repo/);
await expect(runAsync(["maintain", "approve", "--repo", "owner/repo"], e)).rejects.toThrow(/Pass the pending-action id/);
await expect(runAsync(["maintain", "bogus", "--repo", "owner/repo"], e)).rejects.toThrow(/Unknown maintain subcommand/);
});

it("prints help when invoked with no subcommand", async () => {
const e = await env();
const out = await runAsync(["maintain"], e);
expect(out).toMatch(/Usage: gittensory-mcp maintain/);
expect(out).toMatch(/approve <id>/);
expect(out).toMatch(/pause/);
});
});
15 changes: 15 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,21 @@ export async function startFixtureServer(
response.end(JSON.stringify(agentPacketFixture(options.packetMarkdown)));
return;
}
// #784 maintainer controls (agent approval queue + kill-switch).
if (request.url === "/v1/repos/owner/repo/agent/pending-actions" && request.method === "GET") {
response.end(JSON.stringify({ repoFullName: "owner/repo", pendingActions: [{ id: "pa-1", actionClass: "merge", pullNumber: 7, reason: "clean", status: "pending" }] }));
return;
}
if (request.url?.startsWith("/v1/repos/owner/repo/agent/pending-actions/") && request.method === "POST") {
const accepted = request.url.endsWith("/accept");
response.end(JSON.stringify(accepted ? { status: "accepted", executionOutcome: "completed" } : { status: "rejected" }));
return;
}
if (request.url === "/v1/repos/owner/repo/settings" && request.method === "PUT") {
const body = (await readJsonRequest(request)) as { agentPaused?: boolean };
response.end(JSON.stringify({ repoFullName: "owner/repo", agentPaused: body.agentPaused === true }));
return;
}
response.statusCode = 404;
response.end(JSON.stringify({ error: "not_found" }));
});
Expand Down
Loading