173 lines
3.9 KiB
Go
173 lines
3.9 KiB
Go
package graph
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.k3nny.fr/gitlab-sim/internal/model"
|
|
)
|
|
|
|
// Pipeline returns a Mermaid flowchart of pipeline jobs grouped by stage.
|
|
//
|
|
// Hidden template jobs (names starting with ".") are excluded.
|
|
// When any job has a needs: list, DAG mode is used and individual needs arrows
|
|
// are drawn. Otherwise, classic mode draws stage-ordering arrows between
|
|
// stage subgraphs.
|
|
func Pipeline(p *model.Pipeline) string {
|
|
var sb strings.Builder
|
|
w := func(s string) { sb.WriteString(s + "\n") }
|
|
wf := func(f string, a ...any) { fmt.Fprintf(&sb, f+"\n", a...) }
|
|
|
|
w("---")
|
|
w("title: Pipeline")
|
|
w("---")
|
|
w("flowchart LR")
|
|
w(" classDef regular fill:#428fdc,stroke:#1068bf,color:#fff")
|
|
w(" classDef manual fill:#fc6d26,stroke:#e56b1f,color:#fff")
|
|
w(" classDef trigger fill:#6b4fbb,stroke:#5a3fa0,color:#fff")
|
|
w(" classDef delayed fill:#fca326,stroke:#d98a1e,color:#333")
|
|
w("")
|
|
|
|
// Collect visible (non-template) job names; sort for stable output.
|
|
var visible []string
|
|
for name := range p.Jobs {
|
|
if !strings.HasPrefix(name, ".") {
|
|
visible = append(visible, name)
|
|
}
|
|
}
|
|
sort.Strings(visible)
|
|
|
|
if len(visible) == 0 {
|
|
w(" empty[\"no jobs defined\"]")
|
|
return sb.String()
|
|
}
|
|
|
|
// Assign stable numeric IDs so graph output is deterministic.
|
|
jobID := make(map[string]string, len(visible))
|
|
for i, name := range visible {
|
|
jobID[name] = fmt.Sprintf("j%d", i+1)
|
|
}
|
|
|
|
// Group jobs by stage; fall back to "test" (GitLab default) when unset.
|
|
byStage := make(map[string][]string)
|
|
for _, name := range visible {
|
|
stage := p.Jobs[name].Stage
|
|
if stage == "" {
|
|
stage = "test"
|
|
}
|
|
byStage[stage] = append(byStage[stage], 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 {
|
|
stage := p.Jobs[name].Stage
|
|
if stage == "" {
|
|
stage = "test"
|
|
}
|
|
if !seen[stage] {
|
|
stages = append(stages, stage)
|
|
seen[stage] = true
|
|
}
|
|
}
|
|
|
|
// Emit one subgraph per stage.
|
|
for _, stage := range stages {
|
|
jobs := byStage[stage]
|
|
if len(jobs) == 0 {
|
|
continue
|
|
}
|
|
sort.Strings(jobs)
|
|
wf(" subgraph %s[\"%s\"]", stageID(stage), stage)
|
|
for _, name := range jobs {
|
|
wf(" %s[\"%s\"]:::%s", jobID[name], mermaidLabel(name), jobClass(p.Jobs[name]))
|
|
}
|
|
w(" end")
|
|
w("")
|
|
}
|
|
|
|
// DAG mode: at least one visible job has a needs: list.
|
|
dagMode := false
|
|
for _, name := range visible {
|
|
if len(p.Jobs[name].Needs) > 0 {
|
|
dagMode = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if dagMode {
|
|
w(" %% DAG: needs dependencies")
|
|
for _, name := range visible {
|
|
for _, need := range p.Jobs[name].Needs {
|
|
dep := needsJobName(need)
|
|
if dep == "" {
|
|
continue
|
|
}
|
|
if depID, ok := jobID[dep]; ok {
|
|
wf(" %s --> %s", depID, jobID[name])
|
|
}
|
|
}
|
|
}
|
|
} else if len(stages) > 1 {
|
|
w(" %% Classic: stage ordering")
|
|
ids := make([]string, len(stages))
|
|
for i, s := range stages {
|
|
ids[i] = stageID(s)
|
|
}
|
|
wf(" %s", strings.Join(ids, " --> "))
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func stageID(s string) string { return "stage_" + sanitizeID(s) }
|
|
|
|
func sanitizeID(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteByte('_')
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func jobClass(job model.Job) string {
|
|
if job.Trigger != nil {
|
|
return "trigger"
|
|
}
|
|
switch job.When {
|
|
case "manual":
|
|
return "manual"
|
|
case "delayed":
|
|
return "delayed"
|
|
}
|
|
return "regular"
|
|
}
|
|
|
|
func needsJobName(need any) string {
|
|
switch v := need.(type) {
|
|
case string:
|
|
return v
|
|
case map[string]any:
|
|
if _, cross := v["pipeline"]; cross {
|
|
return "" // cross-pipeline needs have no local counterpart
|
|
}
|
|
if j, ok := v["job"].(string); ok {
|
|
return j
|
|
}
|
|
}
|
|
return ""
|
|
}
|