/** * Pure tree-building helpers for the Redmine Browser view. * * Algorithmic contract (kept deliberately simple so the UI stays predictable): * * 1. Tickets that are NOT in the input ``tickets`` list (i.e. filtered out * by the caller) take no part in the relation graph -- they vanish * completely. No edges, no adjacency entries. * * 2. Roots = tickets whose tracker matches ``rootTrackerId`` (typically * "Userstory"). Roots are seeded into the BFS queue in ascending id * order so output is stable across re-renders. * * 3. Construction is a **multi-source breadth-first search**: ALL roots * are seeded into the queue together. The algorithm then walks * strictly level-by-level across the entire forest: * * Level 0: every User Story * Level 1: every ticket directly related to ANY User Story * Level 2: every ticket related to a level-1 ticket, ... * * Consequence: a ticket reachable from two roots ends up under the * one that finds it FIRST at the shallowest distance. A Feature * directly related to a User Story is therefore guaranteed to sit * at depth 1 under that US -- it can never end up nested deeper * under an Acceptance Criteria just because another root reached * the AC earlier. * * 4. Each ticket appears AT MOST ONCE in the entire forest (global * ``visited`` set). Once placed, neither the same nor a different * relation can pull it elsewhere. Cycles are therefore impossible. * * 5. Tickets that no User Story ever reaches end up under the synthetic * ``ORPHAN_ROOT_ID``, built with the same multi-source BFS but * seeded from the orphan candidates. */ import { RedmineTicket } from '../../../api/redmineApi'; const _MAX_NODES = 20000; // hard safety cap across the whole forest export const ORPHAN_ROOT_ID = -1; export interface TreeNode { /** Redmine issue id, or ``ORPHAN_ROOT_ID`` for the virtual orphan root. */ id: number; /** Relation type connecting this node to its parent (``null`` for roots). */ relType: string | null; /** ``out`` = this ticket is the target of the parent's outgoing edge, * ``in`` = this ticket is the source (the edge points back at the parent). */ dir: 'out' | 'in' | null; children: TreeNode[]; } interface Edge { fromId: number; toId: number; relType: string; } interface Neighbor { id: number; relType: string; dir: 'out' | 'in'; } // --------------------------------------------------------------------------- // Edge + adjacency construction // --------------------------------------------------------------------------- const _relationsToEdges = (tickets: RedmineTicket[]): Edge[] => { const seen = new Set(); const edges: Edge[] = []; for (const t of tickets) { if (t.parentId != null) { const key = `p:${t.parentId}:${t.id}`; if (!seen.has(key)) { seen.add(key); edges.push({ fromId: t.parentId, toId: t.id, relType: 'parent' }); } } for (const r of t.relations || []) { const key = `r:${r.id}`; if (seen.has(key)) continue; seen.add(key); edges.push({ fromId: r.issueId, toId: r.issueToId, relType: r.relationType }); } } return edges; }; const _buildAdjacency = ( edges: Edge[], visibleIds: Set, allowedRelTypes: Set | null, ): Map => { const map = new Map(); const _add = (k: number, n: Neighbor) => { const arr = map.get(k); if (arr) arr.push(n); else map.set(k, [n]); }; for (const e of edges) { // Filter rule #1: a filtered-out ticket on EITHER side drops the edge. if (!visibleIds.has(e.fromId)) continue; if (!visibleIds.has(e.toId)) continue; if (allowedRelTypes && !allowedRelTypes.has(e.relType)) continue; _add(e.fromId, { id: e.toId, relType: e.relType, dir: 'out' }); _add(e.toId, { id: e.fromId, relType: e.relType, dir: 'in' }); } // Stable neighbour order so re-renders produce the same tree. for (const [k, arr] of map) { arr.sort((a, b) => a.id - b.id); map.set(k, arr); } return map; }; // --------------------------------------------------------------------------- // Multi-source BFS over a set of seed roots, sharing one ``visited`` set. // Returns the seed roots in their input order, with their full BFS subtrees // attached. The BFS interleaves roots level by level, so a ticket reachable // from multiple roots is attached to the first root that reaches it at the // shallowest distance. // --------------------------------------------------------------------------- const _bfsForest = ( seedIds: number[], adjacency: Map, visited: Set, ): TreeNode[] => { const roots: TreeNode[] = []; const queue: TreeNode[] = []; // Seed every root first -- THIS is what makes it multi-source. for (const id of seedIds) { if (visited.has(id)) continue; // a previous seed already absorbed it visited.add(id); const node: TreeNode = { id, relType: null, dir: null, children: [] }; roots.push(node); queue.push(node); } // Standard FIFO BFS. Because roots were enqueued in order, the first // root that can claim a neighbour at level 1 wins it. while (queue.length > 0) { if (visited.size >= _MAX_NODES) break; const node = queue.shift()!; const neighbors = adjacency.get(node.id) || []; for (const n of neighbors) { if (visited.has(n.id)) continue; // already placed elsewhere -> skip visited.add(n.id); const child: TreeNode = { id: n.id, relType: n.relType, dir: n.dir, children: [] }; node.children.push(child); queue.push(child); } } return roots; }; // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export interface BuildForestOptions { rootTrackerId: number | null; /** Only edges whose ``relType`` is in this list contribute to the tree. * ``undefined`` / empty array means "all relation types allowed". */ allowedRelTypes?: string[]; } export interface Forest { trees: TreeNode[]; reachableCount: number; orphanCount: number; } export const buildForest = ( tickets: RedmineTicket[], opts: BuildForestOptions, ): Forest => { const { rootTrackerId } = opts; const allowedRelTypes = opts.allowedRelTypes && opts.allowedRelTypes.length > 0 ? new Set(opts.allowedRelTypes) : null; const visibleIds = new Set(tickets.map(t => t.id)); const edges = _relationsToEdges(tickets); const adjacency = _buildAdjacency(edges, visibleIds, allowedRelTypes); // Root seeds in stable order (by id ASC). Multi-source BFS will then // interleave their expansion level by level. const rootSeeds = (rootTrackerId != null ? tickets.filter(t => t.trackerId === rootTrackerId) : [] ).slice().sort((a, b) => a.id - b.id).map(t => t.id); const visited = new Set(); const trees: TreeNode[] = _bfsForest(rootSeeds, adjacency, visited); const reachableCount = visited.size; // Orphans = tickets the root BFS never reached. They form their own // multi-source BFS forest under the synthetic Orphan node. const orphanSeeds = tickets .filter(t => !visited.has(t.id)) .sort((a, b) => a.id - b.id) .map(t => t.id); const orphanCount = orphanSeeds.length; const orphanChildren = _bfsForest(orphanSeeds, adjacency, visited); if (orphanChildren.length > 0) { trees.push({ id: ORPHAN_ROOT_ID, relType: null, dir: null, children: orphanChildren, }); } return { trees, reachableCount, orphanCount }; }; // --------------------------------------------------------------------------- // Flatten + expand-all helpers (unchanged contract) // --------------------------------------------------------------------------- export interface FlatRow { node: TreeNode; depth: number; /** Per-ancestor: ``true`` if that ancestor has a following sibling. */ indentLines: boolean[]; isLast: boolean; hasChildren: boolean; } export const flattenForest = ( trees: TreeNode[], expanded: Set, ): FlatRow[] => { const rows: FlatRow[] = []; const _walk = (node: TreeNode, depth: number, lines: boolean[], isLast: boolean) => { const hasChildren = node.children.length > 0; rows.push({ node, depth, indentLines: lines, isLast, hasChildren }); if (!hasChildren) return; if (!expanded.has(node.id)) return; node.children.forEach((child, idx) => { const childIsLast = idx === node.children.length - 1; _walk(child, depth + 1, [...lines, !isLast], childIsLast); }); }; trees.forEach((root, idx) => { _walk(root, 0, [], idx === trees.length - 1); }); return rows; }; export const collectAllIds = (trees: TreeNode[]): number[] => { const acc: number[] = []; const _w = (n: TreeNode) => { if (n.children.length > 0) acc.push(n.id); n.children.forEach(_w); }; trees.forEach(_w); return acc; };