Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 16 additions & 78 deletions src/app/api/agents/[agentName]/next-task/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)),
];
Expand All @@ -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,
);

Expand All @@ -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,
Expand Down
83 changes: 10 additions & 73 deletions src/app/api/agents/[agentName]/queue/route.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 });
Expand Down
134 changes: 134 additions & 0 deletions src/lib/agent-queue-fetch.ts
Original file line number Diff line number Diff line change
@@ -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<typeof toAgentQueuePrFixItem>[];
/** 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<AgentQueueFetchResult> {
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,
};
}