/// import { type Cell, cell, Default, handler, lift, recipe, str, } from "commontools"; interface IssueInput { id: string; title?: string; dependencies?: string[]; } interface IssueDependencyArgs { issues: Default; } interface Issue { id: string; title: string; dependencies: string[]; } type RejectionReason = "missing" | "self" | "cycle"; interface RejectedEdge { from: string; to: string; reason: RejectionReason; } interface GraphDetails { adjacency: Record; order: string[]; roots: string[]; blocked: string[]; hasCycle: boolean; } interface GraphContext { source: Cell; rejected: Cell; } const normalizeId = (value: unknown): string | undefined => { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; }; const normalizeTitle = (value: unknown, fallback: string): string => { if (typeof value === "string") { const trimmed = value.trim(); if (trimmed.length > 0) return trimmed; } return fallback; }; const extractDependencyList = (value: unknown, selfId: string): string[] => { if (!Array.isArray(value)) return []; const result: string[] = []; for (const candidate of value) { const dependency = normalizeId(candidate); if (!dependency) continue; if (dependency === selfId) continue; if (result.includes(dependency)) continue; result.push(dependency); } return result; }; const sanitizeIssueList = (value: unknown): Issue[] => { if (!Array.isArray(value)) return []; const raw: Issue[] = []; const seen = new Set(); for (const entry of value) { if (!entry || typeof entry !== "object") continue; const id = normalizeId((entry as IssueInput).id); if (!id || seen.has(id)) continue; seen.add(id); const title = normalizeTitle((entry as IssueInput).title, id); const dependencies = extractDependencyList( (entry as IssueInput).dependencies, id, ); raw.push({ id, title, dependencies }); } const knownIds = new Set(raw.map((item) => item.id)); return raw.map((item) => ({ id: item.id, title: item.title, dependencies: item.dependencies.filter((dep) => knownIds.has(dep)), })); }; const prepareDependencyList = ( dependencies: string[], knownIds: Set, selfId: string, ): string[] => { const result: string[] = []; for (const candidate of dependencies) { const dependency = normalizeId(candidate); if (!dependency) continue; if (dependency === selfId) continue; if (!knownIds.has(dependency)) continue; if (result.includes(dependency)) continue; result.push(dependency); } return result; }; const copyIssueForArgument = (issue: Issue): IssueInput => ({ id: issue.id, title: issue.title, dependencies: issue.dependencies.slice(), }); const recordRejection = ( cellRef: Cell, entry: RejectedEdge, ) => { const existing = cellRef.get(); const history = Array.isArray(existing) ? existing.slice(-4) : []; history.push(entry); cellRef.set(history); }; const commitIssues = (context: GraphContext, entries: Issue[]) => { context.source.set(entries.map(copyIssueForArgument)); }; const buildGraphDetails = (issues: Issue[]): GraphDetails => { const dependencyMap: Record = {}; const dependentMap: Record = {}; const indegree = new Map(); for (const issue of issues) { dependencyMap[issue.id] = []; dependentMap[issue.id] = []; indegree.set(issue.id, 0); } for (const issue of issues) { for (const dependency of issue.dependencies) { if (!indegree.has(dependency)) continue; dependencyMap[issue.id].push(dependency); dependentMap[dependency].push(issue.id); indegree.set(issue.id, (indegree.get(issue.id) ?? 0) + 1); } } const initialDegrees = new Map(indegree); const queue = Array.from(indegree.entries()) .filter(([, degree]) => degree === 0) .map(([id]) => id) .sort(); const order: string[] = []; const remaining = new Map(indegree); while (queue.length > 0) { const currentId = queue.shift(); if (!currentId) break; order.push(currentId); const dependents = dependentMap[currentId] ?? []; for (const dependent of dependents) { const nextDegree = (remaining.get(dependent) ?? 0) - 1; remaining.set(dependent, nextDegree); if (nextDegree === 0) { queue.push(dependent); queue.sort(); } } } const hasCycle = order.length !== issues.length; const blocked = hasCycle ? issues .map((issue) => issue.id) .filter((id) => !order.includes(id)) .sort() : []; const roots = Array.from(initialDegrees.entries()) .filter(([, degree]) => degree === 0) .map(([id]) => id) .sort(); return { adjacency: dependencyMap, order, roots, blocked, hasCycle }; }; const wouldCreateCycle = (issues: Issue[]): boolean => buildGraphDetails(issues).hasCycle; const registerNewIssue = handler( ( event: | { id?: string; title?: string; dependencies?: string[] } | undefined, context: GraphContext, ) => { const id = normalizeId(event?.id); if (!id) return; const current = sanitizeIssueList(context.source.get()); if (current.some((issue) => issue.id === id)) return; const knownIds = new Set(current.map((issue) => issue.id)); const dependencies = prepareDependencyList( extractDependencyList(event?.dependencies, id), knownIds, id, ); const title = normalizeTitle(event?.title, id); const next = [...current, { id, title, dependencies }]; commitIssues(context, next); }, ); const linkDependency = handler( ( event: { from?: string; to?: string } | undefined, context: GraphContext, ) => { const from = normalizeId(event?.from); const to = normalizeId(event?.to); if (!from || !to) return; if (from === to) { recordRejection(context.rejected, { from, to, reason: "self" }); return; } const current = sanitizeIssueList(context.source.get()); const fromIndex = current.findIndex((issue) => issue.id === from); const toExists = current.some((issue) => issue.id === to); if (fromIndex === -1 || !toExists) { recordRejection(context.rejected, { from, to, reason: "missing" }); return; } const knownIds = new Set(current.map((issue) => issue.id)); const baseIssue = current[fromIndex]; const mergedDependencies = [ ...baseIssue.dependencies, to, ]; const dependencies = prepareDependencyList( mergedDependencies, knownIds, from, ); if (dependencies.length === baseIssue.dependencies.length) return; const next = current.slice(); next[fromIndex] = { ...baseIssue, dependencies }; if (wouldCreateCycle(next)) { recordRejection(context.rejected, { from, to, reason: "cycle" }); return; } commitIssues(context, next); }, ); const unlinkDependency = handler( ( event: { from?: string; to?: string } | undefined, context: GraphContext, ) => { const from = normalizeId(event?.from); const to = normalizeId(event?.to); if (!from || !to) return; const current = sanitizeIssueList(context.source.get()); const fromIndex = current.findIndex((issue) => issue.id === from); if (fromIndex === -1) return; const baseIssue = current[fromIndex]; if (!baseIssue.dependencies.includes(to)) return; const next = current.slice(); next[fromIndex] = { ...baseIssue, dependencies: baseIssue.dependencies.filter((dep) => dep !== to), }; commitIssues(context, next); }, ); export const issueDependencyGraph = recipe( "Issue Dependency Graph", ({ issues }) => { const rejectedEdges = cell([]); const sanitizedIssues = lift(sanitizeIssueList)(issues); const issuesView = lift((entries: Issue[]) => entries.map((issue) => ({ id: issue.id, title: issue.title, dependencies: issue.dependencies.slice(), })) )(sanitizedIssues); const graphDetails = lift(buildGraphDetails)(sanitizedIssues); const adjacency = lift((details: GraphDetails) => { const result: Record = {}; for (const key of Object.keys(details.adjacency)) { result[key] = details.adjacency[key].slice(); } return result; })(graphDetails); const order = lift((details: GraphDetails) => details.order.slice())( graphDetails, ); const roots = lift((details: GraphDetails) => details.roots.slice())( graphDetails, ); const blocked = lift((details: GraphDetails) => details.blocked.slice())( graphDetails, ); const hasCycle = lift((details: GraphDetails) => details.hasCycle)( graphDetails, ); const rejectionHistory = lift((entries: RejectedEdge[] | undefined) => Array.isArray(entries) ? entries.map((entry) => ({ ...entry })) : [] )(rejectedEdges); const orderText = lift((ids: string[]) => ids.length > 0 ? ids.join(" -> ") : "none" )(order); const cycleStatus = lift((flag: boolean) => flag ? "cycle" : "valid")( hasCycle, ); const summary = str`${cycleStatus}: ${orderText}`; return { issues: issuesView, adjacency, order, roots, blocked, hasCycle, summary, rejectionHistory, addIssue: registerNewIssue({ source: issues, rejected: rejectedEdges, }), linkDependency: linkDependency({ source: issues, rejected: rejectedEdges, }), unlinkDependency: unlinkDependency({ source: issues, rejected: rejectedEdges, }), }; }, );