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
33 changes: 26 additions & 7 deletions packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 <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.",
" 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.",
" set-level <action> <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",
Expand Down Expand Up @@ -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 <id> | reject <id> | 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 <action> <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 <id> | reject <id> | pause | resume | set-level <action> <level>.`);
}

async function runCli(args) {
Expand Down
68 changes: 68 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { canLoginAccessRepo, canWatchRepo, loadControlPanelAccessScope, loadCont
import {
countOpenIssues,
countOpenPullRequests,
createPendingAgentActionIfAbsent,
getBounty,
listBountiesByRepo,
getContributorEvidence,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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<void> {
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.
Expand Down Expand Up @@ -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<z.ZodObject<typeof proposeActionShape>>): Promise<ToolPayload> {
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<z.ZodObject<typeof scorePreviewShape>>): Promise<ToolPayload> {
if (!input.contributorLogin) throw new Error("contributorLogin is required for score breakdown.");
this.requireContributorAccess(input.contributorLogin);
Expand Down
72 changes: 69 additions & 3 deletions test/unit/mcp-automation-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} });
Expand Down Expand Up @@ -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);
});
});
14 changes: 13 additions & 1 deletion test/unit/mcp-cli-maintain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> };
// 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 () => {
Expand Down
8 changes: 6 additions & 2 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> };
response.end(JSON.stringify({ repoFullName: "owner/repo", agentPaused: body.agentPaused === true, ...(body.autonomy ? { autonomy: body.autonomy } : {}) }));
return;
}
response.statusCode = 404;
Expand Down
Loading