Skip to content
88 linesCodeBlameRaw
1/**
2 * Workflow status badges: an SVG saying how a workflow's latest finished
3 * run went, at `/{workspace}/{repo}/actions/workflows/{file}/badge.svg`,
4 * and the Markdown that shows one.
5 */
6
7import type { WorkflowRun } from "@g1t/contracts";
8
9export type BadgeState = "passing" | "failing" | "cancelled" | "no status";
10
11const COLORS: Record<BadgeState, string> = {
12 passing: "#2ea043",
13 failing: "#cf222e",
14 cancelled: "#6e7681",
15 "no status": "#6e7681",
16};
17const LABEL_COLOR = "#2b2a33";
18
19/** What the newest finished run among `runs` (newest first) came to. */
20export function badgeState(runs: WorkflowRun[]): BadgeState {
21 const run = runs.find((candidate) => candidate.status === "completed");
22 switch (run?.conclusion) {
23 case "success":
24 case "skipped":
25 return "passing";
26 case "failure":
27 return "failing";
28 case "cancelled":
29 return "cancelled";
30 default:
31 return "no status";
32 }
33}
34
35/** Roughly how wide `text` is in 11px Verdana, as badges are drawn. */
36export function textWidth(text: string): number {
37 let width = 0;
38 for (const c of text) {
39 if ("iljtfI.,:;'|!".includes(c)) width += 3.5;
40 else if ("mwMW@%".includes(c)) width += 10.5;
41 else if (c === " ") width += 3.8;
42 else if (/[A-Z0-9]/.test(c)) width += 7.6;
43 else width += 6.6;
44 }
45 return Math.ceil(width);
46}
47
48function escape(text: string): string {
49 return text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
50}
51
52/** A flat badge: `label` on dark gray, `state` in its color. */
53export function badgeSvg(label: string, state: BadgeState): string {
54 const shown = label.length > 60 ? `${label.slice(0, 59)}…` : label;
55 const left = textWidth(shown) + 12;
56 const right = textWidth(state) + 12;
57 const width = left + right;
58 const title = escape(`${shown}: ${state}`);
59 const text = (x: number, value: string) =>
60 `<text x="${x}" y="15" fill="#010101" fill-opacity=".3">${escape(value)}</text><text x="${x}" y="14">${escape(value)}</text>`;
61 return [
62 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="20" role="img" aria-label="${title}">`,
63 `<title>${title}</title>`,
64 `<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>`,
65 `<clipPath id="r"><rect width="${width}" height="20" rx="3" fill="#fff"/></clipPath>`,
66 `<g clip-path="url(#r)"><rect width="${left}" height="20" fill="${LABEL_COLOR}"/><rect x="${left}" width="${right}" height="20" fill="${COLORS[state]}"/><rect width="${width}" height="20" fill="url(#s)"/></g>`,
67 `<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">`,
68 text(left / 2, shown),
69 text(left + right / 2, state),
70 `</g></svg>`,
71 ].join("");
72}
73
74/** The badge's address, with its branch and event when chosen. */
75export function badgeUrl(site: string, repo: string, file: string, options: { branch?: string; event?: string } = {}): string {
76 const query = new URLSearchParams();
77 if (options.branch) query.set("branch", options.branch);
78 if (options.event) query.set("event", options.event);
79 const tail = query.size > 0 ? `?${query}` : "";
80 return `${site}/${repo}/actions/workflows/${encodeURIComponent(file)}/badge.svg${tail}`;
81}
82
83/** Markdown for a badge that links to the workflow's runs. */
84export function badgeMarkdown(site: string, repo: string, workflow: { name: string; file: string }, options: { branch?: string; event?: string } = {}): string {
85 const alt = workflow.name.replace(/[[\]]/g, "");
86 const runs = `${site}/${repo}/actions?workflow=${encodeURIComponent(workflow.file)}`;
87 return `[![${alt}](${badgeUrl(site, repo, workflow.file, options)})](${runs})`;
88}