diff --git a/src/app/api/agents/[agentName]/next-task/route.test.ts b/src/app/api/agents/[agentName]/next-task/route.test.ts index cd71f2d..881a186 100644 --- a/src/app/api/agents/[agentName]/next-task/route.test.ts +++ b/src/app/api/agents/[agentName]/next-task/route.test.ts @@ -701,4 +701,447 @@ describe("GET /api/agents/[agentName]/next-task", () => { expect(body.type).toBe("idle"); expect(body.shouldRun).toBe(false); }); + + // ─── Groom mode tests ────────────────────────────────────────────── + + describe("mode=groom", () => { + it("default next-task behavior is unchanged when mode is absent", 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" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("implement"); + expect(body.issue.number).toBe(42); + }); + + it("mode=groom returns one groom task", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 10, + title: "Unlabeled issue", + url: "https://github.com/org/repo/issues/10", + labels: [], + currentLane: null, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("groom"); + expect(body.shouldRun).toBe(true); + expect(body.agentName).toBe("groomer"); + }); + + it("unlabeled issue is eligible", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 10, + title: "Unlabeled issue", + url: "https://github.com/org/repo/issues/10", + labels: [], + currentLane: null, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("groom"); + expect(body.issue.number).toBe(10); + }); + + it("issue missing status is eligible", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 20, + title: "Missing status", + url: "https://github.com/org/repo/issues/20", + labels: ["priority/p1"], + currentLane: "normal", + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("groom"); + expect(body.issue.number).toBe(20); + }); + + it("issue missing priority is eligible", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 30, + title: "Missing priority", + url: "https://github.com/org/repo/issues/30", + labels: ["status/ready"], + currentLane: "normal", + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("groom"); + expect(body.issue.number).toBe(30); + }); + + it("backlog lane issue is eligible", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 40, + title: "Backlog issue", + url: "https://github.com/org/repo/issues/40", + labels: ["status/backlog", "priority/p2"], + currentLane: "backlog", + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("groom"); + expect(body.issue.number).toBe(40); + }); + + it("fully labeled issue with lane is not eligible", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 50, + title: "Fully labeled", + url: "https://github.com/org/repo/issues/50", + labels: ["status/ready", "priority/p0", "agent/alice"], + currentLane: "normal", + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("idle"); + expect(body.reason).toBe("No grooming work available"); + }); + + it("closed issues are excluded", async () => { + mocks.issueFindMany.mockResolvedValue([]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + expect(mocks.issueFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ state: "open" }), + }), + ); + const body = await res.json(); + expect(body.type).toBe("idle"); + }); + + it("disabled repo issues are excluded", async () => { + mocks.issueFindMany.mockResolvedValue([]); + + await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + expect(mocks.issueFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ repository: { enabled: true } }), + }), + ); + }); + + it("no candidates returns idle", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 50, + title: "Fully labeled", + url: "https://github.com/org/repo/issues/50", + labels: ["status/ready", "priority/p0", "agent/alice"], + currentLane: "normal", + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("idle"); + expect(body.shouldRun).toBe(false); + expect(body.reason).toBe("No grooming work available"); + }); + + it("groom mode does not claim or mutate anything", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 10, + title: "Unlabeled issue", + url: "https://github.com/org/repo/issues/10", + labels: [], + currentLane: null, + repository: { fullName: "org/repo" }, + }, + ]); + + await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + expect(mocks.issueFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ state: "open" }), + }), + ); + }); + + it("groom mode returns one object, not an array", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 10, + title: "Unlabeled issue", + url: "https://github.com/org/repo/issues/10", + labels: [], + currentLane: null, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect(Array.isArray(body)).toBe(false); + expect(body.type).toBe("groom"); + }); + + it("groom mode does not require harness-specific fields", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 10, + title: "Unlabeled issue", + url: "https://github.com/org/repo/issues/10", + labels: [], + currentLane: null, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect("harness" in body).toBe(false); + expect("workflowRepo" in body).toBe(false); + }); + + it("prefers unlabeled issues over partially labeled", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 20, + title: "Missing status", + url: "https://github.com/org/repo/issues/20", + labels: ["priority/p1"], + currentLane: "normal", + repository: { fullName: "org/repo" }, + }, + { + number: 10, + title: "Unlabeled issue", + url: "https://github.com/org/repo/issues/10", + labels: [], + currentLane: null, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("groom"); + expect(body.issue.number).toBe(10); + }); + + it("prefers missing status over missing priority", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 30, + title: "Missing priority", + url: "https://github.com/org/repo/issues/30", + labels: ["status/ready"], + currentLane: "normal", + repository: { fullName: "org/repo" }, + }, + { + number: 20, + title: "Missing status", + url: "https://github.com/org/repo/issues/20", + labels: ["priority/p1"], + currentLane: "normal", + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("groom"); + expect(body.issue.number).toBe(20); + }); + + it("prefers lowest issue number as tie breaker", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 30, + title: "Also unlabeled", + url: "https://github.com/org/repo/issues/30", + labels: [], + currentLane: null, + repository: { fullName: "org/repo" }, + }, + { + number: 10, + title: "Unlabeled issue", + url: "https://github.com/org/repo/issues/10", + labels: [], + currentLane: null, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("groom"); + expect(body.issue.number).toBe(10); + }); + + it("includes issue context in groom task", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 10, + title: "Unlabeled issue", + url: "https://github.com/org/repo/issues/10", + labels: [], + currentLane: null, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("groom"); + expect(body.issue.repoFullName).toBe("org/repo"); + expect(body.issue.number).toBe(10); + expect(body.issue.title).toBe("Unlabeled issue"); + expect(body.issue.url).toBe("https://github.com/org/repo/issues/10"); + }); + + it("includes lane in groom task when available", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 40, + title: "Backlog issue", + url: "https://github.com/org/repo/issues/40", + labels: ["status/backlog", "priority/p2"], + currentLane: "backlog", + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("groom"); + expect(body.lane).toBe("backlog"); + }); + + it("defaults lane to backlog when currentLane is null", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 10, + title: "Unlabeled issue", + url: "https://github.com/org/repo/issues/10", + labels: [], + currentLane: null, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("groom"); + expect(body.lane).toBe("backlog"); + }); + }); }); diff --git a/src/app/api/agents/[agentName]/next-task/route.ts b/src/app/api/agents/[agentName]/next-task/route.ts index 36a51ff..907b6d6 100644 --- a/src/app/api/agents/[agentName]/next-task/route.ts +++ b/src/app/api/agents/[agentName]/next-task/route.ts @@ -8,6 +8,7 @@ import { createIdleTask, createImplementTask, createFollowupPrTask, + createGroomTask, } from "@/lib/agent-task"; export async function GET( @@ -20,8 +21,73 @@ export async function GET( const excludeDecomposed = searchParams.get("exclude_decomposed"); const includeClaimed = searchParams.get("includeClaimed") === "true"; const includeRenovate = searchParams.get("includeRenovate") === "true"; + const mode = searchParams.get("mode"); try { + // Groom mode: return exactly one issue to triage/enrich + if (mode === "groom") { + const issues = await prisma.issue.findMany({ + where: { + state: "open", + repository: { enabled: true }, + }, + select: { + number: true, + title: true, + url: true, + labels: true, + currentLane: true, + repository: { select: { fullName: true } }, + }, + orderBy: { number: "asc" }, + }); + + const candidates = issues + .map((issue) => { + const hasStatus = issue.labels.some((l) => l.startsWith("status/")); + const hasPriority = issue.labels.some((l) => l.startsWith("priority/")); + const hasAgent = issue.labels.some((l) => l.startsWith("agent/")); + const hasLane = !!issue.currentLane; + const isBacklog = issue.currentLane === "backlog"; + const isUnlabeled = issue.labels.length === 0; + + // Eligible if missing any key metadata + const eligible = + isUnlabeled || !hasStatus || !hasPriority || !hasAgent || !hasLane || isBacklog; + + // Score: fewer missing fields = lower priority (higher number) + // Priority order: unlabeled > missing status > missing priority > backlog lane + let score = 0; + if (isUnlabeled) score += 1000; + if (!hasStatus) score += 500; + if (!hasPriority) score += 250; + if (isBacklog) score += 100; + if (!hasAgent) score += 50; + if (!hasLane && !isBacklog) score += 25; + + return { issue, eligible, score }; + }) + .filter((c) => c.eligible) + .sort((a, b) => b.score - a.score || a.issue.number - b.issue.number); + + if (candidates.length === 0) { + return NextResponse.json(createIdleTask("No grooming work available")); + } + + const best = candidates[0].issue; + const task = createGroomTask({ + agentName, + lane: best.currentLane ?? "backlog", + issue: { + repoFullName: best.repository.fullName, + number: best.number, + title: best.title, + url: best.url, + }, + }); + return NextResponse.json(task); + } + const issues = await prisma.issue.findMany({ where: { state: "open", diff --git a/src/lib/agent-task.test.ts b/src/lib/agent-task.test.ts index 1e04ba9..e60bbb2 100644 --- a/src/lib/agent-task.test.ts +++ b/src/lib/agent-task.test.ts @@ -257,6 +257,16 @@ describe("createGroomTask", () => { expect(task.issue).toBeUndefined(); }); + it("preserves lane when provided", () => { + const task = createGroomTask({ ...baseInput, lane: "backlog" }); + expect(task.lane).toBe("backlog"); + }); + + it("omits lane when not provided", () => { + const task = createGroomTask(baseInput); + expect(task.lane).toBeUndefined(); + }); + it("preserves custom instructions", () => { const task = createGroomTask({ ...baseInput, instructions: "Custom grooming" }); expect(task.instructions).toBe("Custom grooming"); diff --git a/src/lib/agent-task.ts b/src/lib/agent-task.ts index 6f3d161..7148436 100644 --- a/src/lib/agent-task.ts +++ b/src/lib/agent-task.ts @@ -52,6 +52,7 @@ export interface GroomTask { type: "groom"; shouldRun: true; agentName: string; + lane?: string; issue?: IssueRef; instructions: string; stopAfter: string; @@ -154,6 +155,7 @@ export function createFollowupPrTask(input: FollowupPrTaskInput): FollowupPrTask export interface GroomTaskInput { agentName: string; + lane?: string; issue?: IssueRef; instructions?: string; stopAfter?: string; @@ -165,6 +167,7 @@ export function createGroomTask(input: GroomTaskInput): GroomTask { type: "groom", shouldRun: true, agentName: input.agentName, + lane: input.lane, issue: input.issue, instructions: input.instructions ?? GROOM_INSTRUCTIONS, stopAfter: input.stopAfter ?? GROOM_STOP_AFTER,