diff --git a/src/app/api/agents/[agentName]/next-task/route.ts b/src/app/api/agents/[agentName]/next-task/route.ts index c8b3fe2..8660a0e 100644 --- a/src/app/api/agents/[agentName]/next-task/route.ts +++ b/src/app/api/agents/[agentName]/next-task/route.ts @@ -1,17 +1,14 @@ import { NextResponse } from "next/server"; import { authorizeRequest } from "@/lib/auth"; -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 { prisma } from "@/lib/prisma"; import { createIdleTask, createImplementTask, createFollowupPrTask, createGroomTask, } from "@/lib/agent-task"; -import { isBacklogLane, isValidLane, getLaneIds, getBacklogLane, resolveRequestLane } from "@/lib/lane-config"; +import { isBacklogLane, getBacklogLane } from "@/lib/lane-config"; +import { fetchAgentQueueData } from "@/lib/agent-queue-fetch"; export async function GET( request: Request, @@ -95,84 +92,25 @@ export async function GET( return NextResponse.json(task); } - 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 { laneValid, rankedQueue, prFixItems, availableLanes } = await fetchAgentQueueData({ + agentName, + lane, + excludeDecomposed: excludeDecomposed === "true", + includeClaimed, + includeRenovate, }); - const issueLane = resolveRequestLane(lane?.toLowerCase()); - const prFixLane = lane; - - // Validate lane against configured lanes (allow omitting lane for backward compatibility) - if (issueLane === null && lane) { + if (!laneValid) { return NextResponse.json( { - error: `Invalid lane: "${lane}". Must be one of: ${getLaneIds().join(", ")}`, + error: `Invalid lane: "${lane}". Must be one of: ${availableLanes.join(", ")}`, }, { status: 400 }, ); } - 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 ?? undefined, - 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]; + if (prFixItems.length > 0) { + const first = prFixItems[0]; const reasons = [ ...new Set([first.reason, ...first.feedback].filter(Boolean)), ]; @@ -192,9 +130,9 @@ export async function GET( return NextResponse.json(task); } - if (queue.length > 0) { + if (rankedQueue.length > 0) { // Scan for linked PR follow-up before returning implement task - const followupItem = queue.find( + const followupItem = rankedQueue.find( (item) => item.linkedPrHealth?.needsFollowup && item.linkedPrHealth?.number, ); @@ -221,7 +159,7 @@ export async function GET( return NextResponse.json(task); } - const first = queue[0]; + const first = rankedQueue[0]; const task = createImplementTask({ agentName, lane: first.lane ?? undefined, diff --git a/src/app/api/agents/[agentName]/queue/route.ts b/src/app/api/agents/[agentName]/queue/route.ts index 56e0846..9bf2ec8 100644 --- a/src/app/api/agents/[agentName]/queue/route.ts +++ b/src/app/api/agents/[agentName]/queue/route.ts @@ -1,11 +1,6 @@ import { NextResponse } from "next/server"; import { authorizeRequest } from "@/lib/auth"; -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 { isValidLane, getLaneIds } from "@/lib/lane-config"; +import { fetchAgentQueueData } from "@/lib/agent-queue-fetch"; export async function GET(request: Request, { params }: { params: Promise<{ agentName: string }> }) { const { agentName } = await params; @@ -21,82 +16,24 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen const includeRenovate = searchParams.get("includeRenovate") === "true"; try { - // Fetch all open issues from enabled repos, using GitHub Issues as source of truth - 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 { laneValid, rankedQueue, prFixItems, availableLanes } = await fetchAgentQueueData({ + agentName, + lane, + excludeDecomposed: excludeDecomposed === "true", + includeClaimed, + includeRenovate, }); - const issueLane = lane?.toLowerCase(); - const prFixLane = lane; - - // Validate lane against configured lanes (allow omitting lane for backward compatibility) - if (issueLane && !isValidLane(issueLane)) { + if (!laneValid) { return NextResponse.json( { - error: `Invalid lane: "${lane}". Must be one of: ${getLaneIds().join(", ")}`, + error: `Invalid lane: "${lane}". Must be one of: ${availableLanes.join(", ")}`, }, { status: 400 }, ); } - // Find issues that have active leases from OTHER agents — exclude them - // so other agents don't overlap on leased work. - const leasedIssueIds = await findLeasedIssueIds(agentName); - - const prFixItems = await listQueuedPrFixItems(asPrFixQueueClient(prisma), { lane: prFixLane }); - - // Filter out leased issue IDs before building the queue - 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), - }, - ); - - return NextResponse.json([...prFixItems.map(toAgentQueuePrFixItem), ...queue]); + return NextResponse.json([...prFixItems, ...rankedQueue]); } catch (error) { console.error("Failed to fetch agent queue:", error); return NextResponse.json({ error: "Failed to fetch agent queue" }, { status: 500 }); diff --git a/src/lib/agent-queue-fetch.ts b/src/lib/agent-queue-fetch.ts new file mode 100644 index 0000000..a7aa80d --- /dev/null +++ b/src/lib/agent-queue-fetch.ts @@ -0,0 +1,134 @@ +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 { resolveRequestLane, getLaneIds } from "@/lib/lane-config"; +import type { RankedIssue } from "@/lib/agent-queue"; + +/** + * Parameters for fetching the agent queue. + */ +export interface AgentQueueFetchParams { + /** Agent name (used for lease exclusion and ranking) */ + agentName: string; + /** Raw lane filter from query string (may be null/undefined) */ + lane: string | null; + /** Whether to exclude decomposed audit parents */ + excludeDecomposed: boolean; + /** Whether to include issues claimed by other agents */ + includeClaimed: boolean; + /** Whether to include Renovate issues */ + includeRenovate: boolean; +} + +/** + * Result of fetching the agent queue data. + */ +export interface AgentQueueFetchResult { + /** Resolved lane id (after alias resolution), or null if no lane was provided */ + resolvedLane: string | null; + /** Whether the lane is valid (false if an invalid lane was provided) */ + laneValid: boolean; + /** Ranked and filtered issue queue */ + rankedQueue: RankedIssue[]; + /** PR fix queue items */ + prFixItems: ReturnType[]; + /** Available lane ids for error messages */ + availableLanes: string[]; +} + +/** + * Fetch and build the agent queue data shared by `/queue` and `/next-task` routes. + * + * This function: + * 1. Fetches all open issues from enabled repos + * 2. Filters out issues leased by other agents + * 3. Builds a ranked issue queue via `buildAgentQueue` + * 4. Lists queued PR fix items + * + * Lane resolution uses `resolveRequestLane` which handles alias mapping. + */ +export async function fetchAgentQueueData( + params: AgentQueueFetchParams, +): Promise { + const { agentName, lane, excludeDecomposed, includeClaimed, includeRenovate } = params; + + // Fetch all open issues from enabled repos (GitHub Issues as source of truth) + 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, + }, + }); + + // Resolve lane through alias map (returns null for unknown lanes) + const resolvedLane = resolveRequestLane(lane?.toLowerCase()); + const availableLanes = getLaneIds(); + + // Validate: if a lane was provided but resolution returned null, it's invalid + const laneValid = !(lane && resolvedLane === null); + + // Find issues that have active leases from OTHER agents — exclude them + const leasedIssueIds = await findLeasedIssueIds(agentName); + + // List queued PR fix items (uses raw lane for pr-fix queue normalization) + const prFixItemsRaw = await listQueuedPrFixItems(asPrFixQueueClient(prisma), { + lane, + }); + + // Filter out leased issue IDs before building the queue + const filteredIssues = issues.filter((issue) => !leasedIssueIds.includes(issue.id)); + + // Build ranked issue queue + const rankedQueue = 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: resolvedLane ?? undefined, + excludeDecomposed, + includeClaimed, + includeRenovate, + excludedLabels: parseExcludedLabels(process.env.DISPATCH_EXCLUDED_LABELS), + }, + ); + + return { + resolvedLane, + laneValid, + rankedQueue, + prFixItems: prFixItemsRaw.map(toAgentQueuePrFixItem), + availableLanes, + }; +}