feat(gitlab-sim): 🚀 branches support
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
package cicontext
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.k3nny.fr/gitlab-sim/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 returns false when the pipeline's workflow:rules block would
|
||||
// prevent any pipeline from starting in the given context.
|
||||
// Returns true when ctx is empty, when there is no workflow block, or when no
|
||||
// rule is configured.
|
||||
func EvalWorkflow(p *model.Pipeline, ctx *Context) bool {
|
||||
if ctx.IsEmpty() || p.Workflow == nil || len(p.Workflow.Rules) == 0 {
|
||||
return true
|
||||
}
|
||||
vars := ctx.Get
|
||||
for _, rule := range p.Workflow.Rules {
|
||||
if !ruleIfMatches(rule.If, vars) {
|
||||
continue
|
||||
}
|
||||
when := rule.When
|
||||
if when == "" {
|
||||
when = "always"
|
||||
}
|
||||
return when != "never"
|
||||
}
|
||||
return false // 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
|
||||
}
|
||||
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 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
|
||||
}
|
||||
Reference in New Issue
Block a user