diff --git a/src/lib/mc-client.test.ts b/src/lib/mc-client.test.ts index bc81808..b239610 100644 --- a/src/lib/mc-client.test.ts +++ b/src/lib/mc-client.test.ts @@ -702,3 +702,112 @@ describe("DispatchClientError", () => { expect(err.statusCode).toBeNull(); }); }); + +describe("unclaimIssue", () => { + beforeEach(async () => { + clearEnv(); + setEnv(); + vi.resetModules(); + await import("./mc-client"); + vi.restoreAllMocks(); + }); + + it("calls resolve then POST /api/issues/unclaim", async () => { + const { unclaimIssue } = await import("./mc-client"); + const fetchMock = vi.spyOn(globalThis, "fetch"); + fetchMock + .mockResolvedValueOnce(jsonResponse([mockIssue])) // resolve + .mockResolvedValueOnce(jsonResponse({ success: true, labels: ["priority/p1", "status/ready"] })); // unclaim + + const result = await unclaimIssue("org/repo", 42, "test-agent"); + + expect(result.success).toBe(true); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + expect.stringContaining("/api/issues/unclaim"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + issueId: "issue-cuid-1", + repoFullName: "org/repo", + issueNumber: 42, + agentName: "test-agent", + }), + }), + ); + }); + + it("throws when the issue is not found", async () => { + const { unclaimIssue } = await import("./mc-client"); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(jsonResponse([])); + await expect(unclaimIssue("org/repo", 99, "test-agent")).rejects.toThrow(/not found/); + }); +}); + +describe("queue, issues, pr-fix, groomer clients", () => { + beforeEach(async () => { + clearEnv(); + setEnv(); + vi.resetModules(); + await import("./mc-client"); + vi.restoreAllMocks(); + }); + + it("getQueue hits the agent queue with lane + flags", async () => { + const { getQueue } = await import("./mc-client"); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(jsonResponse([])); + await getQueue("test-agent", { lane: "local", includeClaimed: true }); + const url = String(fetchMock.mock.calls[0][0]); + expect(url).toContain("/api/agents/test-agent/queue"); + expect(url).toContain("lane=local"); + expect(url).toContain("includeClaimed=true"); + }); + + it("listIssues builds the filter query", async () => { + const { listIssues } = await import("./mc-client"); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(jsonResponse([])); + await listIssues({ repo: "org/repo", status: "ready", lane: "local" }); + const url = String(fetchMock.mock.calls[0][0]); + expect(url).toContain("/api/issues?"); + expect(url).toContain("repo=org%2Frepo"); + expect(url).toContain("status=ready"); + expect(url).toContain("lane=local"); + }); + + it("listPrFixes passes lane and include_blocked", async () => { + const { listPrFixes } = await import("./mc-client"); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(jsonResponse([])); + await listPrFixes({ lane: "NORMAL", includeBlocked: true }); + const url = String(fetchMock.mock.calls[0][0]); + expect(url).toContain("/api/pr-fix-queue/queued"); + expect(url).toContain("lane=NORMAL"); + expect(url).toContain("include_blocked=true"); + }); + + it("markPrFix POSTs the mark body", async () => { + const { markPrFix } = await import("./mc-client"); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(jsonResponse({ status: "FIXED" })); + await markPrFix({ repo: "org/repo", pr: 7, status: "fixed", note: "done" }); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/api/pr-fix-queue/mark"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ repo: "org/repo", pr: 7, status: "fixed", note: "done" }), + }), + ); + }); + + it("runGroomer POSTs an empty body by default and a target when given", async () => { + const { runGroomer } = await import("./mc-client"); + const fetchMock = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(jsonResponse({ candidateNumber: 12 })) + .mockResolvedValueOnce(jsonResponse({ candidateNumber: 43 })); + await runGroomer(); + await runGroomer({ repoFullName: "org/repo", issueNumber: 43 }); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: "POST", body: JSON.stringify({}) }); + expect(fetchMock.mock.calls[1][1]).toMatchObject({ + method: "POST", + body: JSON.stringify({ repoFullName: "org/repo", issueNumber: 43 }), + }); + }); +}); diff --git a/src/lib/mc-client.ts b/src/lib/mc-client.ts index a011249..9f1d210 100644 --- a/src/lib/mc-client.ts +++ b/src/lib/mc-client.ts @@ -34,6 +34,11 @@ export interface ClaimIssueResult { labels: string[]; } +export interface UnclaimIssueResult { + success: boolean; + labels: string[]; +} + export interface SetStatusResult { success: boolean; status: string; @@ -221,6 +226,102 @@ export async function claimIssue( }); } +export async function unclaimIssue( + repoFullName: string, + issueNumber: number, + agentName: string, +): Promise { + const resolved = await resolveIssue(repoFullName, issueNumber); + + return mcJson("/api/issues/unclaim", { + method: "POST", + body: JSON.stringify({ + issueId: resolved.issueId, + repoFullName, + issueNumber, + agentName, + }), + }); +} + +export async function getQueue( + agentName: string, + options?: { + lane?: string; + excludeDecomposed?: boolean; + includeClaimed?: boolean; + includeRenovate?: boolean; + }, +): Promise { + const params = new URLSearchParams(); + if (options?.lane) params.set("lane", options.lane); + if (options?.excludeDecomposed) params.set("exclude_decomposed", "true"); + if (options?.includeClaimed) params.set("includeClaimed", "true"); + if (options?.includeRenovate) params.set("includeRenovate", "true"); + const qs = params.toString(); + + return mcJson( + `/api/agents/${encodeURIComponent(agentName)}/queue${qs ? `?${qs}` : ""}`, + { method: "GET" }, + ); +} + +export async function listIssues(filters?: { + repo?: string; + status?: string; + lane?: string; + agent?: string; + priority?: string; + includeClosed?: boolean; +}): Promise { + const params = new URLSearchParams(); + if (filters?.repo) params.set("repo", filters.repo); + if (filters?.status) params.set("status", filters.status); + if (filters?.lane) params.set("lane", filters.lane); + if (filters?.agent) params.set("agent", filters.agent); + if (filters?.priority) params.set("priority", filters.priority); + if (filters?.includeClosed) params.set("includeClosed", "true"); + const qs = params.toString(); + + return mcJson(`/api/issues${qs ? `?${qs}` : ""}`, { method: "GET" }); +} + +export async function listPrFixes(options?: { + lane?: string; + includeBlocked?: boolean; +}): Promise { + const params = new URLSearchParams(); + if (options?.lane) params.set("lane", options.lane); + if (options?.includeBlocked) params.set("include_blocked", "true"); + const qs = params.toString(); + + return mcJson(`/api/pr-fix-queue/queued${qs ? `?${qs}` : ""}`, { + method: "GET", + }); +} + +export async function markPrFix(input: { + repo: string; + pr: number; + status: string; + note?: string; +}): Promise { + return mcJson("/api/pr-fix-queue/mark", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export async function runGroomer(target?: { + repoFullName?: string; + issueNumber?: number; +}): Promise { + return mcJson("/api/groomer/run", { + method: "POST", + body: JSON.stringify(target ?? {}), + }); +} + export async function setIssueStatus( repoFullName: string, issueNumber: number, diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 77c2be5..ebc3ac5 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -2,6 +2,12 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { resolveIssueHandler, claimIssueHandler, + unclaimIssueHandler, + getQueueHandler, + listIssuesHandler, + listPrFixesHandler, + markPrFixHandler, + runGroomerHandler, setIssueStatusHandler, claimWorkHandler, refreshIssueHandler, @@ -776,3 +782,152 @@ describe("startup DISPATCH_AGENT_NAME warning", () => { delete process.env.DISPATCH_AGENT_NAME; }); }); + +describe("unclaimIssueHandler", () => { + beforeEach(() => { vi.restoreAllMocks(); }); + + it("resolves then POSTs /api/issues/unclaim and returns the result", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + jsonResponse([ + { + id: "issue-cuid-1", + number: 42, + title: "Fix the thing", + body: null, + state: "open", + url: "https://github.com/org/repo/issues/42", + labels: ["agent/test-agent", "priority/p1", "status/in-progress"], + assignees: [], + commentsCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + closedAt: null, + lastSyncedAt: new Date(), + currentLane: "local", + repository: { fullName: "org/repo" }, + }, + ]), + ) + .mockResolvedValueOnce( + jsonResponse({ success: true, labels: ["priority/p1", "status/ready"] }), + ); + + const result = await unclaimIssueHandler(makeArgs({ repoFullName: "org/repo", issueNumber: 42, agentName: "test-agent" })); + + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text as string); + expect(parsed.success).toBe(true); + expect(parsed.labels).toContain("status/ready"); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + expect.stringContaining("/api/issues/unclaim"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + issueId: "issue-cuid-1", + repoFullName: "org/repo", + issueNumber: 42, + agentName: "test-agent", + }), + }), + ); + }); + + it("errors when agentName is missing and DISPATCH_AGENT_NAME is unset", async () => { + const saved = process.env.DISPATCH_AGENT_NAME; + delete process.env.DISPATCH_AGENT_NAME; + try { + const result = await unclaimIssueHandler(makeArgs({ repoFullName: "org/repo", issueNumber: 42 })); + expect(result.isError).toBe(true); + const parsed = JSON.parse(result.content[0].text as string); + expect(parsed.error).toMatch(/agentName is required/); + } finally { + if (saved !== undefined) process.env.DISPATCH_AGENT_NAME = saved; + } + }); + + it("returns error when the unclaim API rejects", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + jsonResponse([ + { + id: "issue-cuid-1", + number: 42, + title: "Fix the thing", + body: null, + state: "open", + url: "https://github.com/org/repo/issues/42", + labels: ["priority/p1", "status/ready"], + assignees: [], + commentsCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + closedAt: null, + lastSyncedAt: new Date(), + currentLane: "local", + repository: { fullName: "org/repo" }, + }, + ]), + ) + .mockResolvedValueOnce(errorResponse("Issue is not assigned to test-agent", 400)); + + const result = await unclaimIssueHandler(makeArgs({ repoFullName: "org/repo", issueNumber: 42, agentName: "test-agent" })); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/not assigned/); + }); +}); + +describe("queue / pr-fix / groomer handlers", () => { + beforeEach(() => { vi.restoreAllMocks(); }); + + it("getQueueHandler returns the queue and falls back to env agent name", async () => { + process.env.DISPATCH_AGENT_NAME = "env-agent"; + try { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(jsonResponse([{ number: 1 }])); + const result = await getQueueHandler(makeArgs({ lane: "local" })); + expect(result.isError).toBeUndefined(); + expect(String(fetchMock.mock.calls[0][0])).toContain("/api/agents/env-agent/queue"); + } finally { + delete process.env.DISPATCH_AGENT_NAME; + } + }); + + it("getQueueHandler errors without an agent name", async () => { + const saved = process.env.DISPATCH_AGENT_NAME; + delete process.env.DISPATCH_AGENT_NAME; + try { + const result = await getQueueHandler(makeArgs({})); + expect(result.isError).toBe(true); + } finally { + if (saved !== undefined) process.env.DISPATCH_AGENT_NAME = saved; + } + }); + + it("listIssuesHandler returns issues as JSON text", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(jsonResponse([{ number: 42 }])); + const result = await listIssuesHandler(makeArgs({ repo: "org/repo", status: "ready" })); + expect(result.isError).toBeUndefined(); + expect(JSON.parse(result.content[0].text as string)).toHaveLength(1); + }); + + it("listPrFixesHandler returns queue items", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(jsonResponse([{ pr: 7 }])); + const result = await listPrFixesHandler(makeArgs({ includeBlocked: true })); + expect(result.isError).toBeUndefined(); + }); + + it("markPrFixHandler surfaces API errors", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(errorResponse("PR fix queue item not found", 404)); + const result = await markPrFixHandler(makeArgs({ repo: "org/repo", pr: 7, status: "fixed" })); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/not found/); + }); + + it("runGroomerHandler returns the groom result", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(jsonResponse({ candidateNumber: 12 })); + const result = await runGroomerHandler(makeArgs({})); + expect(result.isError).toBeUndefined(); + expect(JSON.parse(result.content[0].text as string).candidateNumber).toBe(12); + }); +}); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index a291fed..24a774a 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -5,6 +5,12 @@ import { z } from "zod"; import { claimIssue, claimWork, + unclaimIssue, + getQueue, + listIssues, + listPrFixes, + markPrFix, + runGroomer, resolveAgentName, refreshIssue, syncRepo, @@ -90,6 +96,103 @@ export async function claimIssueHandler(args: ExtraArgs): Promise { ); } +export async function unclaimIssueHandler(args: ExtraArgs): Promise { + const resolvedAgentName = resolveAgentName(args.agentName as string | undefined); + + if (!resolvedAgentName) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + success: false, + error: "agentName is required. Either pass an explicit agentName argument or set the DISPATCH_AGENT_NAME environment variable. Do not use generic identities like 'Dispatch MCP'.", + }, null, 2), + }, + ], + isError: true, + }; + } + + return wrapToolCall(() => + unclaimIssue( + args.repoFullName as string, + args.issueNumber as number, + resolvedAgentName, + ), + ); +} + +export async function getQueueHandler(args: ExtraArgs): Promise { + const resolvedAgentName = resolveAgentName(args.agentName as string | undefined); + + if (!resolvedAgentName) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + success: false, + error: "agentName is required. Either pass an explicit agentName argument or set the DISPATCH_AGENT_NAME environment variable. Do not use generic identities like 'Dispatch MCP'.", + }, null, 2), + }, + ], + isError: true, + }; + } + + return wrapToolCall(() => + getQueue(resolvedAgentName, { + lane: args.lane as string | undefined, + excludeDecomposed: args.excludeDecomposed as boolean | undefined, + includeClaimed: args.includeClaimed as boolean | undefined, + includeRenovate: args.includeRenovate as boolean | undefined, + }), + ); +} + +export async function listIssuesHandler(args: ExtraArgs): Promise { + return wrapToolCall(() => + listIssues({ + repo: args.repo as string | undefined, + status: args.status as string | undefined, + lane: args.lane as string | undefined, + agent: args.agent as string | undefined, + priority: args.priority as string | undefined, + includeClosed: args.includeClosed as boolean | undefined, + }), + ); +} + +export async function listPrFixesHandler(args: ExtraArgs): Promise { + return wrapToolCall(() => + listPrFixes({ + lane: args.lane as string | undefined, + includeBlocked: args.includeBlocked as boolean | undefined, + }), + ); +} + +export async function markPrFixHandler(args: ExtraArgs): Promise { + return wrapToolCall(() => + markPrFix({ + repo: args.repo as string, + pr: args.pr as number, + status: args.status as string, + note: args.note as string | undefined, + }), + ); +} + +export async function runGroomerHandler(args: ExtraArgs): Promise { + return wrapToolCall(() => + runGroomer({ + repoFullName: args.repoFullName as string | undefined, + issueNumber: args.issueNumber as number | undefined, + }), + ); +} + export async function setIssueStatusHandler(args: ExtraArgs): Promise { return wrapToolCall(() => setIssueStatus( @@ -180,6 +283,106 @@ export function createServer(): McpServerType { claimIssueHandler, ); + // ── unclaim_issue ──────────────────────────────────────────────────────── + + server.registerTool( + "unclaim_issue", + { + description: + "Release an agent's claim on a Dispatch issue: removes the agent/* label, releases the lease (and AgentWork records on the operator path), and flips status/in-progress to status/ready — the issue keeps its groomed lane, so it is immediately re-claimable. Refuses closed/done issues and issues not assigned to the given agent. If agentName is omitted, falls back to DISPATCH_AGENT_NAME env var. Error if neither is set — do not use generic identities like 'Dispatch MCP'.", + inputSchema: { + repoFullName: z.string().describe("GitHub repo full name (e.g. 'org/repo')"), + issueNumber: z.number().int().positive().describe("GitHub issue number"), + agentName: z.string().optional().describe("Agent whose claim is being released. Falls back to DISPATCH_AGENT_NAME env var if omitted. Required unless DISPATCH_AGENT_NAME is set."), + }, + }, + unclaimIssueHandler, + ); + + // ── get_queue ──────────────────────────────────────────────────────────── + + server.registerTool( + "get_queue", + { + description: + "Fetch an agent's ranked work queue (PR-fix items first, then ranked issues). Optionally filter by lane. If agentName is omitted, falls back to DISPATCH_AGENT_NAME env var.", + inputSchema: { + agentName: z.string().optional().describe("Agent whose queue to fetch. Falls back to DISPATCH_AGENT_NAME env var if omitted."), + lane: z.string().optional().describe("Filter to a single execution lane (e.g. 'local', 'frontier')"), + excludeDecomposed: z.boolean().optional().describe("Exclude issues already decomposed into sub-issues"), + includeClaimed: z.boolean().optional().describe("Include issues already claimed by an agent (agent/* labels)"), + includeRenovate: z.boolean().optional().describe("Include Renovate-authored issues (excluded by default)"), + }, + }, + getQueueHandler, + ); + + // ── list_issues ────────────────────────────────────────────────────────── + + server.registerTool( + "list_issues", + { + description: + "List Dispatch-tracked issues with optional filters: repo, status (backlog|ready|in-progress|in-review|done), lane, agent, priority. Open issues only unless includeClosed.", + inputSchema: { + repo: z.string().optional().describe("Filter by repo full name (e.g. 'org/repo')"), + status: z.string().optional().describe("Filter by status label value (e.g. 'ready', 'in-progress')"), + lane: z.string().optional().describe("Filter by execution lane"), + agent: z.string().optional().describe("Filter by claiming agent name"), + priority: z.string().optional().describe("Filter by priority label value (e.g. 'p1')"), + includeClosed: z.boolean().optional().describe("Include closed issues (default false)"), + }, + }, + listIssuesHandler, + ); + + // ── list_pr_fixes ──────────────────────────────────────────────────────── + + server.registerTool( + "list_pr_fixes", + { + description: + "List queued PR-fix items (CI failures, CHANGES_REQUESTED reviews, merge conflicts) awaiting a fix push. Optionally filter by lane (e.g. 'NORMAL', 'ESCALATED') or include BLOCKED items.", + inputSchema: { + lane: z.string().optional().describe("Filter by PR-fix lane (e.g. 'NORMAL', 'ESCALATED', 'NEEDS_HUMAN')"), + includeBlocked: z.boolean().optional().describe("Include BLOCKED (needs-human) items (default false)"), + }, + }, + listPrFixesHandler, + ); + + // ── mark_pr_fix ────────────────────────────────────────────────────────── + + server.registerTool( + "mark_pr_fix", + { + description: + "Mark a PR-fix queue item's outcome after working it: status one of 'fixed', 'blocked', 'stale', or 'ignored', with an optional note. Writes an audit row.", + inputSchema: { + repo: z.string().describe("GitHub repo full name (e.g. 'org/repo')"), + pr: z.number().int().positive().describe("Pull request number"), + status: z.string().describe("Outcome: fixed, blocked, stale, or ignored"), + note: z.string().optional().describe("Optional note explaining the outcome"), + }, + }, + markPrFixHandler, + ); + + // ── run_groomer ────────────────────────────────────────────────────────── + + server.registerTool( + "run_groomer", + { + description: + "Trigger a hosted groomer run immediately instead of waiting for the scheduled cron: grooms one backlog candidate into a claimable lane. Optionally target a specific issue via repoFullName + issueNumber. Rate-limited server-side.", + inputSchema: { + repoFullName: z.string().optional().describe("Target repo full name (requires issueNumber)"), + issueNumber: z.number().int().positive().optional().describe("Target a specific issue to groom"), + }, + }, + runGroomerHandler, + ); + // ── set_issue_status ───────────────────────────────────────────────────── server.registerTool(