From e5833cc4641bf3cd512b940b2b15f156bd095a4e Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Tue, 16 Jun 2026 21:33:56 -0600 Subject: [PATCH] feat: make PR follow-up a first-class next-task source --- .../[agentName]/next-task/route.test.ts | 307 ++++++++++++++++++ .../api/agents/[agentName]/next-task/route.ts | 28 ++ 2 files changed, 335 insertions(+) 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 a6a7de7..cd71f2d 100644 --- a/src/app/api/agents/[agentName]/next-task/route.test.ts +++ b/src/app/api/agents/[agentName]/next-task/route.test.ts @@ -394,4 +394,311 @@ describe("GET /api/agents/[agentName]/next-task", () => { }), ); }); + + // Linked PR follow-up tests + + it("returns followup-pr when issue has linked PR needing follow-up", 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" }, + linkedPrNumber: 15, + linkedPrUrl: "https://github.com/org/repo/pull/15", + linkedPrNeedsFollowup: true, + linkedPrFollowupReasons: ["tests failing", "lint errors"], + linkedPrReviewDecision: null, + linkedPrMergeState: null, + linkedPrHealthCheckedAt: 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" }) }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.type).toBe("followup-pr"); + expect(body.shouldRun).toBe(true); + }); + + it("linked PR follow-up beats normal implement work", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-followup", + number: 42, + title: "Issue with PR needing follow-up", + url: "https://github.com/org/repo/issues/42", + labels: ["priority/p1", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + linkedPrNumber: 15, + linkedPrUrl: "https://github.com/org/repo/pull/15", + linkedPrNeedsFollowup: true, + linkedPrFollowupReasons: ["needs changes"], + linkedPrReviewDecision: null, + linkedPrMergeState: null, + linkedPrHealthCheckedAt: new Date("2026-01-01T00:00:00Z"), + }, + { + id: "issue-normal", + number: 99, + title: "Normal issue", + url: "https://github.com/org/repo/issues/99", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + linkedPrNumber: null, + linkedPrUrl: null, + linkedPrNeedsFollowup: false, + linkedPrFollowupReasons: [], + linkedPrReviewDecision: null, + linkedPrMergeState: null, + linkedPrHealthCheckedAt: null, + }, + ]); + + 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.pullRequest.number).toBe(15); + }); + + it("PR-fix queue item still beats linked PR follow-up", 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: "review changes requested", + feedback: ["update tests"], + evidenceKeys: ["review:1"], + author: "bot", + queuedAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + ]); + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-followup", + number: 42, + title: "Issue with PR needing follow-up", + url: "https://github.com/org/repo/issues/42", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + linkedPrNumber: 15, + linkedPrUrl: "https://github.com/org/repo/pull/15", + linkedPrNeedsFollowup: true, + linkedPrFollowupReasons: ["needs changes"], + linkedPrReviewDecision: null, + linkedPrMergeState: null, + linkedPrHealthCheckedAt: 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.pullRequest.number).toBe(12); + }); + + it("linked PR follow-up includes issue context", 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" }, + linkedPrNumber: 15, + linkedPrUrl: "https://github.com/org/repo/pull/15", + linkedPrNeedsFollowup: true, + linkedPrFollowupReasons: ["needs changes"], + linkedPrReviewDecision: null, + linkedPrMergeState: null, + linkedPrHealthCheckedAt: 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(42); + expect(body.issue.title).toBe("Fix login bug"); + expect(body.issue.url).toBe("https://github.com/org/repo/issues/42"); + }); + + it("linked PR follow-up includes pull request context", 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" }, + linkedPrNumber: 15, + linkedPrUrl: "https://github.com/org/repo/pull/15", + linkedPrNeedsFollowup: true, + linkedPrFollowupReasons: ["needs changes"], + linkedPrReviewDecision: null, + linkedPrMergeState: null, + linkedPrHealthCheckedAt: 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.pullRequest.repoFullName).toBe("org/repo"); + expect(body.pullRequest.number).toBe(15); + expect(body.pullRequest.url).toBe("https://github.com/org/repo/pull/15"); + }); + + it("linked PR follow-up uses followup reasons", 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" }, + linkedPrNumber: 15, + linkedPrUrl: "https://github.com/org/repo/pull/15", + linkedPrNeedsFollowup: true, + linkedPrFollowupReasons: ["tests failing", "lint errors", "missing docs"], + linkedPrReviewDecision: null, + linkedPrMergeState: null, + linkedPrHealthCheckedAt: 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("tests failing"); + expect(body.reasons).toContain("lint errors"); + expect(body.reasons).toContain("missing docs"); + }); + + it("missing followup reasons uses fallback reason", 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" }, + linkedPrNumber: 15, + linkedPrUrl: "https://github.com/org/repo/pull/15", + linkedPrNeedsFollowup: true, + linkedPrFollowupReasons: [], + linkedPrReviewDecision: null, + linkedPrMergeState: null, + linkedPrHealthCheckedAt: 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(["Linked PR needs follow-up"]); + }); + + it("normal issue still returns implement when no follow-up exists", 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" }, + linkedPrNumber: null, + linkedPrUrl: null, + linkedPrNeedsFollowup: false, + linkedPrFollowupReasons: [], + linkedPrReviewDecision: null, + linkedPrMergeState: null, + linkedPrHealthCheckedAt: null, + }, + ]); + + 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("idle still works when queue is empty", async () => { + mocks.issueFindMany.mockResolvedValue([]); + + 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(body.type).toBe("idle"); + expect(body.shouldRun).toBe(false); + }); }); diff --git a/src/app/api/agents/[agentName]/next-task/route.ts b/src/app/api/agents/[agentName]/next-task/route.ts index 8e2d33e..36a51ff 100644 --- a/src/app/api/agents/[agentName]/next-task/route.ts +++ b/src/app/api/agents/[agentName]/next-task/route.ts @@ -110,6 +110,34 @@ export async function GET( } if (queue.length > 0) { + // Scan for linked PR follow-up before returning implement task + const followupItem = queue.find( + (item) => item.linkedPrHealth?.needsFollowup && item.linkedPrHealth?.number, + ); + + if (followupItem && followupItem.linkedPrHealth?.number) { + const health = followupItem.linkedPrHealth; + const task = createFollowupPrTask({ + agentName, + lane: followupItem.lane ?? undefined, + issue: { + repoFullName: followupItem.repoFullName ?? "", + number: followupItem.number, + title: followupItem.title, + url: followupItem.url, + }, + pullRequest: { + repoFullName: followupItem.repoFullName ?? "", + number: health.number!, + url: health.url ?? undefined, + }, + reasons: health.followupReasons.length > 0 + ? health.followupReasons + : ["Linked PR needs follow-up"], + }); + return NextResponse.json(task); + } + const first = queue[0]; const task = createImplementTask({ agentName,