feat(graph): show jobs per file in include dependency graph

Each node in 'glint graph includes' now lists the jobs defined directly
in that file. Jobs appear as rounded Mermaid nodes with a distinct
light-purple style, connected with dashed arrows (-.->). This visual
distinction separates ownership (file -.-> job) from the include
hierarchy (file --> included-file).

The root file's jobs are collected by re-parsing it without include
resolution; local and fetched project/component nodes populate their
job list in the existing recurse* methods.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:03:50 +02:00
parent e5f926b55f
commit a962c996c1
2 changed files with 60 additions and 4 deletions
+4
View File
@@ -7,6 +7,10 @@ This project uses [Semantic Versioning](https://semver.org).
## [Unreleased]
### Added
- **`glint graph includes` shows jobs per file** — each node in the Mermaid include dependency graph now shows the jobs defined directly in that file. Jobs are rendered as rounded nodes (`(name)`) in a distinct light-purple style, connected with dashed arrows (`-.->`) to distinguish ownership from the include hierarchy (solid `-->` arrows). The root pipeline file always shows its direct jobs; local and fetched project/component nodes show theirs when the file can be read.
### Fixed
- **Variable map form now parses correctly** — `variables:` entries that use the extended `{value, description, options}` form (GitLab CI 13.7+) no longer cause `yaml: cannot unmarshal !!map into string`. Both `Pipeline.Variables` and per-job `Variables` now accept either plain strings or map-form declarations.
+56 -4
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"git.k3nny.fr/glint/internal/fetcher"
@@ -11,6 +12,8 @@ import (
)
// Includes returns a Mermaid flowchart of the full include dependency tree.
// Each node shows the jobs defined directly in that file, connected with
// dashed arrows (solid arrows represent the include hierarchy itself).
// It recurses into project:, component:, and local: includes to expose
// transitive dependencies. Includes that cannot be fetched are shown but not expanded.
func Includes(sourcePath string, rawIncludes []any, cfg fetcher.GitLabConfig) string {
@@ -19,7 +22,12 @@ func Includes(sourcePath string, rawIncludes []any, cfg fetcher.GitLabConfig) st
cfg: cfg,
baseDir: filepath.Dir(sourcePath),
}
root := &treeNode{id: "root", label: mermaidLabel(sourcePath), class: "main"}
root := &treeNode{
id: "root",
label: mermaidLabel(sourcePath),
class: "main",
jobs: directJobs(sourcePath),
}
b.buildChildren(root, rawIncludes)
return renderTree(root)
}
@@ -29,6 +37,7 @@ type treeNode struct {
label string
class string
children []*treeNode
jobs []string // job names defined directly in this file
}
// treeBuilder accumulates state while recursively traversing include entries.
@@ -126,7 +135,11 @@ func (b *treeBuilder) recurseLocal(node *treeNode, path string) {
return
}
p, err := model.ParseBytes(data)
if err != nil || len(p.Include) == 0 {
if err != nil {
return
}
node.jobs = jobNames(p)
if len(p.Include) == 0 {
return
}
orig := b.baseDir
@@ -147,7 +160,11 @@ func (b *treeBuilder) recurseProject(node *treeNode, project, filePath, ref stri
return
}
p, err := model.ParseBytes(data)
if err != nil || len(p.Include) == 0 {
if err != nil {
return
}
node.jobs = jobNames(p)
if len(p.Include) == 0 {
return
}
b.buildChildren(node, p.Include)
@@ -172,7 +189,11 @@ func (b *treeBuilder) recurseComponent(node *treeNode, ref string) {
return
}
p, err := model.ParseBytes(data)
if err != nil || len(p.Include) == 0 {
if err != nil {
return
}
node.jobs = jobNames(p)
if len(p.Include) == 0 {
return
}
b.buildChildren(node, p.Include)
@@ -193,11 +214,19 @@ func renderTree(root *treeNode) string {
w(" classDef local fill:#428fdc,stroke:#1068bf,color:#fff")
w(" classDef remote fill:#868686,stroke:#686868,color:#fff")
w(" classDef template fill:#fc6d26,stroke:#e56b1f,color:#fff")
w(" classDef job fill:#f5f5ff,stroke:#7175a0,color:#333")
w("")
jobCounter := 0
var emit func(n *treeNode)
emit = func(n *treeNode) {
wf(" %s[\"%s\"]:::%s", n.id, n.label, n.class)
for _, jobName := range n.jobs {
jobCounter++
jobID := fmt.Sprintf("job%d", jobCounter)
wf(" %s(\"%s\"):::job", jobID, mermaidLabel(jobName))
wf(" %s -.-> %s", n.id, jobID)
}
for _, child := range n.children {
emit(child)
wf(" %s --> %s", n.id, child.id)
@@ -208,6 +237,29 @@ func renderTree(root *treeNode) string {
return sb.String()
}
// directJobs parses a single pipeline file (without include resolution) and
// returns the sorted list of job names defined directly in it.
func directJobs(path string) []string {
p, err := model.Parse(path)
if err != nil {
return nil
}
return jobNames(p)
}
// jobNames returns a sorted slice of all job names defined in p.
func jobNames(p *model.Pipeline) []string {
if len(p.Jobs) == 0 {
return nil
}
names := make([]string, 0, len(p.Jobs))
for name := range p.Jobs {
names = append(names, name)
}
sort.Strings(names)
return names
}
// parseComponentRef parses a CI/CD component reference of the form
// <host>/<project-path>/<component-name>@<version>.
func parseComponentRef(ref string) (host, project, component, version string, err error) {