Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

355 linesCodeBlameRaw
1import type { Conclusion, Job } from "@g1t/contracts";
2
3/**
4 * A run's jobs as a graph: grouped (a matrix's jobs into one node, a called
5 * workflow's jobs into a box under the job that calls it), placed in
6 * columns by how deep their `needs` go, ordered within a column so fewer
7 * connectors cross, and joined by connectors. Pure, so the run page draws
8 * it again from each refresh of its data.
9 */
10
11/** What the graph reads of a job. */
12export type GraphJob = Pick<Job, "id" | "key" | "name" | "needs" | "status" | "conclusion" | "startedAt" | "finishedAt"> &
13 Partial<Pick<Job, "environment" | "environmentUrl" | "uses">>;
14
15/** Where a node or a group stands, as the status icon shows it. */
16export type Standing = { status: Job["status"]; conclusion: Conclusion | null };
17
18/** One job; a matrix's jobs (one key, several jobs); or a job calling a workflow, with that workflow's jobs. */
19export type Unit =
20 | { kind: "job"; key: string; label: string; needs: string[]; job: GraphJob }
21 | { kind: "matrix"; key: string; label: string; needs: string[]; jobs: GraphJob[] }
22 | { kind: "call"; key: string; label: string; needs: string[]; uses: string | null; callers: GraphJob[]; units: Unit[] };
23
24/** Sizes, in pixels. */
25export const GRAPH = {
26 nodeWidth: 220,
27 nodeHeight: 44,
28 /** The line a deployment's address takes. */
29 urlHeight: 18,
30 /** A matrix's job, listed in its expanded node. */
31 rowHeight: 32,
32 columnGap: 48,
33 rowGap: 16,
34 groupPad: 12,
35 groupHead: 32,
36 margin: 16,
37} as const;
38
39/** `test` from `test (ubuntu-latest, 20)`. */
40export function baseName(name: string): string {
41 return name.replace(/\s*\([^()]*\)$/, "") || name;
42}
43
44/** A called workflow's job by its own name: `test`, not `build / test`. */
45function within(name: string, parent: string | null): string {
46 return parent && name.startsWith(`${parent} / `) ? name.slice(parent.length + 3) : name;
47}
48
49function unique(values: string[]): string[] {
50 return [...new Set(values)];
51}
52
53/**
54 * The run's jobs as units, in the order they were listed. A called
55 * workflow's jobs have keys under their caller's (`build/test`), and a
56 * matrix's jobs share their key.
57 */
58export function groupJobs(jobs: GraphJob[], prefix = "", parent: string | null = null): Unit[] {
59 const order: string[] = [];
60 const direct = new Map<string, GraphJob[]>();
61 const nested = new Map<string, GraphJob[]>();
62 for (const job of jobs) {
63 if (!job.key.startsWith(prefix)) continue;
64 const rest = job.key.slice(prefix.length);
65 if (!rest) continue;
66 const segment = rest.split("/")[0]!;
67 // A matrix's calls each have their jobs under `build[i]/`, in `build`'s box.
68 const key = prefix + segment.replace(/\[\d+\]$/, "");
69 if (!direct.has(key)) {
70 order.push(key);
71 direct.set(key, []);
72 nested.set(key, []);
73 }
74 (rest === segment ? direct : nested).get(key)!.push(job);
75 }
76 return order.map((key): Unit => {
77 const own = direct.get(key)!;
78 const inner = nested.get(key)!;
79 const needs = unique(own.flatMap((job) => job.needs));
80 const label = own.length > 0 ? within(own.length > 1 ? baseName(own[0]!.name) : own[0]!.name, parent) : key.slice(prefix.length);
81 if (inner.length > 0 || own.some((job) => job.uses)) {
82 const callerName = own.length > 0 ? (own.length > 1 ? baseName(own[0]!.name) : own[0]!.name) : label;
83 // One call, or one for each combination of the caller's matrix,
84 // whose jobs keep the combination in their names.
85 const calls = unique(inner.map((job) => job.key.slice(0, job.key.indexOf("/", key.length) + 1)));
86 return {
87 kind: "call",
88 key,
89 label,
90 needs,
91 uses: own.find((job) => job.uses)?.uses ?? null,
92 callers: own,
93 units: calls.flatMap((under) => groupJobs(inner, under, under === `${key}/` ? callerName : baseName(callerName))),
94 };
95 }
96 if (own.length > 1) return { kind: "matrix", key, label, needs, jobs: own };
97 return { kind: "job", key, label, needs, job: own[0]! };
98 });
99}
100
101/** Every job of a unit, its called workflow's included. */
102export function jobsOf(unit: Unit): GraphJob[] {
103 if (unit.kind === "job") return [unit.job];
104 if (unit.kind === "matrix") return unit.jobs;
105 return [...unit.callers, ...unit.units.flatMap(jobsOf)];
106}
107
108/** Where several jobs stand together: running while any runs, else the worst way any ended. */
109export function standingOf(jobs: GraphJob[]): Standing {
110 if (jobs.length === 0) return { status: "waiting", conclusion: null };
111 if (jobs.every((job) => job.status === "completed")) {
112 const ended = jobs.map((job) => job.conclusion);
113 const conclusion: Conclusion = ended.includes("failure")
114 ? "failure"
115 : ended.includes("cancelled")
116 ? "cancelled"
117 : ended.includes("success")
118 ? "success"
119 : "skipped";
120 return { status: "completed", conclusion };
121 }
122 const has = (status: Job["status"]) => jobs.some((job) => job.status === status);
123 if (has("in_progress") || has("calling")) return { status: "in_progress", conclusion: null };
124 if (has("pending")) return { status: "pending", conclusion: null };
125 if (has("queued")) return { status: "queued", conclusion: null };
126 return { status: "waiting", conclusion: null };
127}
128
129/** A unit's standing; a called workflow's is its caller's while it has one. */
130export function unitStanding(unit: Unit): Standing {
131 if (unit.kind === "call" && unit.callers.length > 0) return standingOf(unit.callers);
132 return standingOf(jobsOf(unit));
133}
134
135/**
136 * Each unit's column: the longest path of `needs` from a unit needing
137 * nothing. Needs of keys not among `units` are left out, and so is a need
138 * that would close a cycle (a workflow cannot have one, but the graph does
139 * not trust that).
140 */
141export function columns(units: Unit[]): Map<string, number> {
142 const byKey = new Map(units.map((unit) => [unit.key, unit]));
143 const depth = new Map<string, number>();
144 const visiting = new Set<string>();
145 const visit = (key: string): number => {
146 const known = depth.get(key);
147 if (known !== undefined) return known;
148 if (visiting.has(key)) return -1;
149 visiting.add(key);
150 let column = 0;
151 for (const need of byKey.get(key)!.needs) {
152 if (need === key || !byKey.has(need)) continue;
153 const before = visit(need);
154 if (before >= 0) column = Math.max(column, before + 1);
155 }
156 visiting.delete(key);
157 depth.set(key, column);
158 return column;
159 };
160 for (const unit of units) visit(unit.key);
161 return depth;
162}
163
164/** The connectors of one level: from a need to what needs it, never backwards. */
165function links(units: Unit[], column: Map<string, number>): [string, string][] {
166 const out: [string, string][] = [];
167 for (const unit of units)
168 for (const need of unique(unit.needs)) if (need !== unit.key && column.has(need) && column.get(need)! < column.get(unit.key)!) out.push([need, unit.key]);
169 return out;
170}
171
172/**
173 * How many pairs of connectors cross, counting pairs that run between the
174 * same two columns.
175 */
176export function crossings(order: string[][], edges: [string, string][]): number {
177 const at = new Map<string, [number, number]>();
178 order.forEach((keys, column) => keys.forEach((key, row) => at.set(key, [column, row])));
179 let count = 0;
180 for (let i = 0; i < edges.length; i++)
181 for (let j = i + 1; j < edges.length; j++) {
182 const [a, b] = edges[i]!.map((key) => at.get(key)!);
183 const [c, d] = edges[j]!.map((key) => at.get(key)!);
184 if (a![0] !== c![0] || b![0] !== d![0]) continue;
185 if ((a![1] - c![1]) * (b![1] - d![1]) < 0) count++;
186 }
187 return count;
188}
189
190/**
191 * Units per column, top to bottom: first as listed, then sorted by the
192 * mean row of what they need (sweeping right) and of what needs them
193 * (sweeping left), a few times, keeping the order with the fewest
194 * crossings.
195 */
196export function orderColumns(units: Unit[], column: Map<string, number>, sweeps = 4): string[][] {
197 const count = units.length === 0 ? 0 : Math.max(...column.values()) + 1;
198 let order: string[][] = Array.from({ length: count }, () => []);
199 for (const unit of units) order[column.get(unit.key)!]!.push(unit.key);
200 const edges = links(units, column);
201 const needsOf = new Map<string, string[]>();
202 const neededBy = new Map<string, string[]>();
203 for (const [from, to] of edges) {
204 needsOf.set(to, [...(needsOf.get(to) ?? []), from]);
205 neededBy.set(from, [...(neededBy.get(from) ?? []), to]);
206 }
207 let best = order.map((keys) => [...keys]);
208 let fewest = crossings(best, edges);
209 const rowOf = () => {
210 const rows = new Map<string, number>();
211 order.forEach((keys) => keys.forEach((key, row) => rows.set(key, row)));
212 return rows;
213 };
214 const sortBy = (keys: string[], others: Map<string, string[]>, rows: Map<string, number>) => {
215 const centre = (key: string, index: number) => {
216 const near = others.get(key) ?? [];
217 return near.length === 0 ? index : near.reduce((sum, other) => sum + rows.get(other)!, 0) / near.length;
218 };
219 return keys
220 .map((key, index) => ({ key, index, at: centre(key, index) }))
221 .sort((x, y) => x.at - y.at || x.index - y.index)
222 .map(({ key }) => key);
223 };
224 for (let sweep = 0; sweep < sweeps && fewest > 0; sweep++) {
225 const right = sweep % 2 === 0;
226 const range = right ? [...order.keys()].slice(1) : [...order.keys()].reverse().slice(1);
227 for (const index of range) order[index] = sortBy(order[index]!, right ? needsOf : neededBy, rowOf());
228 const now = crossings(order, edges);
229 if (now < fewest) {
230 fewest = now;
231 best = order.map((keys) => [...keys]);
232 }
233 }
234 return best;
235}
236
237/** A placed node: a job, or a matrix's jobs. */
238export type PlacedNode = {
239 unit: Extract<Unit, { kind: "job" | "matrix" }>;
240 x: number;
241 y: number;
242 w: number;
243 h: number;
244 expanded: boolean;
245};
246
247/** A placed box: a job calling a workflow, its jobs inside. */
248export type PlacedGroup = { unit: Extract<Unit, { kind: "call" }>; x: number; y: number; w: number; h: number };
249
250/**
251 * `failed`: from a unit that failed. `active`: to a unit running now.
252 * `idle`: the rest.
253 */
254export type EdgeState = "idle" | "active" | "failed";
255export type PlacedEdge = { from: string; to: string; path: string; state: EdgeState };
256
257export type GraphLayout = { width: number; height: number; nodes: PlacedNode[]; groups: PlacedGroup[]; edges: PlacedEdge[] };
258
259type Level = { width: number; height: number; nodes: PlacedNode[]; groups: PlacedGroup[]; edges: PlacedEdge[] };
260
261/** A job node's height: taller with a deployment's address. */
262function jobHeight(job: GraphJob): number {
263 return GRAPH.nodeHeight + (job.environmentUrl ? GRAPH.urlHeight : 0);
264}
265
266/** The address a matrix's jobs all deploy to, when they share one. */
267export function sharedUrl(jobs: GraphJob[]): string | null {
268 const url = jobs[0]?.environmentUrl ?? null;
269 return url && jobs.every((job) => job.environmentUrl === url) ? url : null;
270}
271
272/** A rounded right-angled connector from a node's right edge to another's left. */
273export function connector(x1: number, y1: number, x2: number, y2: number): string {
274 if (Math.abs(y1 - y2) < 0.5) return `M${x1} ${y1}H${x2}`;
275 // Turn in the gap before the target, so the connector leaves at its source's row.
276 const mx = Math.max(x1 + 8, x2 - GRAPH.columnGap / 2);
277 const r = Math.min(8, Math.abs(y2 - y1) / 2, mx - x1, x2 - mx);
278 const down = y2 > y1 ? 1 : -1;
279 return [`M${x1} ${y1}`, `H${mx - r}`, `Q${mx} ${y1} ${mx} ${y1 + down * r}`, `V${y2 - down * r}`, `Q${mx} ${y2} ${mx + r} ${y2}`, `H${x2}`].join("");
280}
281
282function edgeState(from: Unit, to: Unit): EdgeState {
283 const source = unitStanding(from);
284 if (source.status === "completed" && source.conclusion === "failure") return "failed";
285 const target = unitStanding(to);
286 if (source.status === "completed" && target.status === "in_progress") return "active";
287 return "idle";
288}
289
290function place(units: Unit[], expanded: ReadonlySet<string>, ox: number, oy: number): Level {
291 const column = columns(units);
292 const order = orderColumns(units, column);
293 const byKey = new Map(units.map((unit) => [unit.key, unit]));
294 const level: Level = { width: 0, height: 0, nodes: [], groups: [], edges: [] };
295 // Each unit's size; a called workflow's box holds its own level.
296 const size = (unit: Unit): [number, number] => {
297 if (unit.kind === "job") return [GRAPH.nodeWidth, jobHeight(unit.job)];
298 if (unit.kind === "matrix")
299 return [
300 GRAPH.nodeWidth,
301 GRAPH.nodeHeight + (sharedUrl(unit.jobs) ? GRAPH.urlHeight : 0) + (expanded.has(unit.key) ? unit.jobs.length * GRAPH.rowHeight + 6 : 0),
302 ];
303 const content = place(unit.units, expanded, 0, 0);
304 return [Math.max(GRAPH.nodeWidth, content.width) + GRAPH.groupPad * 2, GRAPH.groupHead + content.height + GRAPH.groupPad];
305 };
306 const sizes = new Map(units.map((unit) => [unit.key, size(unit)]));
307 const box = new Map<string, { x: number; y: number; w: number; h: number }>();
308 let x = ox;
309 for (const keys of order) {
310 let y = oy;
311 let widest = 0;
312 for (const key of keys) {
313 const [w, h] = sizes.get(key)!;
314 box.set(key, { x, y, w, h });
315 y += h + GRAPH.rowGap;
316 widest = Math.max(widest, w);
317 }
318 level.height = Math.max(level.height, y - GRAPH.rowGap - oy);
319 x += widest + GRAPH.columnGap;
320 }
321 level.width = Math.max(0, x - GRAPH.columnGap - ox);
322 for (const unit of units) {
323 const at = box.get(unit.key)!;
324 if (unit.kind === "call") {
325 level.groups.push({ unit, ...at });
326 // Its jobs, moved into its box.
327 const content = place(unit.units, expanded, at.x + GRAPH.groupPad, at.y + GRAPH.groupHead);
328 level.nodes.push(...content.nodes);
329 level.groups.push(...content.groups);
330 level.edges.push(...content.edges);
331 } else {
332 level.nodes.push({ unit, ...at, expanded: unit.kind === "matrix" && expanded.has(unit.key) });
333 }
334 }
335 for (const [from, to] of links(units, column)) {
336 const a = box.get(from)!;
337 const b = box.get(to)!;
338 // Connectors meet a node at the middle of its first line.
339 const anchor = GRAPH.nodeHeight / 2;
340 level.edges.push({ from, to, path: connector(a.x + a.w, a.y + anchor, b.x, b.y + anchor), state: edgeState(byKey.get(from)!, byKey.get(to)!) });
341 }
342 return level;
343}
344
345/** The whole graph, placed: `expanded` names the matrices shown open. */
346export function layoutRun(jobs: GraphJob[], expanded: ReadonlySet<string> = new Set()): GraphLayout {
347 const level = place(groupJobs(jobs), expanded, GRAPH.margin, GRAPH.margin);
348 return {
349 width: level.width + GRAPH.margin * 2,
350 height: level.height + GRAPH.margin * 2,
351 nodes: level.nodes,
352 groups: level.groups,
353 edges: level.edges,
354 };
355}