feat(gitlab-sim): ✨ first commit
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.k3nny.fr/gitlab-sim/internal/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Resolve resolves all extends: references in p.Jobs in place.
|
||||
// Jobs are merged depth-first so that base definitions are resolved before
|
||||
// derived ones. Mutates p.Jobs with the fully merged Job structs.
|
||||
func Resolve(p *model.Pipeline) error {
|
||||
// Build extends graph: jobName -> ordered list of base job names.
|
||||
extendsGraph := make(map[string][]string, len(p.Jobs))
|
||||
for name, job := range p.Jobs {
|
||||
bases, err := parseExtends(job.Extends)
|
||||
if err != nil {
|
||||
return fmt.Errorf("job %q: invalid extends: %w", name, err)
|
||||
}
|
||||
if len(bases) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, base := range bases {
|
||||
if _, ok := p.RawJobs[base]; !ok {
|
||||
return fmt.Errorf("job %q extends unknown job %q", name, base)
|
||||
}
|
||||
}
|
||||
extendsGraph[name] = bases
|
||||
}
|
||||
|
||||
if len(extendsGraph) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Topological sort — bases must be resolved before derived jobs.
|
||||
order, err := topoSort(extendsGraph)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// resolved holds the final merged raw map for each processed job.
|
||||
resolved := make(map[string]map[string]any, len(order))
|
||||
|
||||
for _, name := range order {
|
||||
bases := extendsGraph[name]
|
||||
|
||||
// Merge bases left-to-right (later bases override earlier ones),
|
||||
// then overlay the job's own definition on top.
|
||||
merged := make(map[string]any)
|
||||
for _, baseName := range bases {
|
||||
baseMap := resolved[baseName]
|
||||
if baseMap == nil {
|
||||
baseMap = p.RawJobs[baseName]
|
||||
}
|
||||
merged = deepMerge(merged, baseMap)
|
||||
}
|
||||
merged = deepMerge(merged, p.RawJobs[name])
|
||||
resolved[name] = merged
|
||||
|
||||
// Re-decode the merged map into a Job struct.
|
||||
data, err := yaml.Marshal(merged)
|
||||
if err != nil {
|
||||
return fmt.Errorf("job %q: re-encoding merged definition: %w", name, err)
|
||||
}
|
||||
var j model.Job
|
||||
if err := yaml.Unmarshal(data, &j); err != nil {
|
||||
return fmt.Errorf("job %q: re-decoding merged definition: %w", name, err)
|
||||
}
|
||||
j.Name = name
|
||||
p.Jobs[name] = j
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseExtends normalises the extends field (string or []any) into []string.
|
||||
func parseExtends(extends any) ([]string, error) {
|
||||
if extends == nil {
|
||||
return nil, nil
|
||||
}
|
||||
switch v := extends.(type) {
|
||||
case string:
|
||||
return []string{v}, nil
|
||||
case []any:
|
||||
names := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected string item, got %T", item)
|
||||
}
|
||||
names = append(names, s)
|
||||
}
|
||||
return names, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected type %T", extends)
|
||||
}
|
||||
}
|
||||
|
||||
// topoSort returns the keys of graph in dependency order (bases first).
|
||||
// Returns an error if a cycle is detected.
|
||||
func topoSort(graph map[string][]string) ([]string, error) {
|
||||
const (
|
||||
unvisited = 0
|
||||
visiting = 1
|
||||
visited = 2
|
||||
)
|
||||
|
||||
state := make(map[string]int, len(graph))
|
||||
order := make([]string, 0, len(graph))
|
||||
|
||||
var visit func(name string) error
|
||||
visit = func(name string) error {
|
||||
switch state[name] {
|
||||
case visited:
|
||||
return nil
|
||||
case visiting:
|
||||
return fmt.Errorf("cycle in extends involving job %q", name)
|
||||
}
|
||||
state[name] = visiting
|
||||
for _, dep := range graph[name] {
|
||||
if _, inGraph := graph[dep]; inGraph {
|
||||
if err := visit(dep); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
state[name] = visited
|
||||
order = append(order, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
for name := range graph {
|
||||
if err := visit(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// deepMerge returns a new map that is override merged onto base.
|
||||
// Maps are merged recursively; all other values are replaced by override.
|
||||
func deepMerge(base, override map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(base)+len(override))
|
||||
for k, v := range base {
|
||||
result[k] = v
|
||||
}
|
||||
for k, v := range override {
|
||||
if baseVal, exists := result[k]; exists {
|
||||
if baseMap, ok := baseVal.(map[string]any); ok {
|
||||
if overMap, ok := v.(map[string]any); ok {
|
||||
result[k] = deepMerge(baseMap, overMap)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user