package graph import ( "fmt" "os" "os/exec" "path/filepath" "runtime" "sort" "strings" "time" "git.k3nny.fr/glint/internal/cicontext" "git.k3nny.fr/glint/internal/model" ) // 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 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) { if err := os.MkdirAll(outDir, 0o755); err != nil { return "", fmt.Errorf("creating output directory %s: %w", outDir, err) } svg := pipelineSVG(p, ctx) ts := time.Now().Format("20060102-150405") svgPath := filepath.Join(outDir, "pipeline-"+ts+".svg") if err := os.WriteFile(svgPath, []byte(svg), 0o644); err != nil { return "", fmt.Errorf("writing SVG: %w", err) } pngPath := filepath.Join(outDir, "pipeline-"+ts+".png") if convertToPNG(svgPath, pngPath) { _ = os.Remove(svgPath) return pngPath, 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). func convertToPNG(svgPath, pngPath string) bool { candidates := [][]string{ {"rsvg-convert", "--output", pngPath, svgPath}, {"inkscape", "--export-filename=" + pngPath, svgPath}, {"magick", svgPath, pngPath}, } // `convert` is the legacy ImageMagick name; skip on Windows where the name // collides with the built-in FAT→NTFS converter. if runtime.GOOS != "windows" { candidates = append(candidates, []string{"convert", svgPath, pngPath}) } for _, args := range candidates { if bin, err := exec.LookPath(args[0]); err == nil { if exec.Command(bin, args[1:]...).Run() == nil { return true } } } return false } func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string { // Collect visible (non-template) job names in sorted order. var visible []string for name := range p.Jobs { if !strings.HasPrefix(name, ".") { visible = append(visible, name) } } sort.Strings(visible) if len(visible) == 0 { 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. byStage := make(map[string][]string) for _, name := range visible { s := p.Jobs[name].Stage if s == "" { s = "test" } byStage[s] = append(byStage[s], name) } // Build ordered stage list: declared stages first, then undeclared extras. seen := make(map[string]bool) var stages []string for _, s := range p.Stages { if len(byStage[s]) > 0 && !seen[s] { stages = append(stages, s) seen[s] = true } } for _, name := range visible { s := p.Jobs[name].Stage if s == "" { s = "test" } if !seen[s] { stages = append(stages, s) seen[s] = true } } // ── 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 ci, col := range cols { n := len(col.jobs) h := n*chipH + max(0, n-1)*chipGap if h > maxColH { maxColH = h } for j, name := range col.jobs { chy := topPad + labelH + j*(chipH+chipGap) rightMid[name] = svgPt{colX[ci] + chipW, chy + chipH/2} leftMid[name] = svgPt{colX[ci], chy + chipH/2} } } 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. dagMode := false for _, name := range visible { if len(p.Jobs[name].Needs) > 0 { dagMode = true break } } var sb strings.Builder w := func(s string) { sb.WriteString(s + "\n") } wf := func(f string, a ...any) { fmt.Fprintf(&sb, f+"\n", a...) } // ── Document ────────────────────────────────────────────────────────────── wf(``, svgW, svgH, svgW, svgH) w(` `) // Subtle drop shadow for job chips. w(` `) w(` `) w(` `) w(` `) // White page background. wf(` `, svgW, svgH) // ── 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. wf(` %s`, centerX, topPad+13, svgEsc(strings.ToUpper(col.stage))) // Separator line spanning all sub-columns. wf(` `, x1, topPad+21, x2, topPad+21) 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: " + 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(` `, svgEsc(name)) wf(` %s`, svgEsc(name)) wf(` %s`, 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(` `, cx, chy, chipW, chipH, dashAttr) // Colored status indicator circle. wf(` `, 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(` %s`, cx+textLeft, chy+chipH/2, textColor, svgEsc(svgTrunc(name, 20))) wf(` `) } } // ── Legend ──────────────────────────────────────────────────────────────── legend := []struct{ color, label string }{ {"#1f75cb", "regular"}, {"#fc6d26", "manual"}, {"#6b4fbb", "trigger"}, {"#fca326", "delayed"}, {"#d9534f", "on_failure"}, } const legendItemW = 82 legendY := topPad + labelH + maxColH + botPad/2 lx0 := (svgW - len(legend)*legendItemW) / 2 for k, item := range legend { lx := lx0 + k*legendItemW wf(` `, lx+6, legendY, item.color) wf(` %s`, lx+16, legendY, item.label) } w(``) return sb.String() } // drawChipIcon writes an SVG symbol inside the status circle to help identify // the job type at a glance. func drawChipIcon(sb *strings.Builder, job model.Job, cx, cy int) { if job.Trigger != nil { // Right-pointing chevron for trigger jobs. fmt.Fprintf(sb, " \n", cx-3, cy-3, cx+3, cy, cx-3, cy+3) return } switch job.When { case "manual": // Filled play triangle. fmt.Fprintf(sb, " \n", cx-3, cy-4, cx+5, cy, cx-3, cy+4) case "delayed": // Clock: circle + hands. fmt.Fprintf(sb, " \n", cx, cy) fmt.Fprintf(sb, " \n", cx, cy, cx, cy-3) fmt.Fprintf(sb, " \n", cx, cy, cx+2, cy+1) case "on_failure": // X mark for failure-path jobs. fmt.Fprintf(sb, " \n", cx-3, cy-3, cx+3, cy+3) fmt.Fprintf(sb, " \n", cx+3, cy-3, cx-3, cy+3) default: // Regular job: small white checkmark. fmt.Fprintf(sb, " \n", cx-3, cy, cx-1, cy+3, cx+4, cy-3) } } func chipColor(job model.Job) string { if job.Trigger != nil { return "#6b4fbb" } switch job.When { case "manual": return "#fc6d26" case "delayed": return "#fca326" case "on_failure": return "#d9534f" } 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 { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") return s } func svgTrunc(s string, maxLen int) string { runes := []rune(s) if len(runes) <= maxLen { return s } return string(runes[:maxLen-1]) + "…" } func svgEmpty() string { const w, h = 300, 80 return fmt.Sprintf( ``+ ``+ `no jobs defined`+ ``, 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(` Pipeline Graph
`) sb.WriteString(svgContent) sb.WriteString(`
`) return sb.String() }