diff --git a/src/app/api/agents/[agentName]/next-task/route.test.ts b/src/app/api/agents/[agentName]/next-task/route.test.ts new file mode 100644 index 0000000..a6a7de7 --- /dev/null +++ b/src/app/api/agents/[agentName]/next-task/route.test.ts @@ -0,0 +1,397 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const { mocks } = vi.hoisted(() => ({ + mocks: { + issueFindMany: vi.fn(), + prFixFindMany: vi.fn(), + findLeasedIssueIds: vi.fn(), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + issue: { findMany: mocks.issueFindMany }, + prFixQueueItem: { findMany: mocks.prFixFindMany }, + }, + asPrFixQueueClient: (client: any) => client, +})); + +vi.mock("@/lib/lease", () => ({ + findLeasedIssueIds: mocks.findLeasedIssueIds, +})); + +import { GET } from "./route"; + +describe("GET /api/agents/[agentName]/next-task", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.prFixFindMany.mockResolvedValue([]); + mocks.issueFindMany.mockResolvedValue([]); + mocks.findLeasedIssueIds.mockResolvedValue([]); + }); + + it("returns idle when the queue is empty", async () => { + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.type).toBe("idle"); + expect(body.shouldRun).toBe(false); + expect(body.reason).toBe("No work available"); + }); + + it("returns idle when queue is empty (not an array)", async () => { + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(Array.isArray(body)).toBe(false); + }); + + it("returns one implement task for a normal issue", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-1", + number: 42, + title: "Fix login bug", + url: "https://github.com/org/repo/issues/42", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.type).toBe("implement"); + expect(body.shouldRun).toBe(true); + expect(body.agentName).toBe("example-agent"); + expect(body.issue.number).toBe(42); + expect(body.issue.title).toBe("Fix login bug"); + expect(body.issue.repoFullName).toBe("org/repo"); + expect(body.issue.url).toBe("https://github.com/org/repo/issues/42"); + }); + + it("returns exactly one task, not an array", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-1", + number: 42, + title: "First issue", + url: "https://github.com/org/repo/issues/42", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + { + id: "issue-2", + number: 43, + title: "Second issue", + url: "https://github.com/org/repo/issues/43", + labels: ["priority/p1", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(Array.isArray(body)).toBe(false); + expect(body.type).toBe("implement"); + }); + + it("returns followup-pr task when a PR-fix item is ahead of issue work", async () => { + mocks.prFixFindMany.mockResolvedValue([ + { + id: "prfix-1", + repo: "org/repo", + pr: 12, + issue: 67, + branch: "fix/issue-67", + url: "https://github.com/org/repo/pull/12", + title: "Fix issue 67", + lane: "NORMAL", + status: "QUEUED", + reason: "review changes requested", + feedback: ["please update tests"], + evidenceKeys: ["review:1"], + author: "itsmiso-ai", + queuedAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + ]); + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-1", + number: 99, + title: "Regular issue", + url: "https://github.com/org/repo/issues/99", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.type).toBe("followup-pr"); + expect(body.shouldRun).toBe(true); + expect(body.agentName).toBe("example-agent"); + expect(body.pullRequest.repoFullName).toBe("org/repo"); + expect(body.pullRequest.number).toBe(12); + expect(body.pullRequest.url).toBe("https://github.com/org/repo/pull/12"); + expect(Array.isArray(body.reasons)).toBe(true); + }); + + it("includes linked issue context when PR-fix has an issue number", async () => { + mocks.prFixFindMany.mockResolvedValue([ + { + id: "prfix-1", + repo: "org/repo", + pr: 12, + issue: 67, + branch: "fix/issue-67", + url: "https://github.com/org/repo/pull/12", + title: "Fix issue 67", + lane: "NORMAL", + status: "QUEUED", + reason: "review changes requested", + feedback: ["please update tests"], + evidenceKeys: ["review:1"], + author: "itsmiso-ai", + queuedAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("followup-pr"); + expect(body.issue.repoFullName).toBe("org/repo"); + expect(body.issue.number).toBe(67); + }); + + it("includes both reason and feedback in reasons", async () => { + mocks.prFixFindMany.mockResolvedValue([ + { + id: "prfix-1", + repo: "org/repo", + pr: 12, + issue: null, + branch: "fix/something", + url: "https://github.com/org/repo/pull/12", + title: "Fix something", + lane: "NORMAL", + status: "QUEUED", + reason: "CI failure on main", + feedback: ["update tests", "fix lint"], + evidenceKeys: ["ci:1"], + author: "bot", + queuedAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("followup-pr"); + expect(body.reasons).toContain("CI failure on main"); + expect(body.reasons).toContain("update tests"); + expect(body.reasons).toContain("fix lint"); + }); + + it("preserves lane filtering", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-normal", + number: 10, + title: "Normal issue", + url: "https://github.com/org/repo/issues/10", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + { + id: "issue-escalated", + number: 20, + title: "Escalated issue", + url: "https://github.com/org/repo/issues/20", + labels: ["priority/p0", "status/ready"], + currentLane: "escalated", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("implement"); + expect(body.issue.number).toBe(10); + }); + + it("passes through includeClaimed behavior", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-claimed-other", + number: 30, + title: "Claimed by other agent", + url: "https://github.com/org/repo/issues/30", + labels: ["agent/other-agent", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("idle"); + }); + + it("includes claimed issues when includeClaimed=true", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-claimed-other", + number: 30, + title: "Claimed by other agent", + url: "https://github.com/org/repo/issues/30", + labels: ["agent/other-agent", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request( + "http://localhost/api/agents/example-agent/next-task?lane=normal&includeClaimed=true", + ), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("implement"); + expect(body.issue.number).toBe(30); + }); + + it("does not require harness-specific fields", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-1", + number: 42, + title: "Test issue", + url: "https://github.com/org/repo/issues/42", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect("harness" in body).toBe(false); + expect("workflowRepo" in body).toBe(false); + }); + + it("followup-pr task uses reason when feedback is empty", async () => { + mocks.prFixFindMany.mockResolvedValue([ + { + id: "prfix-1", + repo: "org/repo", + pr: 12, + issue: null, + branch: "fix/something", + url: "https://github.com/org/repo/pull/12", + title: "Fix something", + lane: "NORMAL", + status: "QUEUED", + reason: "CI failure on main", + feedback: [], + evidenceKeys: ["ci:1"], + author: "bot", + queuedAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("followup-pr"); + expect(body.reasons).toEqual(["CI failure on main"]); + }); + + it("does not mutate issue or claim state", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-1", + number: 42, + title: "Test issue", + url: "https://github.com/org/repo/issues/42", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + expect(mocks.issueFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ state: "open" }), + }), + ); + }); +}); diff --git a/src/app/api/agents/[agentName]/next-task/route.ts b/src/app/api/agents/[agentName]/next-task/route.ts new file mode 100644 index 0000000..8e2d33e --- /dev/null +++ b/src/app/api/agents/[agentName]/next-task/route.ts @@ -0,0 +1,135 @@ +import { NextResponse } from "next/server"; +import { prisma, asPrFixQueueClient } from "@/lib/prisma"; +import { buildAgentQueue } from "@/lib/agent-queue"; +import { listQueuedPrFixItems, toAgentQueuePrFixItem } from "@/lib/pr-fix-queue"; +import { findLeasedIssueIds } from "@/lib/lease"; +import { parseExcludedLabels } from "@/lib/config"; +import { + createIdleTask, + createImplementTask, + createFollowupPrTask, +} from "@/lib/agent-task"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ agentName: string }> }, +) { + const { agentName } = await params; + const { searchParams } = new URL(request.url); + const lane = searchParams.get("lane"); + const excludeDecomposed = searchParams.get("exclude_decomposed"); + const includeClaimed = searchParams.get("includeClaimed") === "true"; + const includeRenovate = searchParams.get("includeRenovate") === "true"; + + try { + const issues = await prisma.issue.findMany({ + where: { + state: "open", + repository: { enabled: true }, + }, + select: { + id: true, + number: true, + title: true, + url: true, + labels: true, + currentLane: true, + decomposed: true, + repository: { select: { fullName: true } }, + linkedPrNumber: true, + linkedPrUrl: true, + linkedPrNeedsFollowup: true, + linkedPrFollowupReasons: true, + linkedPrReviewDecision: true, + linkedPrMergeState: true, + linkedPrHealthCheckedAt: true, + }, + }); + + const issueLane = lane?.toLowerCase() as "normal" | "escalated" | "backlog" | undefined; + const prFixLane = lane; + + const leasedIssueIds = await findLeasedIssueIds(agentName); + + const prFixItems = await listQueuedPrFixItems( + asPrFixQueueClient(prisma), + { lane: prFixLane }, + ); + + const filteredIssues = issues.filter( + (issue) => !leasedIssueIds.includes(issue.id), + ); + + const queue = buildAgentQueue( + filteredIssues.map((issue) => ({ + ...issue, + lane: issue.currentLane ?? undefined, + issueId: issue.id, + repoFullName: issue.repository.fullName, + linkedPrHealth: { + number: issue.linkedPrNumber, + url: issue.linkedPrUrl, + needsFollowup: issue.linkedPrNeedsFollowup, + followupReasons: issue.linkedPrFollowupReasons, + reviewDecision: issue.linkedPrReviewDecision, + mergeState: issue.linkedPrMergeState, + checkedAt: issue.linkedPrHealthCheckedAt?.toISOString() ?? null, + }, + })), + agentName, + { + lane: issueLane, + excludeDecomposed: excludeDecomposed === "true", + includeClaimed, + includeRenovate, + excludedLabels: parseExcludedLabels(process.env.DISPATCH_EXCLUDED_LABELS), + }, + ); + + const prFixQueueItems = prFixItems.map(toAgentQueuePrFixItem); + + if (prFixQueueItems.length > 0) { + const first = prFixQueueItems[0]; + const reasons = [ + ...new Set([first.reason, ...first.feedback].filter(Boolean)), + ]; + const task = createFollowupPrTask({ + agentName, + lane: first.lane ?? undefined, + pullRequest: { + repoFullName: first.repo, + number: first.pr, + url: first.url ?? undefined, + }, + issue: first.issue + ? { repoFullName: first.repo, number: first.issue } + : undefined, + reasons, + }); + return NextResponse.json(task); + } + + if (queue.length > 0) { + const first = queue[0]; + const task = createImplementTask({ + agentName, + lane: first.lane ?? undefined, + issue: { + repoFullName: first.repoFullName ?? "", + number: first.number, + title: first.title, + url: first.url, + }, + }); + return NextResponse.json(task); + } + + return NextResponse.json(createIdleTask("No work available")); + } catch (error) { + console.error("Failed to fetch next task:", error); + return NextResponse.json( + { error: "Failed to fetch next task" }, + { status: 500 }, + ); + } +}