/** * GET /api/v1/graph — Full project snapshot for AI agents. * * Returns the entire beads graph with issues, dependencies, comments, * claims, and activity in a single JSON response. No auth required. * * Query params: * ?status=open,in_progress Filter issues by status (comma-separated) * ?priority=0,1 Filter by priority (comma-separated) * ?prefix=beads-map Filter by repo prefix * ?include=comments,activity Opt-in fields (default: all) * ?limit=50 Activity feed cap (default 50, max 200) */ import { loadBeadsData } from "@/lib/parse-beads"; import { discoverBeadsDir, getRepoUrls } from "@/lib/discover"; import { fetchBeadsComments, getClaimedNodes } from "@/lib/comments"; import type { BeadsComment, ClaimInfo } from "@/lib/comments"; import { buildHistoricalFeed } from "@/lib/activity"; import type { GraphNode } from "@/lib/types"; import { jsonResponse, errorResponse, OPTIONS } from "@/lib/api-helpers"; import { readFileSync, existsSync } from "fs"; import { join } from "path"; import { parse as parseYaml } from "yaml"; export const dynamic = "force-dynamic"; export { OPTIONS }; // Read version from package.json at module load time let heartbeadsVersion = "0.0.0"; try { const pkg = JSON.parse( readFileSync(join(process.cwd(), "package.json"), "utf-8") ); heartbeadsVersion = pkg.version || heartbeadsVersion; } catch { // ignore } /** Serialize a BeadsComment tree for API output */ 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), }; } /** Serialize a claim for API output */ function serializeClaim(claim: ClaimInfo) { return { handle: claim.handle, did: claim.did, ...(claim.displayName ? { displayName: claim.displayName } : {}), claimed_at: claim.claimedAt, }; } export async function GET(request: Request) { try { const discovery = discoverBeadsDir(); const url = new URL(request.url); const params = url.searchParams; // Parse query params const statusFilter = params.get("status")?.split(",").filter(Boolean); const priorityFilter = params .get("priority") ?.split(",") .map(Number) .filter((n) => !isNaN(n)); const prefixFilter = params.get("prefix") || null; const includeParam = params.get("include"); const limit = Math.min( Math.max(parseInt(params.get("limit") || "50") || 50, 1), 200 ); // Determine what to include (default: everything) let includeComments = true; let includeActivity = true; if (includeParam !== null) { const includes = includeParam.split(",").filter(Boolean); includeComments = includes.includes("comments"); includeActivity = includes.includes("activity"); } // Load beads data + config in parallel with optional comments const beadsPromise = Promise.resolve(loadBeadsData(discovery.beadsDir)); const commentsPromise = includeComments ? fetchBeadsComments().catch((err) => { console.error("[api/v1/graph] Failed to fetch comments:", err); return null; }) : Promise.resolve(null); const [beadsData, commentsResult] = await Promise.all([ beadsPromise, commentsPromise, ]); // Build project metadata let repos: string[] = ["."]; const configPath = join(discovery.beadsDir, "config.yaml"); try { if (existsSync(configPath)) { const content = readFileSync(configPath, "utf-8"); const config = parseYaml(content); const additional = config?.repos?.additional; if (Array.isArray(additional)) { repos = [config?.repos?.primary || ".", ...additional]; } } } catch { // ignore } const repoUrls = getRepoUrls(discovery.beadsDir); const claims = commentsResult ? getClaimedNodes(commentsResult.allComments) : new Map(); // Build node map for enrichment const nodeMap = new Map( beadsData.graphData.nodes.map((n) => [n.id, n]) ); // Filter and enrich issues let issues = beadsData.graphData.nodes; if (statusFilter) { issues = issues.filter((n) => statusFilter.includes(n.status)); } if (priorityFilter) { issues = issues.filter((n) => priorityFilter.includes(n.priority)); } if (prefixFilter) { issues = issues.filter((n) => n.prefix === prefixFilter); } const enrichedIssues = issues.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, closed_at: node.closedAt || null, close_reason: node.closeReason || null, prefix: node.prefix, blockers: node.dependentIds, // issues that block this one (upstream) dependents: node.blockerIds, // issues this one blocks (downstream) ...(includeComments ? { comments: nodeComments.map(serializeComment) } : {}), claimed_by: claim ? serializeClaim(claim) : null, }; }); // Build dependencies const dependencies = beadsData.graphData.links.map((link) => { const src = typeof link.source === "object" ? (link.source as { id: string }).id : link.source; const tgt = typeof link.target === "object" ? (link.target as { id: string }).id : link.target; return { from: src, to: tgt, type: link.type }; }); // Build activity feed let activity: Record[] = []; if (includeActivity) { const feed = buildHistoricalFeed( beadsData.graphData.nodes, beadsData.graphData.links, commentsResult?.allComments || null ); activity = feed.slice(0, limit).map((event) => ({ type: event.type, time: new Date(event.time).toISOString(), issue_id: event.nodeId, ...(event.nodeTitle ? { issue_title: event.nodeTitle } : {}), ...(event.actor ? { actor: { handle: event.actor.handle } } : {}), ...(event.detail ? { detail: event.detail } : {}), })); } // Warnings for partial data const warnings: string[] = []; if (includeComments && !commentsResult) { warnings.push("Failed to fetch comments from indexer"); } return jsonResponse({ project: { name: discovery.issuePrefix || discovery.repoName, prefix: discovery.issuePrefix || null, repos, repoUrls, }, issues: enrichedIssues, dependencies, stats: { total: beadsData.stats.total, open: beadsData.stats.open, in_progress: beadsData.stats.inProgress, blocked: beadsData.stats.blocked, closed: beadsData.stats.closed, actionable: beadsData.stats.actionable, }, ...(includeActivity ? { activity } : {}), _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/graph] Error:", error); return errorResponse("Internal server error", 500); } }