From 2e3653012f53763658df671b43a04727710efb58 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:47:41 -0700 Subject: [PATCH] =?UTF-8?q?feat(agent):=20maintainer=20CLI=20controls=20?= =?UTF-8?q?=E2=80=94=20maintain=20status/approve/reject/pause/resume=20(ad?= =?UTF-8?q?vances=20#784)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 (#784) CLI slice: a `gittensory-mcp maintain` command for the agent auto-maintain layer, thin-proxying the existing maintainer APIs (the API enforces authorization; the CLI never decides locally): - maintain status --repo o/r -> GET the agent approval queue (#779) - maintain approve --repo -> POST .../accept (execute the staged action) - maintain reject --repo -> POST .../reject (cancel it) - maintain pause|resume --repo -> PUT settings { agentPaused } (kill-switch, #130) The CLI exposes approve|reject; it maps to the route's accept|reject decision verb. --json for machine output. Wired into the command dispatch + completion registry + a maintain help command. This is one incremental slice of #784 (the CLI deliverable) — the issue stays open; the dashboard slice is in flight via contributor PR #831, the MCP read tool is a separate PR. Tests: status (plain + json), approve/reject, pause/resume, input validation (--repo required, id required for approve, unknown subcommand), and help. Fixture server gains the queue + settings endpoints. Full suite green (2123). --- packages/gittensory-mcp/bin/gittensory-mcp.js | 59 +++++++++++++++++++ test/unit/mcp-cli-maintain.test.ts | 57 ++++++++++++++++++ test/unit/support/mcp-cli-harness.ts | 15 +++++ 3 files changed, 131 insertions(+) create mode 100644 test/unit/mcp-cli-maintain.test.ts diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index dbdaf3ec2a..c6239fb7c8 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -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"]; @@ -1307,6 +1308,63 @@ function workspaceRootStatus(roots) { }; } +function printMaintainHelp() { + process.stdout.write( + [ + "Usage: gittensory-mcp maintain --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 Approve a staged action -> execute it.", + " reject 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} --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 | reject | pause | resume.`); +} + async function runCli(args) { const command = args[0]; if (command === "--help" || command === "help") return printHelp(); @@ -1314,6 +1372,7 @@ async function runCli(args) { 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); diff --git a/test/unit/mcp-cli-maintain.test.ts b/test/unit/mcp-cli-maintain.test.ts new file mode 100644 index 0000000000..288bbb7603 --- /dev/null +++ b/test/unit/mcp-cli-maintain.test.ts @@ -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 /); + expect(out).toMatch(/pause/); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 92be08598a..f622c0f90a 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -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" })); });