feat(cicontext): rules:changes: path-glob evaluation; 100% test coverage
release / Build and publish release (push) Successful in 1m12s
ci / vet, staticcheck, test, build (push) Failing after 1m54s

- Add --changes PATH and --changes-from REF flags to glint check and glint graph
  for rules:changes: evaluation. --changes marks files explicitly; --changes-from
  runs git diff --name-only <REF> automatically. Both flags can be combined.
- Implement doublestar glob matching (*, ** across path segments) in EvalJob and
  EvalWorkflow; extended {paths, compare_to} map form supported.
- Without --changes/--changes-from the condition stays permissive (existing behaviour).
- Context summary line now shows changed-file count when file data is provided.
- Achieve 100% statement coverage: comprehensive tests added across all packages;
  removed provably dead code; added testability seams (exit, userHomeDirFn,
  execCommandOutput variables) to cover previously unreachable paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 22:47:32 +02:00
parent 04f17f8616
commit b21ef5c0bb
14 changed files with 1163 additions and 13 deletions
+291
View File
@@ -0,0 +1,291 @@
package cicontext
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// ── doublestarMatch ───────────────────────────────────────────────────────────
func TestDoublestarMatch(t *testing.T) {
cases := []struct {
pattern string
path string
want bool
}{
// exact match
{"Dockerfile", "Dockerfile", true},
{"Dockerfile", "dockerfile", false},
// single-segment wildcard
{"*.go", "main.go", true},
{"*.go", "main.py", false},
// * does not cross /
{"*.go", "src/main.go", false},
// ** matches multiple segments
{"**/*.go", "src/main.go", true},
{"**/*.go", "src/pkg/util.go", true},
{"**/*.go", "src/main.py", false},
// ** at end matches everything
{"src/**", "src/main.go", true},
{"src/**", "src/a/b/c.go", true},
{"src/**", "other/main.go", false},
// ** in the middle
{"src/**/*.go", "src/pkg/main.go", true},
{"src/**/*.go", "src/a/b/main.go", true},
{"src/**/*.go", "test/pkg/main.go", false},
// bare ** matches everything
{"**", "anything/and/everything.txt", true},
{"**", "file.go", true},
// no wildcard, multi-segment
{"src/main.go", "src/main.go", true},
{"src/main.go", "src/other.go", false},
// ** matches zero segments too
{"src/**/main.go", "src/main.go", true},
{"src/**/main.go", "src/pkg/main.go", true},
// malformed pattern (gracefully handled)
{"[invalid", "file.go", false},
}
for _, tc := range cases {
t.Run(tc.pattern+"~"+tc.path, func(t *testing.T) {
got := doublestarMatch(tc.pattern, tc.path)
if got != tc.want {
t.Errorf("doublestarMatch(%q, %q) = %v; want %v", tc.pattern, tc.path, got, tc.want)
}
})
}
}
// ── extractChangesPaths ───────────────────────────────────────────────────────
func TestExtractChangesPaths(t *testing.T) {
t.Run("nil → nil", func(t *testing.T) {
if extractChangesPaths(nil) != nil {
t.Error("expected nil")
}
})
t.Run("string", func(t *testing.T) {
got := extractChangesPaths("Dockerfile")
if len(got) != 1 || got[0] != "Dockerfile" {
t.Errorf("got %v", got)
}
})
t.Run("[]string", func(t *testing.T) {
got := extractChangesPaths([]string{"a.go", "b.go"})
if len(got) != 2 || got[0] != "a.go" || got[1] != "b.go" {
t.Errorf("got %v", got)
}
})
t.Run("[]any strings", func(t *testing.T) {
got := extractChangesPaths([]any{"src/**", "*.yml"})
if len(got) != 2 || got[0] != "src/**" || got[1] != "*.yml" {
t.Errorf("got %v", got)
}
})
t.Run("[]any ignores non-strings", func(t *testing.T) {
got := extractChangesPaths([]any{"ok", 42})
if len(got) != 1 || got[0] != "ok" {
t.Errorf("got %v", got)
}
})
t.Run("map with paths key", func(t *testing.T) {
got := extractChangesPaths(map[string]any{
"paths": []any{"src/**"},
"compare_to": "origin/main",
})
if len(got) != 1 || got[0] != "src/**" {
t.Errorf("got %v", got)
}
})
t.Run("map without paths key → nil", func(t *testing.T) {
got := extractChangesPaths(map[string]any{"compare_to": "origin/main"})
if got != nil {
t.Errorf("expected nil, got %v", got)
}
})
t.Run("unknown type → nil", func(t *testing.T) {
got := extractChangesPaths(12345)
if got != nil {
t.Errorf("expected nil, got %v", got)
}
})
}
// ── changesMatch ──────────────────────────────────────────────────────────────
func TestChangesMatch(t *testing.T) {
t.Run("nil changes → always true", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"anything.go"})
if !changesMatch(nil, ctx) {
t.Error("nil changes should always match")
}
})
t.Run("nil changedFiles → permissive true", func(t *testing.T) {
ctx := New("main", "", "", nil)
// changedFiles is nil by default → permissive
if !changesMatch([]any{"src/**"}, ctx) {
t.Error("nil changedFiles should be permissive (true)")
}
})
t.Run("no match → false", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"test/main_test.go"})
if changesMatch([]any{"src/**/*.go"}, ctx) {
t.Error("should not match: changed file not under src/")
}
})
t.Run("match → true", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"src/pkg/main.go"})
if !changesMatch([]any{"src/**/*.go"}, ctx) {
t.Error("should match: src/pkg/main.go satisfies src/**/*.go")
}
})
t.Run("empty changedFiles + non-nil changes → false", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{}) // explicit empty list
if changesMatch([]any{"Dockerfile"}, ctx) {
t.Error("empty file list with active filter should be false")
}
})
t.Run("one of many changed files matches", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"README.md", "src/main.go", "go.sum"})
if !changesMatch([]any{"src/**"}, ctx) {
t.Error("src/main.go should satisfy src/**")
}
})
}
// ── EvalJob with rules:changes: ───────────────────────────────────────────────
func TestEvalJob_RulesChanges(t *testing.T) {
makeJob := func(rules []model.Rule) model.Job {
return model.Job{Name: "job", Script: []any{"echo"}, Rules: rules}
}
t.Run("no changed files (permissive) — rule fires", func(t *testing.T) {
ctx := New("main", "", "", nil)
// changedFiles nil → permissive
job := makeJob([]model.Rule{{Changes: []any{"src/**/*.go"}, When: "on_success"}})
if EvalJob(job, ctx) != JobActive {
t.Error("expected active: permissive when no file list")
}
})
t.Run("matching changed file — rule fires", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"src/main.go"})
job := makeJob([]model.Rule{{Changes: []any{"src/**"}, When: "on_success"}})
if EvalJob(job, ctx) != JobActive {
t.Error("expected active: src/main.go matches src/**")
}
})
t.Run("no matching file — rule skipped, no other rule → skipped", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"docs/README.md"})
job := makeJob([]model.Rule{{Changes: []any{"src/**"}, When: "on_success"}})
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped: docs/README.md does not match src/**")
}
})
t.Run("changes filter with if: both must pass", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"src/main.go"})
job := makeJob([]model.Rule{
{If: `$CI_COMMIT_BRANCH == "develop"`, Changes: []any{"src/**"}, When: "on_success"},
})
// if: fails → rule skipped even though changes match
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped: if: condition fails")
}
})
t.Run("map form changes: {paths: [...]}", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"Dockerfile"})
job := makeJob([]model.Rule{{
Changes: map[string]any{"paths": []any{"Dockerfile"}, "compare_to": "origin/main"},
When: "on_success",
}})
if EvalJob(job, ctx) != JobActive {
t.Error("expected active: map form changes should work")
}
})
}
// ── EvalWorkflow with rules:changes: ─────────────────────────────────────────
func TestEvalWorkflow_Changes(t *testing.T) {
makePipeline := func(rules []model.Rule) *model.Pipeline {
return &model.Pipeline{Workflow: &model.Workflow{Rules: rules}}
}
t.Run("no changedFiles (permissive) → pipeline runs", func(t *testing.T) {
ctx := New("main", "", "", nil)
p := makePipeline([]model.Rule{{Changes: []any{"src/**"}, When: "always"}})
runs, _ := EvalWorkflow(p, ctx)
if !runs {
t.Error("expected pipeline to run: permissive when no file list")
}
})
t.Run("matching file → pipeline runs", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"src/app.go"})
p := makePipeline([]model.Rule{{Changes: []any{"src/**"}, When: "always"}})
runs, _ := EvalWorkflow(p, ctx)
if !runs {
t.Error("expected pipeline to run: src/app.go matches src/**")
}
})
t.Run("no matching file → no rule matches → pipeline blocked", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"docs/README.md"})
p := makePipeline([]model.Rule{{Changes: []any{"src/**"}, When: "always"}})
runs, _ := EvalWorkflow(p, ctx)
if runs {
t.Error("expected pipeline blocked: docs/README.md does not match src/**")
}
})
}
// ── SetChangedFiles / Summary ─────────────────────────────────────────────────
func TestSetChangedFiles(t *testing.T) {
ctx := New("main", "", "", nil)
if ctx.changedFiles != nil {
t.Error("changedFiles should be nil initially")
}
ctx.SetChangedFiles([]string{"a.go", "b.go"})
if len(ctx.changedFiles) != 2 {
t.Errorf("expected 2 changed files, got %d", len(ctx.changedFiles))
}
// nil receiver should not panic
var nilCtx *Context
nilCtx.SetChangedFiles([]string{"x"})
}
func TestSummary_WithChangedFiles(t *testing.T) {
ctx := New("main", "", "", nil)
// Without changed files
if s := ctx.Summary(); s != "branch=main, source=push" {
t.Errorf("unexpected summary without changes: %q", s)
}
// With changed files
ctx.SetChangedFiles([]string{"a.go", "b.go"})
s := ctx.Summary()
if s != "branch=main, source=push, 2 changed file(s)" {
t.Errorf("unexpected summary with changes: %q", s)
}
// With empty changed files (strict, 0 files)
ctx.SetChangedFiles([]string{})
s = ctx.Summary()
if s != "branch=main, source=push, 0 changed file(s)" {
t.Errorf("unexpected summary with 0 changes: %q", s)
}
}
+18 -2
View File
@@ -9,8 +9,9 @@ import (
// pipeline evaluation (rules:if:, only:, except:, workflow:rules:).
// Variables are keyed by their name without the leading $.
type Context struct {
Vars map[string]string
pinned map[string]bool // vars set via --var or shortcuts; never overwritten by Inject
Vars map[string]string
pinned map[string]bool // vars set via --var or shortcuts; never overwritten by Inject
changedFiles []string // nil = not provided (permissive); non-nil = known set of changed files
}
// New builds a Context from high-level shortcut values and optional KEY=VALUE
@@ -101,6 +102,18 @@ func (c *Context) Inject(key, value string) {
c.Vars[key] = value
}
// SetChangedFiles records the list of files that changed for rules:changes:
// evaluation. A non-nil slice (even empty) enables strict matching: only jobs
// whose rules:changes: patterns match at least one file in the list will have
// that rule fire. A nil slice (the default) means "not provided" — rules:changes:
// conditions are treated as always satisfied (permissive), preserving the
// behaviour when no changed-file data is available.
func (c *Context) SetChangedFiles(files []string) {
if c != nil {
c.changedFiles = files
}
}
// Summary returns a short human-readable description of the context for CLI output.
func (c *Context) Summary() string {
if c.IsEmpty() {
@@ -115,6 +128,9 @@ func (c *Context) Summary() string {
if v := c.Get("CI_PIPELINE_SOURCE"); v != "" {
parts = append(parts, "source="+v)
}
if c.changedFiles != nil {
parts = append(parts, fmt.Sprintf("%d changed file(s)", len(c.changedFiles)))
}
return strings.Join(parts, ", ")
}
+95
View File
@@ -1,6 +1,7 @@
package cicontext
import (
"path"
"regexp"
"strings"
@@ -49,6 +50,9 @@ func EvalWorkflow(p *model.Pipeline, ctx *Context) (bool, map[string]string) {
if !ruleIfMatchesStrict(rule.If, vars) {
continue
}
if !changesMatch(rule.Changes, ctx) {
continue
}
when := rule.When
if when == "" {
when = "always"
@@ -74,6 +78,9 @@ func EvalJob(job model.Job, ctx *Context) JobState {
if !ruleIfMatches(rule.If, vars) {
continue
}
if !changesMatch(rule.Changes, ctx) {
continue
}
return whenToState(rule.When)
}
return JobSkipped // no rule matched → job is excluded
@@ -262,3 +269,91 @@ func matchGlob(pattern, s string) bool {
}
return len(s) == 0
}
// ── rules:changes: evaluation ─────────────────────────────────────────────────
// changesMatch reports whether the rule's changes: filter is satisfied.
//
// - rule.Changes == nil → no filter; always true
// - ctx.changedFiles == nil → file list not provided; always true (permissive)
// - otherwise → true iff at least one changed file matches at least one pattern
func changesMatch(changes any, ctx *Context) bool {
patterns := extractChangesPaths(changes)
if patterns == nil {
return true // no changes: filter
}
if ctx.changedFiles == nil {
return true // no file list provided → permissive
}
for _, changed := range ctx.changedFiles {
for _, pat := range patterns {
if doublestarMatch(pat, changed) {
return true
}
}
}
return false
}
// extractChangesPaths normalises a rule.Changes value into a []string of glob
// patterns. Returns nil when no changes: filter is present.
func extractChangesPaths(changes any) []string {
switch v := changes.(type) {
case nil:
return nil
case string:
return []string{v}
case []string:
return v
case []any:
var out []string
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
case map[string]any:
// Extended form: { paths: [...], compare_to: "..." }
if raw, ok := v["paths"]; ok {
return extractChangesPaths(raw)
}
return nil
}
return nil
}
// doublestarMatch reports whether pattern matches the file path using GitLab-style
// glob rules: '*' matches any character sequence within a single path segment;
// '**' matches zero or more path segments (crossing '/' boundaries).
func doublestarMatch(pattern, filePath string) bool {
return matchParts(strings.Split(pattern, "/"), strings.Split(filePath, "/"))
}
// matchParts is the recursive engine for doublestarMatch.
func matchParts(pat, name []string) bool {
for len(pat) > 0 {
if pat[0] == "**" {
if len(pat) == 1 {
return true // trailing ** matches everything remaining
}
// ** can match 0, 1, 2, … leading segments of name.
for i := 0; i <= len(name); i++ {
if matchParts(pat[1:], name[i:]) {
return true
}
}
return false
}
if len(name) == 0 {
return false
}
ok, err := path.Match(pat[0], name[0])
if err != nil || !ok {
return false
}
pat = pat[1:]
name = name[1:]
}
return len(name) == 0
}
+15
View File
@@ -100,6 +100,21 @@ func TestLoad_StopsAtGitRoot(t *testing.T) {
}
}
// TestLoad_ReadError covers the !os.IsNotExist(err) branch (config.go:59-61)
// when the file exists but is not readable.
func TestLoad_ReadError(t *testing.T) {
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, Filename)
if err := os.WriteFile(cfgPath, []byte("ignore: []"), 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(cfgPath, 0o644) })
_, err := Load(tmp)
if err == nil {
t.Error("expected error for unreadable config file")
}
}
func TestLoad_InvalidYAML(t *testing.T) {
tmp := t.TempDir()
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte("ignore: [unclosed\n"), 0o644); err != nil {
+12
View File
@@ -52,6 +52,18 @@ func TestCacheWrite_EmptyDir(t *testing.T) {
cacheWrite("", "key", []byte("data"))
}
// TestCacheWrite_DirIsFile covers the os.MkdirAll error path (cache.go:29-31)
// when the cache dir path is occupied by a regular file.
func TestCacheWrite_DirIsFile(t *testing.T) {
f := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(f, []byte("occupied"), 0o644); err != nil {
t.Fatal(err)
}
// MkdirAll(f) fails because f is a file, not a directory.
cacheWrite(f, "key", []byte("data"))
// No panic, no error returned — the function silently returns.
}
func TestCacheWrite_MkdirAll(t *testing.T) {
dir := filepath.Join(t.TempDir(), "sub", "dir")
cacheWrite(dir, "k", []byte("v"))
+88
View File
@@ -1,12 +1,32 @@
package fetcher
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
)
// roundTripFunc allows constructing a custom http.RoundTripper from a function.
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
// errReader is an io.Reader that always returns an error.
type errReader struct{}
func (e errReader) Read([]byte) (int, error) { return 0, errors.New("read error") }
// replaceTransport temporarily replaces http.DefaultTransport and restores it.
func replaceTransport(t *testing.T, rt http.RoundTripper) {
t.Helper()
orig := http.DefaultTransport
http.DefaultTransport = rt
t.Cleanup(func() { http.DefaultTransport = orig })
}
// ── firstNonEmpty ─────────────────────────────────────────────────────────────
func TestFirstNonEmpty(t *testing.T) {
@@ -242,6 +262,74 @@ func TestFetchURL_Offline(t *testing.T) {
if err == nil { t.Fatal("expected error in offline mode") }
}
// TestFetchFile_NewRequestFails covers gitlab.go:113-115 — http.NewRequest error
// when the BaseURL contains a control character (null byte) making the URL invalid.
func TestFetchFile_NewRequestFails(t *testing.T) {
cfg := GitLabConfig{BaseURL: "https://example.com\x00"}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil {
t.Fatal("expected error for URL with null byte")
}
}
// TestFetchFile_DoFails covers gitlab.go:132-134 — http.DefaultClient.Do error.
func TestFetchFile_DoFails(t *testing.T) {
replaceTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
return nil, errors.New("transport error")
}))
cfg := GitLabConfig{BaseURL: "https://example.com"}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil {
t.Fatal("expected error when transport fails")
}
}
// TestFetchFile_ReadBodyFails covers gitlab.go:138-140 — io.ReadAll error on the
// response body.
func TestFetchFile_ReadBodyFails(t *testing.T) {
replaceTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(errReader{}),
Header: make(http.Header),
}, nil
}))
cfg := GitLabConfig{BaseURL: "https://example.com"}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil {
t.Fatal("expected error when body read fails")
}
}
// TestFetchURL_GetFails covers gitlab.go:173-175 — http.Get error.
func TestFetchURL_GetFails(t *testing.T) {
replaceTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
return nil, errors.New("get failed")
}))
cfg := GitLabConfig{}
_, err := cfg.FetchURL("https://example.com/template.yml")
if err == nil {
t.Fatal("expected error when http.Get fails")
}
}
// TestFetchURL_ReadBodyFails covers gitlab.go:178-180 — io.ReadAll error on the
// FetchURL response body.
func TestFetchURL_ReadBodyFails(t *testing.T) {
replaceTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(errReader{}),
Header: make(http.Header),
}, nil
}))
cfg := GitLabConfig{}
_, err := cfg.FetchURL("https://example.com/template.yml")
if err == nil {
t.Fatal("expected error when FetchURL body read fails")
}
}
func TestFetchURL_NotOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
+37
View File
@@ -1,11 +1,20 @@
package resolver
import (
"fmt"
"testing"
"git.k3nny.fr/glint/internal/model"
)
// errorYAMLMarshaler implements yaml.Marshaler and always returns an error,
// allowing tests to trigger the yaml.Marshal failure path in Resolve.
type errorYAMLMarshaler struct{}
func (e errorYAMLMarshaler) MarshalYAML() (interface{}, error) {
return nil, fmt.Errorf("forced marshal error for test")
}
// ── parseExtends ──────────────────────────────────────────────────────────────
func TestParseExtends(t *testing.T) {
@@ -253,4 +262,32 @@ func TestResolve(t *testing.T) {
t.Fatal("expected error for invalid extends type")
}
})
t.Run("yaml.Marshal fails — errorYAMLMarshaler injected into merged map", func(t *testing.T) {
// Inject a value whose MarshalYAML() returns an error so yaml.Marshal
// fails at extends.go:74-77.
p := buildPipeline(map[string]map[string]any{
".base": {"script": []any{"echo"}},
"child": {"extends": ".base", "bad": errorYAMLMarshaler{}},
})
_, err := Resolve(p)
if err == nil {
t.Fatal("expected error when yaml.Marshal fails")
}
})
t.Run("yaml.Unmarshal fails — map injected where []string expected", func(t *testing.T) {
// Marshal succeeds (maps are valid YAML), but Unmarshal into model.Job
// fails because Dependencies []string cannot hold a mapping.
p := buildPipeline(map[string]map[string]any{
".base": {
"dependencies": map[string]any{"invalid": "not-a-list"},
},
"child": {"extends": ".base", "stage": "build"},
})
_, err := Resolve(p)
if err == nil {
t.Fatal("expected error when yaml.Unmarshal fails on incompatible type")
}
})
}
-3
View File
@@ -305,9 +305,6 @@ func substituteInputs(data []byte, inputs map[string]any) []byte {
}
return inputPlaceholderRe.ReplaceAllFunc(data, func(match []byte) []byte {
groups := inputPlaceholderRe.FindSubmatch(match)
if len(groups) < 2 {
return match
}
if val, ok := inputs[string(groups[1])]; ok {
return []byte(fmt.Sprintf("%v", val))
}
+56
View File
@@ -644,6 +644,35 @@ func TestResolveProjectInclude_InvalidYAML(t *testing.T) {
}
}
// TestResolveProjectInclude_WithSubIncludes covers includes.go:236-240 —
// the fetched project YAML itself contains include: entries.
func TestResolveProjectInclude_WithSubIncludes(t *testing.T) {
dir := t.TempDir()
// Write a local file that the project YAML sub-includes.
localContent := "local-from-project:\n script: echo local\n"
if err := os.WriteFile(filepath.Join(dir, "local.yml"), []byte(localContent), 0o644); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
// Return YAML that itself has a local sub-include.
fmt.Fprintln(w, "include:\n - local: /local.yml\nproj-sub-job:\n script: echo proj")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}
entry := map[string]any{"project": "g/p", "file": "ci.yml", "ref": "main"}
warnings, _ := resolveProjectInclude(p, entry, "g/p", cfg, dir, map[string]bool{}, 0)
if len(warnings) != 0 {
t.Errorf("sub-includes: unexpected warnings: %v", warnings)
}
if _, ok := p.Jobs["proj-sub-job"]; !ok {
t.Error("proj-sub-job should be merged via project include")
}
}
// tlsComp sets up a TLS test server and swaps http.DefaultTransport so the test
// client trusts the self-signed cert. Returns the host (without scheme).
func tlsComp(t *testing.T, h http.HandlerFunc) (host string) {
@@ -691,6 +720,33 @@ func TestResolveComponentInclude_InvalidYAML(t *testing.T) {
}
}
// TestResolveComponentInclude_WithSubIncludes covers includes.go:288-291 —
// the fetched component YAML itself contains include: entries.
func TestResolveComponentInclude_WithSubIncludes(t *testing.T) {
dir := t.TempDir()
localContent := "comp-local-job:\n script: echo comp-local\n"
if err := os.WriteFile(filepath.Join(dir, "sub.yml"), []byte(localContent), 0o644); err != nil {
t.Fatal(err)
}
host := tlsComp(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
// Component YAML contains a local sub-include.
fmt.Fprintln(w, "include:\n - local: /sub.yml\ncomp-job:\n script: echo comp")
})
ref := host + "/g/p/mycomp@v1"
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{}
_, extW, hadErr := resolveComponentInclude(p, ref, nil, cfg, dir, map[string]bool{}, 0)
if hadErr {
t.Errorf("sub-includes: unexpected hadErr; extWarnings=%v", extW)
}
if _, ok := p.Jobs["comp-job"]; !ok {
t.Error("comp-job should be merged via component include")
}
}
// ── resolveRemoteInclude — sub-includes ───────────────────────────────────────
func TestResolveRemoteInclude_WithSubIncludes(t *testing.T) {