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
129 changes: 129 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getLatestRepoGithubTotalsSnapshot,
getInstallation,
getIssue,
getPendingAgentAction,
getRepository,
getRepositorySettings,
getRepoQueueTrendSnapshot,
Expand All @@ -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";
Expand Down Expand Up @@ -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), {
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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<z.ZodObject<typeof listPendingActionsShape>>): Promise<ToolPayload> {
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<z.ZodObject<typeof decidePendingActionShape>>): Promise<ToolPayload> {
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<z.ZodObject<typeof scorePreviewShape>>): Promise<ToolPayload> {
if (!input.contributorLogin) throw new Error("contributorLogin is required for score breakdown.");
this.requireContributorAccess(input.contributorLogin);
Expand Down
100 changes: 99 additions & 1 deletion test/unit/mcp-automation-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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");
});
});
Loading