Files
gitea/web_src/js/components/WorkflowGraph.utils.ts
bn-zr fa5d876171 fix(actions): Fix how jobs in matrixes are grouped (#38980)
The workflow graph decided which job rows belonged to the same matrix by
parsing display names: it stripped a trailing `" (...)"` off `name` and
grouped rows sharing the prefix. That guesses at a string the user
controls, and it fails both ways. `jobparser` only appends the `
(<combination>)` suffix when `name:` contains no `${{ }}`, so a leg
named `E2E on ${{ matrix.browser }}` never grouped, while two unrelated
jobs `build (fast)` and `build (slow)` folded into one bogus matrix
panel.

Matrix legs already have a real identity: expansion clones one row per
combination, all sharing the workflow's `JobID` and differing only in
`Name`. Group on that instead, so a matrix is whatever the backend says
it is. Matrix expansion state is keyed on the graph node id for the same
reason.

Closes https://github.com/go-gitea/gitea/issues/38975, though that
report's own example already groups on main, since `explicit (${{
matrix.leg }})` interpolates to a name that still ends in a suffix. The
interpolated shapes above are the broken ones.

Assisted-by: Claude Code:claude-opus-5
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-20 05:41:14 +00:00

539 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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[];
};
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<string>;
edgeKeys: Set<string>;
};
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<string, string[]>;
outgoingByNodeId: Map<string, string[]>;
};
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>): string {
return Array.from(ids).sort().join('');
}
function graphIdForJob(job: ActionsJob): string {
return `job:${job.id}`;
}
// matrix legs are named `<job name> (<combination>)`; a workflow-provided `name:` may not be
function matrixLabel(matrixJobs: ActionsJob[], jobId: string): string {
const prefixes = new Set(matrixJobs.map((job) => {
const idx = job.name.indexOf(' (');
return idx === -1 ? '' : job.name.slice(0, idx).trim();
}));
const [prefix] = prefixes;
return prefixes.size === 1 && prefix ? prefix : jobId;
}
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<string, string[]> {
const directNeedsByJobId = new Map<string, string[]>();
const dependentsByJobId = new Map<string, Set<string>>();
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<string, boolean>();
function canReach(fromJobId: string, toJobId: string): boolean {
const cacheKey = `${fromJobId}->${toJobId}`;
if (reachabilityCache.has(cacheKey)) return reachabilityCache.get(cacheKey)!;
const visited = new Set<string>();
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<string, string[]>();
for (const [jobId, needs] of directNeedsByJobId) {
reducedNeedsByJobId.set(jobId, needs.filter((need) => {
return needs.every((other) => other === need || !canReach(need, other));
}));
}
return reducedNeedsByJobId;
}
export function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
const jobMap = new Map<string, ActionsJob>();
for (const job of jobs) {
jobMap.set(job.name, job);
if (job.jobId) jobMap.set(job.jobId, job);
}
const levels = new Map<string, number>();
const visited = new Set<string>();
const recursionStack = new Set<string>();
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<string>();
const collect = (startId: string, adj: Map<string, string[]>, edgeKeyForward: boolean): Set<string> => {
const seen = new Set<string>();
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[],
expandedMatrixNodeIds: ReadonlySet<string>,
options: WorkflowGraphLayoutOptions,
): VisualGraphBuild {
const jobsByJobId = new Map<string, ActionsJob[]>();
const jobIndexById = new Map<number, number>();
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);
}
// legs of one matrix job share its `jobId`; their display names are free-form so cannot key them
const isMatrixLeg = (job: ActionsJob): boolean => Boolean(job.jobId) && jobsByJobId.get(job.jobId)!.length > 1;
const directNeedsByJobId = buildDirectNeedsMap(jobs);
const rawLevels = computeJobLevels(jobs);
const dependentsByJobId = new Map<string, string[]>();
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<number, string>();
const groupsById = new Map<string, ActionsJob[]>();
const groupCandidateBuckets = new Map<string, ActionsJob[]>();
for (const job of jobs) {
if (isMatrixLeg(job)) 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<number, string>();
const emittedNodeIds = new Set<string>();
const nodes: GraphNode[] = [];
for (const job of jobs) {
const matrixJobs = isMatrixLeg(job) ? jobsByJobId.get(job.jobId)! : null;
const visualId = matrixJobs ? `matrix:${job.jobId}` : (groupedJobIds.get(job.id) || graphIdForJob(job));
visualIdByJobId.set(job.id, visualId);
if (emittedNodeIds.has(visualId)) continue;
emittedNodeIds.add(visualId);
if (matrixJobs) {
nodes.push({
id: visualId,
type: 'matrix',
name: matrixLabel(matrixJobs, job.jobId),
status: aggregateStatus(matrixJobs),
duration: '',
x: 0, y: 0, level: 0,
displayHeight: matrixPanelHeight(matrixJobs.length, expandedMatrixNodeIds.has(visualId), options),
jobs: matrixJobs,
});
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<string>();
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<string, string[]>();
const outgoingByNodeId = new Map<string, string[]>();
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<string, number>();
function levelFor(id: string, visiting = new Set<string>()): 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<string, GraphNode>, 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<number, GraphNode[]>();
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<string, GraphNode>,
edges: Edge[],
options: WorkflowGraphLayoutOptions,
): Pick<WorkflowGraphModel, 'routedEdges' | 'sharedSegments'> {
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[],
expandedMatrixNodeIds: ReadonlySet<string> = new Set(),
partialOptions: Partial<WorkflowGraphLayoutOptions> = {},
): WorkflowGraphModel {
const options = {...defaultLayoutOptions, ...partialOptions};
const {nodes, edges} = buildVisualGraph(jobs, expandedMatrixNodeIds, 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> = {}): WorkflowGraphLayoutOptions {
return {...defaultLayoutOptions, ...partialOptions};
}