package graph import ( "fmt" "sort" "strings" "git.k3nny.fr/glint/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 "" }