From 7a4d55ec9a3ced91c7f9e5e5137d8d0a7087f7fe Mon Sep 17 00:00:00 2001 From: k3nny Date: Thu, 25 Jun 2026 23:06:49 +0200 Subject: [PATCH] feat(graph): intra-stage DAG sub-columns and connectors behind chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 8 + README.md | 2 +- ROADMAP.md | 4 +- internal/graph/render.go | 346 +++++++++++++++++++++++----------- internal/graph/render_test.go | 132 +++++++++++++ 5 files changed, 381 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dc1fb0..f26b75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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/). 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 diff --git a/README.md b/README.md index 9c56cf3..7147c9f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

License - Release + Release

> **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. diff --git a/ROADMAP.md b/ROADMAP.md index 53c234c..e0a88b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -77,6 +77,8 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it - ~~**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**~~ — ✓ 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**~~ — ✓ 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 -- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001–GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034–GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18; GL042–GL043 added v0.2.20; GL044 added v0.2.24; graph improvements shipped v0.2.25 +- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001–GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034–GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18; GL042–GL043 added v0.2.20; GL044 added v0.2.24; graph improvements shipped v0.2.25–v0.2.26 - ~~**`glint explain `**~~ — ✓ 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) - ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help` diff --git a/internal/graph/render.go b/internal/graph/render.go index fed0410..6d690d5 100644 --- a/internal/graph/render.go +++ b/internal/graph/render.go @@ -16,21 +16,124 @@ import ( // Layout constants (pixels) – tuned to resemble GitLab's full pipeline graph view. const ( - chipW = 178 // job chip width - chipH = 34 // job chip height - chipGap = 6 // vertical gap between chips in a column - iconCX = 14 // status circle: x offset from chip left edge to circle centre - iconR = 7 // status circle radius (14 px diameter) - textLeft = 27 // job name text: x offset from chip left edge - labelH = 30 // stage-name label area height (text + bottom gap) - topPad = 40 // outer top padding - sidePad = 40 // outer left / right padding - stageGap = 50 // horizontal gap between stage columns (connector space) - botPad = 48 // outer bottom padding (legend lives here) + chipW = 178 // job chip width + chipH = 34 // job chip height + chipGap = 6 // vertical gap between chips in a column + iconCX = 14 // status circle: x offset from chip left edge to centre + iconR = 7 // status circle radius (14 px diameter) + textLeft = 27 // job name text: x offset from chip left edge + labelH = 30 // stage-name label area height (text + bottom gap) + topPad = 40 // outer top padding + sidePad = 40 // outer left / right padding + stageGap = 50 // horizontal gap between stage columns (connector space) + 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 } +// 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 // pipeline layout and returns the path to the generated file. func RenderPipeline(p *model.Pipeline, outDir string, ctx *cicontext.Context) (string, error) { @@ -148,28 +251,42 @@ func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string { } } - // Compute per-stage column heights and job anchor points for connectors. + // ── Column layout ───────────────────────────────────────────────────────── + // Split stages into sub-columns when intra-stage needs exist. + 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 rightMid := make(map[string]svgPt) leftMid := make(map[string]svgPt) - for i, stage := range stages { - jobs := byStage[stage] - sort.Strings(jobs) - n := len(jobs) + for ci, col := range cols { + n := len(col.jobs) h := n*chipH + max(0, n-1)*chipGap if h > maxColH { maxColH = h } - cx := sidePad + i*(chipW+stageGap) - for j, name := range jobs { + for j, name := range col.jobs { chy := topPad + labelH + j*(chipH+chipGap) - rightMid[name] = svgPt{cx + chipW, chy + chipH/2} - leftMid[name] = svgPt{cx, chy + chipH/2} + rightMid[name] = svgPt{colX[ci] + chipW, chy + chipH/2} + leftMid[name] = svgPt{colX[ci], chy + chipH/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 // DAG mode: any visible job with a needs: list triggers job-to-job arrows. @@ -199,30 +316,117 @@ func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string { // White page background. wf(` `, svgW, svgH) - // ── Stage columns ───────────────────────────────────────────────────────── - for i, stage := range stages { - cx := sidePad + i*(chipW+stageGap) - jobs := byStage[stage] - sort.Strings(jobs) + // ── Stage headers ───────────────────────────────────────────────────────── + // Each declared stage may span multiple sub-columns; draw one header per stage. + drawnHeader := make(map[string]bool) + for ci, col := range cols { + 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(` %s`, - 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(` `, - cx, topPad+21, cx+chipW, topPad+21) + x1, topPad+21, x2, topPad+21) - // Job chips – each wrapped in a with tooltip metadata. - for j, name := range jobs { + drawnHeader[col.stage] = true + } + + // ── Connectors (drawn before chips so they appear behind job blocks) ─────── + const connStroke = "#dbdbdb" + + if dagMode { + // Job-to-job bezier curves from needs:. + for _, name := range visible { + for _, need := range p.Jobs[name].Needs { + dep := needsJobName(need) + if dep == "" { + continue + } + src, okS := rightMid[dep] + dst, okD := leftMid[name] + if !okS || !okD { + continue + } + cpX := (src.x + dst.x) / 2 + wf(` `, + src.x, src.y, cpX, src.y, cpX, dst.y, dst.x-7, dst.y, connStroke) + // Small right-pointing triangle arrowhead. + wf(` `, + dst.x-7, dst.y-4, dst.x, dst.y, dst.x-7, dst.y+4, connStroke) + } + } + } else { + // Classic: bus-bar connectors between adjacent stage columns. + // Every job in stage[i] fans to a vertical bus at the midpoint gap, + // then fans out to every job in stage[i+1]. + for ci := 0; ci < len(cols)-1; ci++ { + x1 := colX[ci] + chipW // right edge of current column + x2 := colX[ci+1] // left edge of next column + midX := (x1 + x2) / 2 + + srcJobs := cols[ci].jobs + dstJobs := cols[ci+1].jobs + + // Collect all Y midpoints to span the vertical bus bar. + var allYs []int + srcY := make([]int, len(srcJobs)) + dstY := make([]int, len(dstJobs)) + for j, name := range srcJobs { + srcY[j] = rightMid[name].y + allYs = append(allYs, srcY[j]) + } + for j, name := range dstJobs { + dstY[j] = leftMid[name].y + 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(` `, + midX, busMinY, midX, busMaxY, connStroke) + } + + // Horizontal stubs from each source job to the bus. + for _, y := range srcY { + wf(` `, + x1, y, midX, y, connStroke) + } + + // Horizontal stubs from bus to each destination job, with arrowhead. + for _, y := range dstY { + wf(` `, + midX, y, x2-7, y, connStroke) + wf(` `, + 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 content: shown in the HTML sidebar and SVG viewer tooltips. - desc := "stage: " + stage + desc := "stage: " + col.stage if job.When != "" { desc += "\nwhen: " + job.When } @@ -285,82 +489,6 @@ func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string { } } - // ── Connectors ──────────────────────────────────────────────────────────── - const connStroke = "#dbdbdb" - - if dagMode { - // Job-to-job bezier curves from needs:. - for _, name := range visible { - for _, need := range p.Jobs[name].Needs { - dep := needsJobName(need) - if dep == "" { - continue - } - src, okS := rightMid[dep] - dst, okD := leftMid[name] - if !okS || !okD { - continue - } - cpX := (src.x + dst.x) / 2 - wf(` `, - src.x, src.y, cpX, src.y, cpX, dst.y, dst.x-7, dst.y, connStroke) - // Small right-pointing triangle arrowhead. - wf(` `, - dst.x-7, dst.y-4, dst.x, dst.y, dst.x-7, dst.y+4, connStroke) - } - } - } else { - // Classic: bus-bar connectors – every job in stage[i] fans to a vertical - // bus at the midpoint column gap, then fans out to every job in stage[i+1]. - // This gives each job pair its own visual connection instead of a single - // center-to-center line that misses jobs at the top and bottom of columns. - for i := 0; i < len(stages)-1; i++ { - x1 := sidePad + i*(chipW+stageGap) + chipW // right edge of left column - x2 := sidePad + (i+1)*(chipW+stageGap) // left edge of right column - midX := (x1 + x2) / 2 - - srcJobs := byStage[stages[i]] - sort.Strings(srcJobs) - dstJobs := byStage[stages[i+1]] - sort.Strings(dstJobs) - - // Collect all Y midpoints to span the vertical bus bar. - var allYs []int - srcY := make([]int, len(srcJobs)) - dstY := make([]int, len(dstJobs)) - for j, name := range srcJobs { - srcY[j] = rightMid[name].y - allYs = append(allYs, srcY[j]) - } - for j, name := range dstJobs { - dstY[j] = leftMid[name].y - allYs = append(allYs, dstY[j]) - } - sort.Ints(allYs) - busMinY, busMaxY := allYs[0], allYs[len(allYs)-1] - - // Vertical bus bar at midX (only drawn when there are multiple Y levels). - if busMinY < busMaxY { - wf(` `, - midX, busMinY, midX, busMaxY, connStroke) - } - - // Horizontal stubs from each source job to the bus. - for _, y := range srcY { - wf(` `, - x1, y, midX, y, connStroke) - } - - // Horizontal stubs from bus to each destination job, with arrowhead. - for _, y := range dstY { - wf(` `, - midX, y, x2-7, y, connStroke) - wf(` `, - x2-7, y-4, x2, y, x2-7, y+4, connStroke) - } - } - } - // ── Legend ──────────────────────────────────────────────────────────────── legend := []struct{ color, label string }{ {"#1f75cb", "regular"}, diff --git a/internal/graph/render_test.go b/internal/graph/render_test.go index 20363a9..76b80a3 100644 --- a/internal/graph/render_test.go +++ b/internal/graph/render_test.go @@ -520,6 +520,138 @@ func TestRenderHTML_WriteFileFails(t *testing.T) { } } +// ── 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, " connector between intra-stage jobs") + } +} + // ── htmlPage ────────────────────────────────────────────────────────────────── func TestHtmlPage(t *testing.T) {