Compare commits

...

3 Commits

Author SHA1 Message Date
k3nny 7a4d55ec9a feat(graph): intra-stage DAG sub-columns and connectors behind chips
ci / vet, staticcheck, test, build (push) Failing after 2m58s
release / Build and publish release (push) Successful in 1m25s
When jobs within the same declared GitLab stage have needs: relationships
between each other, the pipeline graph now splits that stage into
topological sub-columns: jobs with no same-stage deps are in sub-column 0,
jobs that depend on them shift one column right. A new computeColumns()
function handles the topo-sort; a narrower subStageGap (20 px vs 50 px
stageGap) separates sub-columns; stage headers span all sub-columns.

SVG connector lines (Bézier curves in DAG mode, bus-bar stubs in classic
mode) are now emitted before job chip rectangles so connectors visually
pass behind chips rather than on top of them.

100% statement coverage maintained (99 tests in graph package).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 23:06:49 +02:00
k3nny 0e51844c3a feat(graph): pipeline graph improvements — on_failure, tooltips, bus-bar connectors, skipped colouring, HTML and Mermaid output
ci / vet, staticcheck, test, build (push) Failing after 1m58s
release / Build and publish release (push) Successful in 1m10s
- when: on_failure visual distinction: red circle (#d9534f), X-mark icon, dashed
  chip border, and Mermaid classDef; legend entry added
- Job tooltip / detail panel: each chip wrapped in <g data-job="…"><title>…</title>
  <desc>…</desc> so SVG viewers and the HTML sidebar show stage, when, image, needs
- Multi-job connector accuracy: classic mode now uses a bus-bar pattern (vertical
  rail at midpoint + per-job stubs) instead of one center-to-center line per stage pair
- Blocked/skipped state colouring: RenderPipeline and pipelineSVG accept
  *cicontext.Context; skipped jobs rendered in grey (#868686) with dimmed text
- Interactive HTML output (--format html): self-contained .html with inline SVG,
  mouse-wheel zoom, drag-to-pan, double-click reset, and a click-to-open sidebar
- Mermaid pipeline output (--format mermaid): prints existing Pipeline() flowchart
  to stdout; suitable for mermaid.live or Markdown embedding
- imageString() helper to extract image name from string or map form
- 100% statement coverage maintained; 809 tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 00:56:06 +02:00
k3nny 8dc30d9207 feat(linter): rules:needs: validation (GL044)
ci / vet, staticcheck, test, build (push) Failing after 2m4s
release / Build and publish release (push) Successful in 1m12s
Add GL044 to validate jobs listed in rules:needs: overrides (GitLab CI
16.4+). rules:needs: lets a specific rule override the job's top-level
needs: list; any referenced job must exist in the pipeline. Unknown jobs
produce an error; optional: true entries produce a warning, matching the
GL027 behaviour for top-level needs:. Cross-pipeline needs and skipped
jobs (when a context is active) are excluded from checking.

Implementation:
- model.Rule gains a Needs []any field (yaml:"needs")
- checkRulesNeeds(p, skipped) added to needs.go; wired into Lint
- GL044 / RuleRulesNeedsUnknown added to rules.go and explain.go
- 6 unit tests + 2 testdata fixtures; task validate updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 00:33:52 +02:00
19 changed files with 1247 additions and 133 deletions
+34
View File
@@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org). This project uses [Semantic Versioning](https://semver.org).
## [0.2.26] - 2026-06-25
### Changed
- **Same-stage job ordering** — when jobs within the same declared GitLab stage have `needs:` relationships between each other, the pipeline graph now splits that stage into topological sub-columns: jobs with no same-stage dependencies occupy the leftmost sub-column; jobs that depend on them are placed one sub-column to the right. Sub-columns use a narrower 20 px gap (vs. the 50 px gap between stages), and the stage header spans all sub-columns. Stages with no intra-stage `needs:` are unaffected.
- **Graph links rendered behind job chips** — SVG connector lines (Bézier curves in DAG mode, bus-bar stubs in classic mode) are now drawn before the job chip rectangles, so connector lines pass behind chips rather than on top of them.
## [0.2.25] - 2026-06-25
### Added
- **`when: on_failure` visual distinction** — SVG and HTML pipeline graphs now render failure-path jobs with a red status circle (`#d9534f`), an X-mark icon, and a dashed chip border. A new `on_failure` legend entry and Mermaid `classDef` entry are included.
- **Job tooltip / detail panel** — every job chip in SVG and HTML output is wrapped in a `<g data-job="…"><title>…</title><desc>…</desc>` group. SVG viewers that support `<title>` show the job name on hover; `<desc>` carries `stage`, `when`, `image`, and `needs` details. In HTML output the sidebar reads these directly from the DOM.
- **Multi-job connector accuracy** — classic mode (no `needs:`) now uses a bus-bar connector: horizontal stubs from every job in stage N to a vertical rail at the midpoint, then stubs from the rail to every job in stage N+1. The previous single center-to-center line left top and bottom jobs visually disconnected.
- **Skipped / blocked state colouring** — `glint graph pipeline` now accepts the same context flags as `glint check` (`--branch`, `--tag`, `--source`, `--var`, `--changes`, `--changes-from`). Jobs that evaluate to `JobSkipped` in the given context are rendered with a grey circle (`#868686`) and dimmed job name. When no context flags are given, all jobs render with their normal colours.
- **Interactive HTML output** (`--format html`) — `glint graph pipeline --format html` writes a self-contained `.html` file to `--out`. The page embeds the SVG inline with mouse-wheel zoom (centred on cursor), drag-to-pan, double-click-to-reset, and a collapsible sidebar showing job details on chip click. No external dependencies; works offline.
- **Mermaid pipeline output** (`--format mermaid`) — `glint graph pipeline --format mermaid` prints the existing Mermaid flowchart (`pipeline.go`) to stdout. Suitable for pasting into [mermaid.live](https://mermaid.live) or embedding in Markdown documentation.
- **`imageString` helper** — internal utility that extracts the image name from a `Job.Image` field that may be a plain string or a map (`image: {name: ..., entrypoint: ...}`); used by the tooltip desc builder.
## [0.2.24] - 2026-06-25
### Added
- **`rules:needs:` validation (GL044)** — validates jobs listed in `rules:needs:` overrides (GitLab CI 16.4+). Each entry must reference a job that exists in the pipeline; `optional: true` entries that reference missing jobs are downgraded to warnings (same behaviour as GL027 for top-level `needs:`). Cross-pipeline needs (maps with a `pipeline:` key) are ignored. Skipped jobs are excluded when a context is provided. `glint explain GL044` documents the rule with a bad-YAML example and fix.
- **`Rule.Needs` model field** — `rules:` entries now parse their `needs:` key into `model.Rule.Needs []any`, making the per-rule needs list available for validation and future evaluation.
## [0.2.23] - 2026-06-25 ## [0.2.23] - 2026-06-25
### Added ### Added
+23 -1
View File
@@ -69,6 +69,7 @@ description, bad-YAML example, and fix.
| GL029 | ERR | Circular dependency in `needs:` graph | | GL029 | ERR | Circular dependency in `needs:` graph |
| GL030 | ERR | `dependencies:` references a job that doesn't exist | | GL030 | ERR | `dependencies:` references a job that doesn't exist |
| GL031 | ERR | `dependencies:` references a job in the same or a later stage | | GL031 | ERR | `dependencies:` references a job in the same or a later stage |
| GL044 | ERR/WARN | `rules:needs:` references a job that doesn't exist (WARN when `optional: true`) |
### Expression & reachability ### Expression & reachability
@@ -281,8 +282,29 @@ jobs or pipeline-level findings.
| `pipeline` | GitLab CI-style SVG/PNG written to `--out` directory (default: `glint-out/`); converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available | | `pipeline` | GitLab CI-style SVG/PNG written to `--out` directory (default: `glint-out/`); converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available |
| `all` | `includes` to stdout + `pipeline` file path to stderr | | `all` | `includes` to stdout + `pipeline` file path to stderr |
**`glint graph pipeline --format <FORMAT>`**
| Format | Effect |
|--------|--------|
| `svg` (default) | Write GitLab CI-style SVG/PNG to `--out` |
| `mermaid` | Print Mermaid flowchart to stdout (paste into [mermaid.live](https://mermaid.live)) |
| `html` | Write self-contained HTML to `--out` with mouse pan/zoom and a click-to-open job-detail sidebar |
**Visual distinctions in SVG and HTML output:**
- **Regular** — blue circle with checkmark
- **Manual** — orange circle with play triangle
- **Trigger** — purple circle with chevron
- **Delayed** — yellow circle with clock
- **`when: on_failure`** — red circle (`#d9534f`) with X mark; dashed chip border
- **Skipped** (with `--branch`/`--tag`/`--source` context flags) — grey circle, dimmed job name
In DAG pipelines (any job has `needs:`) the pipeline graph uses job-to-job In DAG pipelines (any job has `needs:`) the pipeline graph uses job-to-job
Bézier connectors instead of stage-to-stage lines. Bézier connectors. In classic mode a bus-bar pattern (vertical rail + per-job
stubs) accurately connects every job across adjacent stages.
Each chip carries a `<title>` and `<desc>` with stage, when, image, and needs —
shown as a tooltip in SVG viewers and as a sidebar panel in HTML output.
--- ---
+2 -2
View File
@@ -6,7 +6,7 @@
<p align="center"> <p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.23-blue.svg" alt="Release"></a> <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.26-blue.svg" alt="Release"></a>
</p> </p>
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome. > **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
@@ -20,7 +20,7 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
- **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped; `--context branch=main --context branch=develop` prints a multi-column comparison table across multiple contexts in one run - **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped; `--context branch=main --context branch=develop` prints a multi-column comparison table across multiple contexts in one run
- **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations) - **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations)
- **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression - **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression
- **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG - **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG; `--format mermaid` emits a Mermaid flowchart; `--format html` produces a self-contained HTML file with pan/zoom and a job-detail sidebar; context flags grey out skipped jobs
See [FEATURES.md](FEATURES.md) for the complete feature reference and lint rules table, and [ROADMAP.md](ROADMAP.md) for planned improvements. See [FEATURES.md](FEATURES.md) for the complete feature reference and lint rules table, and [ROADMAP.md](ROADMAP.md) for planned improvements.
+9 -7
View File
@@ -71,12 +71,14 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
- ~~**Terminal job tree**~~ — ✓ shipped in v0.2.0 as `glint graph tree`; stages as branches, jobs as leaves, context-aware annotations - ~~**Terminal job tree**~~ — ✓ shipped in v0.2.0 as `glint graph tree`; stages as branches, jobs as leaves, context-aware annotations
- ~~**`glint graph includes` shows jobs per file**~~ — ✓ shipped post-v0.2.0; each include node shows the jobs it defines as dashed-arrow rounded nodes in a distinct style - ~~**`glint graph includes` shows jobs per file**~~ — ✓ shipped post-v0.2.0; each include node shows the jobs it defines as dashed-arrow rounded nodes in a distinct style
- **Multi-job connector accuracy** — draw one connector per job pair rather than one per stage pair in classic mode, so pipelines with uneven columns look correct - ~~**Multi-job connector accuracy**~~✓ shipped v0.2.25; classic mode uses a bus-bar pattern (vertical rail at the midpoint + horizontal stubs per job) instead of a single center-to-center line, so uneven columns look correct
- **Job tooltip / detail panel** — embed a hidden `<title>` and `<desc>` per chip so SVG viewers show `stage`, `when`, `image`, and `needs` on hover - ~~**Job tooltip / detail panel**~~✓ shipped v0.2.25; each chip is wrapped in `<g data-job="…"><title>…</title><desc>…</desc>` SVG viewers show stage, when, image, and needs on hover; HTML output uses the data for the sidebar
- **`when: on_failure` visual distinction** — dashed border or distinct icon for failure-path jobs - ~~**`when: on_failure` visual distinction**~~ ✓ shipped v0.2.25; dashed chip border + X-mark icon + red circle (`#d9534f`); legend entry added; Mermaid `on_failure` class wired
- **Blocked / skipped state colouring** — grey out jobs that are statically unreachable given known `rules:` conditions - ~~**Blocked / skipped state colouring**~~✓ shipped v0.2.25; `glint graph pipeline` accepts context flags (`--branch`, `--tag`, etc.); jobs evaluated as skipped are greyed out (`#868686`) with dimmed text and no icon
- **Interactive HTML output** — self-contained `.html` file with pan/zoom and a job-detail sidebar; no external dependencies - ~~**Interactive HTML output**~~ ✓ shipped v0.2.25; `glint graph pipeline --format html` writes a self-contained `.html` file with mouse pan/zoom and a click-to-open job-detail sidebar; no external dependencies
- **Mermaid pipeline output** — keep `pipeline.go` but wire it up through `--graph pipeline --format mermaid` for users who want to paste into mermaid.live - ~~**Mermaid pipeline output**~~✓ shipped v0.2.25; `glint graph pipeline --format mermaid` prints a Mermaid flowchart to stdout (paste into mermaid.live)
- ~~**Same-stage job ordering**~~ — ✓ shipped v0.2.26; jobs within a stage that have `needs:` between each other are placed in topological sub-columns (left-to-right by depth); stage header spans all sub-columns
- ~~**Graph links rendered behind job chips**~~ — ✓ shipped v0.2.26; SVG connectors (Bézier curves and bus-bar stubs) are drawn before job chips so lines pass behind rectangles
--- ---
@@ -113,7 +115,7 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
## Reliability and developer experience ## Reliability and developer experience
- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18; GL042GL043 added v0.2.20 - ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18; GL042GL043 added v0.2.20; GL044 added v0.2.24; graph improvements shipped v0.2.25v0.2.26
- ~~**`glint explain <rule-id>`**~~ — ✓ shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules - ~~**`glint explain <rule-id>`**~~ — ✓ shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07) - ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07)
- ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help` - ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
+6
View File
@@ -113,10 +113,16 @@ tasks:
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/inherit_dead_fields.yml - cmd: ./{{.BINARY}} check testdata/inherit_dead_fields.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/rules_needs_valid.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/rules_needs_invalid.yml
ignore_error: true
- cmd: ./{{.BINARY}} explain GL007 - cmd: ./{{.BINARY}} explain GL007
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} explain gl042 - cmd: ./{{.BINARY}} explain gl042
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} explain GL044
ignore_error: false
- cmd: ./{{.BINARY}} explain - cmd: ./{{.BINARY}} explain
ignore_error: false ignore_error: false
+32 -1
View File
@@ -206,7 +206,16 @@ glint graph includes .gitlab-ci.yml > includes.mmd
glint graph pipeline .gitlab-ci.yml glint graph pipeline .gitlab-ci.yml
# prints the output path, e.g.: glint-out/pipeline-20260614-143022.png # prints the output path, e.g.: glint-out/pipeline-20260614-143022.png
# Mermaid to stdout + pipeline file path to stderr # Pipeline graph with skipped-job colouring (grey out context-unreachable jobs)
glint graph pipeline --branch main .gitlab-ci.yml
# Interactive HTML with pan/zoom and job-detail sidebar
glint graph pipeline --format html .gitlab-ci.yml
# Mermaid flowchart of the pipeline to stdout
glint graph pipeline --format mermaid .gitlab-ci.yml
# Mermaid includes graph to stdout + pipeline SVG file path to stderr
glint graph all .gitlab-ci.yml > includes.mmd glint graph all .gitlab-ci.yml > includes.mmd
# Custom output directory # Custom output directory
@@ -224,6 +233,28 @@ include type: orange (main file), purple (project), green (component), blue
**Pipeline graph** — GitLab CI-style SVG rendered to a timestamped file. **Pipeline graph** — GitLab CI-style SVG rendered to a timestamped file.
Converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available. Converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available.
**Pipeline graph formats** (`--format`):
| Flag | Output |
|------|--------|
| `svg` (default) | Write SVG/PNG to `--out` |
| `html` | Write self-contained HTML to `--out` — inline SVG with mouse pan/zoom, drag, and a job-detail sidebar that opens on chip click |
| `mermaid` | Print Mermaid flowchart to stdout |
**Visual chip styles:**
| Type | Colour | Icon | Border |
|------|--------|------|--------|
| regular | blue `#1f75cb` | checkmark | solid |
| manual | orange `#fc6d26` | play triangle | solid |
| trigger | purple `#6b4fbb` | chevron | solid |
| delayed | yellow `#fca326` | clock | solid |
| on_failure | red `#d9534f` | X mark | dashed |
| skipped (context) | grey `#868686` | none | solid |
Pass context flags (`--branch`, `--tag`, `--source`, `--var`) to grey out jobs
that would be skipped in the given pipeline event.
DAG mode (Bézier arrows between jobs) activates automatically when any job has DAG mode (Bézier arrows between jobs) activates automatically when any job has
a `needs:` list; classic mode uses stage-column connectors otherwise. a `needs:` list; classic mode uses stage-column connectors otherwise.
+33 -8
View File
@@ -445,7 +445,8 @@ func cmdGraph(args []string) {
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)") gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
cacheDir := fs.String("cache-dir", "", "directory to cache fetched remote includes (created if needed)") cacheDir := fs.String("cache-dir", "", "directory to cache fetched remote includes (created if needed)")
offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir") offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir")
out := fs.String("out", "glint-out", "output directory for Mermaid graph files (pipeline mode)") out := fs.String("out", "glint-out", "output directory for rendered graph files (pipeline mode)")
format := fs.String("format", "svg", "pipeline output format: svg, mermaid, or html")
fs.Usage = func() { fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version) fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Visualise the pipeline as a job tree and/or Mermaid graph. fmt.Fprint(os.Stderr, `Visualise the pipeline as a job tree and/or Mermaid graph.
@@ -462,6 +463,15 @@ Options:
Output directory for rendered graph files. Output directory for rendered graph files.
Used by the pipeline and all modes only. [default: glint-out] Used by the pipeline and all modes only. [default: glint-out]
--format <FORMAT>
Output format for pipeline mode: svg (default), mermaid, or html.
svg: write a GitLab CI-style SVG/PNG to --out (converted to PNG
when rsvg-convert, inkscape, or magick is available).
mermaid: print a Mermaid flowchart to stdout (paste into mermaid.live).
html: write a self-contained HTML file with pan/zoom and a
job-detail sidebar to --out.
[default: svg] [possible values: svg, mermaid, html]
--token <TOKEN> --token <TOKEN>
GitLab personal access token. Used to fetch remote project: includes GitLab personal access token. Used to fetch remote project: includes
when building the include dependency graph. when building the include dependency graph.
@@ -518,6 +528,8 @@ Examples:
glint graph includes .gitlab-ci.yml > includes.mmd glint graph includes .gitlab-ci.yml > includes.mmd
glint graph pipeline .gitlab-ci.yml glint graph pipeline .gitlab-ci.yml
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
glint graph pipeline --format mermaid .gitlab-ci.yml
glint graph pipeline --format html .gitlab-ci.yml
glint graph all .gitlab-ci.yml > includes.mmd glint graph all .gitlab-ci.yml > includes.mmd
`) `)
} }
@@ -602,16 +614,29 @@ Examples:
case "includes": case "includes":
fmt.Print(graph.Includes(path, p.Include, cfg)) fmt.Print(graph.Includes(path, p.Include, cfg))
case "pipeline": case "pipeline":
outPath, err := graph.RenderPipeline(p, *out) switch *format {
if err != nil { case "mermaid":
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err) fmt.Print(graph.Pipeline(p))
exit(2) case "html":
return outPath, err := graph.RenderHTML(p, *out, ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
exit(2)
return
}
fmt.Println(outPath)
default: // "svg"
outPath, err := graph.RenderPipeline(p, *out, ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
exit(2)
return
}
fmt.Println(outPath)
} }
fmt.Println(outPath)
case "all": case "all":
fmt.Print(graph.Includes(path, p.Include, cfg)) fmt.Print(graph.Includes(path, p.Include, cfg))
outPath, err := graph.RenderPipeline(p, *out) outPath, err := graph.RenderPipeline(p, *out, ctx)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err) fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
exit(2) exit(2)
+3
View File
@@ -27,6 +27,7 @@ func Pipeline(p *model.Pipeline) string {
w(" classDef manual fill:#fc6d26,stroke:#e56b1f,color:#fff") w(" classDef manual fill:#fc6d26,stroke:#e56b1f,color:#fff")
w(" classDef trigger fill:#6b4fbb,stroke:#5a3fa0,color:#fff") w(" classDef trigger fill:#6b4fbb,stroke:#5a3fa0,color:#fff")
w(" classDef delayed fill:#fca326,stroke:#d98a1e,color:#333") w(" classDef delayed fill:#fca326,stroke:#d98a1e,color:#333")
w(" classDef on_failure fill:#d9534f,stroke:#c0392b,color:#fff")
w("") w("")
// Collect visible (non-template) job names; sort for stable output. // Collect visible (non-template) job names; sort for stable output.
@@ -149,6 +150,8 @@ func jobClass(job model.Job) string {
return "manual" return "manual"
case "delayed": case "delayed":
return "delayed" return "delayed"
case "on_failure":
return "on_failure"
} }
return "regular" return "regular"
} }
+13
View File
@@ -105,10 +105,23 @@ func TestSanitizeID(t *testing.T) {
func TestJobClass(t *testing.T) { func TestJobClass(t *testing.T) {
if jobClass(model.Job{When: "manual"}) != "manual" { t.Error("manual") } if jobClass(model.Job{When: "manual"}) != "manual" { t.Error("manual") }
if jobClass(model.Job{When: "delayed"}) != "delayed" { t.Error("delayed") } if jobClass(model.Job{When: "delayed"}) != "delayed" { t.Error("delayed") }
if jobClass(model.Job{When: "on_failure"}) != "on_failure" { t.Error("on_failure") }
if jobClass(model.Job{Trigger: "x"}) != "trigger" { t.Error("trigger") } if jobClass(model.Job{Trigger: "x"}) != "trigger" { t.Error("trigger") }
if jobClass(model.Job{}) != "regular" { t.Error("regular") } if jobClass(model.Job{}) != "regular" { t.Error("regular") }
} }
func TestPipeline_OnFailureClass(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"cleanup"},
Jobs: map[string]model.Job{
"cleanup-job": {Name: "cleanup-job", Stage: "cleanup", When: "on_failure"},
},
}
out := Pipeline(p)
if !strings.Contains(out, "on_failure") { t.Error("expected on_failure class") }
if !strings.Contains(out, "#d9534f") { t.Error("expected on_failure color in classDef") }
}
// ── needsJobName ────────────────────────────────────────────────────────────── // ── needsJobName ──────────────────────────────────────────────────────────────
func TestNeedsJobName(t *testing.T) { func TestNeedsJobName(t *testing.T) {
+510 -88
View File
@@ -10,34 +10,138 @@ import (
"strings" "strings"
"time" "time"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model" "git.k3nny.fr/glint/internal/model"
) )
// Layout constants (pixels) tuned to resemble GitLab's full pipeline graph view. // Layout constants (pixels) tuned to resemble GitLab's full pipeline graph view.
const ( const (
chipW = 178 // job chip width chipW = 178 // job chip width
chipH = 34 // job chip height chipH = 34 // job chip height
chipGap = 6 // vertical gap between chips in a column chipGap = 6 // vertical gap between chips in a column
iconCX = 14 // status circle: x offset from chip left edge to circle centre iconCX = 14 // status circle: x offset from chip left edge to centre
iconR = 7 // status circle radius (14 px diameter) iconR = 7 // status circle radius (14 px diameter)
textLeft = 27 // job name text: x offset from chip left edge textLeft = 27 // job name text: x offset from chip left edge
labelH = 30 // stage-name label area height (text + bottom gap) labelH = 30 // stage-name label area height (text + bottom gap)
topPad = 40 // outer top padding topPad = 40 // outer top padding
sidePad = 40 // outer left / right padding sidePad = 40 // outer left / right padding
stageGap = 50 // horizontal gap between stage columns (connector space) stageGap = 50 // horizontal gap between stage columns (connector space)
botPad = 48 // outer bottom padding (legend lives here) subStageGap = 20 // horizontal gap between sub-columns within the same stage
botPad = 48 // outer bottom padding (legend lives here)
) )
type svgPt struct{ x, y int } type svgPt struct{ x, y int }
// column represents one vertical stack of job chips in the SVG.
// A declared GitLab stage maps to one column unless jobs within it have
// same-stage needs:, in which case it splits into topological sub-columns.
type column struct {
stage string // declared GitLab stage name
jobs []string // job names in this column, topologically ordered
}
// computeColumns assigns jobs to SVG columns.
// When jobs within the same declared stage have needs: relationships between
// each other, the stage is split into topological sub-columns so that
// depended-upon jobs appear to the left of the jobs that depend on them.
func computeColumns(stages []string, byStage map[string][]string, jobs map[string]model.Job) []column {
var cols []column
for _, stage := range stages {
stageJobs := byStage[stage]
sort.Strings(stageJobs)
if len(stageJobs) == 0 {
continue
}
// Index same-stage membership.
stageSet := make(map[string]bool, len(stageJobs))
for _, j := range stageJobs {
stageSet[j] = true
}
// Check whether any job has a same-stage need.
hasIntraNeeds := false
outer:
for _, j := range stageJobs {
for _, need := range jobs[j].Needs {
if dep := needsJobName(need); dep != "" && stageSet[dep] {
hasIntraNeeds = true
break outer
}
}
}
if !hasIntraNeeds {
cols = append(cols, column{stage: stage, jobs: stageJobs})
continue
}
// Compute topological depth within the stage.
depth := make(map[string]int, len(stageJobs))
for _, j := range stageJobs {
depth[j] = -1
}
inProgress := make(map[string]bool, len(stageJobs))
var computeDepth func(j string) int
computeDepth = func(j string) int {
if depth[j] >= 0 {
return depth[j]
}
if inProgress[j] {
return 0 // cycle guard (cycles already rejected by GL029)
}
inProgress[j] = true
maxDep := -1
for _, need := range jobs[j].Needs {
dep := needsJobName(need)
if dep == "" || !stageSet[dep] {
continue
}
d := computeDepth(dep)
if d > maxDep {
maxDep = d
}
}
depth[j] = maxDep + 1
return depth[j]
}
for _, j := range stageJobs {
if depth[j] < 0 {
computeDepth(j)
}
}
// One sub-column per topological depth level.
maxDepth := 0
for _, j := range stageJobs {
if depth[j] > maxDepth {
maxDepth = depth[j]
}
}
for l := 0; l <= maxDepth; l++ {
var levelJobs []string
for _, j := range stageJobs {
if depth[j] == l {
levelJobs = append(levelJobs, j)
}
}
if len(levelJobs) > 0 {
sort.Strings(levelJobs)
cols = append(cols, column{stage: stage, jobs: levelJobs})
}
}
}
return cols
}
// RenderPipeline writes a PNG (or SVG fallback) file with a GitLab CI-style // RenderPipeline writes a PNG (or SVG fallback) file with a GitLab CI-style
// pipeline layout and returns the path to the generated file. // pipeline layout and returns the path to the generated file.
func RenderPipeline(p *model.Pipeline, outDir string) (string, error) { func RenderPipeline(p *model.Pipeline, outDir string, ctx *cicontext.Context) (string, error) {
if err := os.MkdirAll(outDir, 0o755); err != nil { if err := os.MkdirAll(outDir, 0o755); err != nil {
return "", fmt.Errorf("creating output directory %s: %w", outDir, err) return "", fmt.Errorf("creating output directory %s: %w", outDir, err)
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, ctx)
ts := time.Now().Format("20060102-150405") ts := time.Now().Format("20060102-150405")
svgPath := filepath.Join(outDir, "pipeline-"+ts+".svg") svgPath := filepath.Join(outDir, "pipeline-"+ts+".svg")
@@ -53,6 +157,24 @@ func RenderPipeline(p *model.Pipeline, outDir string) (string, error) {
return svgPath, nil return svgPath, nil
} }
// RenderHTML writes a self-contained HTML file with the pipeline graph embedded
// inline with pan/zoom and a job-detail sidebar, then returns the output path.
func RenderHTML(p *model.Pipeline, outDir string, ctx *cicontext.Context) (string, error) {
if err := os.MkdirAll(outDir, 0o755); err != nil {
return "", fmt.Errorf("creating output directory %s: %w", outDir, err)
}
svg := pipelineSVG(p, ctx)
html := htmlPage(svg)
ts := time.Now().Format("20060102-150405")
htmlPath := filepath.Join(outDir, "pipeline-"+ts+".html")
if err := os.WriteFile(htmlPath, []byte(html), 0o644); err != nil {
return "", fmt.Errorf("writing HTML: %w", err)
}
return htmlPath, nil
}
// convertToPNG tries rsvg-convert, Inkscape, magick, and convert (not on Windows). // convertToPNG tries rsvg-convert, Inkscape, magick, and convert (not on Windows).
func convertToPNG(svgPath, pngPath string) bool { func convertToPNG(svgPath, pngPath string) bool {
candidates := [][]string{ candidates := [][]string{
@@ -75,7 +197,7 @@ func convertToPNG(svgPath, pngPath string) bool {
return false return false
} }
func pipelineSVG(p *model.Pipeline) string { func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string {
// Collect visible (non-template) job names in sorted order. // Collect visible (non-template) job names in sorted order.
var visible []string var visible []string
for name := range p.Jobs { for name := range p.Jobs {
@@ -89,6 +211,16 @@ func pipelineSVG(p *model.Pipeline) string {
return svgEmpty() return svgEmpty()
} }
// Precompute which jobs are skipped in the given context.
skippedJobs := make(map[string]bool)
if ctx != nil {
for _, name := range visible {
if cicontext.EvalJob(p.Jobs[name], ctx) == cicontext.JobSkipped {
skippedJobs[name] = true
}
}
}
// Group by stage; fall back to "test" (GitLab default) when stage is unset. // Group by stage; fall back to "test" (GitLab default) when stage is unset.
byStage := make(map[string][]string) byStage := make(map[string][]string)
for _, name := range visible { for _, name := range visible {
@@ -119,32 +251,42 @@ func pipelineSVG(p *model.Pipeline) string {
} }
} }
// Compute per-stage column heights and job anchor points for connectors. // ── Column layout ─────────────────────────────────────────────────────────
colH := make([]int, len(stages)) // height of the chip stack (no label) // Split stages into sub-columns when intra-stage needs exist.
colCtY := make([]int, len(stages)) // y centre of the chip stack cols := computeColumns(stages, byStage, p.Jobs)
// X position of each column's left edge.
colX := make([]int, len(cols))
if len(cols) > 0 {
colX[0] = sidePad
for i := 1; i < len(cols); i++ {
gap := stageGap
if cols[i].stage == cols[i-1].stage {
gap = subStageGap
}
colX[i] = colX[i-1] + chipW + gap
}
}
// Compute SVG dimensions and per-job connector anchor points.
maxColH := 0 maxColH := 0
rightMid := make(map[string]svgPt) rightMid := make(map[string]svgPt)
leftMid := make(map[string]svgPt) leftMid := make(map[string]svgPt)
for i, stage := range stages { for ci, col := range cols {
jobs := byStage[stage] n := len(col.jobs)
sort.Strings(jobs)
n := len(jobs)
h := n*chipH + max(0, n-1)*chipGap h := n*chipH + max(0, n-1)*chipGap
colH[i] = h
if h > maxColH { if h > maxColH {
maxColH = h maxColH = h
} }
cx := sidePad + i*(chipW+stageGap) for j, name := range col.jobs {
for j, name := range jobs {
chy := topPad + labelH + j*(chipH+chipGap) chy := topPad + labelH + j*(chipH+chipGap)
rightMid[name] = svgPt{cx + chipW, chy + chipH/2} rightMid[name] = svgPt{colX[ci] + chipW, chy + chipH/2}
leftMid[name] = svgPt{cx, chy + chipH/2} leftMid[name] = svgPt{colX[ci], chy + chipH/2}
} }
colCtY[i] = topPad + labelH + h/2
} }
svgW := sidePad*2 + len(stages)*chipW + max(0, len(stages)-1)*stageGap svgW := colX[len(cols)-1] + chipW + sidePad
svgH := topPad + labelH + maxColH + botPad svgH := topPad + labelH + maxColH + botPad
// DAG mode: any visible job with a needs: list triggers job-to-job arrows. // DAG mode: any visible job with a needs: list triggers job-to-job arrows.
@@ -174,49 +316,35 @@ func pipelineSVG(p *model.Pipeline) string {
// White page background. // White page background.
wf(` <rect width="%d" height="%d" fill="#ffffff"/>`, svgW, svgH) wf(` <rect width="%d" height="%d" fill="#ffffff"/>`, svgW, svgH)
// ── Stage columns ───────────────────────────────────────────────────────── // ── Stage headers ─────────────────────────────────────────────────────────
for i, stage := range stages { // Each declared stage may span multiple sub-columns; draw one header per stage.
cx := sidePad + i*(chipW+stageGap) drawnHeader := make(map[string]bool)
jobs := byStage[stage] for ci, col := range cols {
sort.Strings(jobs) if drawnHeader[col.stage] {
continue
}
// Span all sub-columns that belong to this stage.
x1 := colX[ci]
x2 := colX[ci] + chipW
for j := ci + 1; j < len(cols) && cols[j].stage == col.stage; j++ {
x2 = colX[j] + chipW
}
centerX := (x1 + x2) / 2
// Stage name small, gray, uppercase, centered above the chip stack. // Stage name small, gray, uppercase.
wf(` <text x="%d" y="%d" text-anchor="middle" `+ wf(` <text x="%d" y="%d" text-anchor="middle" `+
`font-family="'GitLab Sans','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif" `+ `font-family="'GitLab Sans','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif" `+
`font-size="11" font-weight="600" letter-spacing="0.8" fill="#868686">%s</text>`, `font-size="11" font-weight="600" letter-spacing="0.8" fill="#868686">%s</text>`,
cx+chipW/2, topPad+13, svgEsc(strings.ToUpper(stage))) centerX, topPad+13, svgEsc(strings.ToUpper(col.stage)))
// Subtle separator line below stage name. // Separator line spanning all sub-columns.
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="#eaeaea" stroke-width="1"/>`, wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="#eaeaea" stroke-width="1"/>`,
cx, topPad+21, cx+chipW, topPad+21) x1, topPad+21, x2, topPad+21)
// Job chips. drawnHeader[col.stage] = true
for j, name := range jobs {
job := p.Jobs[name]
chy := topPad + labelH + j*(chipH+chipGap)
color := chipColor(job)
// Chip card (white, rounded, subtle border + shadow).
wf(` <rect x="%d" y="%d" width="%d" height="%d" rx="4" `+
`fill="#ffffff" stroke="#dde1e7" stroke-width="1" filter="url(#chip-shadow)"/>`,
cx, chy, chipW, chipH)
// Colored status indicator circle.
wf(` <circle cx="%d" cy="%d" r="%d" fill="%s"/>`,
cx+iconCX, chy+chipH/2, iconR, color)
// Icon symbol inside the circle.
drawChipIcon(&sb, job, cx+iconCX, chy+chipH/2)
// Job name text.
wf(` <text x="%d" y="%d" dominant-baseline="middle" `+
`font-family="'GitLab Sans','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif" `+
`font-size="13" fill="#303030">%s</text>`,
cx+textLeft, chy+chipH/2, svgEsc(svgTrunc(name, 20)))
}
} }
// ── Connectors ──────────────────────────────────────────────────────────── // ── Connectors (drawn before chips so they appear behind job blocks) ───────
const connStroke = "#dbdbdb" const connStroke = "#dbdbdb"
if dagMode { if dagMode {
@@ -241,27 +369,123 @@ func pipelineSVG(p *model.Pipeline) string {
} }
} }
} else { } else {
// Classic: one connector per adjacent stage pair. // Classic: bus-bar connectors between adjacent stage columns.
for i := 0; i < len(stages)-1; i++ { // Every job in stage[i] fans to a vertical bus at the midpoint gap,
x1 := sidePad + i*(chipW+stageGap) + chipW // then fans out to every job in stage[i+1].
x2 := sidePad + (i+1)*(chipW+stageGap) for ci := 0; ci < len(cols)-1; ci++ {
y1 := colCtY[i] x1 := colX[ci] + chipW // right edge of current column
y2 := colCtY[i+1] x2 := colX[ci+1] // left edge of next column
midX := (x1 + x2) / 2 midX := (x1 + x2) / 2
if y1 == y2 { srcJobs := cols[ci].jobs
// Straight horizontal line. dstJobs := cols[ci+1].jobs
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
x1, y1, x2-7, y2, connStroke) // Collect all Y midpoints to span the vertical bus bar.
} else { var allYs []int
// L-shaped elbow: right → vertical → right. srcY := make([]int, len(srcJobs))
wf(` <polyline points="%d,%d %d,%d %d,%d %d,%d" `+ dstY := make([]int, len(dstJobs))
`stroke="%s" stroke-width="2" fill="none" stroke-linejoin="round"/>`, for j, name := range srcJobs {
x1, y1, midX, y1, midX, y2, x2-7, y2, connStroke) srcY[j] = rightMid[name].y
allYs = append(allYs, srcY[j])
} }
// Arrowhead at the destination. for j, name := range dstJobs {
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`, dstY[j] = leftMid[name].y
x2-7, y2-4, x2, y2, x2-7, y2+4, connStroke) allYs = append(allYs, dstY[j])
}
sort.Ints(allYs)
busMinY, busMaxY := allYs[0], allYs[len(allYs)-1]
// Vertical bus bar at midX (only when there are multiple Y levels).
if busMinY < busMaxY {
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
midX, busMinY, midX, busMaxY, connStroke)
}
// Horizontal stubs from each source job to the bus.
for _, y := range srcY {
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
x1, y, midX, y, connStroke)
}
// Horizontal stubs from bus to each destination job, with arrowhead.
for _, y := range dstY {
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
midX, y, x2-7, y, connStroke)
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
x2-7, y-4, x2, y, x2-7, y+4, connStroke)
}
}
}
// ── Job chips (drawn after connectors so they appear on top) ─────────────
for ci, col := range cols {
for j, name := range col.jobs {
job := p.Jobs[name]
chy := topPad + labelH + j*(chipH+chipGap)
isSkipped := skippedJobs[name]
cx := colX[ci]
// Build <desc> content: shown in the HTML sidebar and SVG viewer tooltips.
desc := "stage: " + col.stage
if job.When != "" {
desc += "\nwhen: " + job.When
}
if img := imageString(job.Image); img != "" {
desc += "\nimage: " + img
}
var needNames []string
for _, n := range job.Needs {
if s := needsJobName(n); s != "" {
needNames = append(needNames, s)
}
}
if len(needNames) > 0 {
desc += "\nneeds: " + strings.Join(needNames, ", ")
}
if isSkipped {
desc += "\nstate: skipped"
}
// data-job attribute enables JS click detection in the HTML output.
wf(` <g data-job="%s">`, svgEsc(name))
wf(` <title>%s</title>`, svgEsc(name))
wf(` <desc>%s</desc>`, svgEsc(desc))
color := chipColor(job)
if isSkipped {
color = "#868686"
}
// Chip card (white, rounded, subtle border + shadow).
// on_failure jobs get a dashed border to signal the failure path.
dashAttr := ""
if !isSkipped && job.When == "on_failure" {
dashAttr = ` stroke-dasharray="4,3"`
}
wf(` <rect x="%d" y="%d" width="%d" height="%d" rx="4" `+
`fill="#ffffff" stroke="#dde1e7" stroke-width="1"%s filter="url(#chip-shadow)"/>`,
cx, chy, chipW, chipH, dashAttr)
// Colored status indicator circle.
wf(` <circle cx="%d" cy="%d" r="%d" fill="%s"/>`,
cx+iconCX, chy+chipH/2, iconR, color)
// Icon inside the circle (omitted for skipped — grey circle speaks for itself).
if !isSkipped {
drawChipIcon(&sb, job, cx+iconCX, chy+chipH/2)
}
// Job name text — dimmed for skipped jobs.
textColor := "#303030"
if isSkipped {
textColor = "#868686"
}
wf(` <text x="%d" y="%d" dominant-baseline="middle" `+
`font-family="'GitLab Sans','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif" `+
`font-size="13" fill="%s">%s</text>`,
cx+textLeft, chy+chipH/2, textColor, svgEsc(svgTrunc(name, 20)))
wf(` </g>`)
} }
} }
@@ -271,6 +495,7 @@ func pipelineSVG(p *model.Pipeline) string {
{"#fc6d26", "manual"}, {"#fc6d26", "manual"},
{"#6b4fbb", "trigger"}, {"#6b4fbb", "trigger"},
{"#fca326", "delayed"}, {"#fca326", "delayed"},
{"#d9534f", "on_failure"},
} }
const legendItemW = 82 const legendItemW = 82
legendY := topPad + labelH + maxColH + botPad/2 legendY := topPad + labelH + maxColH + botPad/2
@@ -293,7 +518,7 @@ func pipelineSVG(p *model.Pipeline) string {
func drawChipIcon(sb *strings.Builder, job model.Job, cx, cy int) { func drawChipIcon(sb *strings.Builder, job model.Job, cx, cy int) {
if job.Trigger != nil { if job.Trigger != nil {
// Right-pointing chevron for trigger jobs. // Right-pointing chevron for trigger jobs.
fmt.Fprintf(sb, " <polyline points=\"%d,%d %d,%d %d,%d\" "+ fmt.Fprintf(sb, " <polyline points=\"%d,%d %d,%d %d,%d\" "+
"stroke=\"#fff\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n", "stroke=\"#fff\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n",
cx-3, cy-3, cx+3, cy, cx-3, cy+3) cx-3, cy-3, cx+3, cy, cx-3, cy+3)
return return
@@ -301,18 +526,24 @@ func drawChipIcon(sb *strings.Builder, job model.Job, cx, cy int) {
switch job.When { switch job.When {
case "manual": case "manual":
// Filled play triangle. // Filled play triangle.
fmt.Fprintf(sb, " <polygon points=\"%d,%d %d,%d %d,%d\" fill=\"#fff\"/>\n", fmt.Fprintf(sb, " <polygon points=\"%d,%d %d,%d %d,%d\" fill=\"#fff\"/>\n",
cx-3, cy-4, cx+5, cy, cx-3, cy+4) cx-3, cy-4, cx+5, cy, cx-3, cy+4)
case "delayed": case "delayed":
// Clock hands: vertical + horizontal. // Clock: circle + hands.
fmt.Fprintf(sb, " <circle cx=\"%d\" cy=\"%d\" r=\"5\" stroke=\"#fff\" stroke-width=\"1.2\" fill=\"none\"/>\n", cx, cy) fmt.Fprintf(sb, " <circle cx=\"%d\" cy=\"%d\" r=\"5\" stroke=\"#fff\" stroke-width=\"1.2\" fill=\"none\"/>\n", cx, cy)
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>\n", fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>\n",
cx, cy, cx, cy-3) cx, cy, cx, cy-3)
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>\n", fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>\n",
cx, cy, cx+2, cy+1) cx, cy, cx+2, cy+1)
case "on_failure":
// X mark for failure-path jobs.
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n",
cx-3, cy-3, cx+3, cy+3)
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n",
cx+3, cy-3, cx-3, cy+3)
default: default:
// Regular job: small white checkmark outline. // Regular job: small white checkmark.
fmt.Fprintf(sb, " <polyline points=\"%d,%d %d,%d %d,%d\" "+ fmt.Fprintf(sb, " <polyline points=\"%d,%d %d,%d %d,%d\" "+
"stroke=\"#fff\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n", "stroke=\"#fff\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n",
cx-3, cy, cx-1, cy+3, cx+4, cy-3) cx-3, cy, cx-1, cy+3, cx+4, cy-3)
} }
@@ -327,10 +558,25 @@ func chipColor(job model.Job) string {
return "#fc6d26" return "#fc6d26"
case "delayed": case "delayed":
return "#fca326" return "#fca326"
case "on_failure":
return "#d9534f"
} }
return "#1f75cb" return "#1f75cb"
} }
// imageString extracts the image name from a job Image field (string or map form).
func imageString(img any) string {
switch v := img.(type) {
case string:
return v
case map[string]any:
if name, ok := v["name"].(string); ok {
return name
}
}
return ""
}
func svgEsc(s string) string { func svgEsc(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;") s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;") s = strings.ReplaceAll(s, "<", "&lt;")
@@ -356,3 +602,179 @@ func svgEmpty() string {
`</svg>`, `</svg>`,
w, h, w, h, w/2, h/2) w, h, w, h, w/2, h/2)
} }
// htmlPage wraps an SVG pipeline graph in a self-contained HTML page with
// mouse pan/zoom and a job-detail sidebar that appears on chip click.
func htmlPage(svgContent string) string {
var sb strings.Builder
sb.WriteString(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pipeline Graph</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #f5f5f5;
display: flex;
height: 100vh;
overflow: hidden;
}
#sidebar {
width: 260px;
flex-shrink: 0;
background: #fff;
border-right: 1px solid #e0e0e0;
display: none;
flex-direction: column;
overflow-y: auto;
}
#sidebar.open { display: flex; }
#sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: .6rem .8rem;
border-bottom: 1px solid #e0e0e0;
background: #fafafa;
flex-shrink: 0;
}
#sidebar-header h3 {
font-size: .9rem;
color: #303030;
word-break: break-all;
}
#close-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: #868686;
padding: .2rem .4rem;
margin-left: .4rem;
flex-shrink: 0;
}
#close-btn:hover { color: #303030; }
#sidebar-body { padding: .8rem; flex: 1; }
#sidebar-body pre {
font-size: .82rem;
color: #555;
white-space: pre-wrap;
line-height: 1.6;
}
#viewport {
flex: 1;
overflow: hidden;
cursor: grab;
position: relative;
background: #f5f5f5;
}
#viewport.dragging { cursor: grabbing; }
#svg-wrap {
display: inline-block;
transform-origin: 0 0;
padding: 20px;
}
g[data-job] { cursor: pointer; }
g[data-job]:hover > rect { stroke: #1f75cb; stroke-width: 2; }
</style>
</head>
<body>
<div id="sidebar">
<div id="sidebar-header">
<h3 id="sidebar-title">Job details</h3>
<button id="close-btn" title="Close">&#x2715;</button>
</div>
<div id="sidebar-body">
<pre id="sidebar-details"></pre>
</div>
</div>
<div id="viewport">
<div id="svg-wrap">
`)
sb.WriteString(svgContent)
sb.WriteString(`
</div>
</div>
<script>
(function() {
'use strict';
var vp = document.getElementById('viewport');
var wrap = document.getElementById('svg-wrap');
var sidebar = document.getElementById('sidebar');
var titleEl = document.getElementById('sidebar-title');
var detailsEl = document.getElementById('sidebar-details');
var tx = 0, ty = 0, scale = 1;
function applyTransform() {
wrap.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
}
// Zoom toward the cursor position.
vp.addEventListener('wheel', function(e) {
e.preventDefault();
var factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
var r = vp.getBoundingClientRect();
var mx = e.clientX - r.left;
var my = e.clientY - r.top;
tx = mx - (mx - tx) * factor;
ty = my - (my - ty) * factor;
scale *= factor;
applyTransform();
}, { passive: false });
// Drag to pan (skip if clicking on a job chip).
var dragging = false, ox = 0, oy = 0;
vp.addEventListener('mousedown', function(e) {
if (e.target.closest('g[data-job]')) return;
dragging = true;
ox = e.clientX - tx;
oy = e.clientY - ty;
vp.classList.add('dragging');
});
window.addEventListener('mousemove', function(e) {
if (!dragging) return;
tx = e.clientX - ox;
ty = e.clientY - oy;
applyTransform();
});
window.addEventListener('mouseup', function() {
dragging = false;
vp.classList.remove('dragging');
});
// Double-click on the background to reset the view.
vp.addEventListener('dblclick', function(e) {
if (e.target.closest('g[data-job]')) return;
tx = 0; ty = 0; scale = 1;
applyTransform();
});
// Click a job chip to show its details in the sidebar.
document.addEventListener('click', function(e) {
var g = e.target.closest('g[data-job]');
if (!g) return;
var jobName = g.dataset.job;
var descEl = g.querySelector('desc');
var desc = descEl ? descEl.textContent : '';
titleEl.textContent = jobName;
detailsEl.textContent = desc;
sidebar.classList.add('open');
});
// Close the sidebar.
document.getElementById('close-btn').addEventListener('click', function() {
sidebar.classList.remove('open');
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') sidebar.classList.remove('open');
});
})();
</script>
</body>
</html>
`)
return sb.String()
}
+344 -26
View File
@@ -5,6 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model" "git.k3nny.fr/glint/internal/model"
) )
@@ -59,9 +60,20 @@ func TestChipColor(t *testing.T) {
if chipColor(model.Job{Trigger: "x"}) != "#6b4fbb" { t.Error("trigger color") } if chipColor(model.Job{Trigger: "x"}) != "#6b4fbb" { t.Error("trigger color") }
if chipColor(model.Job{When: "manual"}) != "#fc6d26" { t.Error("manual color") } if chipColor(model.Job{When: "manual"}) != "#fc6d26" { t.Error("manual color") }
if chipColor(model.Job{When: "delayed"}) != "#fca326" { t.Error("delayed color") } if chipColor(model.Job{When: "delayed"}) != "#fca326" { t.Error("delayed color") }
if chipColor(model.Job{When: "on_failure"}) != "#d9534f" { t.Error("on_failure color") }
if chipColor(model.Job{}) != "#1f75cb" { t.Error("regular color") } if chipColor(model.Job{}) != "#1f75cb" { t.Error("regular color") }
} }
// ── imageString ───────────────────────────────────────────────────────────────
func TestImageString(t *testing.T) {
if imageString("alpine") != "alpine" { t.Error("string form") }
if imageString(map[string]any{"name": "golang:1.21"}) != "golang:1.21" { t.Error("map form") }
if imageString(map[string]any{"other": "x"}) != "" { t.Error("map without name key") }
if imageString(nil) != "" { t.Error("nil") }
if imageString(42) != "" { t.Error("unexpected type") }
}
// ── drawChipIcon ────────────────────────────────────────────────────────────── // ── drawChipIcon ──────────────────────────────────────────────────────────────
func TestDrawChipIcon(t *testing.T) { func TestDrawChipIcon(t *testing.T) {
@@ -72,6 +84,7 @@ func TestDrawChipIcon(t *testing.T) {
{model.Job{Trigger: "x"}, "<polyline"}, {model.Job{Trigger: "x"}, "<polyline"},
{model.Job{When: "manual"}, "<polygon"}, {model.Job{When: "manual"}, "<polygon"},
{model.Job{When: "delayed"}, "<circle"}, {model.Job{When: "delayed"}, "<circle"},
{model.Job{When: "on_failure"}, "<line"},
{model.Job{}, "<polyline"}, {model.Job{}, "<polyline"},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -86,7 +99,7 @@ func TestDrawChipIcon(t *testing.T) {
// ── pipelineSVG ─────────────────────────────────────────────────────────────── // ── pipelineSVG ───────────────────────────────────────────────────────────────
func TestPipelineSVG_Empty(t *testing.T) { func TestPipelineSVG_Empty(t *testing.T) {
svg := pipelineSVG(&model.Pipeline{}) svg := pipelineSVG(&model.Pipeline{}, nil)
if !strings.Contains(svg, "no jobs defined") { if !strings.Contains(svg, "no jobs defined") {
t.Errorf("expected 'no jobs defined', got:\n%s", svg) t.Errorf("expected 'no jobs defined', got:\n%s", svg)
} }
@@ -100,13 +113,32 @@ func TestPipelineSVG_ClassicMode(t *testing.T) {
"test-job": {Name: "test-job", Stage: "test"}, "test-job": {Name: "test-job", Stage: "test"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") } if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
if !strings.Contains(svg, "build-job") { t.Error("expected build-job") } if !strings.Contains(svg, "build-job") { t.Error("expected build-job") }
if !strings.Contains(svg, "test-job") { t.Error("expected test-job") } if !strings.Contains(svg, "test-job") { t.Error("expected test-job") }
// Classic mode connector // Classic mode bus-bar draws <line> elements for connectors
if !strings.Contains(svg, "<polyline") && !strings.Contains(svg, "<line") { if !strings.Contains(svg, "<line") {
t.Error("expected connector in classic mode") t.Error("expected <line> connector in classic mode")
}
}
func TestPipelineSVG_ClassicMode_MultiJob(t *testing.T) {
// Two source jobs, two destination jobs → bus-bar with vertical segment
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-a": {Name: "build-a", Stage: "build"},
"build-b": {Name: "build-b", Stage: "build"},
"test-a": {Name: "test-a", Stage: "test"},
"test-b": {Name: "test-b", Stage: "test"},
},
}
svg := pipelineSVG(p, nil)
// Bus bar: horizontal stubs from each src + dst, plus a vertical bus line
// and an arrowhead polygon for each dst job.
if !strings.Contains(svg, "<polygon") {
t.Error("expected arrowhead <polygon> in multi-job classic mode")
} }
} }
@@ -118,7 +150,7 @@ func TestPipelineSVG_DAGMode(t *testing.T) {
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"build-job"}}, "test-job": {Name: "test-job", Stage: "test", Needs: []any{"build-job"}},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
// DAG mode draws <path> bezier curves // DAG mode draws <path> bezier curves
if !strings.Contains(svg, "<path") { if !strings.Contains(svg, "<path") {
t.Error("expected <path> connector in DAG mode") t.Error("expected <path> connector in DAG mode")
@@ -126,7 +158,7 @@ func TestPipelineSVG_DAGMode(t *testing.T) {
} }
func TestPipelineSVG_DAGMode_StraightConnector(t *testing.T) { func TestPipelineSVG_DAGMode_StraightConnector(t *testing.T) {
// Two stages at same height → straight <line> connector in classic mode // Two stages at same height → straight connector in classic mode
p := &model.Pipeline{ p := &model.Pipeline{
Stages: []string{"a", "b"}, Stages: []string{"a", "b"},
Jobs: map[string]model.Job{ Jobs: map[string]model.Job{
@@ -134,7 +166,7 @@ func TestPipelineSVG_DAGMode_StraightConnector(t *testing.T) {
"j2": {Name: "j2", Stage: "b"}, "j2": {Name: "j2", Stage: "b"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") } if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
} }
@@ -142,16 +174,21 @@ func TestPipelineSVG_AllJobTypes(t *testing.T) {
p := &model.Pipeline{ p := &model.Pipeline{
Stages: []string{"deploy"}, Stages: []string{"deploy"},
Jobs: map[string]model.Job{ Jobs: map[string]model.Job{
"manual-job": {Name: "manual-job", Stage: "deploy", When: "manual"}, "manual-job": {Name: "manual-job", Stage: "deploy", When: "manual"},
"delayed-job": {Name: "delayed-job", Stage: "deploy", When: "delayed"}, "delayed-job": {Name: "delayed-job", Stage: "deploy", When: "delayed"},
"trigger-job": {Name: "trigger-job", Stage: "deploy", Trigger: "other/project"}, "trigger-job": {Name: "trigger-job", Stage: "deploy", Trigger: "other/project"},
"onfailure-job": {Name: "onfailure-job", Stage: "deploy", When: "on_failure"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
// Each type has its own color in the SVG
if !strings.Contains(svg, "#fc6d26") { t.Error("expected manual color") } if !strings.Contains(svg, "#fc6d26") { t.Error("expected manual color") }
if !strings.Contains(svg, "#fca326") { t.Error("expected delayed color") } if !strings.Contains(svg, "#fca326") { t.Error("expected delayed color") }
if !strings.Contains(svg, "#6b4fbb") { t.Error("expected trigger color") } if !strings.Contains(svg, "#6b4fbb") { t.Error("expected trigger color") }
if !strings.Contains(svg, "#d9534f") { t.Error("expected on_failure color") }
// on_failure chips have a dashed border
if !strings.Contains(svg, `stroke-dasharray="4,3"`) {
t.Error("expected dashed border on on_failure chip")
}
} }
func TestPipelineSVG_JobNoStage(t *testing.T) { func TestPipelineSVG_JobNoStage(t *testing.T) {
@@ -160,7 +197,7 @@ func TestPipelineSVG_JobNoStage(t *testing.T) {
"myjob": {Name: "myjob"}, "myjob": {Name: "myjob"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "myjob") { t.Error("expected myjob") } if !strings.Contains(svg, "myjob") { t.Error("expected myjob") }
} }
@@ -172,10 +209,85 @@ func TestPipelineSVG_CrossPipelineNeed(t *testing.T) {
Needs: []any{map[string]any{"pipeline": "other", "job": "x"}}}, Needs: []any{map[string]any{"pipeline": "other", "job": "x"}}},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") } if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
} }
func TestPipelineSVG_Tooltip(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"build-job": {
Name: "build-job",
Stage: "build",
When: "on_success",
Image: "golang:1.21",
Needs: []any{"prep-job"},
},
},
}
svg := pipelineSVG(p, nil)
// Tooltip <title> and <desc> should appear inside the <g data-job> wrapper
if !strings.Contains(svg, `<title>build-job</title>`) {
t.Error("expected <title>build-job</title>")
}
if !strings.Contains(svg, `<desc>`) {
t.Error("expected <desc> element")
}
if !strings.Contains(svg, "image: golang:1.21") {
t.Error("expected image in desc")
}
if !strings.Contains(svg, "needs: prep-job") {
t.Error("expected needs in desc")
}
if !strings.Contains(svg, `data-job="build-job"`) {
t.Error("expected data-job attribute")
}
}
func TestPipelineSVG_SkippedJob(t *testing.T) {
// A job with rules that never match in the given context should be greyed out.
p := &model.Pipeline{
Stages: []string{"deploy"},
Jobs: map[string]model.Job{
"deploy-job": {
Name: "deploy-job",
Stage: "deploy",
Script: []any{"deploy.sh"},
Rules: []model.Rule{
{If: `$CI_COMMIT_TAG != ""`, When: "on_success"},
},
},
},
}
// Branch context: CI_COMMIT_TAG is empty → rule doesn't match → job skipped
ctx := cicontext.New("main", "", "push", nil)
svg := pipelineSVG(p, ctx)
// Skipped jobs use grey (#868686) instead of the regular blue
if !strings.Contains(svg, `fill="#868686"`) {
t.Error("expected grey fill for skipped job circle")
}
// Skipped state should appear in the tooltip desc
if !strings.Contains(svg, "state: skipped") {
t.Error("expected 'state: skipped' in tooltip desc")
}
}
func TestPipelineSVG_ContextNil(t *testing.T) {
// nil context → no skipped jobs, regular colors
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"j": {Name: "j", Stage: "build"},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, `fill="#1f75cb"`) {
t.Error("expected regular blue fill with nil context")
}
}
// ── RenderPipeline ──────────────────────────────────────────────────────────── // ── RenderPipeline ────────────────────────────────────────────────────────────
func TestRenderPipeline(t *testing.T) { func TestRenderPipeline(t *testing.T) {
@@ -186,7 +298,7 @@ func TestRenderPipeline(t *testing.T) {
}, },
} }
dir := t.TempDir() dir := t.TempDir()
path, err := RenderPipeline(p, dir) path, err := RenderPipeline(p, dir, nil)
if err != nil { if err != nil {
t.Fatalf("RenderPipeline: %v", err) t.Fatalf("RenderPipeline: %v", err)
} }
@@ -202,7 +314,7 @@ func TestRenderPipeline_InvalidDir(t *testing.T) {
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, err := RenderPipeline(&model.Pipeline{}, filePath+"/nested") _, err := RenderPipeline(&model.Pipeline{}, filePath+"/nested", nil)
if err == nil { if err == nil {
t.Error("expected error writing to path under a file") t.Error("expected error writing to path under a file")
} }
@@ -222,7 +334,7 @@ func TestRenderPipeline_WriteFileFails(t *testing.T) {
Stages: []string{"build"}, Stages: []string{"build"},
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}}, Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
} }
_, err := RenderPipeline(p, dir) _, err := RenderPipeline(p, dir, nil)
if err == nil { if err == nil {
t.Error("expected error when writing SVG to read-only directory") t.Error("expected error when writing SVG to read-only directory")
} }
@@ -241,7 +353,7 @@ func TestRenderPipeline_SVGFallback(t *testing.T) {
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}}, Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
} }
dir := t.TempDir() dir := t.TempDir()
path, err := RenderPipeline(p, dir) path, err := RenderPipeline(p, dir, nil)
if err != nil { if err != nil {
t.Fatalf("RenderPipeline: %v", err) t.Fatalf("RenderPipeline: %v", err)
} }
@@ -264,7 +376,7 @@ func TestConvertToPNG_NoConverter(t *testing.T) {
func TestConvertToPNG_FakeConverter(t *testing.T) { func TestConvertToPNG_FakeConverter(t *testing.T) {
// Install a fake rsvg-convert that just creates the output file and exits 0. // Install a fake rsvg-convert that just creates the output file and exits 0.
// This exercises the "return true" branch (line 72) and os.Remove in RenderPipeline. // This exercises the "return true" branch and os.Remove in RenderPipeline.
binDir := t.TempDir() binDir := t.TempDir()
script := "#!/bin/sh\n# rsvg-convert --output <out> <in>\ncp \"$3\" \"$2\"\n" script := "#!/bin/sh\n# rsvg-convert --output <out> <in>\ncp \"$3\" \"$2\"\n"
fakeBin := binDir + "/rsvg-convert" fakeBin := binDir + "/rsvg-convert"
@@ -303,7 +415,7 @@ func TestRenderPipeline_PNGConversion(t *testing.T) {
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}}, Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
} }
dir := t.TempDir() dir := t.TempDir()
path, err := RenderPipeline(p, dir) path, err := RenderPipeline(p, dir, nil)
if err != nil { if err != nil {
t.Fatalf("RenderPipeline: %v", err) t.Fatalf("RenderPipeline: %v", err)
} }
@@ -315,7 +427,7 @@ func TestRenderPipeline_PNGConversion(t *testing.T) {
// ── pipelineSVG coverage gaps ───────────────────────────────────────────────── // ── pipelineSVG coverage gaps ─────────────────────────────────────────────────
func TestPipelineSVG_LShapedElbow(t *testing.T) { func TestPipelineSVG_LShapedElbow(t *testing.T) {
// Classic mode with unequal stage sizes → different column heights → L-shaped elbow // Classic mode with unequal stage sizes → vertical bus bar required
p := &model.Pipeline{ p := &model.Pipeline{
Stages: []string{"build", "test"}, Stages: []string{"build", "test"},
Jobs: map[string]model.Job{ Jobs: map[string]model.Job{
@@ -324,9 +436,10 @@ func TestPipelineSVG_LShapedElbow(t *testing.T) {
"j3": {Name: "j3", Stage: "test"}, "j3": {Name: "j3", Stage: "test"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<polyline") { // The bus bar is a <line>; arrowheads are <polygon>
t.Error("expected <polyline> for L-shaped elbow connector") if !strings.Contains(svg, "<polygon") {
t.Error("expected <polygon> arrowhead in bus-bar connector")
} }
} }
@@ -339,9 +452,214 @@ func TestPipelineSVG_DAGNeedNotInPositionMap(t *testing.T) {
".hidden-template": {Name: ".hidden-template", Stage: "build"}, ".hidden-template": {Name: ".hidden-template", Stage: "build"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
// Should render without panic; .hidden-template is not visible so no connector drawn. // Should render without panic; .hidden-template is not visible so no connector drawn.
if !strings.Contains(svg, "<svg") { if !strings.Contains(svg, "<svg") {
t.Error("expected valid SVG output") t.Error("expected valid SVG output")
} }
} }
// ── RenderHTML ────────────────────────────────────────────────────────────────
func TestRenderHTML(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
},
}
dir := t.TempDir()
path, err := RenderHTML(p, dir, nil)
if err != nil {
t.Fatalf("RenderHTML: %v", err)
}
if !strings.HasSuffix(path, ".html") {
t.Errorf("expected .html output, got %q", path)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading output: %v", err)
}
html := string(data)
if !strings.Contains(html, "<!DOCTYPE html>") { t.Error("expected DOCTYPE") }
if !strings.Contains(html, "<svg") { t.Error("expected embedded <svg") }
if !strings.Contains(html, "build-job") { t.Error("expected job name in HTML") }
if !strings.Contains(html, "applyTransform") { t.Error("expected JS pan/zoom function") }
if !strings.Contains(html, "sidebar") { t.Error("expected sidebar element") }
}
func TestRenderHTML_InvalidDir(t *testing.T) {
dir := t.TempDir()
filePath := dir + "/notadir"
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
_, err := RenderHTML(&model.Pipeline{}, filePath+"/nested", nil)
if err == nil {
t.Error("expected error writing to path under a file")
}
}
func TestRenderHTML_WriteFileFails(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping as root — file permissions don't apply")
}
dir := t.TempDir()
if err := os.Chmod(dir, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(dir, 0o755) })
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
}
_, err := RenderHTML(p, dir, nil)
if err == nil {
t.Error("expected error when writing HTML to read-only directory")
}
}
// ── computeColumns ────────────────────────────────────────────────────────────
func TestComputeColumns_NoIntraNeeds(t *testing.T) {
// No intra-stage needs → one column per stage.
stages := []string{"build", "test"}
byStage := map[string][]string{
"build": {"build-a", "build-b"},
"test": {"test-a"},
}
jobs := map[string]model.Job{
"build-a": {Name: "build-a", Stage: "build"},
"build-b": {Name: "build-b", Stage: "build"},
"test-a": {Name: "test-a", Stage: "test"},
}
cols := computeColumns(stages, byStage, jobs)
if len(cols) != 2 {
t.Fatalf("expected 2 columns (one per stage), got %d", len(cols))
}
if cols[0].stage != "build" || len(cols[0].jobs) != 2 {
t.Errorf("col[0]: got stage=%q jobs=%v", cols[0].stage, cols[0].jobs)
}
if cols[1].stage != "test" || len(cols[1].jobs) != 1 {
t.Errorf("col[1]: got stage=%q jobs=%v", cols[1].stage, cols[1].jobs)
}
}
func TestComputeColumns_IntraNeeds(t *testing.T) {
// job-b depends on job-a in the same stage → split into 2 sub-columns.
stages := []string{"build"}
byStage := map[string][]string{
"build": {"job-a", "job-b"},
}
jobs := map[string]model.Job{
"job-a": {Name: "job-a", Stage: "build"},
"job-b": {Name: "job-b", Stage: "build", Needs: []any{"job-a"}},
}
cols := computeColumns(stages, byStage, jobs)
if len(cols) != 2 {
t.Fatalf("expected 2 sub-columns, got %d", len(cols))
}
// Both belong to the same stage.
if cols[0].stage != "build" || cols[1].stage != "build" {
t.Error("both sub-columns should be in stage 'build'")
}
// job-a has no same-stage deps → depth 0 → first sub-column.
if len(cols[0].jobs) != 1 || cols[0].jobs[0] != "job-a" {
t.Errorf("col[0] should contain job-a, got %v", cols[0].jobs)
}
// job-b depends on job-a → depth 1 → second sub-column.
if len(cols[1].jobs) != 1 || cols[1].jobs[0] != "job-b" {
t.Errorf("col[1] should contain job-b, got %v", cols[1].jobs)
}
}
func TestComputeColumns_EmptyStageSkipped(t *testing.T) {
// Stages with no jobs are silently skipped.
stages := []string{"build", "empty", "test"}
byStage := map[string][]string{
"build": {"j1"},
"test": {"j2"},
}
jobs := map[string]model.Job{
"j1": {Name: "j1", Stage: "build"},
"j2": {Name: "j2", Stage: "test"},
}
cols := computeColumns(stages, byStage, jobs)
if len(cols) != 2 {
t.Fatalf("expected 2 columns (empty stage skipped), got %d", len(cols))
}
}
func TestComputeColumns_CrossStageNeedIgnored(t *testing.T) {
// job-b has needs: for both job-a (same stage) and prev-job (different stage).
// The cross-stage need must be ignored when computing topological depth.
stages := []string{"build"}
byStage := map[string][]string{
"build": {"job-a", "job-b"},
}
jobs := map[string]model.Job{
"job-a": {Name: "job-a", Stage: "build"},
"job-b": {Name: "job-b", Stage: "build", Needs: []any{"job-a", "prev-job"}},
"prev-job": {Name: "prev-job", Stage: "prepare"},
}
cols := computeColumns(stages, byStage, jobs)
// Still split into 2 sub-columns; the cross-stage need is ignored.
if len(cols) != 2 {
t.Fatalf("expected 2 sub-columns, got %d", len(cols))
}
if cols[0].jobs[0] != "job-a" {
t.Errorf("expected job-a in first sub-column, got %v", cols[0].jobs)
}
if cols[1].jobs[0] != "job-b" {
t.Errorf("expected job-b in second sub-column, got %v", cols[1].jobs)
}
}
func TestComputeColumns_CycleGuard(t *testing.T) {
// Intra-stage cycle must not hang (cycle guard returns depth 0).
stages := []string{"build"}
byStage := map[string][]string{
"build": {"a", "b"},
}
jobs := map[string]model.Job{
"a": {Name: "a", Stage: "build", Needs: []any{"b"}},
"b": {Name: "b", Stage: "build", Needs: []any{"a"}},
}
cols := computeColumns(stages, byStage, jobs)
// Should return without hanging; exact column count is implementation-defined.
if len(cols) == 0 {
t.Error("expected at least one column")
}
}
func TestPipelineSVG_IntraStageDAG(t *testing.T) {
// job-b depends on job-a in the same stage → SVG should render both,
// placed as sub-columns (sub-stage gap between them).
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"job-a": {Name: "job-a", Stage: "build"},
"job-b": {Name: "job-b", Stage: "build", Needs: []any{"job-a"}},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "job-a") { t.Error("expected job-a in SVG") }
if !strings.Contains(svg, "job-b") { t.Error("expected job-b in SVG") }
// DAG mode: bezier connectors between the two jobs.
if !strings.Contains(svg, "<path") {
t.Error("expected bezier <path> connector between intra-stage jobs")
}
}
// ── htmlPage ──────────────────────────────────────────────────────────────────
func TestHtmlPage(t *testing.T) {
svgContent := `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect/></svg>`
html := htmlPage(svgContent)
if !strings.Contains(html, "<!DOCTYPE html>") { t.Error("expected DOCTYPE") }
if !strings.Contains(html, svgContent) { t.Error("expected SVG embedded in HTML") }
if !strings.Contains(html, "id=\"viewport\"") { t.Error("expected viewport element") }
if !strings.Contains(html, "id=\"sidebar\"") { t.Error("expected sidebar element") }
if !strings.Contains(html, "applyTransform") { t.Error("expected JS transform function") }
}
+37
View File
@@ -816,4 +816,41 @@ my-job:
my-job: my-job:
script: echo hi`, script: echo hi`,
}, },
RuleRulesNeedsUnknown: {
Title: "'rules:needs:' references unknown job",
Severity: Error,
Description: "A job listed in a rule's 'needs:' override does not exist in " +
"the pipeline. 'rules:needs:' (GitLab CI 16.4+) overrides the top-level " +
"'needs:' list when that specific rule matches. GitLab will refuse to " +
"create the pipeline if any referenced job is missing.",
Example: `stages: [build, test]
build:
stage: build
script: make
test:
stage: test
script: make test
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs: [build, lint] # lint does not exist`,
Fix: `stages: [build, test]
lint:
stage: build
script: make lint
build:
stage: build
script: make
test:
stage: test
script: make test
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs: [build, lint]`,
},
} }
+1
View File
@@ -62,6 +62,7 @@ func Lint(p *model.Pipeline, skipped map[string]bool) []Finding {
findings = append(findings, checkWorkflow(p)...) findings = append(findings, checkWorkflow(p)...)
findings = append(findings, checkJobs(p)...) findings = append(findings, checkJobs(p)...)
findings = append(findings, checkNeeds(p, skipped)...) findings = append(findings, checkNeeds(p, skipped)...)
findings = append(findings, checkRulesNeeds(p, skipped)...)
findings = append(findings, checkDependencies(p, skipped)...) findings = append(findings, checkDependencies(p, skipped)...)
findings = append(findings, checkVariableRefs(p)...) findings = append(findings, checkVariableRefs(p)...)
findings = append(findings, checkRulesIfReachability(p)...) findings = append(findings, checkRulesIfReachability(p)...)
+38
View File
@@ -85,6 +85,44 @@ func checkNeeds(p *model.Pipeline, skipped map[string]bool) []Finding {
return findings return findings
} }
// checkRulesNeeds validates rules:needs: entries across all jobs. Each entry
// in a rule's needs: list must reference a job that exists in the pipeline.
// Cross-pipeline needs (maps with a "pipeline" key) are ignored. Skipped jobs
// are excluded from checking (same semantics as top-level needs: via GL027).
func checkRulesNeeds(p *model.Pipeline, skipped map[string]bool) []Finding {
var findings []Finding
for name, job := range p.Jobs {
if skipped[name] {
continue
}
for i, rule := range job.Rules {
if len(rule.Needs) == 0 {
continue
}
for _, entry := range parseNeedEntries(rule.Needs) {
if _, exists := p.Jobs[entry.job]; !exists {
sev := Error
if entry.optional {
sev = Warning
}
findings = append(findings, Finding{
Severity: sev,
Rule: RuleRulesNeedsUnknown,
Job: name,
File: job.File,
Line: job.Line,
Message: fmt.Sprintf(
"rules[%d].needs: references unknown job %q",
i, entry.job,
),
})
}
}
}
}
return findings
}
// parseNeedEntries extracts needs entries from a needs: list, preserving the // parseNeedEntries extracts needs entries from a needs: list, preserving the
// optional flag. Each element is a plain string (job name) or a map with a // optional flag. Each element is a plain string (job name) or a map with a
// "job" key. Cross-pipeline needs (maps with a "pipeline" key) are skipped. // "job" key. Cross-pipeline needs (maps with a "pipeline" key) are skipped.
+123
View File
@@ -31,6 +31,129 @@ func TestCheckNeeds_SkippedJob(t *testing.T) {
} }
} }
// TestCheckRulesNeeds_UnknownJob verifies that an unknown job in rules:needs: produces GL044.
func TestCheckRulesNeeds_UnknownJob(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {
Name: "build-job",
Stage: "build",
Script: []any{"make"},
},
"test-job": {
Name: "test-job",
Stage: "test",
Script: []any{"make test"},
Rules: []model.Rule{
{
If: `$CI_COMMIT_BRANCH == "main"`,
Needs: []any{"build-job", "nonexistent"},
},
},
},
},
}
findings := checkRulesNeeds(p, nil)
var got bool
for _, f := range findings {
if f.Rule == RuleRulesNeedsUnknown && f.Severity == Error {
got = true
}
}
if !got {
t.Errorf("expected GL044 error for unknown rules:needs: job; got %v", findings)
}
}
// TestCheckRulesNeeds_KnownJob verifies no finding when all rules:needs: jobs exist.
func TestCheckRulesNeeds_KnownJob(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
"test-job": {
Name: "test-job",
Stage: "test",
Script: []any{"make test"},
Rules: []model.Rule{{Needs: []any{"build-job"}}},
},
},
}
if findings := checkRulesNeeds(p, nil); len(findings) != 0 {
t.Errorf("expected no findings for valid rules:needs:; got %v", findings)
}
}
// TestCheckRulesNeeds_Optional verifies that optional: true downgrades to Warning.
func TestCheckRulesNeeds_Optional(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {
Name: "test-job",
Script: []any{"make test"},
Rules: []model.Rule{
{Needs: []any{map[string]any{"job": "ghost", "optional": true}}},
},
},
},
}
findings := checkRulesNeeds(p, nil)
if len(findings) != 1 || findings[0].Severity != Warning {
t.Errorf("optional unknown rules:needs: should produce Warning; got %v", findings)
}
}
// TestCheckRulesNeeds_CrossPipeline verifies that cross-pipeline needs are ignored.
func TestCheckRulesNeeds_CrossPipeline(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {
Name: "test-job",
Script: []any{"make test"},
Rules: []model.Rule{
{Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}},
},
},
},
}
if findings := checkRulesNeeds(p, nil); len(findings) != 0 {
t.Errorf("cross-pipeline rules:needs: should be ignored; got %v", findings)
}
}
// TestCheckRulesNeeds_SkippedJob verifies that a skipped job's rules:needs: violations are suppressed.
func TestCheckRulesNeeds_SkippedJob(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {
Name: "test-job",
Script: []any{"make test"},
Rules: []model.Rule{{Needs: []any{"nonexistent"}}},
},
},
}
if findings := checkRulesNeeds(p, map[string]bool{"test-job": true}); len(findings) != 0 {
t.Errorf("skipped job: expected no findings; got %v", findings)
}
}
// TestCheckRulesNeeds_NoNeeds verifies no findings when rules have no needs: override.
func TestCheckRulesNeeds_NoNeeds(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {
Name: "test-job",
Script: []any{"make test"},
Rules: []model.Rule{{When: "on_success"}},
},
},
}
if findings := checkRulesNeeds(p, nil); len(findings) != 0 {
t.Errorf("rules without needs: should produce no findings; got %v", findings)
}
}
// TestCheckNeeds_StageOrder verifies that a job needing a job in a later stage // TestCheckNeeds_StageOrder verifies that a job needing a job in a later stage
// produces RuleNeedsStageOrder (line 64-76 in needs.go). // produces RuleNeedsStageOrder (line 64-76 in needs.go).
func TestCheckNeeds_StageOrder(t *testing.T) { func TestCheckNeeds_StageOrder(t *testing.T) {
+4
View File
@@ -154,4 +154,8 @@ const (
// pipeline has no 'default:' block, making the declaration a no-op. Also fires // pipeline has no 'default:' block, making the declaration a no-op. Also fires
// when 'inherit: default: [list]' names fields not set in the default: block. // when 'inherit: default: [list]' names fields not set in the default: block.
RuleInheritNoDefault = "GL043" RuleInheritNoDefault = "GL043"
// GL044: a rules:needs: entry references a job that does not exist in the pipeline.
// rules:needs: overrides the top-level needs: when a specific rule matches (GitLab CI 16.4+).
RuleRulesNeedsUnknown = "GL044"
) )
+1
View File
@@ -89,6 +89,7 @@ type Rule struct {
Changes any `yaml:"changes"` // []string or {paths,compare_to} map Changes any `yaml:"changes"` // []string or {paths,compare_to} map
Exists any `yaml:"exists"` // []string or map form Exists any `yaml:"exists"` // []string or map form
Variables map[string]any `yaml:"variables"` // set/override variables when rule matches (GitLab CI 15.0+) Variables map[string]any `yaml:"variables"` // set/override variables when rule matches (GitLab CI 15.0+)
Needs []any `yaml:"needs"` // override needs: when this rule matches (GitLab CI 16.4+)
} }
// ReservedKeys are top-level GitLab CI keys that are NOT job definitions. // ReservedKeys are top-level GitLab CI keys that are NOT job definitions.
+15
View File
@@ -0,0 +1,15 @@
stages:
- build
- test
build-job:
stage: build
script: make
test-job:
stage: test
script: make test
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
needs: [build-job, nonexistent-job] # nonexistent-job does not exist → GL044
- when: on_success
+19
View File
@@ -0,0 +1,19 @@
stages:
- build
- test
build-job:
stage: build
script: make
lint-job:
stage: build
script: make lint
test-job:
stage: test
script: make test
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
needs: [build-job, lint-job] # both exist → clean
- when: on_success