/** * GET /api/v1/ready — Actionable issues for AI agents. * * Returns issues that are ready to work on: open or in_progress * with no unresolved blockers. Sorted by priority (critical first). * No auth required. * * Query params: * ?unclaimed=true Only unclaimed issues * ?type=bug,feature Filter by issue type (comma-separated) * ?assignee=handle Filter by assignee * ?prefix=beads-map Filter by repo prefix * ?limit=20 Max issues returned (default: all) */ import { loadBeadsData } from "@/lib/parse-beads"; import { discoverBeadsDir } from "@/lib/discover"; import { fetchBeadsComments, getClaimedNodes } from "@/lib/comments"; import type { BeadsComment, ClaimInfo } from "@/lib/comments"; import type { GraphNode } from "@/lib/types"; import { jsonResponse, errorResponse, OPTIONS } from "@/lib/api-helpers"; import { readFileSync } from "fs"; import { join } from "path"; export const dynamic = "force-dynamic"; export { OPTIONS }; let heartbeadsVersion = "0.0.0"; try { const pkg = JSON.parse( readFileSync(join(process.cwd(), "package.json"), "utf-8") ); heartbeadsVersion = pkg.version || heartbeadsVersion; } catch { // ignore } function serializeComment(c: BeadsComment): Record { return { author: { handle: c.handle, did: c.did, ...(c.displayName ? { displayName: c.displayName } : {}), }, text: c.text, createdAt: c.createdAt, likes: c.likes.length, replies: c.replies.map(serializeComment), }; } export async function GET(request: Request) { try { const discovery = discoverBeadsDir(); const url = new URL(request.url); const params = url.searchParams; // Parse query params const unclaimedOnly = params.get("unclaimed") === "true"; const typeFilter = params.get("type")?.split(",").filter(Boolean); const assigneeFilter = params.get("assignee") || null; const prefixFilter = params.get("prefix") || null; const limitParam = params.get("limit"); const limit = limitParam ? Math.max(parseInt(limitParam) || 0, 1) : null; // Load beads data + comments in parallel const [beadsData, commentsResult] = await Promise.all([ Promise.resolve(loadBeadsData(discovery.beadsDir)), fetchBeadsComments().catch((err) => { console.error("[api/v1/ready] Failed to fetch comments:", err); return null; }), ]); const claims = commentsResult ? getClaimedNodes(commentsResult.allComments) : new Map(); // Build status map for blocker resolution const nodeMap = new Map( beadsData.graphData.nodes.map((n) => [n.id, n]) ); // Filter to "ready" issues: // - status is open or in_progress // - all blockers have status "closed" let readyIssues = beadsData.graphData.nodes.filter((node) => { if (node.status !== "open" && node.status !== "in_progress") { return false; } // Check all blockers are closed // blockerIds = IDs of issues that BLOCK this one (i.e. this depends on them) // Actually in the GraphNode, blockerIds are "IDs of issues this blocks" // and dependentIds are "IDs of issues blocking this" // Let me verify: from types.ts comments: // blockerCount: number; // issues this blocks // dependentCount: number; // issues that depend on this // blockerIds: string[]; // IDs of issues this blocks // dependentIds: string[]; // IDs of issues blocking this // // Wait, the naming is confusing. Let me check parse-beads for the actual logic. // In the graph: source = depends_on_id (blocker), target = issue_id (blocked) // So for issue X: // blockerIds = nodes that X blocks (X is upstream of them) // dependentIds = nodes that block X (X depends on them, they are upstream) // // For "ready", we need: all of X's UPSTREAM blockers (dependentIds) are closed for (const depId of node.dependentIds) { const depNode = nodeMap.get(depId); if (depNode && depNode.status !== "closed") { return false; // Has an unresolved blocker } } return true; }); // Apply filters if (typeFilter) { readyIssues = readyIssues.filter((n) => typeFilter.includes(n.issueType) ); } if (assigneeFilter) { readyIssues = readyIssues.filter( (n) => n.assignee === assigneeFilter ); } if (prefixFilter) { readyIssues = readyIssues.filter((n) => n.prefix === prefixFilter); } if (unclaimedOnly) { readyIssues = readyIssues.filter((n) => !claims.has(n.id)); } // Sort by priority ascending (0=critical first), then created_at ascending (oldest first) readyIssues.sort((a, b) => { if (a.priority !== b.priority) return a.priority - b.priority; return ( new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); }); // Apply limit if (limit) { readyIssues = readyIssues.slice(0, limit); } // Enrich issues const enrichedIssues = readyIssues.map((node) => { const nodeComments = commentsResult?.commentsByNode.get(node.id) || []; const claim = claims.get(node.id) || null; return { id: node.id, title: node.title, description: node.description || null, status: node.status, priority: node.priority, issue_type: node.issueType, owner: node.owner || null, assignee: node.assignee || null, labels: node.labels, created_at: node.createdAt, updated_at: node.updatedAt, prefix: node.prefix, blockers: node.dependentIds, // issues that block this one (upstream) dependents: node.blockerIds, // issues this one blocks (downstream) comments: nodeComments.map(serializeComment), claimed_by: claim ? { handle: claim.handle, did: claim.did, ...(claim.displayName ? { displayName: claim.displayName } : {}), claimed_at: claim.claimedAt, } : null, }; }); // Build summary stats const byPriority: Record = {}; const byType: Record = {}; let unclaimed = 0; for (const issue of readyIssues) { const p = String(issue.priority); byPriority[p] = (byPriority[p] || 0) + 1; byType[issue.issueType] = (byType[issue.issueType] || 0) + 1; if (!claims.has(issue.id)) unclaimed++; } const warnings: string[] = []; if (!commentsResult) { warnings.push("Failed to fetch comments from indexer"); } return jsonResponse({ issues: enrichedIssues, stats: { total_ready: enrichedIssues.length, unclaimed, by_priority: byPriority, by_type: byType, }, _meta: { generated_at: new Date().toISOString(), api_version: "v1" as const, heartbeads_version: heartbeadsVersion, ...(warnings.length ? { warnings } : {}), }, }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unknown error"; if (message.includes("No .beads/ directory found")) { return errorResponse( "No .beads directory found", 404, "Run bd init in your project first, or set BEADS_DIR." ); } console.error("[api/v1/ready] Error:", error); return errorResponse("Internal server error", 500); } }