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:
@@ -7,6 +7,10 @@ This project uses [Semantic Versioning](https://semver.org).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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.
|
- **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.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.k3nny.fr/glint/internal/fetcher"
|
"git.k3nny.fr/glint/internal/fetcher"
|
||||||
@@ -11,6 +12,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Includes returns a Mermaid flowchart of the full include dependency tree.
|
// 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
|
// It recurses into project:, component:, and local: includes to expose
|
||||||
// transitive dependencies. Includes that cannot be fetched are shown but not expanded.
|
// transitive dependencies. Includes that cannot be fetched are shown but not expanded.
|
||||||
func Includes(sourcePath string, rawIncludes []any, cfg fetcher.GitLabConfig) string {
|
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,
|
cfg: cfg,
|
||||||
baseDir: filepath.Dir(sourcePath),
|
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)
|
b.buildChildren(root, rawIncludes)
|
||||||
return renderTree(root)
|
return renderTree(root)
|
||||||
}
|
}
|
||||||
@@ -29,6 +37,7 @@ type treeNode struct {
|
|||||||
label string
|
label string
|
||||||
class string
|
class string
|
||||||
children []*treeNode
|
children []*treeNode
|
||||||
|
jobs []string // job names defined directly in this file
|
||||||
}
|
}
|
||||||
|
|
||||||
// treeBuilder accumulates state while recursively traversing include entries.
|
// treeBuilder accumulates state while recursively traversing include entries.
|
||||||
@@ -126,7 +135,11 @@ func (b *treeBuilder) recurseLocal(node *treeNode, path string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
p, err := model.ParseBytes(data)
|
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
|
return
|
||||||
}
|
}
|
||||||
orig := b.baseDir
|
orig := b.baseDir
|
||||||
@@ -147,7 +160,11 @@ func (b *treeBuilder) recurseProject(node *treeNode, project, filePath, ref stri
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
p, err := model.ParseBytes(data)
|
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
|
return
|
||||||
}
|
}
|
||||||
b.buildChildren(node, p.Include)
|
b.buildChildren(node, p.Include)
|
||||||
@@ -172,7 +189,11 @@ func (b *treeBuilder) recurseComponent(node *treeNode, ref string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
p, err := model.ParseBytes(data)
|
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
|
return
|
||||||
}
|
}
|
||||||
b.buildChildren(node, p.Include)
|
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 local fill:#428fdc,stroke:#1068bf,color:#fff")
|
||||||
w(" classDef remote fill:#868686,stroke:#686868,color:#fff")
|
w(" classDef remote fill:#868686,stroke:#686868,color:#fff")
|
||||||
w(" classDef template fill:#fc6d26,stroke:#e56b1f,color:#fff")
|
w(" classDef template fill:#fc6d26,stroke:#e56b1f,color:#fff")
|
||||||
|
w(" classDef job fill:#f5f5ff,stroke:#7175a0,color:#333")
|
||||||
w("")
|
w("")
|
||||||
|
|
||||||
|
jobCounter := 0
|
||||||
var emit func(n *treeNode)
|
var emit func(n *treeNode)
|
||||||
emit = func(n *treeNode) {
|
emit = func(n *treeNode) {
|
||||||
wf(" %s[\"%s\"]:::%s", n.id, n.label, n.class)
|
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 {
|
for _, child := range n.children {
|
||||||
emit(child)
|
emit(child)
|
||||||
wf(" %s --> %s", n.id, child.id)
|
wf(" %s --> %s", n.id, child.id)
|
||||||
@@ -208,6 +237,29 @@ func renderTree(root *treeNode) string {
|
|||||||
return sb.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
|
// parseComponentRef parses a CI/CD component reference of the form
|
||||||
// <host>/<project-path>/<component-name>@<version>.
|
// <host>/<project-path>/<component-name>@<version>.
|
||||||
func parseComponentRef(ref string) (host, project, component, version string, err error) {
|
func parseComponentRef(ref string) (host, project, component, version string, err error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user