ui-nyla/src/pages/views/redmine/redmineTreeLogic.ts
2026-04-21 21:30:15 +02:00

262 lines
8.9 KiB
TypeScript

/**
* 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<string>();
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<number>,
allowedRelTypes: Set<string> | null,
): Map<number, Neighbor[]> => {
const map = new Map<number, Neighbor[]>();
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<number, Neighbor[]>,
visited: Set<number>,
): 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<number>();
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<number>,
): 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;
};