diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 9a3527c85a..a834cc1362 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -17,6 +17,7 @@ import { getLatestRepoGithubTotalsSnapshot, getInstallation, getIssue, + getPendingAgentAction, getRepository, getRepositorySettings, getRepoQueueTrendSnapshot, @@ -42,6 +43,7 @@ import { markNotificationDeliveriesRead, recordProductUsageEvent, } from "../db/repositories"; +import { decidePendingAgentAction } from "../services/agent-approval-queue"; import { buildNotificationFeed } from "../notifications/service"; import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; import { getRepositoryCollaboratorPermission } from "../github/app"; @@ -370,6 +372,45 @@ const automationStateOutputSchema = { pendingActionCount: z.number().optional(), }; +// #784 (MCP slice) — surface + decide the approval queue, so an MCP client can do the full loop it can +// already propose into: list staged actions, then accept (execute) or reject one. +const listPendingActionsShape = { + owner: z.string().min(1), + repo: z.string().min(1), + status: z.enum(["pending", "accepted", "rejected"]).optional(), +}; + +const pendingActionEntrySchema = z.object({ + id: z.string(), + actionClass: z.string(), + pullNumber: z.number(), + status: z.string(), + autonomyLevel: z.string(), + reason: z.string().nullable(), + decidedBy: z.string().nullable(), + decidedAt: z.string().nullable(), + createdAt: z.string(), +}); + +const listPendingActionsOutputSchema = { + repoFullName: z.string().optional(), + status: z.string().optional(), + pendingActions: z.array(pendingActionEntrySchema).optional(), +}; + +const decidePendingActionShape = { + owner: z.string().min(1), + repo: z.string().min(1), + id: z.string().min(1), + decision: z.enum(["accept", "reject"]), +}; + +const decidePendingActionOutputSchema = { + status: z.string().optional(), + executionOutcome: z.string().optional(), + action: pendingActionEntrySchema.optional(), +}; + const focusManifestInputSchema = z .record(z.string(), z.unknown()) .refine((manifest) => isJsonByteLengthWithinLimit(manifest, MAX_FOCUS_MANIFEST_BYTES), { @@ -1256,6 +1297,28 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.proposeAction(input)), ); + server.registerTool( + "gittensory_list_pending_actions", + { + description: + "List the agent actions staged in a repo's approval queue (default status=pending), so a maintainer can review what is awaiting a decision. Maintainer access required.", + inputSchema: listPendingActionsShape, + outputSchema: listPendingActionsOutputSchema, + }, + async (input) => this.toolResult(await this.listPendingActions(input)), + ); + + server.registerTool( + "gittensory_decide_pending_action", + { + description: + "Accept (execute) or reject a staged approval-queue action by id. Accept runs it through the live executor gates; reject cancels it. Idempotent and scoped to this repo. Maintainer access required.", + inputSchema: decidePendingActionShape, + outputSchema: decidePendingActionOutputSchema, + }, + async (input) => this.toolResult(await this.decidePendingAction(input)), + ); + server.registerTool( "gittensory_explain_score_breakdown", { @@ -2121,6 +2184,72 @@ export class GittensoryMcp { }; } + // #784 — surface the approval queue an MCP client can already propose into. Maintainer-manage scoped + // (the full queue with reasons is more sensitive than the bare count in get_automation_state). + private async listPendingActions(input: z.infer>): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireRepoManageAccess(fullName); + const status = input.status ?? "pending"; + const actions = await listPendingAgentActions(this.env, { repoFullName: fullName, status }); + return { + summary: `${actions.length} ${status} action(s) in the ${fullName} approval queue.`, + data: { + repoFullName: fullName, + status, + pendingActions: actions.map((action) => ({ + id: action.id, + actionClass: action.actionClass, + pullNumber: action.pullNumber, + status: action.status, + autonomyLevel: action.autonomyLevel, + reason: action.reason, + decidedBy: action.decidedBy, + decidedAt: action.decidedAt, + createdAt: action.createdAt, + })), + }, + }; + } + + // #784 — accept (execute) or reject a staged action. Mirrors the HTTP decision route: maintainer-manage + // access, repo-scoped (a guessed id from another repo's queue cannot be decided), idempotent. + private async decidePendingAction(input: z.infer>): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireRepoManageAccess(fullName); + const pending = await getPendingAgentAction(this.env, input.id); + // Scope to THIS repo so a maintainer cannot decide another repo's queue via a guessed id. + if (!pending || pending.repoFullName !== fullName) { + return { summary: `No pending action ${input.id} on ${fullName}.`, data: { status: "not_found" } }; + } + const result = await decidePendingAgentAction(this.env, { id: pending.id, decision: input.decision, decidedBy: this.identity.actor }); + const action = result.action; + /* v8 ignore next 2 -- not_found is returned above; accepted/rejected/already_decided always carry the action. */ + if (!action) return { summary: `Action ${input.id} was already decided.`, data: { status: result.status } }; + return { + summary: + result.status === "accepted" + ? `Accepted ${pending.actionClass} on ${fullName}#${pending.pullNumber} (execution: ${result.executionOutcome}).` + : result.status === "rejected" + ? `Rejected ${pending.actionClass} on ${fullName}#${pending.pullNumber}.` + : `Action ${input.id} was already decided.`, + data: { + status: result.status, + ...(result.executionOutcome !== undefined ? { executionOutcome: result.executionOutcome } : {}), + action: { + id: action.id, + actionClass: action.actionClass, + pullNumber: action.pullNumber, + status: action.status, + autonomyLevel: action.autonomyLevel, + reason: action.reason, + decidedBy: action.decidedBy, + decidedAt: action.decidedAt, + createdAt: action.createdAt, + }, + }, + }; + } + 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 341de48311..646d8a62c1 100644 --- a/test/unit/mcp-automation-state.test.ts +++ b/test/unit/mcp-automation-state.test.ts @@ -3,7 +3,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { GittensoryMcp } from "../../src/mcp/server"; import { getRepositoryCollaboratorPermission } from "../../src/github/app"; -import { createPendingAgentActionIfAbsent, listPendingAgentActions, upsertInstallation, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; +import { createPendingAgentActionIfAbsent, getPendingAgentAction, listPendingAgentActions, upsertInstallation, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; import type { AuthIdentity } from "../../src/auth/security"; import { createTestEnv } from "../helpers/d1"; @@ -176,3 +176,101 @@ describe("MCP gittensory_propose_action (#784)", () => { expect(await listPendingAgentActions(env, { repoFullName: "owner/repo" })).toHaveLength(0); }); }); + +describe("MCP gittensory_list_pending_actions (#784)", () => { + it("surfaces the approval queue with action details (default status=pending)", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: {}, reason: "clean" }); + await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 8, installationId: 5, actionClass: "label", autonomyLevel: "auto_with_approval", params: { label: "x" }, reason: "tidy" }); + + const client = await connect(env); + const result = await client.callTool({ name: "gittensory_list_pending_actions", arguments: { owner: "owner", repo: "repo" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { repoFullName: string; status: string; pendingActions: Array<{ pullNumber: number; actionClass: string; status: string; reason: string | null; autonomyLevel: string }> }; + expect(data.repoFullName).toBe("owner/repo"); + expect(data.status).toBe("pending"); + expect(data.pendingActions.map((action) => action.pullNumber).sort()).toEqual([7, 8]); + expect(data.pendingActions.every((action) => action.status === "pending" && action.autonomyLevel === "auto_with_approval")).toBe(true); + }); + + it("filters by status and returns an empty queue when none match", 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 result = await client.callTool({ name: "gittensory_list_pending_actions", arguments: { owner: "owner", repo: "repo", status: "accepted" } }); + const data = result.structuredContent as { status: string; pendingActions: unknown[] }; + expect(data.status).toBe("accepted"); + expect(data.pendingActions).toEqual([]); + }); + + it("forbids a session without live write access", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + mockedPermission.mockResolvedValue("read"); + const client = await connect(env, { kind: "session", actor: "rando" } as AuthIdentity); + const result = await client.callTool({ name: "gittensory_list_pending_actions", arguments: { owner: "owner", repo: "repo" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toMatch(/write access/i); + }); +}); + +describe("MCP gittensory_decide_pending_action (#784)", () => { + it("rejects a staged action without executing and is idempotent", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + const { action } = await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: {}, reason: "x" }); + + const client = await connect(env); + const result = await client.callTool({ name: "gittensory_decide_pending_action", arguments: { owner: "owner", repo: "repo", id: action.id, decision: "reject" } }); + expect(result.isError).toBeFalsy(); + expect((result.structuredContent as { status: string }).status).toBe("rejected"); + expect((await getPendingAgentAction(env, action.id))?.status).toBe("rejected"); + + const second = await client.callTool({ name: "gittensory_decide_pending_action", arguments: { owner: "owner", repo: "repo", id: action.id, decision: "accept" } }); + expect((second.structuredContent as { status: string }).status).toBe("already_decided"); + }); + + it("accepts a staged action and honors dry-run mode (no live mutation)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: "x" }); + await upsertInstallation(env, { + installation: { id: 5, account: { login: "owner", id: 1, type: "User" }, repository_selection: "selected", permissions: { metadata: "read", pull_requests: "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); + await upsertRepositorySettings(env, { repoFullName: "owner/repo", autonomy: { merge: "auto_with_approval" }, agentDryRun: true }); + await upsertPullRequestFromGitHub(env, "owner/repo", { number: 7, title: "PR", state: "open", user: { login: "contributor" }, head: { sha: "h7" }, labels: [], body: "x" }); + const { action } = await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: { mergeMethod: "squash" }, reason: "clean" }); + + const client = await connect(env); + const result = await client.callTool({ name: "gittensory_decide_pending_action", arguments: { owner: "owner", repo: "repo", id: action.id, decision: "accept" } }); + const data = result.structuredContent as { status: string; executionOutcome: string }; + expect(data.status).toBe("accepted"); + expect(data.executionOutcome).toBe("dry_run"); + expect((await getPendingAgentAction(env, action.id))?.status).toBe("accepted"); + }); + + it("is repo-scoped: a guessed id from another repo's queue is not_found and left untouched", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + await upsertRepositoryFromGitHub(env, { name: "other", full_name: "owner/other", private: false, owner: { login: "owner" } }, 5); + const { action } = await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/other", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: {}, reason: "x" }); + + const client = await connect(env); + const result = await client.callTool({ name: "gittensory_decide_pending_action", arguments: { owner: "owner", repo: "repo", id: action.id, decision: "reject" } }); + expect((result.structuredContent as { status: string }).status).toBe("not_found"); + expect((await getPendingAgentAction(env, action.id))?.status).toBe("pending"); + }); + + it("forbids a session without live write access and leaves the action pending", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + const { action } = await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: {}, reason: "x" }); + mockedPermission.mockResolvedValue("read"); + const client = await connect(env, { kind: "session", actor: "rando" } as AuthIdentity); + const result = await client.callTool({ name: "gittensory_decide_pending_action", arguments: { owner: "owner", repo: "repo", id: action.id, decision: "reject" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toMatch(/write access/i); + expect((await getPendingAgentAction(env, action.id))?.status).toBe("pending"); + }); +});