262 lines
8.9 KiB
TypeScript
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;
|
|
};
|