import { readFileSync, existsSync } from "fs"; import { join, resolve } from "path"; import { parse as parseYaml } from "yaml"; import { discoverBeadsDir } from "./discover"; import type { BeadIssue, BeadDependency, GraphNode, GraphLink, GraphData, BeadsApiResponse, } from "./types"; /** * Extract the prefix from a bead ID (e.g. "my-app-5gw" -> "my-app") */ export function extractPrefix(id: string): string { const lastDash = id.lastIndexOf("-"); if (lastDash === -1) return id; return id.substring(0, lastDash); } /** * Read repos.additional paths from .beads/config.yaml */ export function getAdditionalRepoPaths(beadsDir: string): string[] { const configPath = join(beadsDir, "config.yaml"); try { const content = readFileSync(configPath, "utf-8"); const config = parseYaml(content); const additional = config?.repos?.additional; if (Array.isArray(additional)) { return additional .map((p: string) => resolve(beadsDir, "..", p)) .filter((p: string) => existsSync(join(p, ".beads", "issues.jsonl"))); } } catch (err) { console.error("Failed to read config.yaml:", err); } return []; } /** * Parse a single issues.jsonl file and return issues */ function parseJsonlFile(filePath: string): BeadIssue[] { let content: string; try { content = readFileSync(filePath, "utf-8"); } catch (err) { console.error(`Failed to read ${filePath}:`, err); return []; } const lines = content.split("\n").filter((line) => line.trim().length > 0); const issues: BeadIssue[] = []; for (let i = 0; i < lines.length; i++) { try { issues.push(JSON.parse(lines[i]) as BeadIssue); } catch (err) { console.warn(`Skipping malformed line ${i + 1} in ${filePath}:`, err); } } return issues; } /** * Parse issues.jsonl from the primary .beads directory and all additional repos */ export function parseIssuesJsonl(beadsDir?: string): BeadIssue[] { const dir = beadsDir || discoverBeadsDir().beadsDir; // Load primary repo issues const primaryPath = join(dir, "issues.jsonl"); const issues = parseJsonlFile(primaryPath); // Load additional repo issues from config.yaml const additionalRepos = getAdditionalRepoPaths(dir); for (const repoPath of additionalRepos) { const repoJsonl = join(repoPath, ".beads", "issues.jsonl"); const repoIssues = parseJsonlFile(repoJsonl); issues.push(...repoIssues); } // Deduplicate by issue ID (primary repo wins for non-empty fields). // When a duplicate is found, merge any empty fields from the duplicate into the first copy. // This handles the case where the hub DB lost created_by/owner during import // but individual repos have them. const seen = new Map(); const result: BeadIssue[] = []; for (const issue of issues) { // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((issue as any).status === "tombstone") continue; const existing = seen.get(issue.id); if (existing) { // Merge: fill empty fields from duplicate if (!existing.created_by && issue.created_by) existing.created_by = issue.created_by; if (!existing.owner && issue.owner) existing.owner = issue.owner; if (!existing.assignee && issue.assignee) existing.assignee = issue.assignee; if (!existing.close_reason && issue.close_reason) existing.close_reason = issue.close_reason; if (!existing.acceptance_criteria && issue.acceptance_criteria) existing.acceptance_criteria = issue.acceptance_criteria; if (existing.estimated_minutes == null && issue.estimated_minutes != null) existing.estimated_minutes = issue.estimated_minutes; if ((!existing.labels || existing.labels.length === 0) && issue.labels && issue.labels.length > 0) existing.labels = issue.labels; } else { seen.set(issue.id, issue); result.push(issue); } } return result; } /** * Extract all dependencies from issues (they're embedded in each issue) */ export function extractDependencies(issues: BeadIssue[]): BeadDependency[] { const deps: BeadDependency[] = []; const seen = new Set(); for (const issue of issues) { if (issue.dependencies) { for (const dep of issue.dependencies) { const key = `${dep.issue_id}->${dep.depends_on_id}:${dep.type}`; if (!seen.has(key)) { seen.add(key); deps.push(dep); } } } } return deps; } /** * Build graph data from issues and dependencies */ export function buildGraphData( issues: BeadIssue[], dependencies: BeadDependency[] ): GraphData { const issueMap = new Map(issues.map((i) => [i.id, i])); // Count blockers and dependents for each issue const blockerCounts = new Map(); // issue -> issues it blocks const dependentCounts = new Map(); // issue -> issues that block it for (const dep of dependencies) { if (dep.type === "blocks" || dep.type === "parent-child") { // For blocks: depends_on_id blocks issue_id // For parent-child: depends_on_id is parent of issue_id // Both count as connections for node sizing if (!blockerCounts.has(dep.depends_on_id)) { blockerCounts.set(dep.depends_on_id, []); } blockerCounts.get(dep.depends_on_id)!.push(dep.issue_id); if (!dependentCounts.has(dep.issue_id)) { dependentCounts.set(dep.issue_id, []); } dependentCounts.get(dep.issue_id)!.push(dep.depends_on_id); } } const nodes: GraphNode[] = issues.map((issue) => ({ id: issue.id, title: issue.title, description: issue.description, status: issue.status, priority: issue.priority, issueType: issue.issue_type, owner: issue.owner, assignee: issue.assignee, createdBy: issue.created_by, createdAt: issue.created_at, updatedAt: issue.updated_at, closedAt: issue.closed_at, closeReason: issue.close_reason, acceptanceCriteria: issue.acceptance_criteria, estimatedMinutes: issue.estimated_minutes, prefix: extractPrefix(issue.id), labels: issue.labels || [], blockerCount: blockerCounts.get(issue.id)?.length || 0, dependentCount: dependentCounts.get(issue.id)?.length || 0, blockerIds: blockerCounts.get(issue.id) || [], dependentIds: dependentCounts.get(issue.id) || [], })); // Build links: include blocking AND parent-child dependencies where both nodes exist const links: GraphLink[] = dependencies .filter( (d) => (d.type === "blocks" || d.type === "parent-child") && issueMap.has(d.issue_id) && issueMap.has(d.depends_on_id) ) .map((d) => ({ // For both types: depends_on_id is the "upstream" node (blocker or parent) // blocks: blocker -> blocked // parent-child: parent -> child source: d.depends_on_id, target: d.issue_id, type: d.type, createdAt: d.created_at, })); return { nodes, links }; } /** * Compute stats from issues and dependencies */ export function computeStats( issues: BeadIssue[], dependencies: BeadDependency[], graphData: GraphData ) { const prefixes = [...new Set(issues.map((i) => extractPrefix(i.id)))]; // An issue is "actionable" if it's open and has no open blockers const openIssueIds = new Set( issues.filter((i) => i.status !== "closed").map((i) => i.id) ); const blockedByOpen = new Set(); for (const dep of dependencies) { if (dep.type === "blocks" && openIssueIds.has(dep.depends_on_id)) { // issue_id is blocked by depends_on_id which is still open blockedByOpen.add(dep.issue_id); } } const actionable = issues.filter( (i) => (i.status === "open" || i.status === "in_progress") && !blockedByOpen.has(i.id) ).length; return { total: issues.length, open: issues.filter((i) => i.status === "open").length, inProgress: issues.filter((i) => i.status === "in_progress").length, blocked: issues.filter((i) => i.status === "blocked").length, closed: issues.filter((i) => i.status === "closed").length, actionable, edges: graphData.links.length, prefixes, }; } /** * Full pipeline: parse, extract, build, compute */ export function loadBeadsData(beadsDir?: string): BeadsApiResponse { const issues = parseIssuesJsonl(beadsDir); const dependencies = extractDependencies(issues); const graphData = buildGraphData(issues, dependencies); const stats = computeStats(issues, dependencies, graphData); return { issues, dependencies, graphData, stats }; }