import type {ActionsJob, ActionsStatus} from '../modules/gitea-actions.ts'; export type GraphNodeType = 'job' | 'matrix' | 'group'; export type GraphNode = { id: string; type: GraphNodeType; name: string; status: ActionsStatus; duration: string; x: number; y: number; level: number; displayHeight: number; jobs: ActionsJob[]; matrixKey?: string; }; export type Edge = { fromId: string; toId: string; key: string; }; export type RoutedEdge = Edge & { path: string; fromNode: GraphNode; toNode: GraphNode; }; export type SharedSegment = { key: string; edgeKeys: string[]; path: string; }; export type GraphHighlightState = { nodeIds: Set; edgeKeys: Set; }; export type WorkflowGraphLayoutOptions = { margin: number; nodeWidth: number; nodeHeight: number; columnGap: number; laneGap: number; groupRowHeight: number; groupPadY: number; matrixCollapsedHeight: number; matrixHeaderHeight: number; matrixRowHeight: number; matrixPadY: number; }; export type WorkflowGraphModel = { nodes: GraphNode[]; edges: Edge[]; routedEdges: RoutedEdge[]; sharedSegments: SharedSegment[]; adjacency: NodeAdjacency; }; export type NodeAdjacency = { incomingByNodeId: Map; outgoingByNodeId: Map; }; const defaultLayoutOptions: WorkflowGraphLayoutOptions = { margin: 24, nodeWidth: 220, nodeHeight: 40, columnGap: 96, laneGap: 32, groupRowHeight: 28, groupPadY: 8, matrixCollapsedHeight: 78, matrixHeaderHeight: 24, matrixRowHeight: 26, matrixPadY: 6, }; function canonicalKey(ids: Iterable): string { return Array.from(ids).sort().join(''); } function graphIdForJob(job: ActionsJob): string { return `job:${job.id}`; } export function matrixKeyFromJobName(name: string): string | null { const idx = name.indexOf(' ('); if (idx === -1) return null; return name.slice(0, idx).trim() || null; } export function boxBottom(node: GraphNode): number { return node.y + node.displayHeight; } export function boxCenterY(node: GraphNode): number { return node.y + node.displayHeight / 2; } function matrixPanelHeight(rowCount: number, expanded: boolean, options: WorkflowGraphLayoutOptions): number { if (rowCount <= 0) return options.nodeHeight; if (!expanded) return options.matrixCollapsedHeight; return options.matrixHeaderHeight + rowCount * options.matrixRowHeight + options.matrixPadY * 2; } function groupPanelHeight(rowCount: number, options: WorkflowGraphLayoutOptions): number { return rowCount * options.groupRowHeight + options.groupPadY * 2; } function compareStatusWorstFirst(a: ActionsStatus, b: ActionsStatus): number { const rank = (s: ActionsStatus) => { if (s === 'failure') return 0; if (s === 'cancelled') return 1; if (s === 'running') return 2; if (s === 'waiting') return 3; if (s === 'blocked') return 4; if (s === 'success') return 5; if (s === 'skipped') return 6; return 7; }; return rank(a) - rank(b); } function aggregateStatus(children: ActionsJob[]): ActionsStatus { return children.map((c) => c.status).slice().sort(compareStatusWorstFirst)[0] ?? 'unknown'; } function buildDirectNeedsMap(jobs: ActionsJob[]): Map { const directNeedsByJobId = new Map(); const dependentsByJobId = new Map>(); for (const job of jobs) { const needs = job.needs || []; directNeedsByJobId.set(job.jobId, needs); for (const need of needs) { if (!dependentsByJobId.has(need)) dependentsByJobId.set(need, new Set()); dependentsByJobId.get(need)!.add(job.jobId); } } const reachabilityCache = new Map(); function canReach(fromJobId: string, toJobId: string): boolean { const cacheKey = `${fromJobId}->${toJobId}`; if (reachabilityCache.has(cacheKey)) return reachabilityCache.get(cacheKey)!; const visited = new Set(); const stack = Array.from(dependentsByJobId.get(fromJobId) || []); while (stack.length > 0) { const current = stack.pop()!; if (current === toJobId) { reachabilityCache.set(cacheKey, true); return true; } if (visited.has(current)) continue; visited.add(current); stack.push(...(dependentsByJobId.get(current) || [])); } reachabilityCache.set(cacheKey, false); return false; } const reducedNeedsByJobId = new Map(); for (const [jobId, needs] of directNeedsByJobId) { reducedNeedsByJobId.set(jobId, needs.filter((need) => { return !needs.some((other) => other !== need && canReach(need, other)); })); } return reducedNeedsByJobId; } export function computeJobLevels(jobs: ActionsJob[]): Map { const jobMap = new Map(); for (const job of jobs) { jobMap.set(job.name, job); if (job.jobId) jobMap.set(job.jobId, job); } const levels = new Map(); const visited = new Set(); const recursionStack = new Set(); function dfs(jobNameOrId: string): number { if (recursionStack.has(jobNameOrId)) return 0; if (visited.has(jobNameOrId)) return levels.get(jobNameOrId) ?? 0; recursionStack.add(jobNameOrId); visited.add(jobNameOrId); const job = jobMap.get(jobNameOrId); if (!job) { recursionStack.delete(jobNameOrId); return 0; } if (!job.needs?.length) { levels.set(job.jobId, 0); if (job.jobId !== job.name) levels.set(job.name, 0); recursionStack.delete(jobNameOrId); return 0; } let maxLevel = -1; for (const need of job.needs) { if (!jobMap.has(need)) continue; maxLevel = Math.max(maxLevel, dfs(need)); } const level = maxLevel + 1; levels.set(job.name, level); levels.set(job.jobId, level); recursionStack.delete(jobNameOrId); return level; } for (const job of jobs) { if (!visited.has(job.jobId)) dfs(job.jobId); } return levels; } export function computeGraphHighlightState(hoveredId: string | null, adjacency: NodeAdjacency): GraphHighlightState { if (!hoveredId) return {nodeIds: new Set(), edgeKeys: new Set()}; const {incomingByNodeId, outgoingByNodeId} = adjacency; const edgeKeys = new Set(); const collect = (startId: string, adj: Map, edgeKeyForward: boolean): Set => { const seen = new Set(); const queue = [startId]; while (queue.length > 0) { const current = queue.shift()!; if (seen.has(current)) continue; seen.add(current); for (const next of adj.get(current) || []) { edgeKeys.add(edgeKeyForward ? `${current}->${next}` : `${next}->${current}`); if (!seen.has(next)) queue.push(next); } } return seen; }; const ancestors = collect(hoveredId, incomingByNodeId, false); const descendants = collect(hoveredId, outgoingByNodeId, true); return {nodeIds: new Set([...ancestors, ...descendants]), edgeKeys}; } type VisualGraphBuild = { nodes: GraphNode[]; edges: Edge[]; }; function buildVisualGraph( jobs: ActionsJob[], expandedMatrixKeys: ReadonlySet, options: WorkflowGraphLayoutOptions, ): VisualGraphBuild { const jobsByJobId = new Map(); const jobIndexById = new Map(); for (const [index, job] of jobs.entries()) { jobIndexById.set(job.id, index); if (!jobsByJobId.has(job.jobId)) jobsByJobId.set(job.jobId, []); jobsByJobId.get(job.jobId)!.push(job); } const matrixJobsByKey = new Map(); for (const job of jobs) { // Reusable callers are distinct workflow files — never fold them into a matrix bucket // even if their display name happens to look like "name (variant)". if (job.isReusableCaller) continue; const matrixKey = matrixKeyFromJobName(job.name); if (!matrixKey) continue; if (!matrixJobsByKey.has(matrixKey)) matrixJobsByKey.set(matrixKey, []); matrixJobsByKey.get(matrixKey)!.push(job); } for (const list of matrixJobsByKey.values()) { list.sort((a, b) => (jobIndexById.get(a.id) ?? 0) - (jobIndexById.get(b.id) ?? 0)); } const directNeedsByJobId = buildDirectNeedsMap(jobs); const rawLevels = computeJobLevels(jobs); const dependentsByJobId = new Map(); const rawEdges: Array<{from: ActionsJob; to: ActionsJob}> = []; for (const job of jobs) { for (const need of directNeedsByJobId.get(job.jobId) || []) { for (const upstream of jobsByJobId.get(need) || []) { rawEdges.push({from: upstream, to: job}); if (!dependentsByJobId.has(upstream.jobId)) dependentsByJobId.set(upstream.jobId, []); dependentsByJobId.get(upstream.jobId)!.push(job.jobId); } } } for (const list of dependentsByJobId.values()) list.sort(); // Group sibling jobs that share an identical (parents, children) signature into a single // collapsed "group" node. This is a visual aggregation only - the underlying jobs are // preserved on the node so the panel can list them. const groupedJobIds = new Map(); const groupsById = new Map(); const groupCandidateBuckets = new Map(); for (const job of jobs) { if (matrixKeyFromJobName(job.name)) continue; // Reusable callers represent distinct workflow files — keep each as its own node so the // graph mirrors GitHub Actions, where every caller shows up as its own box even when // siblings share an identical (parents, children) dependency signature. if (job.isReusableCaller) continue; const needsKey = canonicalKey(directNeedsByJobId.get(job.jobId) || []); const childrenKey = (dependentsByJobId.get(job.jobId) || []).join(''); if (!needsKey && !childrenKey) continue; const level = rawLevels.get(job.jobId) ?? 0; const key = `group:${level}:${needsKey}:${childrenKey}`; if (!groupCandidateBuckets.has(key)) groupCandidateBuckets.set(key, []); groupCandidateBuckets.get(key)!.push(job); } for (const [groupId, groupJobs] of groupCandidateBuckets) { if (groupJobs.length < 2) continue; groupJobs.sort((a, b) => (jobIndexById.get(a.id) ?? 0) - (jobIndexById.get(b.id) ?? 0)); groupsById.set(groupId, groupJobs); for (const job of groupJobs) groupedJobIds.set(job.id, groupId); } const visualIdByJobId = new Map(); for (const job of jobs) { const matrixKey = matrixKeyFromJobName(job.name); // Symmetric with the matrix-bucket loop above: a reusable caller whose display name // happens to look like "name (variant)" must never be folded into the matrix node, or it // would silently vanish (its visualId would point at a matrix node it isn't part of). if (matrixKey && !job.isReusableCaller && (matrixJobsByKey.get(matrixKey)?.length ?? 0) > 1) { visualIdByJobId.set(job.id, `matrix:${matrixKey}`); continue; } visualIdByJobId.set(job.id, groupedJobIds.get(job.id) || graphIdForJob(job)); } const emittedNodeIds = new Set(); const nodes: GraphNode[] = []; for (const job of jobs) { const visualId = visualIdByJobId.get(job.id); if (!visualId || emittedNodeIds.has(visualId)) continue; emittedNodeIds.add(visualId); const matrixKey = matrixKeyFromJobName(job.name); if (matrixKey && visualId.startsWith('matrix:')) { const matrixJobs = matrixJobsByKey.get(matrixKey) || []; nodes.push({ id: visualId, type: 'matrix', name: matrixKey, status: aggregateStatus(matrixJobs), duration: '', x: 0, y: 0, level: 0, displayHeight: matrixPanelHeight(matrixJobs.length, expandedMatrixKeys.has(matrixKey), options), jobs: matrixJobs, matrixKey, }); continue; } const groupJobs = groupsById.get(visualId); if (groupJobs) { nodes.push({ id: visualId, type: 'group', name: groupJobs.map((g) => g.name).join(', '), status: aggregateStatus(groupJobs), duration: '', x: 0, y: 0, level: 0, displayHeight: groupPanelHeight(groupJobs.length, options), jobs: groupJobs, }); continue; } nodes.push({ id: visualId, type: 'job', name: job.name, status: job.status, duration: job.duration, x: 0, y: 0, level: 0, displayHeight: options.nodeHeight, jobs: [job], }); } const seenEdges = new Set(); const edges: Edge[] = []; for (const {from, to} of rawEdges) { const fromId = visualIdByJobId.get(from.id); const toId = visualIdByJobId.get(to.id); if (!fromId || !toId || fromId === toId) continue; const key = `${fromId}->${toId}`; if (seenEdges.has(key)) continue; seenEdges.add(key); edges.push({fromId, toId, key}); } return {nodes, edges}; } function buildNodeAdjacency(edges: Edge[]): NodeAdjacency { const incomingByNodeId = new Map(); const outgoingByNodeId = new Map(); for (const edge of edges) { if (!incomingByNodeId.has(edge.toId)) incomingByNodeId.set(edge.toId, []); incomingByNodeId.get(edge.toId)!.push(edge.fromId); if (!outgoingByNodeId.has(edge.fromId)) outgoingByNodeId.set(edge.fromId, []); outgoingByNodeId.get(edge.fromId)!.push(edge.toId); } return {incomingByNodeId, outgoingByNodeId}; } function assignNodeLevels(nodes: GraphNode[], {incomingByNodeId}: NodeAdjacency): void { const cache = new Map(); function levelFor(id: string, visiting = new Set()): number { if (cache.has(id)) return cache.get(id)!; if (visiting.has(id)) return 0; visiting.add(id); const incoming = incomingByNodeId.get(id) || []; const level = incoming.length > 0 ? Math.max(...incoming.map((fromId) => levelFor(fromId, visiting))) + 1 : 0; visiting.delete(id); cache.set(id, level); return level; } for (const node of nodes) node.level = levelFor(node.id); } // Roots stay in input order; later levels are sorted by the mean parent Y so that simple // chains stay on a straight horizontal line. function assignNodeCoordinates(nodesById: Map, nodes: GraphNode[], adjacency: NodeAdjacency, options: WorkflowGraphLayoutOptions): void { const {incomingByNodeId} = adjacency; const inputRank = (node: GraphNode): number => Math.min(...node.jobs.map((j) => j.id)); const nodesByLevel = new Map(); for (const node of nodes) { if (!nodesByLevel.has(node.level)) nodesByLevel.set(node.level, []); nodesByLevel.get(node.level)!.push(node); } const orderedLevels = Array.from(nodesByLevel.keys()).sort((a, b) => a - b); // Initial X assignment and a default Y so barycenters can use a finite value. for (const level of orderedLevels) { const list = nodesByLevel.get(level)!; list.sort((a, b) => inputRank(a) - inputRank(b)); let yCursor = options.margin; for (const node of list) { node.x = options.margin + level * (options.nodeWidth + options.columnGap); node.y = yCursor; yCursor += node.displayHeight + options.laneGap; } } function packLevel(level: number, anchorOf: (n: GraphNode) => number): void { const list = nodesByLevel.get(level)!; const sorted = Array.from(list).sort((a, b) => anchorOf(a) - anchorOf(b) || inputRank(a) - inputRank(b)); // Pack tight to top after sorting. Using barycenter only for order (not Y) keeps terminal // nodes like build-image close to the top of their column instead of being pulled down to // the mean Y of their parents — matching GitHub Actions' compact layout. let prevBottom = options.margin - options.laneGap; for (const node of sorted) { node.y = prevBottom + options.laneGap; prevBottom = boxBottom(node); } nodesByLevel.set(level, sorted); } function meanCenterOf(ids: string[]): number | null { if (ids.length === 0) return null; let sum = 0; for (const id of ids) sum += boxCenterY(nodesById.get(id)!); return sum / ids.length; } // Down-only barycenter pass: each child is anchored to the mean Y of its parents. Roots // keep their initial yaml-declaration order (via inputRank), matching how GitHub Actions // arranges root jobs. This produces a "main chain on top" layout where job-100 → job-101 → // job-102 stays on a straight horizontal line. for (const level of orderedLevels) { if (level === 0) continue; packLevel(level, (node) => meanCenterOf(incomingByNodeId.get(node.id) || []) ?? boxCenterY(node)); } } // Per-edge connector: source stub → cubic-bezier corner down/up to column midpoint → // vertical run → cubic-bezier corner back to horizontal → target stub. The corner radius is // fixed (not clamped to the row delta) so any two edges sharing the same source produce the // same source-side path and overlap into a single visual line until they diverge at the V. const cornerRadius = 12; function connectorPath(sx: number, sy: number, ex: number, ey: number, options: WorkflowGraphLayoutOptions): string { if (Math.abs(sy - ey) < 0.5) return `M ${sx} ${sy} H ${ex}`; // Anchor the V segment in the column gap immediately before the target instead of the // horizontal midpoint. The long H stays at the source's Y, matching GitHub Actions' style // — a multi-column edge runs along the source row across intermediate columns, then turns // up/down only when it reaches the target column. const midX = Math.max(ex - options.columnGap / 2, (sx + ex) / 2); const dy = ey > sy ? 1 : -1; // Keep the same H prefix to `midX - cornerRadius` for every edge so that edges sharing a // source overlap visually until they fork. When there isn't 2*cornerRadius of vertical // room for the V segment, emit a single S-curve between (midX - r, sy) and (midX + r, ey) // instead of a backward V kink. if (Math.abs(ey - sy) < cornerRadius * 2) { return [ `M ${sx} ${sy}`, `H ${midX - cornerRadius}`, `C ${midX} ${sy} ${midX} ${ey} ${midX + cornerRadius} ${ey}`, `H ${ex}`, ].join(' '); } const half = cornerRadius / 2; return [ `M ${sx} ${sy}`, `H ${midX - cornerRadius}`, `C ${midX - half} ${sy} ${midX} ${sy + half * dy} ${midX} ${sy + cornerRadius * dy}`, `V ${ey - cornerRadius * dy}`, `C ${midX} ${ey - half * dy} ${midX + half} ${ey} ${midX + cornerRadius} ${ey}`, `H ${ex}`, ].join(' '); } function buildRoutedEdges( nodesById: Map, edges: Edge[], options: WorkflowGraphLayoutOptions, ): Pick { const routedEdges: RoutedEdge[] = []; for (const edge of edges) { const fromNode = nodesById.get(edge.fromId); const toNode = nodesById.get(edge.toId); if (!fromNode || !toNode) continue; const startX = fromNode.x + options.nodeWidth; const endX = toNode.x; const startY = boxCenterY(fromNode); const endY = boxCenterY(toNode); routedEdges.push({...edge, fromNode, toNode, path: connectorPath(startX, startY, endX, endY, options)}); } return {routedEdges, sharedSegments: []}; } export function createWorkflowGraphModel( jobs: ActionsJob[], expandedMatrixKeys: ReadonlySet = new Set(), partialOptions: Partial = {}, ): WorkflowGraphModel { const options = {...defaultLayoutOptions, ...partialOptions}; const {nodes, edges} = buildVisualGraph(jobs, expandedMatrixKeys, options); const nodesById = new Map(nodes.map((n) => [n.id, n])); const adjacency = buildNodeAdjacency(edges); assignNodeLevels(nodes, adjacency); assignNodeCoordinates(nodesById, nodes, adjacency, options); return {nodes, edges, ...buildRoutedEdges(nodesById, edges, options), adjacency}; } export function getWorkflowGraphLayoutOptions(partialOptions: Partial = {}): WorkflowGraphLayoutOptions { return {...defaultLayoutOptions, ...partialOptions}; }