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
109 changes: 109 additions & 0 deletions src/lib/mc-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
});
});
});
101 changes: 101 additions & 0 deletions src/lib/mc-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export interface ClaimIssueResult {
labels: string[];
}

export interface UnclaimIssueResult {
success: boolean;
labels: string[];
}

export interface SetStatusResult {
success: boolean;
status: string;
Expand Down Expand Up @@ -221,6 +226,102 @@ export async function claimIssue(
});
}

export async function unclaimIssue(
repoFullName: string,
issueNumber: number,
agentName: string,
): Promise<UnclaimIssueResult> {
const resolved = await resolveIssue(repoFullName, issueNumber);

return mcJson<UnclaimIssueResult>("/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<unknown[]> {
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<unknown[]>(
`/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<unknown[]> {
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<unknown[]>(`/api/issues${qs ? `?${qs}` : ""}`, { method: "GET" });
}

export async function listPrFixes(options?: {
lane?: string;
includeBlocked?: boolean;
}): Promise<unknown[]> {
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<unknown[]>(`/api/pr-fix-queue/queued${qs ? `?${qs}` : ""}`, {
method: "GET",
});
}

export async function markPrFix(input: {
repo: string;
pr: number;
status: string;
note?: string;
}): Promise<unknown> {
return mcJson<unknown>("/api/pr-fix-queue/mark", {
method: "POST",
body: JSON.stringify(input),
});
}

export async function runGroomer(target?: {
repoFullName?: string;
issueNumber?: number;
}): Promise<unknown> {
return mcJson<unknown>("/api/groomer/run", {
method: "POST",
body: JSON.stringify(target ?? {}),
});
}

export async function setIssueStatus(
repoFullName: string,
issueNumber: number,
Expand Down
155 changes: 155 additions & 0 deletions src/mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
import {
resolveIssueHandler,
claimIssueHandler,
unclaimIssueHandler,
getQueueHandler,
listIssuesHandler,
listPrFixesHandler,
markPrFixHandler,
runGroomerHandler,
setIssueStatusHandler,
claimWorkHandler,
refreshIssueHandler,
Expand Down Expand Up @@ -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);
});
});
Loading