Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a4d55ec9a |
@@ -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/).
|
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
|
## [0.2.25] - 2026-06-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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.25-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.
|
||||||
|
|||||||
+3
-1
@@ -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
|
- ~~**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
|
- ~~**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)
|
- ~~**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; 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 <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`
|
||||||
|
|||||||
+237
-109
@@ -16,21 +16,124 @@ import (
|
|||||||
|
|
||||||
// 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, ctx *cicontext.Context) (string, error) {
|
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
|
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
|
||||||
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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
@@ -199,30 +316,117 @@ func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) 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 – each wrapped in a <g> with tooltip metadata.
|
drawnHeader[col.stage] = true
|
||||||
for j, name := range jobs {
|
}
|
||||||
|
|
||||||
|
// ── 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(` <path d="M%d,%d C%d,%d %d,%d %d,%d" stroke="%s" stroke-width="2" fill="none"/>`,
|
||||||
|
src.x, src.y, cpX, src.y, cpX, dst.y, dst.x-7, dst.y, connStroke)
|
||||||
|
// Small right-pointing triangle arrowhead.
|
||||||
|
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
|
||||||
|
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(` <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]
|
job := p.Jobs[name]
|
||||||
chy := topPad + labelH + j*(chipH+chipGap)
|
chy := topPad + labelH + j*(chipH+chipGap)
|
||||||
isSkipped := skippedJobs[name]
|
isSkipped := skippedJobs[name]
|
||||||
|
cx := colX[ci]
|
||||||
|
|
||||||
// Build <desc> content: shown in the HTML sidebar and SVG viewer tooltips.
|
// Build <desc> content: shown in the HTML sidebar and SVG viewer tooltips.
|
||||||
desc := "stage: " + stage
|
desc := "stage: " + col.stage
|
||||||
if job.When != "" {
|
if job.When != "" {
|
||||||
desc += "\nwhen: " + 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(` <path d="M%d,%d C%d,%d %d,%d %d,%d" stroke="%s" stroke-width="2" fill="none"/>`,
|
|
||||||
src.x, src.y, cpX, src.y, cpX, dst.y, dst.x-7, dst.y, connStroke)
|
|
||||||
// Small right-pointing triangle arrowhead.
|
|
||||||
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
|
|
||||||
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(` <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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Legend ────────────────────────────────────────────────────────────────
|
// ── Legend ────────────────────────────────────────────────────────────────
|
||||||
legend := []struct{ color, label string }{
|
legend := []struct{ color, label string }{
|
||||||
{"#1f75cb", "regular"},
|
{"#1f75cb", "regular"},
|
||||||
|
|||||||
@@ -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, "<path") {
|
||||||
|
t.Error("expected bezier <path> connector between intra-stage jobs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── htmlPage ──────────────────────────────────────────────────────────────────
|
// ── htmlPage ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestHtmlPage(t *testing.T) {
|
func TestHtmlPage(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user