From 5868efebb7c1dab3ace09308de9ec6ac438daa1f Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Tue, 1 Sep 2026 15:06:51 +0200 Subject: [PATCH] feat(diagrams): let each diagram name its own roles, and lock the door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration shim recolored the diagrams without touching them, which was the point of landing it first — but a table that infers intent from a hex can only be as good as the colors it reads, and those colors were four dialects that disagreed. Two datastores came out the same color because both were pastel greens; "tests passed" and "merged to main" fused because both were greens. Each fence now says what it means, and the inference goes away. The 19 fences drop from 713 lines of DOT to 286, because most of what they said was color. What is left is structure, plus a role per element and a layout kind per fence — `queue`, `flow` or `arch`, which set direction and spacing and nothing else. A few things the diagrams had wrong get fixed on the way through, since the rewrite makes them visible: batches.mdx had a pull request that was grey rather than queued only because it was declared implicitly by an edge and so missed the fill every other node got. The emoji come out of the lifecycle and two-step diagrams. They render differently on every platform, Graphviz cannot measure them, and they carried no meaning that the color does not now carry. `pnpm check:diagram-tokens` is what makes this permanent — the same shape as `check:internal-leaks`, a deterministic scan wired into CI. Nothing about a hardcoded color fails a build on its own: the diagram renders, it just renders wrong on half the site, which is exactly how 63 of them accumulated unnoticed. It reads inside Graphviz fences only, so a color in prose or a CSS example is untouched, and it reads the three hand-drawn diagram components end to end, since those have no fence to scan and are where two of the four dialects lived. A CI job that nothing waits on is decoration, so `diagram-tokens` joins the `CheckRuns` anchor in `.mergify.yml` — the list the merge protection, both queue rules and the review-request rule all share. Note this leaves `actionlint`, `config-examples` and `internal-leaks` outside that anchor, which they were before this change; whether they belong there is a separate question from this diff. `enterprise/architecture.mdx` keeps its click-to-zoom overlay. The diagram is 945pt now rather than 1736pt, which is what makes it legible at all, but at 80% of the prose column that still scales its labels to roughly 8px — so the overlay is still doing something. Change-Id: I6cfa48c833499804a082a59bf6199236172cdcca --- .github/workflows/ci.yaml | 17 ++ .mergify.yml | 1 + package.json | 1 + plugins/remark-graphviz.test.ts | 75 +++---- plugins/remark-graphviz.ts | 136 ++++-------- scripts/check-diagram-tokens.mjs | 196 ++++++++++++++++++ scripts/check-diagram-tokens.test.mjs | 93 +++++++++ .../docs/ci-insights/flaky-test-detection.mdx | 92 ++++---- src/content/docs/enterprise/architecture.mdx | 52 ++--- src/content/docs/integrations/buildkite.mdx | 27 +-- src/content/docs/integrations/gha.mdx | 32 ++- src/content/docs/merge-queue/batches.mdx | 70 +++---- src/content/docs/merge-queue/direct-merge.mdx | 47 ++--- src/content/docs/merge-queue/lifecycle.mdx | 86 ++------ src/content/docs/merge-queue/performance.mdx | 64 +++--- src/content/docs/merge-queue/queue-modes.mdx | 137 ++++-------- src/content/docs/merge-queue/scopes.mdx | 34 ++- src/content/docs/merge-queue/stacks.mdx | 96 ++++----- src/content/docs/merge-queue/two-step.mdx | 156 +++----------- src/styles/index.css | 10 +- src/util/diagramSvg.ts | 40 +--- 21 files changed, 675 insertions(+), 787 deletions(-) create mode 100644 scripts/check-diagram-tokens.mjs create mode 100644 scripts/check-diagram-tokens.test.mjs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 386e3714ed..8e438ded28 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -104,6 +104,23 @@ jobs: - name: Check for leaked internal information run: node scripts/check-internal-leaks.mjs + diagram-tokens: + timeout-minutes: 5 + runs-on: ubuntu-24.04 + steps: + - name: Checkout 🛎️ + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node 🔧 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .node-version + + # No dependencies needed: the scanner is plain Node. Its own unit tests + # run in the `test` job. + - name: Check that diagrams resolve their colours from tokens + run: node scripts/check-diagram-tokens.mjs + build: timeout-minutes: 20 runs-on: ubuntu-24.04 diff --git a/.mergify.yml b/.mergify.yml index f4ed5738fa..ece2fc223c 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -30,6 +30,7 @@ merge_protections: - check-success = lint - check-success = build - check-success = test + - check-success = diagram-tokens - or: - label = ignore-broken-links - check-success = test-broken-links diff --git a/package.json b/package.json index b72a75c813..b8b3a80976 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "format:check": "biome format src integrations plugins scripts", "check": "astro check && eslint . && biome check .", "check:config-examples": "node scripts/validate-config-examples.mjs", + "check:diagram-tokens": "node scripts/check-diagram-tokens.mjs", "check:internal-leaks": "node scripts/check-internal-leaks.mjs", "check:links": "linkinator / enterprise/ --server-root dist --config linkinator.config.mjs" }, diff --git a/plugins/remark-graphviz.test.ts b/plugins/remark-graphviz.test.ts index 10109f461a..ce61406cee 100644 --- a/plugins/remark-graphviz.test.ts +++ b/plugins/remark-graphviz.test.ts @@ -45,37 +45,40 @@ describe('remarkGraphvizPlugin', () => { expect(svg).not.toMatch(/\bstroke="/); }); - it('maps the colours the docs were drawn with onto roles', async () => { + it('passes the role a fence names straight through to the SVG', async () => { + // The whole system rests on this: Graphviz copies `class` verbatim, so the + // plugin never has to resolve a colour and CSS can do it at paint time. const svg = await render(`digraph { - node [style=filled]; - A [fillcolor="#347D39"]; - B [fillcolor="#6B7280"]; - C [fillcolor="#FFF4ED"]; - subgraph cluster_b { style="rounded,filled"; fillcolor="#1CB893"; label="Batch"; A; } - A -> B [color="#9CA3AF"]; + A [class="failed"]; + subgraph cluster_b { class="batch"; label="Batch"; A; } + A -> B [class="muted"]; }`); - expect(classesOf(svg, 'A')).toContain('queued'); - expect(classesOf(svg, 'B')).toContain('muted'); - expect(classesOf(svg, 'C')).toContain('pending'); - // Teal is a container on a cluster and a value on a node. - expect(classesOf(svg, 'cluster_b')).toContain('batch'); - expect(classesOf(svg, 'A->B')).toContain('muted'); + expect(classesOf(svg, 'A')).toEqual(['node', 'failed']); + expect(classesOf(svg, 'cluster_b')).toEqual(['cluster', 'batch']); + expect(classesOf(svg, 'A->B')).toEqual(['edge', 'muted']); }); - it('reads a cluster drawn with a stroke and no fill', async () => { - const svg = await render(`digraph { - subgraph cluster_w { style="rounded"; color="#6B7280"; label="Waiting"; A; } - }`); - expect(classesOf(svg, 'cluster_w')).toContain('muted'); + it('never invents a role for an element that names none', async () => { + const svg = await render('digraph { A; A -> B; }'); + expect(classesOf(svg, 'A')).toEqual(['node']); + expect(classesOf(svg, 'A->B')).toEqual(['edge']); }); - it('lets an authored role win over the substitution table', async () => { - const svg = await render(`digraph { - node [style=filled]; - A [fillcolor="#347D39", class="failed"]; - }`); - expect(classesOf(svg, 'A')).toContain('failed'); - expect(classesOf(svg, 'A')).not.toContain('queued'); + it('applies the layout defaults of the kind a fence opts into', async () => { + const flow = await render('digraph { A -> B; }', 'class="flow"'); + const queue = await render('digraph { A -> B; }', 'class="queue"'); + // `flow` is top-to-bottom and `queue` is left-to-right, so the same two + // nodes come out stacked in one and side by side in the other. + const box = (svg: string) => /viewBox="[\d.]+ [\d.]+ ([\d.]+) ([\d.]+)"/.exec(svg)!; + expect(Number(box(flow)[1])).toBeLessThan(Number(box(flow)[2])); + expect(Number(box(queue)[1])).toBeGreaterThan(Number(box(queue)[2])); + }); + + it('does not reach Object.prototype for a kind a fence made up', async () => { + // The class comes from the fence, so an unguarded lookup would splice + // `function Object() { [native code] }` into the DOT and fail the render. + const svg = await render('digraph { A -> B; }', 'class="constructor"'); + expect(svg).toMatch(/^ { @@ -105,28 +108,6 @@ describe('remarkGraphvizPlugin', () => { expect(square).toMatch(/]*>\s*A<\/title>\s* { - // An unmapped fill must fall back to the element default, not to whatever - // role the node's darker border happens to match. - const svg = await render(`digraph { - node [style=filled]; - A [fillcolor="#ABCDEF", color="#374151"]; - }`); - expect(classesOf(svg, 'A')).toEqual(['node']); - }); - - it('does not guess a colour for an element whose author named a class', async () => { - // Even an unrecognised class means the author said something; appending a - // guessed role would contradict them with no warning. - const svg = await render(`digraph { - node [style=filled]; - A [class="Queued", fillcolor="#DC2626"]; - B [shape=plaintext, class="plain", fillcolor="#347D39"]; - }`); - expect(classesOf(svg, 'A')).toEqual(['node', 'Queued']); - expect(classesOf(svg, 'B')).toEqual(['node', 'plain']); - }); - it('strips the alpha Graphviz emits alongside a colour', async () => { const svg = await render(`digraph { node [style=filled]; diff --git a/plugins/remark-graphviz.ts b/plugins/remark-graphviz.ts index eb4b8be5c2..97922f25e7 100644 --- a/plugins/remark-graphviz.ts +++ b/plugins/remark-graphviz.ts @@ -3,12 +3,7 @@ import { load } from 'cheerio'; import type * as mdast from 'mdast'; import type * as unified from 'unified'; import { CONTINUE, visit } from 'unist-util-visit'; -import { - type DiagramKind, - type DiagramRole, - finishDiagramSvg, - type ShapePaint, -} from '../src/util/diagramSvg'; +import { finishDiagramSvg } from '../src/util/diagramSvg'; /** * Render `dot` / `circo` / `neato` fences to inline SVG, and hand every colour @@ -16,12 +11,17 @@ import { * * Graphviz supports a `class` attribute on graphs, nodes, edges and clusters * and copies it verbatim into the SVG (`class="node queued"`). So this plugin - * never resolves a colour: it injects shape and spacing defaults, drops the - * opaque canvas, tags each element with a *role*, and strips the inline paint - * so the `.dg` rules in `index.css` resolve surface, border and label at paint - * time from the role accents in `theme.css`. Dark mode then arrives through the - * same `:root.theme-dark` block as every other surface on the site, with no - * second render and no string matching. + * never resolves a colour and never names a role: the fence names them, and + * the plugin only injects shape and spacing defaults, drops the opaque canvas + * and strips the inline paint, so the `.dg` rules in `index.css` resolve + * surface, border and label at paint time from the role accents in + * `theme.css`. Dark mode then arrives through the same `:root.theme-dark` + * block as every other surface on the site, with no second render and no + * string matching. + * + * The one class the rendering side still adds is `plain`, in + * `finishDiagramSvg` — a shape fact (this element is a caption, not a box), + * not a colour. */ const viz = await instance(); @@ -46,103 +46,41 @@ const METRICS_FONT = 'Helvetica'; * default is below. */ const BASE = ` - graph [bgcolor="transparent", fontname="${METRICS_FONT}", fontsize=13, - labelloc="t", pad="0.12", nodesep=0.45, ranksep=0.55]; + graph [bgcolor="transparent", style="rounded", fontname="${METRICS_FONT}", + fontsize=13, labelloc="t", pad="0.12", nodesep=0.45, ranksep=0.55]; node [fontname="${METRICS_FONT}", fontsize=13, shape=box, style="rounded,filled", penwidth=1.4, margin="0.24,0.15", height=0.42]; edge [fontname="${METRICS_FONT}", fontsize=10, penwidth=1.3, arrowsize=0.7]; `; /** - * Transitional: the colours the docs were drawn with, mapped onto roles. - * - * Four independent dialects grew here — queue-green, emoji-pastel, - * nineties-pastel and near-white-blueprint — because there was no palette to be - * consistent with. This table maps by the hue family each dialect used, so two - * elements drawn in the same colour still read alike. It is lossy in the other - * direction: where one dialect used two shades of a hue for two meanings, both - * land on one role — PostgreSQL and Redis both become `datastore`, and - * "tests passed" and "merged to main" both become `merged`. That is the price - * of recolouring the whole corpus without editing a single fence, and it is - * paid back one page at a time as each fence names its own roles. - * - * It is a migration shim with a known end: once every fence names its own role, - * nothing reaches this table and it goes away. Keys are lowercase hex. + * Diagram kinds. A fence opts into one through its class — ```dot class="queue" + * — and it sets layout, never colour. A fence that names none gets BASE alone + * and lays itself out. */ -const LEGACY_ROLES: Record = { - // Queue dialect — batches, performance, stacks, queue-modes, scopes, - // direct-merge, gha, buildkite. - '#347d39': 'queued', // queue green: a pull request in the queue - '#1cb893': 'config', // Merge Queue teal, as a node: a scope or a config value - '#6b7280': 'muted', // skipped, waiting, not selected - '#9ca3af': 'muted', // cascaded out, dashed side-links - '#111827': 'external', // CI, ci-gate, main - '#0b1120': 'external', - '#2563eb': 'pending', // the detect-scopes step, mid-run - '#dc2626': 'failed', - '#374151': 'chrome', // the edge colour the old plugin string-matched - '#4b5563': 'chrome', - '#5b21b6': 'chrome', // stacks: edges and their labels - - // Emoji-pastel dialect — lifecycle, two-step. - '#f3f4f6': 'external', // dequeued: out of the queue - '#fff4ed': 'pending', // queueing, validating, testing - '#ede9fe': 'queued', - '#f3e8ff': 'queued', // the queue command - '#dbeafe': 'config', - '#d1fae5': 'merged', - '#ddd6fe': 'merged', // merged to main - '#fee2e2': 'failed', - '#10b981': 'merged', // the "passed" edge - '#ef4444': 'failed', // the "failed" edge - '#7c3aed': 'chrome', // the default edge colour on both pages - - // Nineties-pastel dialect — flaky-test-detection. - '#c9e7f8': 'config', // the commit under test - '#b7f5c1': 'merged', // tests passed - '#f8c9c9': 'failed', // tests failed - '#d8f0ff': 'external', // "consistent (not flaky)" - '#ffe9b3': 'pending', // "flagged as flaky" - '#999999': 'muted', // the dashed commit clusters - - // Near-white-blueprint dialect — enterprise/architecture. - '#f6f8fb': 'external', // the default node fill - '#ffffff': 'external', // GitHub - '#24292e': 'external', - '#fff3d6': 'config', // the reverse proxy: the entry point - '#e6f0ff': 'mergify', // dashboard and workers - '#f0ecfe': 'mergify', // the subscription API - '#f4fbff': 'mergify', // the on-premise cluster - '#e4f5ed': 'datastore', // PostgreSQL - '#fce3e8': 'datastore', // Redis - '#fdfeff': 'batch', // the customer-infrastructure cluster - '#8892bf': 'chrome', +const KINDS: Record = { + queue: `rankdir="LR"; splines="polyline"; nodesep=0.32; ranksep=0.45;`, + flow: `rankdir="TB"; splines="spline"; nodesep=0.55; ranksep=0.6;`, + arch: `rankdir="TB"; splines="ortho"; nodesep=0.8; ranksep=1.0; + node [width=2.5, margin="0.34,0.24"];`, }; -/** A cluster reads its colour differently: teal is a container, not a value. */ -const LEGACY_CLUSTER_ROLES: Record = { - ...LEGACY_ROLES, - '#1cb893': 'batch', -}; - -/** Inject the base defaults immediately after the opening brace. */ -function injectDefaults(source: string): string { +/** + * Inject the base defaults, plus any kind the fence opted into, immediately + * after the opening brace, so anything the fence writes afterwards overrides + * them. `Object.hasOwn` because the class comes from the fence: a fence + * classed `constructor` would otherwise inject `Object`'s own into the DOT. + */ +function injectDefaults(source: string, classes: string[]): string { const brace = source.indexOf('{'); if (brace === -1) return source; - return `${source.slice(0, brace + 1)}\n${BASE}\n${source.slice(brace + 1)}`; -} -/** - * Name the role of an element the fence did not name, from the paint Graphviz - * gave it. A cluster drawn with `style=rounded` and no fill carries its colour - * on the stroke instead; nothing else falls back, because an unmapped fill must - * not let a border speak for the shape it merely outlines. - */ -function legacyRoleFor(kind: DiagramKind, { fill, stroke }: ShapePaint): DiagramRole | undefined { - if (kind === 'edge') return stroke ? LEGACY_ROLES[stroke.toLowerCase()] : undefined; - const table = kind === 'cluster' ? LEGACY_CLUSTER_ROLES : LEGACY_ROLES; - const color = !fill || fill === 'none' ? (kind === 'cluster' ? stroke : undefined) : fill; - return color ? table[color.toLowerCase()] : undefined; + let defaults = BASE; + for (const kind of classes) { + if (Object.hasOwn(KINDS, kind)) defaults += `\n ${KINDS[kind]}\n`; + } + + return `${source.slice(0, brace + 1)}\n${defaults}\n${source.slice(brace + 1)}`; } export function remarkGraphvizPlugin(): unified.Plugin<[], mdast.Root> { @@ -165,7 +103,7 @@ export function remarkGraphvizPlugin(): unified.Plugin<[], mdast.Root> { const attrs = attrString ? load(``)(`element`) : null; const classes = (attrs?.attr('class') ?? '').split(/\s+/).filter(Boolean); - const svgString = viz.renderString(injectDefaults(node.value), { + const svgString = viz.renderString(injectDefaults(node.value, classes), { format: 'svg', engine: lang, }); @@ -175,7 +113,7 @@ export function remarkGraphvizPlugin(): unified.Plugin<[], mdast.Root> { // from them, so a fence can add a kind without losing `dg`. const fenceAttrs = attrs?.attr(); if (fenceAttrs) $(`svg`).attr(fenceAttrs); - finishDiagramSvg($, { classes, roleFor: legacyRoleFor }); + finishDiagramSvg($, { classes }); // Rewrite the fence in place: it stops being a code block and becomes // the rendered SVG. mdast has no in-place conversion, so the node diff --git a/scripts/check-diagram-tokens.mjs b/scripts/check-diagram-tokens.mjs new file mode 100644 index 0000000000..4047c02132 --- /dev/null +++ b/scripts/check-diagram-tokens.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node +/** + * Scan the docs for diagrams that name a colour. + * + * A diagram names a *role* — `queued`, `merged`, `failed`, `external` — and the + * page resolves it from the design tokens at paint time, so the diagram themes + * itself and every page uses one palette. That worked until it didn't: before + * this rule existed, 63 distinct colours had accumulated across 18 pages in four + * unrelated dialects, none of which the token system could reach and none of + * which adapted to dark mode. + * + * Nothing about a hardcoded colour fails a build on its own — the diagram + * renders, it just renders wrong on half the site — so this is the thing that + * keeps it from happening again. See DESIGN.md "Diagrams" for the roles. + * + * Usage: + * node scripts/check-diagram-tokens.mjs [paths...] + * # with no paths: the docs pages, the components, and the shared + * # post-processor — see DEFAULT_TARGETS below. + * node scripts/check-diagram-tokens.mjs --json [paths...] + * + * To allow a specific line, put a comment on the line before it naming the rule: + * + * // diagram-tokens: allow graphviz-color-attr — a legend of the palette itself + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..'); +const DEFAULT_TARGETS = ['src/content/docs', 'src/components', 'src/util/diagramSvg.ts']; + +/** + * Page extensions, scanned fence by fence. The diagram components below are + * not pages and are matched by name instead, whatever their extension — which + * is why `.astro` is absent here and `src/util/diagramSvg.ts` is named as a + * target of its own: it lives outside every directory scanned above. + */ +const SCANNED_EXTENSIONS = ['.mdx', '.md']; + +/** + * Each rule is deliberately narrow, because a false positive lands on a docs + * contributor. `#RRGGBB` is required in full: a three-digit match would fire on + * every `PR #101` in a diagram label, which is how the diagrams talk. + */ +export const RULES = [ + { + id: 'graphviz-color-attr', + label: 'Graphviz colour attribute', + // `(?= 0; p -= 1) { + if (!lines[p].trim()) continue; + const m = lines[p].match(ALLOW_RE); + if (m) for (const id of m[1].split(',')) allowed.add(id.trim().toLowerCase()); + break; + } + return allowed; +} + +/** + * The 1-based line numbers that belong to a diagram: everything inside a + * Graphviz fence, plus its attribute line. Everything else in a docs page is + * prose and code samples, where a colour is ordinary content — a CSS example, + * a screenshot description, a config value. + */ +function diagramLines(text) { + const lines = new Set(); + for (const m of text.matchAll(FENCE_RE)) { + const start = text.slice(0, m.index).split('\n').length; + const length = `${m[2]}\n${m[3]}`.split('\n').length; + for (let i = 0; i < length; i += 1) lines.add(start + i); + } + return lines; +} + +/** Scan text; returns [{line, rule, label, match}, ...]. */ +export function scanText(text, { wholeFile = false, rules = RULES } = {}) { + const lines = text.split('\n'); + const inDiagram = wholeFile ? null : diagramLines(text); + const findings = []; + + lines.forEach((line, i) => { + if (inDiagram && !inDiagram.has(i + 1)) return; + let allowed = null; + for (const rule of rules) { + rule.re.lastIndex = 0; + const matches = line.match(rule.re); + if (!matches) continue; + allowed ??= allowedOnLine(lines, i); + if (allowed.has(rule.id)) continue; + for (const match of new Set(matches)) { + findings.push({ line: i + 1, rule: rule.id, label: rule.label, match }); + } + } + }); + + return findings; +} + +export function scanFile(file) { + const wholeFile = DIAGRAM_COMPONENTS.includes(path.basename(file)); + return scanText(fs.readFileSync(file, 'utf8'), { wholeFile }).map((f) => ({ + ...f, + file: path.relative(ROOT, file), + })); +} + +export function* iterFiles(targets) { + for (const t of targets) { + const abs = path.resolve(ROOT, t); + if (!fs.existsSync(abs)) continue; + const stat = fs.statSync(abs); + if (stat.isDirectory()) { + for (const entry of fs.readdirSync(abs, { withFileTypes: true, recursive: true })) { + if (!entry.isFile()) continue; + const name = entry.name; + const isComponent = DIAGRAM_COMPONENTS.includes(name); + const isPage = SCANNED_EXTENSIONS.some((ext) => name.endsWith(ext)); + if (isComponent || isPage) yield path.join(entry.parentPath ?? entry.path, name); + } + } else { + yield abs; + } + } +} + +function main(argv) { + const jsonMode = argv.includes('--json'); + const targets = argv.filter((a) => a !== '--json'); + if (targets.length === 0) targets.push(...DEFAULT_TARGETS); + + const findings = []; + let scanned = 0; + for (const file of iterFiles(targets)) { + scanned += 1; + findings.push(...scanFile(file)); + } + + if (jsonMode) { + process.stdout.write(`${JSON.stringify(findings, null, 2)}\n`); + return findings.length === 0 ? 0 : 1; + } + + console.log(`Scanned ${scanned} file(s) for diagrams that name a colour.`); + if (findings.length === 0) { + console.log('Every diagram resolves its colours from tokens.'); + return 0; + } + console.error(`\n${findings.length} diagram colour(s) written by hand:\n`); + for (const f of findings) { + console.error(` ${f.file}:${f.line} — ${f.label} — ${f.match}`); + } + console.error( + '\nName a role instead, and let the page resolve the colour:\n' + + ' PR1 [class="queued"]; not PR1 [fillcolor="#347D39"];\n' + + 'The roles are queued, pending, merged, failed, config, mergify,\n' + + 'datastore, external, batch, muted and chrome; `plain` marks a caption\n' + + 'rather than a box. See DESIGN.md "Diagrams". If a colour is genuinely\n' + + 'the subject rather than the styling, allow it on the line above:\n' + + ' // diagram-tokens: allow [, ...] — why' + ); + return 1; +} + +// Run as a CLI only when invoked directly, so tests can import the helpers. +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exit(main(process.argv.slice(2))); +} diff --git a/scripts/check-diagram-tokens.test.mjs b/scripts/check-diagram-tokens.test.mjs new file mode 100644 index 0000000000..f3dbf7d86f --- /dev/null +++ b/scripts/check-diagram-tokens.test.mjs @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { iterFiles, scanFile, scanText } from './check-diagram-tokens.mjs'; + +const fence = (body) => ['```dot class="queue"', body, '```'].join('\n'); +const rulesOf = (text, opts) => scanText(text, opts).map((f) => f.rule); + +describe('scanText', () => { + it('catches a colour written inside a diagram', () => { + expect(rulesOf(fence('A [fillcolor="#347D39"];'))).toEqual([ + 'graphviz-color-attr', + 'diagram-hex', + ]); + expect(rulesOf(fence('edge [color="#374151"];'))).toEqual([ + 'graphviz-color-attr', + 'diagram-hex', + ]); + expect(rulesOf(fence('graph [bgcolor="#FAFBFC"];'))).toEqual([ + 'graphviz-color-attr', + 'diagram-hex', + ]); + // A named colour carries no hex, and is just as unreachable by the tokens. + expect(rulesOf(fence('A [fontcolor=white];'))).toEqual(['graphviz-color-attr']); + }); + + it('reports a compound attribute once, as itself', () => { + const found = scanText(fence('A [fillcolor="#347D39"];')); + expect(found.map((f) => f.match)).toEqual(['fillcolor=', '#347D39']); + }); + + it('leaves a diagram that names roles alone', () => { + expect( + rulesOf( + fence(`digraph { + subgraph cluster_b { class="batch"; label="Batch 1"; PR1 [class="queued"]; } + CI [label="Continuous\\nintegration", class="external"]; + PR1 -> CI [class="muted", style=dashed]; +}`) + ) + ).toEqual([]); + }); + + it('treats none and transparent as shape, not colour', () => { + // These say "draw nothing", which is the one thing a role cannot express. + expect(rulesOf(fence('graph [bgcolor="transparent"];'))).toEqual([]); + expect(rulesOf(fence('A [color=none];'))).toEqual([]); + expect(rulesOf(fence('A [color="none", fillcolor="#347D39"];'))).toEqual([ + 'graphviz-color-attr', + 'diagram-hex', + ]); + }); + + it('does not fire on the pull request numbers diagrams are full of', () => { + expect(rulesOf(fence('PR1 [label="PR #101\\nScopes: frontend"];'))).toEqual([]); + }); + + it('ignores colours outside a diagram', () => { + // A docs page is mostly prose and code samples, where a colour is content. + const page = [ + 'Set the badge colour with `color="#347D39"` in your config.', + '', + '```css', + '.badge { color: #347d39; }', + '```', + '', + fence('A [class="queued"];'), + ].join('\n'); + expect(rulesOf(page)).toEqual([]); + }); + + it('scans a hand-drawn diagram component end to end', () => { + // GitGraph and StackMapping draw SVG directly, so they have no fence for the + // fence scan to find — and they are where two of the four dialects lived. + const component = 'const COLORS = { green: "#347D39" };'; + expect(rulesOf(component)).toEqual([]); + expect(rulesOf(component, { wholeFile: true })).toEqual(['diagram-hex']); + }); + + it('honours an allow directive on the line above', () => { + expect( + rulesOf( + fence(`// diagram-tokens: allow diagram-hex — this diagram is about the colour +A [label="#347D39"];`) + ) + ).toEqual([]); + }); +}); + +describe('the docs themselves', () => { + it('has no diagram that names a colour', () => { + const findings = [...iterFiles(['src/content/docs', 'src/components'])].flatMap(scanFile); + expect(findings).toEqual([]); + }); +}); diff --git a/src/content/docs/ci-insights/flaky-test-detection.mdx b/src/content/docs/ci-insights/flaky-test-detection.mdx index 469d925553..eea5bf4662 100644 --- a/src/content/docs/ci-insights/flaky-test-detection.mdx +++ b/src/content/docs/ci-insights/flaky-test-detection.mdx @@ -30,68 +30,52 @@ Below is a simplified visualization of repeated runs on one commit: two executions of the same tests on the exact same commit (same SHA1) produce different results. -```dot class="graph" -strict digraph flaky { +```dot class="queue" +digraph { + label="Two runs of one test, on three commits"; rankdir=LR; - fontname="sans-serif"; - node [style=filled, shape=box, fontname="sans-serif", color="black", fontcolor="black"]; - edge [color="black", fontname="sans-serif"]; - // Non-flaky example (third commit, consistent FAIL results) - subgraph cluster_commit3 { - label="Commit SHA1 ghi789"; - fontname="sans-serif"; - color="#999999"; - style=dashed; - commit3 [label="test_something\n(SHA1 ghi789)", shape=oval, fillcolor="#C9E7F8"]; + subgraph cluster_commit1 { + class="batch"; + label="Commit abc123"; + commit1 [label="test_something2", shape=oval, class="config"]; } + run1 [label="Run #1 — tests pass", class="merged"]; + run2 [label="Run #2 — tests fail", class="failed"]; + detector [label="Flagged as flaky", shape=note, class="pending"]; + commit1 -> run1 [label=" run 1"]; + commit1 -> run2 [label=" run 2"]; + { rank=same; run1; run2; } + run1 -> detector [class="muted", style=dashed, arrowhead=none]; + run2 -> detector [class="muted", style=dashed, arrowhead=none]; - commit3 -> run5 [label="Run #1", arrowhead=normal]; - commit3 -> run6 [label="Run #2", arrowhead=normal]; - run5 [label="Tests FAIL", fillcolor="#F8C9C9"]; - run6 [label="Tests FAIL", fillcolor="#F8C9C9"]; - {rank=same; run5; run6} - stable2 [label="Consistent\n(Not Flaky)", shape=note, fillcolor="#D8F0FF"]; - run5 -> stable2 [style=dashed, arrowhead=none]; - run6 -> stable2 [style=dashed, arrowhead=none]; - - // Non-flaky example (different commit, consistent PASS results) subgraph cluster_commit2 { - label="Commit SHA1 def456"; - fontname="sans-serif"; - color="#999999"; - style=dashed; - commit2 [label="test_something\n(SHA1 def456)", shape=oval, fillcolor="#C9E7F8"]; + class="batch"; + label="Commit def456"; + commit2 [label="test_something", shape=oval, class="config"]; } + run3 [label="Run #1 — tests pass", class="merged"]; + run4 [label="Run #2 — tests pass", class="merged"]; + stable [label="Consistent\nnot flaky", shape=note, class="external"]; + commit2 -> run3 [label=" run 1"]; + commit2 -> run4 [label=" run 2"]; + { rank=same; run3; run4; } + run3 -> stable [class="muted", style=dashed, arrowhead=none]; + run4 -> stable [class="muted", style=dashed, arrowhead=none]; - commit2 -> run3 [label="Run #1", arrowhead=normal]; - commit2 -> run4 [label="Run #2", arrowhead=normal]; - run3 [label="Tests PASS", fillcolor="#B7F5C1"]; - run4 [label="Tests PASS", fillcolor="#B7F5C1"]; - {rank=same; run3; run4} - stable [label="Consistent\n(Not Flaky)", shape=note, fillcolor="#D8F0FF"]; - run3 -> stable [style=dashed, arrowhead=none]; - run4 -> stable [style=dashed, arrowhead=none]; - - - // Flaky example (same commit, differing results) - subgraph cluster_commit1 { - label="Commit SHA1 abc123"; - fontname="sans-serif"; - color="#999999"; - style=dashed; - commit1 [label="test_something2\n(SHA1 abc123)", shape=oval, fillcolor="#C9E7F8"]; + subgraph cluster_commit3 { + class="batch"; + label="Commit ghi789"; + commit3 [label="test_something", shape=oval, class="config"]; } - - commit1 -> run1 [label="Run #1", arrowhead=normal]; - commit1 -> run2 [label="Run #2", arrowhead=normal]; - run1 [label="Tests PASS", fillcolor="#B7F5C1"]; - run2 [label="Tests FAIL", fillcolor="#F8C9C9"]; - {rank=same; run1; run2} - detector [label="Flagged as\nflaky", shape=note, fillcolor="#FFE9B3"]; - run1 -> detector [style=dashed, arrowhead=none]; - run2 -> detector [style=dashed, arrowhead=none]; - + run5 [label="Run #1 — tests fail", class="failed"]; + run6 [label="Run #2 — tests fail", class="failed"]; + stable2 [label="Consistent\nnot flaky", shape=note, class="external"]; + commit3 -> run5 [label=" run 1"]; + commit3 -> run6 [label=" run 2"]; + { rank=same; run5; run6; } + run5 -> stable2 [class="muted", style=dashed, arrowhead=none]; + run6 -> stable2 [class="muted", style=dashed, arrowhead=none]; } ``` diff --git a/src/content/docs/enterprise/architecture.mdx b/src/content/docs/enterprise/architecture.mdx index 6af075ae60..4ba57fb24d 100644 --- a/src/content/docs/enterprise/architecture.mdx +++ b/src/content/docs/enterprise/architecture.mdx @@ -26,54 +26,42 @@ full control over data flows. ## High-level diagram
- ```dot class="graph" - digraph Architecture { - rankdir=TB; - graph [label="Mergify Enterprise – High-Level Architecture", labelloc=t, fontsize=26, pad=1.2, nodesep=1.4, ranksep=1.9, splines=ortho, fontname="Helvetica"]; - node [shape=box, style="rounded,filled", fontname="Helvetica", fontsize=18, fillcolor="#F6F8FB", color="#C6D4F3", fontcolor="#1F2937", margin="0.45,0.35", width=3.4]; - - edge [color="#8892BF", arrowsize=0.9, fontsize=16, fontname="Helvetica"]; + ```dot class="arch" + digraph { + label="Mergify Enterprise — high-level architecture"; subgraph cluster_customer { - label="Customer Infrastructure"; - style="rounded,filled"; - color="#E1E8FF"; - fillcolor="#FDFEFF"; + class="batch"; + label="Customer infrastructure"; - User [shape=plaintext label="👤 User", fontname="Helvetica", fontsize=18]; - ReverseProxy [label="Reverse proxy\n(HTTPS exposed)", fillcolor="#FFF3D6", color="#E5C17C"]; + User [label="User", class="external"]; + ReverseProxy [label="Reverse proxy\n(HTTPS exposed)", class="config"]; subgraph cluster_onprem { - label="Mergify On-Premise"; - style="rounded,filled"; - color="#D8F1FF"; - fillcolor="#F4FBFF"; - - Dashboard [label="Dashboard & API\n(port 5000)", fillcolor="#E6F0FF", color="#A7C4FF"]; - Workers [label="Mergify workers", fillcolor="#E6F0FF", color="#A7C4FF"]; + class="mergify"; + label="Mergify on-premise"; + Dashboard [label="Dashboard & API\n(port 5000)", class="mergify"]; + Workers [label="Mergify workers", class="mergify"]; } - PostgreSQL [label="PostgreSQL database", fillcolor="#E4F5ED", color="#7BC9A9"]; - Redis [label="Redis cluster", fillcolor="#FCE3E8", color="#F18AA0"]; + PostgreSQL [label="PostgreSQL database", class="datastore"]; + Redis [label="Redis cluster", class="datastore"]; } - GitHub [shape=box3d label="GitHub", color="#24292E", fontcolor="#24292E", style=filled, fillcolor="#FFFFFF", fontsize=18]; - Subscription [label="Mergify Subscription API", fillcolor="#F0ECFE", color="#B9A0FF", fontsize=18]; + GitHub [label="GitHub", class="external"]; + Subscription [label="Mergify Subscription API", class="mergify"]; GitHub -> ReverseProxy [label="GitHub events"]; - ReverseProxy -> Dashboard [label="Forwarded events"]; - Dashboard -> Workers [label="Queue jobs via Redis"]; - + ReverseProxy -> Dashboard [label="forwarded events"]; + Dashboard -> Workers [label="queue jobs via Redis"]; Dashboard -> PostgreSQL; Dashboard -> Redis; Workers -> PostgreSQL; Workers -> Redis; - Workers -> GitHub [label="API requests to GitHub", dir=both]; - ReverseProxy -> User [dir=both label="UI access"]; - GitHub -> User [label="Developers", dir=both]; - - Workers -> Subscription [label="Subscription lookup"]; + ReverseProxy -> User [label="UI access", dir=both]; + GitHub -> User [label="developers", dir=both]; + Workers -> Subscription [label="subscription lookup", class="muted", style=dashed]; } ```
diff --git a/src/content/docs/integrations/buildkite.mdx b/src/content/docs/integrations/buildkite.mdx index b49072abcc..c35b62f35c 100644 --- a/src/content/docs/integrations/buildkite.mdx +++ b/src/content/docs/integrations/buildkite.mdx @@ -89,30 +89,21 @@ A Buildkite pipeline driven by scopes has two parts: 2. **Use a dynamic pipeline** to conditionally upload only the steps that match the affected scopes. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - nodesep=0.9; - ranksep=1.1; - splines=polyline; +```dot class="queue" +digraph { + PR [label="Pull request\nchanges", class="queued"]; + Config [label="Scopes config\n(.mergify.yml)", class="config"]; + Detect [label="detect-scopes step\n(mergify-ci plugin)", class="pending"]; - node [shape=box, style="rounded,filled", fontname="sans-serif", margin="0.35,0.2", color="#165B33", fillcolor="#347D39", fontcolor="white"]; - edge [fontname="sans-serif", color="#374151", penwidth=1.2, arrowhead=normal]; - - PR [label="Pull request\nchanges"]; - Config [fillcolor="#1CB893", color="#0B7A5C", fontcolor="#063C2C", label="Scopes config\n(.mergify.yml)"]; - Detect [fillcolor="#2563EB", color="#1E40AF", label="detect-scopes step\n(mergify-ci plugin)"]; - - Frontend [label="frontend-tests\n(run)"]; - API [fillcolor="#6B7280", color="#4B5563", label="api-tests\n(skipped)"]; - Docs [label="docs-tests\n(run)"]; + Frontend [label="frontend-tests\n(run)", class="queued"]; + Docs [label="docs-tests\n(run)", class="queued"]; + API [label="api-tests\n(skipped)", class="muted"]; PR -> Detect; Config -> Detect; Detect -> Frontend [label="scope: frontend"]; Detect -> Docs [label="scope: docs"]; - Detect -> API [style=dashed, color="#9CA3AF", fontcolor="#9CA3AF", label="scope: api (false)"]; + Detect -> API [label="scope: api (false)", class="muted", style=dashed]; } ``` diff --git a/src/content/docs/integrations/gha.mdx b/src/content/docs/integrations/gha.mdx index a754816d90..db14a7a271 100644 --- a/src/content/docs/integrations/gha.mdx +++ b/src/content/docs/integrations/gha.mdx @@ -98,31 +98,23 @@ A GitHub Actions workflow driven by scopes has three parts: 3. **Publish a final status** (for example with a `ci-gate` job) if you want one check that reflects all the jobs that ran. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - nodesep=0.9; - ranksep=1.1; - splines=polyline; - - node [shape=box, style="rounded,filled", fontname="sans-serif", margin="0.35,0.2", color="#165B33", fillcolor="#347D39", fontcolor="white"]; - edge [fontname="sans-serif", color="#374151", penwidth=1.2, arrowhead=normal]; - - PR [label="Pull request\nchanges"]; - Config [fillcolor="#1CB893", color="#0B7A5C", fontcolor="#063C2C", label="Scopes config\n(.mergify.yml)"]; - Detect [fillcolor="#2563EB", color="#1E40AF", label="detect-scopes job\n(gha-mergify-ci)"]; - - Frontend [label="frontend-tests\n(run)"]; - API [fillcolor="#6B7280", color="#4B5563", label="api-tests\n(skipped)"]; - Docs [label="docs-tests\n(run)"]; - Gate [fillcolor="#111827", color="#0B1120", label="ci-gate\n(optional)"]; +```dot class="queue" +digraph { + PR [label="Pull request\nchanges", class="queued"]; + Config [label="Scopes config\n(.mergify.yml)", class="config"]; + Detect [label="detect-scopes job\n(gha-mergify-ci)", class="pending"]; + + Frontend [label="frontend-tests\n(run)", class="queued"]; + Docs [label="docs-tests\n(run)", class="queued"]; + API [label="api-tests\n(skipped)", class="muted"]; PR -> Detect; Config -> Detect; Detect -> Frontend [label="scope: frontend"]; Detect -> Docs [label="scope: docs"]; - Detect -> API [style=dashed, color="#9CA3AF", fontcolor="#9CA3AF", label="scope: api (false)"]; + Detect -> API [label="scope: api (false)", class="muted", style=dashed]; + + Gate [label="ci-gate\n(optional)", class="external"]; Frontend -> Gate; Docs -> Gate; } diff --git a/src/content/docs/merge-queue/batches.mdx b/src/content/docs/merge-queue/batches.mdx index 3805368003..f47aad1e74 100644 --- a/src/content/docs/merge-queue/batches.mdx +++ b/src/content/docs/merge-queue/batches.mdx @@ -51,33 +51,25 @@ queue_rules: batch_size: 3 ``` -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - label="Merge Queue" - - node [style=filled, shape=circle, fontcolor="white", fontname="sans-serif"]; - edge [color="#374151", arrowhead=none, fontname="sans-serif", arrowhead=normal]; - - subgraph cluster_batch_0 { - style="rounded,filled"; - color="#1CB893"; - fillcolor="#1CB893"; - fontcolor="#000000"; - node [style=filled, color="black", fillcolor="#347D39", fontcolor="white"]; - PR1 -> PR2; - PR2 -> PR3; - label = "Batch 1"; - } +```dot class="queue" +digraph { + label="Merge queue"; + + subgraph cluster_batch1 { + class="batch"; + label="Batch 1"; + PR1 [class="queued"]; + PR2 [class="queued"]; + PR3 [class="queued"]; + PR1 -> PR2 -> PR3; + } - PR3 -> PR4; - PR4 -> PR5; - PR5 [label="…", fillcolor="#347D39"]; + PR4 [class="queued"]; + PR5 [label="…", class="queued"]; + PR3 -> PR4 -> PR5; - CI [label="Continuous\nIntegration", fixedsize=false, style="filled", fillcolor="#111827", fontcolor=white, shape=rectangle] - edge [arrowhead=none, style=dashed, arrowtail=normal, color="#9CA3AF", dir=both, fontcolor="#9CA3AF", fontsize="6pt"]; - PR3 -> CI; + CI [label="Continuous\nintegration", class="external"]; + PR3 -> CI [class="muted", style=dashed, dir=both, arrowhead=none, arrowtail=normal]; } ``` @@ -230,35 +222,23 @@ Because of this, a pull request further down the queue may join an earlier batch when it is similar to what is already there, while a closer but unrelated pull request waits for the next batch: -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; +```dot class="queue" +digraph { label="batch_size: 2 — similar pull requests grouped together"; - nodesep=0.5; - ranksep=0.8; - - node [shape=box, style="rounded,filled", fontcolor="white", fontname="sans-serif", margin="0.3,0.18"]; edge [style=invis]; subgraph cluster_batch1 { - style="rounded,filled"; - color="#1CB893"; - fillcolor="#1CB893"; - fontcolor="#000000"; + class="batch"; label="Batch 1"; - PR1 [label="PR #1\n(api/)", fillcolor="#347D39"]; - PR3 [label="PR #3\n(api/)", fillcolor="#347D39"]; + PR1 [label="PR #1\n(api/)", class="queued"]; + PR3 [label="PR #3\n(api/)", class="queued"]; } subgraph cluster_batch2 { - style="rounded,filled"; - color="#1CB893"; - fillcolor="#1CB893"; - fontcolor="#000000"; + class="batch"; label="Batch 2"; - PR2 [label="PR #2\n(docs/)", fillcolor="#347D39"]; - PR4 [label="PR #4\n(web/)", fillcolor="#347D39"]; + PR2 [label="PR #2\n(docs/)", class="queued"]; + PR4 [label="PR #4\n(web/)", class="queued"]; } PR1 -> PR3 -> PR2 -> PR4; diff --git a/src/content/docs/merge-queue/direct-merge.mdx b/src/content/docs/merge-queue/direct-merge.mdx index 242f8c1df1..ed24094094 100644 --- a/src/content/docs/merge-queue/direct-merge.mdx +++ b/src/content/docs/merge-queue/direct-merge.mdx @@ -27,51 +27,34 @@ two merges that touched `api` and `docs`. Nothing PR #42 depends on has changed, still describes the merge and the queue skips straight to merging it. Had the queue landed nothing at all in the meantime, the first question alone would have been enough. -```dot class="graph" -strict digraph { - rankdir="TB"; - label="Direct Merge — what the queue checks before skipping CI"; - labelloc="t"; - nodesep=0.55; - ranksep=0.6; +```dot class="flow" +digraph { + label="Direct merge — what the queue checks before skipping CI"; splines=polyline; - node [shape=box, style="rounded,filled", margin="0.28,0.18"]; - edge [penwidth=1.4]; - subgraph cluster_base { - style="rounded"; - color="#1CB893"; - label="Merged while PR #42 waited"; - - M1 [label="queue merge\nscope: api", fillcolor="#6B7280", color="#4B5563", width=2.6]; - M2 [label="queue merge\nscope: docs", fillcolor="#6B7280", color="#4B5563", width=2.6]; + class="batch"; + label="Merged while #42 waited"; + M1 [label="queue merge\nscope: api", class="muted"]; + M2 [label="queue merge\nscope: docs", class="muted"]; M1 -> M2; } - DELTA [shape=oval, margin="0.3,0.16", fillcolor="#1CB893", fontcolor="#063C2C", - color="#0B7A5C", label="Base delta scopes\napi, docs"]; - - PR [label="PR #42, first in its lane\nscope: frontend, CI already green", - fillcolor="#347D39", color="#165B33"]; - - Q1 [shape=diamond, margin="0.14,0.06", fillcolor="#FFF4ED", color="#FF8A3D", - fontcolor="#C2410C", label="Behind the\nbase branch?"]; + DELTA [label="Base delta scopes\napi, docs", shape=oval, class="config"]; + PR [label="PR #42, first in its lane\nscope: frontend, CI already green", class="queued"]; - Q2 [shape=diamond, margin="0.14,0.06", fillcolor="#FFF4ED", color="#FF8A3D", - fontcolor="#C2410C", label="Any scope\nin common?"]; + Q1 [label="Behind the\nbase branch?", shape=diamond, margin="0.14,0.06", class="pending"]; + Q2 [label="Any scope\nin common?", shape=diamond, margin="0.14,0.06", class="pending"]; - DIRECT [label="Direct merge\nno batch pull request, no queue CI", - fillcolor="#347D39", color="#165B33"]; - QUEUE [label="Batch pull request\nfull queue CI run", - fillcolor="#6B7280", color="#4B5563"]; + DIRECT [label="Direct merge\nno batch pull request, no queue CI", class="merged"]; + QUEUE [label="Batch pull request\nfull queue CI run", class="muted"]; M2 -> DELTA; PR -> Q1; - Q1 -> DIRECT [label=" no", color="#10B981", penwidth=2.5]; + Q1 -> DIRECT [label=" no", class="merged"]; Q1 -> Q2 [label=" yes"]; DELTA -> Q2; - Q2 -> DIRECT [label=" no", color="#10B981", penwidth=2.5]; + Q2 -> DIRECT [label=" no", class="merged"]; Q2 -> QUEUE [label=" yes"]; { rank=same; Q2; DELTA; } diff --git a/src/content/docs/merge-queue/lifecycle.mdx b/src/content/docs/merge-queue/lifecycle.mdx index 441bdb42f2..186abbf07e 100644 --- a/src/content/docs/merge-queue/lifecycle.mdx +++ b/src/content/docs/merge-queue/lifecycle.mdx @@ -37,75 +37,23 @@ Once a pull request meets the specified queue conditions, it is added to the end of the merge queue. However, [priority rules](/merge-queue/priority) can be used to alter its position in the queue. -```dot class="graph" -strict digraph { - fontname="Inter, system-ui, sans-serif"; - rankdir="TB"; - bgcolor="#FAFBFC"; - - // Global node and edge styling - node [ - style="filled,rounded", - shape=rect, - fontcolor="black", - fontname="Inter, system-ui, sans-serif", - fontsize=12, - margin=0.25, - penwidth=2, - width=2.8, - height=0.9 - ]; - - edge [ - color="#7C3AED", - arrowhead=normal, - fontname="Inter, system-ui, sans-serif", - fontsize=9, - penwidth=2 - ]; - - Dequeued [ - label="⚪ Dequeued", - fillcolor="#F3F4F6", - color="#9CA3AF", - fontcolor="#4B5563" - ]; - - Queueing [ - label="⏳ Queueing", - fillcolor="#FFF4ED", - color="#FF8A3D", - fontcolor="#C2410C" - ]; - - Queued [ - label="📋 Queued", - fillcolor="#EDE9FE", - color="#8B5CF6", - fontcolor="#5B21B6" - ]; - - Validating [ - label="🔍 Validating", - fillcolor="#DBEAFE", - color="#3B82F6", - fontcolor="#1E40AF" - ]; - - Merged [ - label="✅ Merged", - fillcolor="#D1FAE5", - color="#10B981", - fontcolor="#065F46" - ]; - - // Transitions with detailed labels - Dequeued -> Queueing [label=" queue command\n or auto_merge "]; - Queueing -> Queued [label=" matches\n queue_conditions "]; - Queued -> Dequeued [label=" unmatches queue_conditions\n or dequeue command "]; - Queued -> Validating [label=" reaches top\n of the queue "]; - Validating -> Merged [label=" matches\n merge_conditions "]; - Validating -> Dequeued [label=" unmatches queue_conditions\n or dequeue command\n or fails merge_conditions "]; +```dot class="flow" +digraph { + label="Pull request lifecycle"; + node [width=1.8]; + + Dequeued [class="external"]; + Queueing [class="pending"]; + Queued [class="queued"]; + Validating [class="pending"]; + Merged [class="merged"]; + + Dequeued -> Queueing [label=" queue command\l or auto_merge\l"]; + Queueing -> Queued [label=" matches\l queue_conditions\l"]; + Queued -> Validating [label=" reaches the top\l of the queue\l"]; + Validating -> Merged [label=" matches\l merge_conditions\l", class="merged"]; + Queued -> Dequeued [label=" unmatches queue_conditions\l or dequeue command\l", class="failed"]; + Validating -> Dequeued [label=" fails merge_conditions\l or dequeue command\l", class="failed"]; } ``` diff --git a/src/content/docs/merge-queue/performance.mdx b/src/content/docs/merge-queue/performance.mdx index 90724786bf..5cc384d061 100644 --- a/src/content/docs/merge-queue/performance.mdx +++ b/src/content/docs/merge-queue/performance.mdx @@ -174,45 +174,35 @@ batches of up to 3 PRs each in parallel. Given 7 queued PRs and a 10-minute CI pipeline, the first 6 merge in 10 minutes instead of the hour required for sequential validation. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - label="Merge Queue" - - node [style=filled, shape=circle, fontcolor="white", fontname="sans-serif"]; - edge [color="#374151", arrowhead=none, fontname="sans-serif", arrowhead=normal]; - - subgraph cluster_batch_1 { - style="rounded,filled"; - color="#1CB893"; - fillcolor="#1CB893"; - fontcolor="#000000"; - node [style=filled, color="black", fillcolor="#347D39", fontcolor="white"]; - PR3 -> PR4; - PR4 -> PR5; - PR5 -> PR6; - label = "Batch 2"; - - subgraph cluster_batch_0 { - style="rounded,filled"; - color="#1CB893"; - fillcolor="#1CB893"; - fontcolor="#000000"; - node [style=filled, color="black", fillcolor="#347D39", fontcolor="white"]; - PR1 -> PR2; - PR2 -> PR3; - label = "Batch 1"; - } +```dot class="queue" +digraph { + label="Merge queue"; + + subgraph cluster_batch2 { + class="batch"; + label="Batch 2"; + + subgraph cluster_batch1 { + class="batch"; + label="Batch 1"; + PR1 [class="queued"]; + PR2 [class="queued"]; + PR3 [class="queued"]; + PR1 -> PR2 -> PR3; } - PR6 -> PR7; - PR7 -> PR8; - PR8 [label="…", fillcolor="#347D39"]; + PR4 [class="queued"]; + PR5 [class="queued"]; + PR6 [class="queued"]; + PR3 -> PR4 -> PR5 -> PR6; + } - CI [label="Continuous\nIntegration", fixedsize=false, style="filled", fillcolor="#111827", fontcolor=white, shape=rectangle] - edge [arrowhead=none, style=dashed, arrowtail=normal, color="#9CA3AF", dir=both, fontcolor="#9CA3AF", fontsize="6pt"]; - PR3 -> CI; - PR6 -> CI; + PR7 [class="queued"]; + PR8 [label="…", class="queued"]; + PR6 -> PR7 -> PR8; + + CI [label="Continuous\nintegration", class="external"]; + PR3 -> CI [class="muted", style=dashed, dir=both, arrowhead=none, arrowtail=normal]; + PR6 -> CI [class="muted", style=dashed, dir=both, arrowhead=none, arrowtail=normal]; } ``` diff --git a/src/content/docs/merge-queue/queue-modes.mdx b/src/content/docs/merge-queue/queue-modes.mdx index e45e2db433..b11355ffa8 100644 --- a/src/content/docs/merge-queue/queue-modes.mdx +++ b/src/content/docs/merge-queue/queue-modes.mdx @@ -38,21 +38,12 @@ is tested on top of the one before it, forming a single ordered pipeline. This g correctness: each pull request is validated against the exact state it will merge into. The trade-off is that unrelated changes still wait for each other. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - label="Serial Mode — Single Queue"; - nodesep=0.6; - ranksep=0.8; - - node [shape=box, style="rounded,filled", fillcolor="#347D39", fontcolor="white", fontname="sans-serif", margin="0.3,0.18"]; - edge [color="#374151", arrowhead=normal, penwidth=1.2, fontname="sans-serif"]; - - PR1 [label="Batch 1\nPR #1 (api)"]; - PR2 [label="Batch 2\nPR #2 (frontend)"]; - PR3 [label="Batch 3\nPR #3 (docs)"]; - +```dot class="queue" +digraph { + label="Serial mode — a single queue"; + PR1 [label="Batch 1\nPR #1 (api)", class="queued"]; + PR2 [label="Batch 2\nPR #2 (frontend)", class="queued"]; + PR3 [label="Batch 3\nPR #3 (docs)", class="queued"]; PR1 -> PR2 -> PR3; } ``` @@ -93,53 +84,33 @@ together so they are tested as a group, preventing semantic conflicts. Batches that share no scope run at the same time: -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="TB"; - label="Parallel Mode\nIndependent Scope Queues"; - nodesep=0.8; - ranksep=0.6; - - node [shape=box, style="rounded,filled", fontcolor="white", fontname="sans-serif", margin="0.3,0.18"]; +```dot class="flow" +digraph { + label="Parallel mode — independent scope queues"; edge [style=invis]; subgraph cluster_running { - style="rounded,filled"; - fillcolor="#1CB893"; - color="#1CB893"; - fontcolor="#000000"; + class="batch"; label="Tested simultaneously"; - - PR1 [label="Batch 1\nPR #1 (api)", fillcolor="#347D39"]; - PR2 [label="Batch 2\nPR #2 (frontend)", fillcolor="#347D39"]; - PR3 [label="Batch 3\nPR #3 (docs)", fillcolor="#347D39"]; + PR1 [label="Batch 1\nPR #1 (api)", class="pending"]; + PR2 [label="Batch 2\nPR #2 (frontend)", class="pending"]; + PR3 [label="Batch 3\nPR #3 (docs)", class="pending"]; + { rank=same; PR1; PR2; PR3; } } - - { rank=same; PR1; PR2; PR3; } } ``` When scopes **do** overlap, Mergify preserves ordering within that scope to guarantee the changes are tested together: -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - label="Parallel Mode — Overlapping Scopes Create Dependencies"; - nodesep=0.6; - ranksep=1.0; - - node [shape=box, style="rounded,filled", fontcolor="white", fontname="sans-serif", margin="0.3,0.18"]; - edge [color="#374151", arrowhead=normal, penwidth=1.2, fontname="sans-serif"]; - - PR1 [label="Batch 1\nPR #1 (api)", fillcolor="#347D39"]; - PR4 [label="Batch 2\nPR #4 (api, frontend)", fillcolor="#347D39"]; - PR3 [label="Batch 3\nPR #3 (docs)", fillcolor="#347D39"]; +```dot class="queue" +digraph { + label="Parallel mode — overlapping scopes create dependencies"; + PR1 [label="Batch 1\nPR #1 (api)", class="queued"]; + PR4 [label="Batch 2\nPR #4 (api, frontend)", class="queued"]; + PR3 [label="Batch 3\nPR #3 (docs)", class="queued"]; PR1 -> PR4 [label="same scope: api"]; - { rank=same; PR1; PR3; } } ``` @@ -293,43 +264,28 @@ Take the configuration above (`max_parallel_checks: 5`, `frontend: 2`, `backend: uncapped) and suppose the queue is ready to test three `frontend` batches, three `backend` batches, and two `docs` batches. The slots might fill like this: -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="TB"; - label="Per-scope capacities — ceiling 5, frontend: 2, backend: 2, docs uncapped"; - nodesep=0.5; - ranksep=0.7; - - node [shape=box, style="rounded,filled", fontcolor="white", fontname="sans-serif", margin="0.3,0.18"]; +```dot class="flow" +digraph { + label="Per-scope capacity — ceiling 5, frontend 2, backend 2, docs uncapped"; edge [style=invis]; subgraph cluster_running { - style="rounded,filled"; - fillcolor="#1CB893"; - color="#1CB893"; - fontcolor="#000000"; + class="batch"; label="Running now — 5 of 5 slots used"; - - F1 [label="frontend #1", fillcolor="#347D39"]; - F2 [label="frontend #2", fillcolor="#347D39"]; - B1 [label="backend #1", fillcolor="#347D39"]; - B2 [label="backend #2", fillcolor="#347D39"]; - D1 [label="docs #1", fillcolor="#347D39"]; - + F1 [label="frontend #1", class="pending"]; + F2 [label="frontend #2", class="pending"]; + B1 [label="backend #1", class="pending"]; + B2 [label="backend #2", class="pending"]; + D1 [label="docs #1", class="pending"]; { rank=same; F1; F2; B1; B2; D1; } } subgraph cluster_waiting { - style="rounded"; - color="#6B7280"; - fontcolor="#6B7280"; + class="muted"; label="Waiting"; - - F3 [label="frontend #3\nfrontend full", fillcolor="#6B7280"]; - B3 [label="backend #3\nbackend full", fillcolor="#6B7280"]; - D2 [label="docs #2\nceiling full", fillcolor="#6B7280"]; - + F3 [label="frontend #3\nfrontend full", class="muted"]; + B3 [label="backend #3\nbackend full", class="muted"]; + D2 [label="docs #2\nceiling full", class="muted"]; { rank=same; F3; B3; D2; } } } @@ -429,30 +385,19 @@ Parallel mode keeps dependencies between batches that share a scope. **Isolated entirely: every batch is a self-contained unit that is tested and merged on its own, with no parent batch and no child batch. A failure in one batch never blocks any other. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="TB"; - label="Isolated Mode\nEvery Batch Independent"; - nodesep=0.8; - ranksep=0.6; - - node [shape=box, style="rounded,filled", fontcolor="white", fontname="sans-serif", margin="0.3,0.18"]; +```dot class="flow" +digraph { + label="Isolated mode — every batch independent"; edge [style=invis]; subgraph cluster_running { - style="rounded,filled"; - fillcolor="#1CB893"; - color="#1CB893"; - fontcolor="#000000"; + class="batch"; label="Tested simultaneously"; - - PR1 [label="Batch 1\nPR #1", fillcolor="#347D39"]; - PR2 [label="Batch 2\nPR #2", fillcolor="#347D39"]; - PR3 [label="Batch 3\nPR #3", fillcolor="#347D39"]; + PR1 [label="Batch 1\nPR #1", class="pending"]; + PR2 [label="Batch 2\nPR #2", class="pending"]; + PR3 [label="Batch 3\nPR #3", class="pending"]; + { rank=same; PR1; PR2; PR3; } } - - { rank=same; PR1; PR2; PR3; } } ``` diff --git a/src/content/docs/merge-queue/scopes.mdx b/src/content/docs/merge-queue/scopes.mdx index a830e0eda9..ee0ca0ee8c 100644 --- a/src/content/docs/merge-queue/scopes.mdx +++ b/src/content/docs/merge-queue/scopes.mdx @@ -28,33 +28,25 @@ prioritizes the combination that shares the most scopes in common. Pull requests scope are tested together first, while unrelated changes stay in the queue until a compatible batch is available or Mergify needs them to fill the requested batch size. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - label="Scope-aware Batch Selection"; - nodesep=0.9; - ranksep=1.2; - splines=polyline; - - edge [fontname="sans-serif", color="#374151", arrowhead=none, penwidth=1.2]; +```dot class="queue" +digraph { + label="Scope-aware batch selection"; + edge [arrowhead=none]; subgraph cluster_preferred { - style="rounded"; - color="#1CB893"; + class="batch"; label="Preferred batch"; - - PR1 [shape=box, style="rounded,filled", fillcolor="#347D39", fontcolor="white", color="#165B33", margin="0.35,0.22", fontname="sans-serif", label="PR #101\nScopes: frontend, api"]; - PR2 [shape=box, style="rounded,filled", fillcolor="#347D39", fontcolor="white", color="#165B33", margin="0.35,0.22", fontname="sans-serif", label="PR #214\nScopes: frontend"]; - PR3 [shape=box, style="rounded,filled", fillcolor="#347D39", fontcolor="white", color="#165B33", margin="0.35,0.22", fontname="sans-serif", label="PR #305\nScopes: api, docs"]; + PR1 [label="PR #101\nScopes: frontend, api", class="queued"]; + PR2 [label="PR #214\nScopes: frontend", class="queued"]; + PR3 [label="PR #305\nScopes: api, docs", class="queued"]; } - PR4 [shape=box, style="rounded,filled", fillcolor="#6B7280", fontcolor="white", color="#4B5563", margin="0.35,0.22", fontname="sans-serif", label="PR #412\nScope: tooling"]; + PR4 [label="PR #412\nScope: tooling", class="muted"]; - frontend [shape=oval, style="filled", fillcolor="#1CB893", fontcolor="#063C2C", color="#0B7A5C", margin="0.3,0.18", fontname="sans-serif", label="frontend"]; - api [shape=oval, style="filled", fillcolor="#1CB893", fontcolor="#063C2C", color="#0B7A5C", margin="0.3,0.18", fontname="sans-serif", label="api"]; - docs [shape=oval, style="filled", fillcolor="#1CB893", fontcolor="#063C2C", color="#0B7A5C", margin="0.3,0.18", fontname="sans-serif", label="docs"]; - tooling [shape=oval, style="filled", fillcolor="#1CB893", fontcolor="#063C2C", color="#0B7A5C", margin="0.3,0.18", fontname="sans-serif", label="tooling"]; + frontend [shape=oval, class="config"]; + api [shape=oval, class="config"]; + docs [shape=oval, class="config"]; + tooling [shape=oval, class="config"]; PR1 -> frontend; PR1 -> api; diff --git a/src/content/docs/merge-queue/stacks.mdx b/src/content/docs/merge-queue/stacks.mdx index 636cad9718..ae322ef067 100644 --- a/src/content/docs/merge-queue/stacks.mdx +++ b/src/content/docs/merge-queue/stacks.mdx @@ -131,25 +131,18 @@ immediate parent branch. Without this, PR2 would be queued against PR1's head branch and could never reach `main`, so the queue would have nothing to merge into. -```dot class="graph" style="max-width: 320px; height: auto; display: block; margin: 1.5em auto" -strict digraph { - fontname="sans-serif"; - fontsize=10; - rankdir="LR"; - nodesep=0.2; - ranksep=0.4; - - node [style=filled, fontname="sans-serif", fontcolor="white", fontsize=10, shape=circle, width=0.45, height=0.45, fixedsize=true]; - edge [fontname="sans-serif", fontsize=9, color="#5B21B6", fontcolor="#5B21B6"]; - - PR1 [fillcolor="#347D39"]; - PR2 [fillcolor="#347D39"]; - PR3 [fillcolor="#347D39"]; - main [label="main", shape=rectangle, fillcolor="#111827", width=0.7, height=0.4, fixedsize=false]; - - PR1 -> main; - PR2 -> main; - PR3 -> main; +```dot class="queue" style="max-width: 320px" +digraph { + node [shape=circle, width=0.5, height=0.5, fixedsize=true]; + + PR1 [class="queued"]; + PR2 [class="queued"]; + PR3 [class="queued"]; + main [label="main", shape=box, fixedsize=false, class="external"]; + + PR1 -> main; + PR2 -> main; + PR3 -> main; } ``` @@ -175,28 +168,23 @@ checks, a stack longer than `batch_size` (or a stack sharing its scope group with higher-priority unrelated PRs) lands across consecutive batches. Order is preserved either way. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - label="Merge Queue with batch_size: 5" - - node [style=filled, shape=circle, fontcolor="white", fontname="sans-serif"]; - edge [color="#374151", arrowhead=normal, fontname="sans-serif"]; - - subgraph cluster_batch_0 { - style="rounded,filled"; - color="#1CB893"; - fillcolor="#1CB893"; - fontcolor="#000000"; - node [fillcolor="#347D39"]; - PR1 -> PR2 -> PR3 -> PR_other; - PR_other [label="PR4"]; - label = "Batch 1 (stack PR1→PR2→PR3 kept together)"; - } - - PR_other -> PR5; - PR5 [label="…", fillcolor="#347D39"]; +```dot class="queue" +digraph { + label="Merge queue with batch_size: 5"; + node [shape=circle, width=0.5, height=0.5, fixedsize=true]; + + subgraph cluster_batch1 { + class="batch"; + label="Batch 1 — the stack kept together"; + PR1 [class="queued"]; + PR2 [class="queued"]; + PR3 [class="queued"]; + PR4 [class="queued"]; + PR1 -> PR2 -> PR3 -> PR4; + } + + PR5 [label="…", class="queued"]; + PR4 -> PR5; } ``` @@ -213,22 +201,20 @@ still in the queue is dequeued automatically with the reason `StackPredecessorDequeued`. This stops the queue from validating PRs whose dependency just broke. There's no point checking PR3 if PR1 just failed. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - label="" +```dot class="queue" +digraph { + ranksep=1.0; + // Not fixedsize: these labels are two lines, so let the circle grow to them. + node [shape=circle]; - node [style=filled, shape=circle, fontname="sans-serif"]; + PR1 [label="PR1\n(failed)", class="failed"]; + PR2 [label="PR2\n(cascaded)", class="muted"]; + PR3 [label="PR3\n(cascaded)", class="muted"]; + PR4 [class="queued"]; - PR1 [fillcolor="#DC2626", fontcolor="white", label="PR1\n(failed)"]; - PR2 [fillcolor="#9CA3AF", fontcolor="white", label="PR2\n(cascaded)"]; - PR3 [fillcolor="#9CA3AF", fontcolor="white", label="PR3\n(cascaded)"]; - PR4 [fillcolor="#347D39", fontcolor="white"]; - - PR1 -> PR2 [label="dequeue", color="#DC2626", fontcolor="#DC2626", fontsize="9"]; - PR2 -> PR3 [label="dequeue", color="#DC2626", fontcolor="#DC2626", fontsize="9"]; - PR3 -> PR4 [style="dashed", color="#9CA3AF"]; + PR1 -> PR2 [label="dequeue", class="failed"]; + PR2 -> PR3 [label="dequeue", class="failed"]; + PR3 -> PR4 [style=dashed, class="muted"]; } ``` diff --git a/src/content/docs/merge-queue/two-step.mdx b/src/content/docs/merge-queue/two-step.mdx index 57a2e56a0e..da28d3f8f3 100644 --- a/src/content/docs/merge-queue/two-step.mdx +++ b/src/content/docs/merge-queue/two-step.mdx @@ -24,134 +24,34 @@ expensive. ## How It Works -```dot class="graph" -strict digraph { - fontname="Inter, system-ui, sans-serif"; - rankdir="TB"; - bgcolor="#FAFBFC"; - - // Global node and edge styling - node [ - style="filled,rounded", - shape=rect, - fontcolor="black", - fontname="Inter, system-ui, sans-serif", - fontsize=12, - margin=0.2, - penwidth=2, - width=2.5, - height=0.8 - ]; - - edge [ - color="#7C3AED", - arrowhead=normal, - fontname="Inter, system-ui, sans-serif", - fontsize=10, - penwidth=2 - ]; - - // Start state - open [ - label="🔄 PR opened or updated", - fillcolor="#EDE9FE", - color="#8B5CF6", - fontcolor="#5B21B6" - ]; - - // Preliminary tests phase - preliminary [ - label="🧪 Preliminary tests\n(Unit tests, linting)", - fillcolor="#FFF4ED", - color="#FF8A3D", - fontcolor="#C2410C" - ] - - // Success/failure branches for preliminary tests - subgraph cluster_preliminary_results { - style="invis"; - preliminary_ok [ - label="✅ Tests passed\n(Ready for queue)", - fillcolor="#D1FAE5", - color="#10B981", - fontcolor="#065F46" - ] - - preliminary_fail [ - label="❌ Tests failed\n(Needs fixes)", - fillcolor="#FEE2E2", - color="#EF4444", - fontcolor="#991B1B" - ] - } - - // Queue action - queue_req [ - label="📝 Queue command\n(@mergifyio queue)", - fillcolor="#F3E8FF", - color="#A855F7", - fontcolor="#6B21A8" - ] - - // Queue state - queued [ - label="⏳ PR queued", - shape=ellipse, - fillcolor="#FFF4ED", - color="#FF8A3D", - fontcolor="#C2410C", - width=2, - height=1 - ]; - - // Pre-merge tests phase - premerge [ - label="🔬 Pre-merge tests\n(Integration, performance)", - fillcolor="#FFF4ED", - color="#FF8A3D", - fontcolor="#C2410C" - ] - - // Success/failure branches for pre-merge tests - subgraph cluster_premerge_results { - style="invis"; - premerge_ok [ - label="✅ All tests passed\n(Ready to merge)", - fillcolor="#D1FAE5", - color="#10B981", - fontcolor="#065F46" - ] - - premerge_fail [ - label="❌ Pre-merge failed\n(Removed from queue)", - fillcolor="#FEE2E2", - color="#EF4444", - fontcolor="#991B1B" - ] - } - - // Final merge - merged [ - label="🎉 Merged to main", - fillcolor="#DDD6FE", - color="#7C3AED", - fontcolor="#5B21B6" - ] - - // Flow connections - main path - open -> preliminary; - preliminary -> preliminary_ok [color="#10B981", penwidth=3]; - preliminary -> preliminary_fail [color="#EF4444", penwidth=3]; - preliminary_ok -> queue_req; - queue_req -> queued; - queued -> premerge; - premerge -> premerge_ok [color="#10B981", penwidth=3]; - premerge -> premerge_fail [color="#EF4444", penwidth=3]; - premerge_ok -> merged [color="#7C3AED", penwidth=3]; - - // Rank constraints for better layout - {rank=same; preliminary_ok, preliminary_fail} - {rank=same; premerge_ok, premerge_fail} +```dot class="flow" +digraph { + label="Two-step continuous integration"; + node [width=2.2]; + + open [label="Pull request opened\nor updated", class="queued"]; + preliminary [label="Preliminary tests\n(unit tests, linting)", class="pending"]; + preliminary_ok [label="Tests passed\nready for the queue", class="merged"]; + preliminary_fail [label="Tests failed\nneeds fixes", class="failed"]; + queue_req [label="Queue command\n(@mergifyio queue)", class="queued"]; + queued [label="Pull request queued", shape=oval, class="queued"]; + premerge [label="Pre-merge tests\n(integration, performance)", class="pending"]; + premerge_ok [label="All tests passed\nready to merge", class="merged"]; + premerge_fail [label="Pre-merge failed\nremoved from the queue", class="failed"]; + merged [label="Merged to main", class="merged"]; + + open -> preliminary; + preliminary -> preliminary_ok [class="merged"]; + preliminary -> preliminary_fail [class="failed"]; + preliminary_ok -> queue_req; + queue_req -> queued; + queued -> premerge; + premerge -> premerge_ok [class="merged"]; + premerge -> premerge_fail [class="failed"]; + premerge_ok -> merged [class="merged"]; + + { rank=same; preliminary_ok; preliminary_fail; } + { rank=same; premerge_ok; premerge_fail; } } ``` diff --git a/src/styles/index.css b/src/styles/index.css index 988c7cbc0d..0514ed93a7 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -924,10 +924,12 @@ html { fill: var(--dg-container); stroke: var(--dg-border); } +/* No letter-spacing: Graphviz sizes a cluster to fit the label it measured in + Helvetica, and anything that widens the painted text past that overflows the + container it labels. The weight is worth the ~4% Inter already costs. */ .dg .cluster > text { fill: var(--dg-label); font-weight: 600; - letter-spacing: 0.02em; } .dg .edge > path, @@ -963,6 +965,12 @@ html { stroke: none; } +/* An architecture diagram is the widest kind there is, and the one whose labels + suffer most from being scaled down. Give it the whole column. */ +.dg.arch { + width: 100%; +} + /* The hand-placed diagrams are drawn at a fixed size and read best at it: they carry no intrinsic width for `width: 80%` to work against. */ .dg-linear, diff --git a/src/util/diagramSvg.ts b/src/util/diagramSvg.ts index e4dae85ff9..e52c6429ef 100644 --- a/src/util/diagramSvg.ts +++ b/src/util/diagramSvg.ts @@ -39,27 +39,9 @@ export const DIAGRAM_ROLES = [ export type DiagramRole = (typeof DIAGRAM_ROLES)[number]; -/** - * The classes Graphviz puts on an element itself. Anything else on the element - * came from the source, and means the author named the element's role. - */ -const STRUCTURAL_CLASSES = new Set(['graph', 'node', 'edge', 'cluster']); - -/** The paint a shape carries, for a caller that infers a role from it. */ -export interface ShapePaint { - fill?: string; - stroke?: string; -} - interface FinishOptions { /** Classes for the ``, after `dg`. */ classes?: string[]; - /** - * Transitional hook: name the role of an element that does not carry one, - * from the paint Graphviz gave it. Only called for elements whose source - * named no class of their own. - */ - roleFor?: (kind: DiagramKind, paint: ShapePaint) => DiagramRole | undefined; } /** The shapes Graphviz draws directly inside each kind of group. */ @@ -75,7 +57,7 @@ const classesOf = (value: string | undefined): string[] => /** * Turn a rendered Graphviz SVG into a themeable `.dg` diagram, in place. */ -export function finishDiagramSvg($: CheerioAPI, { classes = [], roleFor }: FinishOptions = {}) { +export function finishDiagramSvg($: CheerioAPI, { classes = [] }: FinishOptions = {}) { // Graphviz paints an opaque canvas as the first child of the graph group // whenever the source sets its own `bgcolor`. Drop it so the page shows // through — otherwise the diagram carries a light rectangle into dark mode. @@ -89,24 +71,16 @@ export function finishDiagramSvg($: CheerioAPI, { classes = [], roleFor }: Finis const $group = $(element); const existing = classesOf($group.attr('class')); const $shape = $group.children(SHAPES[kind]).first(); - const paint: ShapePaint = { fill: $shape.attr('fill'), stroke: $shape.attr('stroke') }; - - const extra: string[] = []; // Graphviz draws no border for `shape=plaintext` / `shape=none`, so a - // shape with no stroke is a caption rather than a box — even when the - // node inherited `style=filled` and so came out with a fill behind it. - // A shape that asks for a fill and `color=none` is read the same way; - // use `penwidth=0` to keep the fill. - if (paint.stroke === 'none' && !existing.includes('plain')) extra.push('plain'); - - if (roleFor && !existing.some((name) => !STRUCTURAL_CLASSES.has(name))) { - const role = roleFor(kind, paint); - if (role) extra.push(role); + // shape with no stroke is a caption rather than a box — even when it + // inherited `style=filled` and so came out with a fill behind it. A shape + // that asks for a fill and `color=none` is read the same way; use + // `penwidth=0` to keep the fill. + if ($shape.attr('stroke') === 'none' && !existing.includes('plain')) { + $group.attr('class', [...existing, 'plain'].join(' ')); } - if (extra.length > 0) $group.attr('class', [...existing, ...extra].join(' ')); - // Only direct children are painted by `.dg` in index.css; anything deeper // keeps whatever Graphviz gave it. $group