From 575c7764b906db7d5541cde1bb94a0a631258b4b Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:09:24 -0700 Subject: [PATCH] =?UTF-8?q?feat(agent):=20CLI=20set-level=20+=20MCP=20prop?= =?UTF-8?q?ose-action=20=E2=80=94=20finish=20the=20non-dashboard=20#784=20?= =?UTF-8?q?surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the CLI + MCP control deliverables of #784 (the dashboard slice stays with contributor PR #831; this issue is advanced, not finished here): CLI (packages/gittensory-mcp): maintain set-level --repo o/r — read-merge-write so one autonomy class is updated without clearing the others; validates action/level against the autonomy dial; wired into dispatch + completion + help. MCP (src/mcp/server.ts): gittensory_propose_action(owner, repo, pullNumber, actionClass, [params]) — a maintainer stages a PR action into the approval queue (#779) as auto_with_approval; it never auto-executes (a maintainer accepts/rejects via the queue). Gated by a new requireRepoManageAccess (maintainer/owner/operator scope) — stricter than the read-only requireRepoAccess; private-token/static identities trusted. Tests: set-level merge + validation; propose stages an action (idempotent), carries action params, allows an owning-maintainer session, forbids a non-maintainer session, and errors when the App is not installed. New code 100% covered; MCP meta-tests green; full suite green (2131). (MCP CHANGELOG regenerates at mcp-release time, not per-PR.) --- packages/gittensory-mcp/bin/gittensory-mcp.js | 33 +++++++-- src/mcp/server.ts | 68 ++++++++++++++++++ test/unit/mcp-automation-state.test.ts | 72 ++++++++++++++++++- test/unit/mcp-cli-maintain.test.ts | 14 +++- test/unit/support/mcp-cli-harness.ts | 8 ++- 5 files changed, 182 insertions(+), 13 deletions(-) diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index c6239fb7c8..c71566daf1 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -42,10 +42,13 @@ const CLI_COMMAND_SPEC = { profile: ["list", "create", "switch", "remove"], cache: ["status", "clear"], agent: ["plan", "status", "explain", "packet"], - maintain: ["status", "approve", "reject", "pause", "resume"], + maintain: ["status", "approve", "reject", "pause", "resume", "set-level"], }; const COMPLETION_SHELLS = ["bash", "zsh", "fish"]; const AGENT_PROFILE_IDS = ["miner-planner", "miner-auto-dev", "maintainer-triage", "repo-owner-intake"]; +// #784 maintain set-level — the autonomy dial's action classes + levels (must mirror src/settings/autonomy.ts). +const MAINTAIN_ACTION_CLASSES = ["review", "request_changes", "approve", "merge", "close", "label"]; +const MAINTAIN_AUTONOMY_LEVELS = ["observe", "suggest", "propose", "auto_with_approval", "auto"]; const AGENT_PROFILES = { "miner-planner": { id: "miner-planner", @@ -1316,11 +1319,14 @@ function printMaintainHelp() { "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.", + " 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.", + " set-level Set the autonomy level for one action class.", + ` actions: ${MAINTAIN_ACTION_CLASSES.join(", ")}`, + ` levels: ${MAINTAIN_AUTONOMY_LEVELS.join(", ")}`, "", "Pass --json for machine-readable output.", ].join("\n") + "\n", @@ -1362,7 +1368,20 @@ async function maintainCli(args) { emit(payload, `Agent actions ${subcommand === "pause" ? "paused" : "resumed"} for ${repoFullName}.`); return; } - throw new Error(`Unknown maintain subcommand: ${subcommand}. Use status | approve | reject | pause | resume.`); + if (subcommand === "set-level") { + const action = args[1] && !args[1].startsWith("--") ? args[1] : undefined; + const level = args[2] && !args[2].startsWith("--") ? args[2] : undefined; + if (!action || !level) throw new Error("Usage: gittensory-mcp maintain set-level --repo owner/repo."); + if (!MAINTAIN_ACTION_CLASSES.includes(action)) throw new Error(`Unknown action: ${action}. Use ${MAINTAIN_ACTION_CLASSES.join(", ")}.`); + if (!MAINTAIN_AUTONOMY_LEVELS.includes(level)) throw new Error(`Unknown level: ${level}. Use ${MAINTAIN_AUTONOMY_LEVELS.join(", ")}.`); + // Read-merge-write so one class is updated without clearing the others. + const current = await apiGet(`${repoBase}/settings`); + const autonomy = { ...(current.autonomy ?? {}), [action]: level }; + const payload = await apiFetch(`${repoBase}/settings`, { method: "PUT", body: JSON.stringify({ autonomy }) }); + emit(payload, `Set ${action} autonomy to ${level} for ${repoFullName}.`); + return; + } + throw new Error(`Unknown maintain subcommand: ${subcommand}. Use status | approve | reject | pause | resume | set-level .`); } async function runCli(args) { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index fdf5674167..ec60cea129 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -9,6 +9,7 @@ import { canLoginAccessRepo, canWatchRepo, loadControlPanelAccessScope, loadCont import { countOpenIssues, countOpenPullRequests, + createPendingAgentActionIfAbsent, getBounty, listBountiesByRepo, getContributorEvidence, @@ -323,6 +324,25 @@ const planViewOutputSchema = { validation: z.object({ valid: z.boolean(), errors: z.array(z.string()) }).optional(), }; +// #784 (MCP slice) — propose-action: a maintainer stages an action into the approval queue (#779). +const proposeActionShape = { + owner: z.string().min(1), + repo: z.string().min(1), + pullNumber: z.number().int().positive(), + actionClass: z.enum(["review", "request_changes", "approve", "merge", "close", "label"]), + reason: z.string().max(500).optional(), + label: z.string().min(1).max(100).optional(), + reviewBody: z.string().max(60000).optional(), + mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(), + closeComment: z.string().max(60000).optional(), +}; +const proposeActionOutputSchema = { + created: z.boolean().optional(), + action: z + .object({ id: z.string(), actionClass: z.string(), pullNumber: z.number(), status: z.string(), reason: z.string().nullable() }) + .optional(), +}; + // #784 (MCP slice) — the read side of the agent automation control surface for a repo. const automationStateOutputSchema = { repoFullName: z.string().optional(), @@ -1198,6 +1218,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.getAutomationState(input)), ); + server.registerTool( + "gittensory_propose_action", + { + description: + "Stage a PR action (label / request_changes / approve / merge / close) into the repo's approval queue for a maintainer to accept or reject. Maintainer access required; the action is NOT executed until approved.", + inputSchema: proposeActionShape, + outputSchema: proposeActionOutputSchema, + }, + async (input) => this.toolResult(await this.proposeAction(input)), + ); + server.registerTool( "gittensory_explain_score_breakdown", { @@ -1484,6 +1515,15 @@ export class GittensoryMcp { throw new Error("Forbidden: session cannot access this repository."); } + // Stricter than requireRepoAccess (read): a maintainer-MANAGE gate for write actions (#784 propose-action). + // A session must own/maintain the repo (or be an operator); private-token / static identities are trusted. + private async requireRepoManageAccess(repoFullName: string): Promise { + if (this.identity.kind !== "session") return; + const scope = await this.loadSessionAccessScope(); + if (scope.operator || scope.repositoryFullNames.includes(repoFullName)) return; + throw new Error("Forbidden: maintainer access is required to propose an action on this repository."); + } + // Issue-watch gate (#699 path B). Sessions may only watch repos they can SEE: any gittensory-tracked PUBLIC // repo (the miner use case) or a PRIVATE repo they can access — never an arbitrary/private repo they cannot, // so private-repo issues never fan out to them. Non-session (private-token) identities are trusted. @@ -2017,6 +2057,34 @@ export class GittensoryMcp { }; } + // #784 — stage a proposed PR action into the approval queue (#779) for a maintainer to accept/reject. The + // action is auto_with_approval (never auto-executes); maintainer-manage access required. + private async proposeAction(input: z.infer>): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireRepoManageAccess(fullName); + const repo = await getRepository(this.env, fullName); + if (!repo?.installationId) throw new Error("Cannot propose an action: the Gittensory App is not installed on this repository."); + const params = { + ...(input.label !== undefined ? { label: input.label } : {}), + ...(input.reviewBody !== undefined ? { reviewBody: input.reviewBody } : {}), + ...(input.mergeMethod !== undefined ? { mergeMethod: input.mergeMethod } : {}), + ...(input.closeComment !== undefined ? { closeComment: input.closeComment } : {}), + }; + const { action, created } = await createPendingAgentActionIfAbsent(this.env, { + repoFullName: fullName, + pullNumber: input.pullNumber, + installationId: repo.installationId, + actionClass: input.actionClass, + autonomyLevel: "auto_with_approval", + params, + reason: input.reason ?? null, + }); + return { + summary: `${created ? "Staged" : "Already staged"} a ${input.actionClass} on ${fullName}#${input.pullNumber} for maintainer approval.`, + data: { created, action: { id: action.id, actionClass: action.actionClass, pullNumber: action.pullNumber, status: action.status, reason: action.reason } }, + }; + } + private async explainScoreBreakdown(input: z.infer>): Promise { if (!input.contributorLogin) throw new Error("contributorLogin is required for score breakdown."); this.requireContributorAccess(input.contributorLogin); diff --git a/test/unit/mcp-automation-state.test.ts b/test/unit/mcp-automation-state.test.ts index b8fc58e2ab..359dbf4837 100644 --- a/test/unit/mcp-automation-state.test.ts +++ b/test/unit/mcp-automation-state.test.ts @@ -2,11 +2,12 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { describe, expect, it } from "vitest"; import { GittensoryMcp } from "../../src/mcp/server"; -import { createPendingAgentActionIfAbsent, upsertInstallation, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; +import { createPendingAgentActionIfAbsent, listPendingAgentActions, upsertInstallation, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; +import type { AuthIdentity } from "../../src/auth/security"; import { createTestEnv } from "../helpers/d1"; -async function connect(env: Env) { - const server = new GittensoryMcp(env).createServer(); +async function connect(env: Env, identity?: AuthIdentity) { + const server = (identity ? new GittensoryMcp(env, identity) : new GittensoryMcp(env)).createServer(); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); const client = new Client({ name: "gittensory-automation-test", version: "0.1.0" }, { capabilities: {} }); @@ -62,3 +63,68 @@ describe("MCP gittensory_get_automation_state (#784)", () => { expect(data.mode).toBe("live"); // nothing paused or dry-run }); }); + +describe("MCP gittensory_propose_action (#784)", () => { + it("stages a proposed action into the approval queue (idempotent)", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + const client = await connect(env); + const first = await client.callTool({ name: "gittensory_propose_action", arguments: { owner: "owner", repo: "repo", pullNumber: 7, actionClass: "merge", mergeMethod: "squash", reason: "clean" } }); + expect(first.isError).toBeFalsy(); + const data = first.structuredContent as { created: boolean; action: { actionClass: string; status: string; pullNumber: number } }; + expect(data.created).toBe(true); + expect(data.action).toMatchObject({ actionClass: "merge", status: "pending", pullNumber: 7 }); + + const pending = await listPendingAgentActions(env, { repoFullName: "owner/repo", status: "pending" }); + expect(pending).toHaveLength(1); + expect(pending[0]?.params).toMatchObject({ mergeMethod: "squash" }); + expect(pending[0]?.autonomyLevel).toBe("auto_with_approval"); // staged, never auto-executes + + const second = await client.callTool({ name: "gittensory_propose_action", arguments: { owner: "owner", repo: "repo", pullNumber: 7, actionClass: "merge" } }); + expect((second.structuredContent as { created: boolean }).created).toBe(false); + }); + + it("carries the action-specific params (label / reviewBody / closeComment) into the staged action", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + const client = await connect(env); + await client.callTool({ + name: "gittensory_propose_action", + arguments: { owner: "owner", repo: "repo", pullNumber: 9, actionClass: "close", label: "gittensory:blocked", reviewBody: "please fix", closeComment: "closing as noise" }, + }); + const [staged] = await listPendingAgentActions(env, { repoFullName: "owner/repo", status: "pending" }); + expect(staged?.params).toMatchObject({ label: "gittensory:blocked", reviewBody: "please fix", closeComment: "closing as noise" }); + }); + + it("allows a session that maintains the repo (owned installation)", async () => { + const env = createTestEnv(); + await upsertInstallation(env, { + installation: { id: 5, account: { login: "owner", id: 1, type: "User" }, repository_selection: "selected", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }], + }); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + const client = await connect(env, { kind: "session", actor: "owner" } as AuthIdentity); + const result = await client.callTool({ name: "gittensory_propose_action", arguments: { owner: "owner", repo: "repo", pullNumber: 7, actionClass: "merge" } }); + expect(result.isError).toBeFalsy(); + expect((result.structuredContent as { created: boolean }).created).toBe(true); + }); + + it("errors when the App is not installed on the repo", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "noinstall", full_name: "owner/noinstall", private: false, owner: { login: "owner" } }); + const client = await connect(env); + const result = await client.callTool({ name: "gittensory_propose_action", arguments: { owner: "owner", repo: "noinstall", pullNumber: 7, actionClass: "merge" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toMatch(/not installed/i); + }); + + it("forbids a session without maintainer access to the repo", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + const client = await connect(env, { kind: "session", actor: "rando" } as AuthIdentity); + const result = await client.callTool({ name: "gittensory_propose_action", arguments: { owner: "owner", repo: "repo", pullNumber: 7, actionClass: "merge" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toMatch(/maintainer access/i); + expect(await listPendingAgentActions(env, { repoFullName: "owner/repo" })).toHaveLength(0); + }); +}); diff --git a/test/unit/mcp-cli-maintain.test.ts b/test/unit/mcp-cli-maintain.test.ts index 288bbb7603..6f9d24b5d5 100644 --- a/test/unit/mcp-cli-maintain.test.ts +++ b/test/unit/mcp-cli-maintain.test.ts @@ -40,11 +40,23 @@ describe("gittensory-mcp CLI — maintain (#784)", () => { 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 () => { + it("set-level merges one action class into the autonomy dial (read-merge-write)", async () => { + const e = await env(); + const json = JSON.parse(await runAsync(["maintain", "set-level", "merge", "auto_with_approval", "--repo", "owner/repo", "--json"], e)) as { autonomy: Record }; + // existing label:auto preserved + merge added + expect(json.autonomy).toMatchObject({ label: "auto", merge: "auto_with_approval" }); + const plain = await runAsync(["maintain", "set-level", "merge", "auto", "--repo", "owner/repo"], e); + expect(plain).toMatch(/Set merge autonomy to auto for owner\/repo/); + }); + + it("validates inputs: --repo required, id required for approve, known subcommand + action/level", 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/); + await expect(runAsync(["maintain", "set-level", "merge", "--repo", "owner/repo"], e)).rejects.toThrow(/Usage: gittensory-mcp maintain set-level/); + await expect(runAsync(["maintain", "set-level", "bogus", "auto", "--repo", "owner/repo"], e)).rejects.toThrow(/Unknown action/); + await expect(runAsync(["maintain", "set-level", "merge", "bogus", "--repo", "owner/repo"], e)).rejects.toThrow(/Unknown level/); }); it("prints help when invoked with no subcommand", async () => { diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index f622c0f90a..32366e3647 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -233,9 +233,13 @@ export async function startFixtureServer( response.end(JSON.stringify(accepted ? { status: "accepted", executionOutcome: "completed" } : { status: "rejected" })); return; } + if (request.url === "/v1/repos/owner/repo/settings" && request.method === "GET") { + response.end(JSON.stringify({ repoFullName: "owner/repo", autonomy: { label: "auto" }, agentPaused: false, agentDryRun: false })); + 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 })); + const body = (await readJsonRequest(request)) as { agentPaused?: boolean; autonomy?: Record }; + response.end(JSON.stringify({ repoFullName: "owner/repo", agentPaused: body.agentPaused === true, ...(body.autonomy ? { autonomy: body.autonomy } : {}) })); return; } response.statusCode = 404;