diff --git a/src/app/api/agents/[agentName]/tasks/report/route.test.ts b/src/app/api/agents/[agentName]/tasks/report/route.test.ts new file mode 100644 index 0000000..195279d --- /dev/null +++ b/src/app/api/agents/[agentName]/tasks/report/route.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const { mocks } = vi.hoisted(() => ({ + mocks: { + issueFindMany: vi.fn(), + issueUpdate: vi.fn(), + prFixUpdate: vi.fn(), + leaseDelete: vi.fn(), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + issue: { + findMany: mocks.issueFindMany, + update: mocks.issueUpdate, + }, + prFixQueueItem: { + update: mocks.prFixUpdate, + }, + lease: { + delete: mocks.leaseDelete, + }, + }, +})); + +import { POST } from "./route"; + +function postRequest(body: unknown, agentName = "test-agent") { + return POST( + new Request(`http://localhost/api/agents/${agentName}/tasks/report`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ agentName }) }, + ); +} + +describe("POST /api/agents/[agentName]/tasks/report", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 200 for a valid implement report", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + repoFullName: "org/repo", + issueNumber: 42, + pullRequestNumber: 10, + pullRequestUrl: "https://github.com/org/repo/pull/10", + summary: "Implemented the feature", + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.agentName).toBe("test-agent"); + expect(body.report.taskType).toBe("implement"); + expect(body.report.outcome).toBe("pr_opened"); + }); + + it("returns 200 for a valid followup-pr report", async () => { + const res = await postRequest({ + taskType: "followup-pr", + outcome: "pr_updated", + repoFullName: "org/repo", + pullRequestNumber: 10, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.report.taskType).toBe("followup-pr"); + expect(body.report.outcome).toBe("pr_updated"); + }); + + it("returns 200 for a valid groom report", async () => { + const res = await postRequest({ + taskType: "groom", + outcome: "issue_updated", + repoFullName: "org/repo", + issueNumber: 42, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.report.taskType).toBe("groom"); + expect(body.report.outcome).toBe("issue_updated"); + }); + + it("returns 200 with minimal valid payload", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "no_changes_needed", + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + }); + + it("returns 400 for invalid JSON body", async () => { + const res = await POST( + new Request("http://localhost/api/agents/test-agent/tasks/report", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not-json", + }), + { params: Promise.resolve({ agentName: "test-agent" }) }, + ); + + expect(res.status).toBe(400); + }); + + it("returns 400 when body is not an object", async () => { + const res = await postRequest("string-body"); + expect(res.status).toBe(400); + }); + + it("returns 400 when body is null", async () => { + const res = await postRequest(null); + expect(res.status).toBe(400); + }); + + it("returns 400 when taskType is missing", async () => { + const res = await postRequest({ outcome: "pr_opened" }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/taskType/i); + }); + + it("returns 400 when taskType is invalid", async () => { + const res = await postRequest({ taskType: "unknown-type", outcome: "pr_opened" }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/taskType/i); + }); + + it("returns 400 when outcome is missing", async () => { + const res = await postRequest({ taskType: "implement" }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/outcome/i); + }); + + it("returns 400 when outcome is invalid", async () => { + const res = await postRequest({ taskType: "implement", outcome: "unknown-outcome" }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/outcome/i); + }); + + it("returns 400 when issueNumber is not a number", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + issueNumber: "not-a-number", + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when pullRequestNumber is not a number", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + pullRequestNumber: "not-a-number", + }); + expect(res.status).toBe(400); + }); + + it("returns 200 when issueNumber is a valid number", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + issueNumber: 42, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.report.issueNumber).toBe(42); + }); + + it("returns 200 when pullRequestNumber is a valid number", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + pullRequestNumber: 10, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.report.pullRequestNumber).toBe(10); + }); + + it("response includes the route agentName", async () => { + const res = await postRequest( + { taskType: "implement", outcome: "pr_opened" }, + "my-special-agent", + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.agentName).toBe("my-special-agent"); + }); + + it("does not require harness-specific fields", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect("harness" in body).toBe(false); + expect("workflowRepo" in body).toBe(false); + }); + + it("does not mutate issue state", async () => { + await postRequest({ + taskType: "implement", + outcome: "pr_opened", + repoFullName: "org/repo", + issueNumber: 42, + }); + + expect(mocks.issueUpdate).not.toHaveBeenCalled(); + }); + + it("does not mutate PR-fix queue state", async () => { + await postRequest({ + taskType: "followup-pr", + outcome: "pr_updated", + repoFullName: "org/repo", + pullRequestNumber: 10, + }); + + expect(mocks.prFixUpdate).not.toHaveBeenCalled(); + }); + + it("does not release leases", async () => { + await postRequest({ + taskType: "implement", + outcome: "pr_opened", + }); + + expect(mocks.leaseDelete).not.toHaveBeenCalled(); + }); + + it("preserves optional fields in response", async () => { + const res = await postRequest({ + taskType: "followup-pr", + outcome: "blocked", + repoFullName: "org/repo", + issueNumber: 42, + pullRequestNumber: 10, + pullRequestUrl: "https://github.com/org/repo/pull/10", + summary: "Blocked on external dependency", + error: "Cannot proceed without API access", + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.report.repoFullName).toBe("org/repo"); + expect(body.report.issueNumber).toBe(42); + expect(body.report.pullRequestNumber).toBe(10); + expect(body.report.pullRequestUrl).toBe("https://github.com/org/repo/pull/10"); + expect(body.report.summary).toBe("Blocked on external dependency"); + expect(body.report.error).toBe("Cannot proceed without API access"); + }); + + it("accepts all valid outcomes", async () => { + const validOutcomes = [ + "pr_opened", + "pr_updated", + "issue_updated", + "issue_closed", + "blocked", + "failed", + "no_changes_needed", + ]; + + for (const outcome of validOutcomes) { + const res = await postRequest({ taskType: "implement", outcome }); + expect(res.status).toBe(200); + } + }); + + it("accepts all valid taskTypes", async () => { + const validTaskTypes = ["implement", "followup-pr", "groom"]; + + for (const taskType of validTaskTypes) { + const res = await postRequest({ taskType, outcome: "no_changes_needed" }); + expect(res.status).toBe(200); + } + }); + + it("returns 400 when repoFullName is not a string", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + repoFullName: 123, + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when pullRequestUrl is not a string", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + pullRequestUrl: 123, + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when summary is not a string", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + summary: true, + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when error is not a string", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "failed", + error: 500, + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when issueNumber is a decimal", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + issueNumber: 42.5, + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when pullRequestNumber is a decimal", async () => { + const res = await postRequest({ + taskType: "implement", + outcome: "pr_opened", + pullRequestNumber: 10.7, + }); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/agents/[agentName]/tasks/report/route.ts b/src/app/api/agents/[agentName]/tasks/report/route.ts new file mode 100644 index 0000000..af2f277 --- /dev/null +++ b/src/app/api/agents/[agentName]/tasks/report/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from "next/server"; + +const VALID_TASK_TYPES = ["implement", "followup-pr", "groom"] as const; +type ValidTaskType = (typeof VALID_TASK_TYPES)[number]; + +const VALID_OUTCOMES = [ + "pr_opened", + "pr_updated", + "issue_updated", + "issue_closed", + "blocked", + "failed", + "no_changes_needed", +] as const; +type ValidOutcome = (typeof VALID_OUTCOMES)[number]; + +export interface TaskReportBody { + taskType: ValidTaskType; + outcome: ValidOutcome; + repoFullName?: string; + issueNumber?: number; + pullRequestNumber?: number; + pullRequestUrl?: string; + summary?: string; + error?: string; +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ agentName: string }> }, +) { + const { agentName } = await params; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const raw = body as Record; + + const taskType = raw.taskType; + if (typeof taskType !== "string" || !VALID_TASK_TYPES.includes(taskType as ValidTaskType)) { + return NextResponse.json( + { error: `Invalid taskType. Must be one of: ${VALID_TASK_TYPES.join(", ")}` }, + { status: 400 }, + ); + } + + const outcome = raw.outcome; + if (typeof outcome !== "string" || !VALID_OUTCOMES.includes(outcome as ValidOutcome)) { + return NextResponse.json( + { error: `Invalid outcome. Must be one of: ${VALID_OUTCOMES.join(", ")}` }, + { status: 400 }, + ); + } + + if (raw.issueNumber !== undefined && (typeof raw.issueNumber !== "number" || !Number.isInteger(raw.issueNumber))) { + return NextResponse.json( + { error: "issueNumber must be an integer" }, + { status: 400 }, + ); + } + + if (raw.pullRequestNumber !== undefined && (typeof raw.pullRequestNumber !== "number" || !Number.isInteger(raw.pullRequestNumber))) { + return NextResponse.json( + { error: "pullRequestNumber must be an integer" }, + { status: 400 }, + ); + } + + const stringFields: readonly string[] = ["repoFullName", "pullRequestUrl", "summary", "error"]; + for (const field of stringFields) { + if (raw[field] !== undefined && typeof raw[field] !== "string") { + return NextResponse.json( + { error: `${field} must be a string` }, + { status: 400 }, + ); + } + } + + const report: TaskReportBody = { + taskType: taskType as ValidTaskType, + outcome: outcome as ValidOutcome, + repoFullName: raw.repoFullName as string | undefined, + issueNumber: raw.issueNumber as number | undefined, + pullRequestNumber: raw.pullRequestNumber as number | undefined, + pullRequestUrl: raw.pullRequestUrl as string | undefined, + summary: raw.summary as string | undefined, + error: raw.error as string | undefined, + }; + + return NextResponse.json({ + ok: true, + agentName, + report, + }); +}