Files
glint/internal/cicontext/reachability.go
T
k3nny b21ef5c0bb
release / Build and publish release (push) Successful in 1m12s
ci / vet, staticcheck, test, build (push) Failing after 1m54s
feat(cicontext): rules:changes: path-glob evaluation; 100% test coverage
- 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>
2026-06-21 22:47:32 +02:00

360 lines
9.2 KiB
Go

package cicontext
import (
"path"
"regexp"
"strings"
"git.k3nny.fr/glint/internal/model"
)
// JobState describes whether a job would be included in a pipeline run for the
// given context.
type JobState int
const (
JobActive JobState = iota // job will run (on_success, always, delayed, …)
JobManual // job is gated behind a manual trigger
JobSkipped // job is excluded from the pipeline (when:never or no rule matched)
)
func (s JobState) String() string {
switch s {
case JobActive:
return "active"
case JobManual:
return "manual"
default:
return "skipped"
}
}
// EvalWorkflow evaluates the pipeline's workflow:rules block against ctx.
// Returns (runs, ruleVars):
// - runs=false means the pipeline would not start for this context.
// - ruleVars holds any variables: defined on the matching rule; inject these
// into the context so job rules can reference them.
//
// Returns (true, nil) when ctx is empty, when there is no workflow block, or
// when no rules are configured.
func EvalWorkflow(p *model.Pipeline, ctx *Context) (bool, map[string]string) {
if ctx.IsEmpty() || p.Workflow == nil || len(p.Workflow.Rules) == 0 {
return true, nil
}
vars := ctx.Get
for _, rule := range p.Workflow.Rules {
// Workflow rules use strict evaluation: an unparseable condition is
// treated as no-match so later rules (with valid conditions or a
// bare when:) are reached. Permissive-true would cause an early rule
// with a complex/invalid condition to block all subsequent rules.
if !ruleIfMatchesStrict(rule.If, vars) {
continue
}
if !changesMatch(rule.Changes, ctx) {
continue
}
when := rule.When
if when == "" {
when = "always"
}
return when != "never", ExtractStringVars(rule.Variables)
}
return false, nil // no rule matched → pipeline does not run
}
// EvalJob returns the effective JobState for job in the given context.
// When ctx is empty every job is treated as Active (preserves existing
// behaviour when no context flags are given).
func EvalJob(job model.Job, ctx *Context) JobState {
if ctx.IsEmpty() {
return JobActive
}
vars := ctx.Get
// rules: takes priority over only/except.
if len(job.Rules) > 0 {
for _, rule := range job.Rules {
if !ruleIfMatches(rule.If, vars) {
continue
}
if !changesMatch(rule.Changes, ctx) {
continue
}
return whenToState(rule.When)
}
return JobSkipped // no rule matched → job is excluded
}
// Legacy only:/except: filtering.
if job.Only != nil || job.Except != nil {
if !onlyMatches(job.Only, ctx) || exceptMatches(job.Except, ctx) {
return JobSkipped
}
}
return whenToState(job.When)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func ruleIfMatches(ifExpr string, vars func(string) string) bool {
if ifExpr == "" {
return true // no if: condition → rule always matches
}
return EvalIf(ifExpr, vars)
}
func ruleIfMatchesStrict(ifExpr string, vars func(string) string) bool {
if ifExpr == "" {
return true // no if: condition → rule always matches
}
return EvalIfStrict(ifExpr, vars)
}
func whenToState(when string) JobState {
switch when {
case "never":
return JobSkipped
case "manual":
return JobManual
default:
// on_success, always, delayed, "" (default) → active
return JobActive
}
}
// onlyMatches returns true when the job's only: filter is satisfied by ctx.
func onlyMatches(only any, ctx *Context) bool {
if only == nil {
return true // no only → runs everywhere
}
refs := extractRefList(only)
if refs == nil {
return true // unrecognised form → permissive
}
return refListMatches(refs, ctx)
}
// exceptMatches returns true when the job's except: filter excludes the job.
func exceptMatches(except any, ctx *Context) bool {
if except == nil {
return false // no except → nothing excluded
}
refs := extractRefList(except)
if refs == nil {
return false // unrecognised form → don't exclude
}
return refListMatches(refs, ctx)
}
// extractRefList normalises the only:/except: value into a flat []string.
// Returns nil for forms that cannot be statically evaluated.
func extractRefList(v any) []string {
switch x := v.(type) {
case string:
return []string{x}
case []any:
var refs []string
for _, item := range x {
if s, ok := item.(string); ok {
refs = append(refs, s)
}
}
return refs
case map[string]any:
// Map form may carry { refs: [...], variables: [...], kubernetes: ... }.
// We only evaluate the refs list; skip anything more complex.
if raw, ok := x["refs"]; ok {
return extractRefList(raw)
}
return nil
}
return nil
}
// refListMatches returns true when at least one entry in refs matches ctx.
func refListMatches(refs []string, ctx *Context) bool {
branch := ctx.Get("CI_COMMIT_BRANCH")
tag := ctx.Get("CI_COMMIT_TAG")
source := ctx.Get("CI_PIPELINE_SOURCE")
for _, ref := range refs {
switch ref {
case "branches":
if branch != "" {
return true
}
case "tags":
if tag != "" {
return true
}
case "merge_requests":
if source == "merge_request_event" {
return true
}
case "schedules":
if source == "schedule" {
return true
}
case "pipelines":
if source == "pipeline" {
return true
}
case "pushes":
if source == "push" {
return true
}
case "web":
if source == "web" {
return true
}
case "api":
if source == "api" {
return true
}
default:
// Branch name glob or /regex/ pattern.
if refPatternMatches(ref, branch) || refPatternMatches(ref, tag) {
return true
}
}
}
return false
}
func refPatternMatches(pattern, ref string) bool {
if ref == "" {
return false
}
if strings.HasPrefix(pattern, "/") && strings.HasSuffix(pattern, "/") {
re, err := regexp.Compile(pattern[1 : len(pattern)-1])
if err != nil {
return false
}
return re.MatchString(ref)
}
return globMatches(pattern, ref)
}
// globMatches supports the simple '*' wildcard used in GitLab only/except refs.
func globMatches(pattern, s string) bool {
if !strings.Contains(pattern, "*") {
return pattern == s
}
// Recursive match: consume pattern character by character.
return matchGlob(pattern, s)
}
func matchGlob(pattern, s string) bool {
for len(pattern) > 0 {
if pattern[0] == '*' {
pattern = pattern[1:]
if len(pattern) == 0 {
return true // trailing '*' matches everything
}
// Try matching the rest of the pattern at every position in s.
for i := range s {
if matchGlob(pattern, s[i:]) {
return true
}
}
return false
}
if len(s) == 0 || pattern[0] != s[0] {
return false
}
pattern = pattern[1:]
s = s[1:]
}
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
}