From f98e9c42e728ba65aba2bee4e502bc322fcc31d7 Mon Sep 17 00:00:00 2001 From: k3nny Date: Sun, 7 Jun 2026 23:13:52 +0200 Subject: [PATCH] feat(gitlab-sim): :rocket: branches support --- CHANGELOG.md | 17 +- README.md | 56 +++++- ROADMAP.md | 140 +++++++++++++++ Taskfile.yml | 10 ++ internal/cicontext/context.go | 105 +++++++++++ internal/cicontext/eval.go | 280 +++++++++++++++++++++++++++++ internal/cicontext/eval_test.go | 85 +++++++++ internal/cicontext/reachability.go | 249 +++++++++++++++++++++++++ testdata/context_rules.yml | 58 ++++++ 9 files changed, 997 insertions(+), 3 deletions(-) create mode 100644 ROADMAP.md create mode 100644 internal/cicontext/context.go create mode 100644 internal/cicontext/eval.go create mode 100644 internal/cicontext/eval_test.go create mode 100644 internal/cicontext/reachability.go create mode 100644 testdata/context_rules.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index d161f3d..1465e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -This project does not yet use semantic versioning; entries are listed under `[Unreleased]` until a first release is tagged. +This project uses [Semantic Versioning](https://semver.org). -## [Unreleased] +## [0.1.0] - 2026-06-07 ### Added +- **Context simulation (`--branch` / `--tag` / `--source` / `--var`)** — show which jobs would be active, manual, or skipped for a specific pipeline event without leaving the terminal: + - `--branch ` — simulates a branch push; populates `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, and defaults `CI_PIPELINE_SOURCE` to `push` + - `--tag ` — simulates a tag push; populates `CI_COMMIT_TAG`, clears `CI_COMMIT_BRANCH` + - `--source ` — sets `CI_PIPELINE_SOURCE` explicitly (`merge_request_event`, `schedule`, `web`, `api`, `pipeline`, …) + - `--var KEY=VALUE` — sets any CI variable; repeatable; overrides shortcut values + - Evaluates `rules:if:` expressions (`==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, parentheses, `$VAR`, string literals, `null`) + - Evaluates `only:`/`except:` ref keywords (`branches`, `tags`, `merge_requests`, `schedules`, `pushes`, `web`, `api`, `pipelines`), branch name globs (`feat/*`), and `/regex/` patterns + - Evaluates `workflow:rules:` — warns when the pipeline itself would not start for the given context + - `rules:changes:` and `rules:exists:` are not evaluated (no git tree at lint time); rules without `if:` always match + - Linting runs in full regardless of context; context output is printed before findings + - New `internal/cicontext` package (`context.go`, `eval.go`, `reachability.go`); no new external dependencies + - 33 unit tests covering the expression evaluator; 5 fixture runs in `task validate` + - **Graph output (`--graph`)** — visualises the pipeline instead of running lint rules: - `--graph includes` — [Mermaid](https://mermaid.js.org) flowchart of include dependencies written to stdout; one node per `include:` entry (project, component, local, remote, template), colour-coded by type; pipe to a `.mmd` file or paste into [mermaid.live](https://mermaid.live) - `--graph pipeline` — GitLab CI-style SVG/PNG pipeline graph written to a timestamped file in `--graph-out` (default: `gitlab-sim-out/`); jobs rendered as white chip cards with a coloured status indicator (blue: regular, orange: manual, purple: trigger, amber: delayed); DAG mode draws job-to-job Bézier arrows when any job has `needs:`, classic mode draws L-shaped connectors between stage columns; converted to PNG automatically when `rsvg-convert`, `inkscape`, or `magick` is available diff --git a/README.md b/README.md index ec1bc79..f040d78 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # gitlab-sim [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) +[![Release](https://img.shields.io/badge/release-v0.1.0-blue.svg)](CHANGELOG.md) > **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome. @@ -18,6 +19,7 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G - **CI/CD catalog components** — resolves `include: component:` references from the GitLab CI/CD Catalog; public components work without a token - **Deprecation warnings** — flags `only`/`except` usage in favour of `rules` - **Graph output** — emits Mermaid diagrams for the include dependency tree and the pipeline jobs layout (DAG or classic stage ordering) +- **Context simulation** — pass `--branch`, `--tag`, or `--source` to see which jobs would be active, manual, or skipped for a specific branch push, tag, or pipeline event; evaluates `rules:if:` expressions and `only`/`except` filters ## Requirements @@ -143,10 +145,61 @@ Jobs are colour-coded by type: DAG mode (job-to-job Bézier arrows) activates automatically when any job has a `needs:` list. Classic mode draws L-shaped or straight connectors between stage columns otherwise. +### Context simulation + +Pass `--branch`, `--tag`, or `--source` to see which jobs would run for a given +pipeline event. The pipeline is still fully linted; context output is printed first. + +```bash +# What runs on a push to develop? +gitlab-sim --branch develop .gitlab-ci.yml + +# What runs when a v1.2.0 tag is pushed? +gitlab-sim --tag v1.2.0 .gitlab-ci.yml + +# Merge request pipeline +gitlab-sim --source merge_request_event .gitlab-ci.yml + +# Arbitrary variable overrides (repeatable) +gitlab-sim --branch main --var DEPLOY_ENV=production .gitlab-ci.yml +``` + +**Evaluated:** +- `rules:if:` — full expression language: `==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, `()`, `$VAR`, string literals, `null` +- `only:` / `except:` — ref keywords (`branches`, `tags`, `merge_requests`, `schedules`, …), branch name globs (`feat/*`), and `/regex/` patterns + +**Not evaluated** (no git tree at lint time): `rules:changes:`, `rules:exists:`. +Rules without an `if:` clause always match. + +**Predefined variables** set automatically by the shortcut flags: + +| Flag | Variables populated | +|------|---------------------| +| `--branch ` | `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` | +| `--tag ` | `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` (clears `CI_COMMIT_BRANCH`) | +| `--source ` | `CI_PIPELINE_SOURCE` | +| `--var KEY=VALUE` | any variable; overrides shortcuts | + ### Example output ``` -# Clean pipeline +# Clean pipeline, no context +OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages) + +# With --branch develop context +Context: branch=develop, source=push + +Active (3): build, deploy-staging, test +Skipped (2): deploy-prod, release-notes + +OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages) + +# With --tag v1.0.0 context +Context: tag=v1.0.0, source=push + +Active (4): build, deploy-prod, release-notes, test +Skipped (1): deploy-staging + OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages) # Pipeline with issues @@ -241,6 +294,7 @@ task clean # remove build artifacts . ├── cmd/gitlab-sim/ # CLI entrypoint ├── internal/ +│ ├── cicontext/ # CI variable context, rules:if: evaluator, job reachability │ ├── fetcher/ # GitLab API client (project include fetching) │ ├── graph/ # Mermaid and SVG/PNG graph generators │ ├── linter/ # lint rules and findings diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..75147bf --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,140 @@ +# Roadmap + +This document tracks planned improvements to `gitlab-sim`. Items are grouped by theme, roughly in priority order within each group. Nothing here is a commitment — the tool is experimental and the list will shift as real usage surfaces better priorities. + +--- + +## Context-aware validation + +Pipelines in Git Flow, Trunk-Based Development, or any branching strategy are rarely uniform: jobs activate or skip based on `$CI_COMMIT_BRANCH`, `$CI_COMMIT_TAG`, `$CI_PIPELINE_SOURCE`, and similar runtime variables. Today `gitlab-sim` validates structure but cannot tell which jobs are actually reachable for a given context. + +The plan is to make the execution context injectable so the linter can evaluate `rules:if:` / `only` / `except` conditions and report per-context reachability. + +**CLI surface** + +```bash +# Push to develop +gitlab-sim --branch develop .gitlab-ci.yml + +# Tag push (v1.2.0) — sets CI_COMMIT_TAG and clears CI_COMMIT_BRANCH +gitlab-sim --tag v1.2.0 .gitlab-ci.yml + +# Merge request pipeline +gitlab-sim --source merge_request_event \ + --var CI_MERGE_REQUEST_TARGET_BRANCH_NAME=main \ + .gitlab-ci.yml + +# Explicit variable overrides for anything not covered by the shortcuts +gitlab-sim --var CI_COMMIT_BRANCH=feat/my-feature \ + --var CI_ENVIRONMENT_NAME=staging \ + .gitlab-ci.yml + +# Simulate multiple contexts in one run (print per-context job tables) +gitlab-sim --context branch=main \ + --context branch=develop \ + --context tag=v1.0.0 \ + .gitlab-ci.yml +``` + +**What context injection enables** + +- Each job is resolved to **active** / **manual** / **skipped** for the given context +- Warn when the entire pipeline would produce zero runnable jobs (common mistake when a `workflow:rules:` block is too restrictive) +- Lint only the active job subset — skip `needs:` / `dependencies:` cross-checks for jobs that never co-execute in that context +- `--context` multi-simulation: print a table showing which jobs activate per context, making it easy to audit Git Flow rules across branches and tags at once + +**Expression evaluator scope** + +GitLab's `rules:if:` expression language will be implemented incrementally: + +| Priority | Operators / features | +|----------|----------------------| +| 1 (MVP) | `==`, `!=`, `null` check, `&&`, `\|\|`, `!`, parentheses | +| 2 | Regex match `=~` / `!~` with `/pattern/` literals | +| 3 | `$CI_COMMIT_BRANCH =~ /^feat\//`, anchored patterns | +| 4 | `only: branches / tags / merge_requests` shorthand mapping | +| 5 | `changes:` path glob evaluation against a real or mock file tree | + +Predefined variables populated automatically from `--branch` / `--tag` / `--source` shortcuts: `CI_COMMIT_BRANCH`, `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE`, `CI_DEFAULT_BRANCH` (defaulting to `main`). + +--- + +## Lint coverage + +The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice. + +- **Variable reference validation** — warn when a job references `$VAR` (or `${VAR}`) that is not declared anywhere in `variables:`, `default.variables`, or the job itself +- **`services:` validation** — map form requires `name`; `alias` must be a valid DNS label +- **`rules:changes` / `rules:exists`** — warn on glob patterns that can never match (e.g. absolute paths, double `**` on unsupported versions) +- **`timeout` format** — must be a duration string GitLab understands (`1h 30m`, `90 minutes`, etc.) +- **`id_tokens:` / `secrets:`** — presence and required-key checks +- **`pages:publish`** — validate that the path is consistent with `artifacts.paths` +- **`inherit:` completeness** — flag when a job overrides a default field that would require `inherit: default: false` to suppress +- **Unreachable jobs** — detect jobs that can never run because every `rules:` branch evaluates to `never` (static analysis only, no variable expansion) +- **Duplicate stage names** — GitLab silently merges them; warn to avoid confusion +- **`cache:key:files`** — must be a list of paths, not a glob + +--- + +## Include resolution + +- **`include: local:`** full resolution — parse and merge locally-referenced YAML files the same way remote project includes are handled; enables cross-file `extends:` and `needs:` validation for monorepo setups +- **`include: remote:`** (URL) — fetch and merge plain HTTP/HTTPS URLs (no auth required) +- **Recursive include depth limit** — guard against include cycles across files +- **Offline mode / cache** — persist fetched remote templates to a local cache directory; `--offline` flag to skip network calls and use only cached copies +- **`include: inputs:`** — substitute CI component input values into fetched templates before merging, so component-scoped jobs get their correct `stage:` and keyword values + +--- + +## Output formats + +Right now the only output is plain-text findings. Structured output enables integration with other tools. + +- **JSON** (`--format json`) — machine-readable findings with file, job, severity, rule ID, and message; stable schema +- **SARIF** (`--format sarif`) — [Static Analysis Results Interchange Format](https://sarifweb.azurewebsites.net); consumed natively by GitHub Code Scanning and GitLab SAST +- **JUnit XML** (`--format junit`) — lets CI pipelines publish lint results as a test report artifact +- **GitHub / GitLab annotation format** — emit `::error file=…,line=…::message` lines so findings appear as inline comments in PR diffs + +--- + +## Pipeline graph improvements + +The SVG renderer covers the basic layout. These would bring it closer to GitLab's full interactive view. + +- **Multi-job connector accuracy** — draw one connector per job pair rather than one per stage pair in classic mode, so pipelines with uneven columns look correct +- **Job tooltip / detail panel** — embed a hidden `` and `<desc>` per chip so SVG viewers show `stage`, `when`, `image`, and `needs` on hover +- **`when: on_failure` visual distinction** — dashed border or distinct icon for failure-path jobs +- **Blocked / skipped state colouring** — grey out jobs that are statically unreachable given known `rules:` conditions +- **Interactive HTML output** — self-contained `.html` file with pan/zoom and a job-detail sidebar; no external dependencies +- **Mermaid pipeline output** — keep `pipeline.go` but wire it up through `--graph pipeline --format mermaid` for users who want to paste into mermaid.live + +--- + +## CI / editor integration + +- **GitLab CI template** — a `.gitlab-ci.yml` snippet that runs `gitlab-sim` as a pipeline-validation job before the real pipeline executes; publishable to the GitLab CI/CD Catalog +- **GitHub Actions action** — `uses: k3nny/gitlab-sim@v1` wrapper for repositories that mirror or manage GitLab pipelines from GitHub +- **Pre-commit hook** — entry for [pre-commit](https://pre-commit.com) so `gitlab-sim` runs automatically on `git commit` when `.gitlab-ci.yml` changes +- **LSP server** — `gitlab-sim lsp` mode exposing diagnostics over the Language Server Protocol; enables inline squiggles in VS Code, JetBrains, Neovim, etc. without a dedicated extension +- **VS Code extension** — thin wrapper around the LSP server with syntax highlighting for `.gitlab-ci.yml` + +--- + +## Configuration + +- **`.gitlab-sim.yml` config file** — project-level configuration for: + - Rule suppression by rule ID (e.g. `ignore: [no-only, missing-stages]`) + - Severity overrides (demote specific errors to warnings) + - Custom `stages` allowlist for projects that use a non-standard default set + - Token and URL defaults so flags are not needed in every invocation +- **Inline suppression comments** — `# gitlab-sim: ignore next-line <rule-id>` in the pipeline YAML + +--- + +## Reliability and developer experience + +- **Structured rule IDs** — assign a stable short ID to every rule (e.g. `GS001`) so suppression, documentation, and SARIF output are stable across versions +- **`--explain <rule-id>`** — print the rule description, rationale, and an example fix +- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07) +- **Changelog automation** — generate release notes from Conventional Commits via `git-cliff` or similar +- **Fuzz testing** — add a `go test -fuzz` target for the YAML parser to harden it against malformed input diff --git a/Taskfile.yml b/Taskfile.yml index 5fda2dd..1473570 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -45,6 +45,16 @@ tasks: ignore_error: false - cmd: ./{{.BINARY}} testdata/includes_component.yml ignore_error: false + - cmd: ./{{.BINARY}} testdata/context_rules.yml + ignore_error: false + - cmd: ./{{.BINARY}} --branch main testdata/context_rules.yml + ignore_error: false + - cmd: ./{{.BINARY}} --branch develop testdata/context_rules.yml + ignore_error: false + - cmd: ./{{.BINARY}} --branch feat/my-feature testdata/context_rules.yml + ignore_error: false + - cmd: ./{{.BINARY}} --tag v1.0.0 testdata/context_rules.yml + ignore_error: false lint-go: desc: Run go vet on all packages diff --git a/internal/cicontext/context.go b/internal/cicontext/context.go new file mode 100644 index 0000000..aca1d81 --- /dev/null +++ b/internal/cicontext/context.go @@ -0,0 +1,105 @@ +package cicontext + +import "strings" + +// Context holds the simulated CI execution environment used for context-aware +// pipeline evaluation (rules:if:, only:, except:, workflow:rules:). +// Variables are keyed by their name without the leading $. +type Context struct { + Vars map[string]string +} + +// New builds a Context from high-level shortcut values and optional KEY=VALUE +// overrides. Predefined CI variables are derived from the shortcuts so callers +// do not need to know their exact names. +// +// Returns an empty Context (IsEmpty() == true) when all inputs are zero values, +// preserving the existing linting behaviour when no context flags are given. +// +// Override priority (highest wins): extraVars > branch/tag/source shortcuts. +func New(branch, tag, source string, extraVars []string) *Context { + if branch == "" && tag == "" && source == "" && len(extraVars) == 0 { + return &Context{} + } + + vars := make(map[string]string) + + if branch != "" { + vars["CI_COMMIT_BRANCH"] = branch + vars["CI_COMMIT_REF_NAME"] = branch + vars["CI_COMMIT_REF_SLUG"] = slugify(branch) + if source == "" { + source = "push" + } + } + if tag != "" { + vars["CI_COMMIT_TAG"] = tag + vars["CI_COMMIT_REF_NAME"] = tag + vars["CI_COMMIT_REF_SLUG"] = slugify(tag) + delete(vars, "CI_COMMIT_BRANCH") // tag pushes have no branch variable + if source == "" { + source = "push" + } + } + if source != "" { + vars["CI_PIPELINE_SOURCE"] = source + } + if _, ok := vars["CI_DEFAULT_BRANCH"]; !ok { + vars["CI_DEFAULT_BRANCH"] = "main" + } + + // KEY=VALUE overrides win over shortcuts. + for _, kv := range extraVars { + k, v, ok := strings.Cut(kv, "=") + if ok { + vars[k] = v + } + } + + return &Context{Vars: vars} +} + +// IsEmpty reports whether no variables have been set (no context flags given). +func (c *Context) IsEmpty() bool { + return c == nil || len(c.Vars) == 0 +} + +// Get returns the value of a CI variable (key without the leading $). +// Returns an empty string when the variable is not defined. +func (c *Context) Get(key string) string { + if c == nil { + return "" + } + return c.Vars[key] +} + +// Summary returns a short human-readable description of the context for CLI output. +func (c *Context) Summary() string { + if c.IsEmpty() { + return "" + } + var parts []string + if v := c.Get("CI_COMMIT_TAG"); v != "" { + parts = append(parts, "tag="+v) + } else if v := c.Get("CI_COMMIT_BRANCH"); v != "" { + parts = append(parts, "branch="+v) + } + if v := c.Get("CI_PIPELINE_SOURCE"); v != "" { + parts = append(parts, "source="+v) + } + return strings.Join(parts, ", ") +} + +// slugify converts a ref name to its GitLab slug form: +// lowercased, non-alphanumeric characters replaced with '-', leading/trailing '-' removed. +func slugify(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + return strings.Trim(b.String(), "-") +} diff --git a/internal/cicontext/eval.go b/internal/cicontext/eval.go new file mode 100644 index 0000000..44d5523 --- /dev/null +++ b/internal/cicontext/eval.go @@ -0,0 +1,280 @@ +package cicontext + +import ( + "regexp" + "strings" +) + +// EvalIf evaluates a GitLab CI rules:if: expression string against the provided +// variable resolver. +// +// Supported: +// - Variable references: $VAR_NAME +// - String literals: "value" or 'value' +// - Null keyword: null +// - Comparison: == != =~ !~ +// - Boolean: && || ! +// - Grouping: ( ) +// +// Regex patterns use Go's regexp syntax, which covers the common RE2 subset +// used by GitLab CI. Unsupported or unparseable expressions fall back to true +// (permissive) so the linter never silently drops jobs it cannot evaluate. +func EvalIf(expr string, vars func(string) string) bool { + p := &exprParser{s: strings.TrimSpace(expr), vars: vars} + result, ok := p.parseOr() + if !ok || p.pos < len(p.s) { + return true // unparseable → permissive + } + return result +} + +// ── Parser ──────────────────────────────────────────────────────────────────── + +type exprParser struct { + s string + pos int + vars func(string) string +} + +func (p *exprParser) peek() byte { + if p.pos >= len(p.s) { + return 0 + } + return p.s[p.pos] +} + +func (p *exprParser) startsWith(tok string) bool { + return strings.HasPrefix(p.s[p.pos:], tok) +} + +func (p *exprParser) consume(tok string) bool { + if p.startsWith(tok) { + p.pos += len(tok) + return true + } + return false +} + +func (p *exprParser) skipWS() { + for p.pos < len(p.s) && (p.s[p.pos] == ' ' || p.s[p.pos] == '\t') { + p.pos++ + } +} + +// ── Grammar (recursive descent) ─────────────────────────────────────────────── +// +// or_expr → and_expr ( '||' and_expr )* +// and_expr → not_expr ( '&&' not_expr )* +// not_expr → '!' not_expr | primary +// primary → '(' or_expr ')' | comparison +// comparison → value ( op value | regex_op regex )? | value +// value → '$' ident | '"' … '"' | "'" … "'" | 'null' +// op → '==' | '!=' +// regex_op → '=~' | '!~' +// regex → '/' … '/' + +func (p *exprParser) parseOr() (bool, bool) { + left, ok := p.parseAnd() + if !ok { + return false, false + } + for { + p.skipWS() + if !p.consume("||") { + return left, true + } + right, ok := p.parseAnd() + if !ok { + return false, false + } + left = left || right + } +} + +func (p *exprParser) parseAnd() (bool, bool) { + left, ok := p.parseNot() + if !ok { + return false, false + } + for { + p.skipWS() + if !p.consume("&&") { + return left, true + } + right, ok := p.parseNot() + if !ok { + return false, false + } + left = left && right + } +} + +func (p *exprParser) parseNot() (bool, bool) { + p.skipWS() + // '!' is logical NOT only when not followed by '=' (which would be '!='). + if p.peek() == '!' && !p.startsWith("!=") { + p.pos++ + val, ok := p.parseNot() // right-associative: !!x == x + return !val, ok + } + return p.parsePrimary() +} + +func (p *exprParser) parsePrimary() (bool, bool) { + p.skipWS() + if p.peek() == '(' { + p.pos++ // consume '(' + val, ok := p.parseOr() + if !ok { + return false, false + } + p.skipWS() + if p.peek() != ')' { + return false, false // unmatched parenthesis + } + p.pos++ // consume ')' + return val, true + } + return p.parseComparison() +} + +func (p *exprParser) parseComparison() (bool, bool) { + p.skipWS() + leftStr, ok := p.parseValue() + if !ok { + return false, false + } + p.skipWS() + + switch { + case p.consume("=="): + p.skipWS() + rightStr, ok := p.parseValue() + if !ok { + return false, false + } + return leftStr == rightStr, true + + case p.consume("!="): + p.skipWS() + rightStr, ok := p.parseValue() + if !ok { + return false, false + } + return leftStr != rightStr, true + + case p.consume("=~"): + p.skipWS() + pat, ok := p.parseRegexLiteral() + if !ok { + return false, false + } + re, err := regexp.Compile(pat) + if err != nil { + return true, true // bad pattern → permissive + } + return re.MatchString(leftStr), true + + case p.consume("!~"): + p.skipWS() + pat, ok := p.parseRegexLiteral() + if !ok { + return false, false + } + re, err := regexp.Compile(pat) + if err != nil { + return true, true // bad pattern → permissive + } + return !re.MatchString(leftStr), true + } + + // No operator: variable is truthy when non-empty (defined and non-null). + return leftStr != "", true +} + +// parseValue reads $VAR, "string", 'string', or null. +// null and undefined variables both produce an empty string. +func (p *exprParser) parseValue() (string, bool) { + p.skipWS() + + if p.peek() == '$' { + p.pos++ // consume '$' + name := p.parseIdent() + if name == "" { + return "", false + } + return p.vars(name), true + } + + // null keyword — must not be a prefix of a longer identifier. + if p.startsWith("null") { + end := p.pos + 4 + if end >= len(p.s) || !isIdentByte(p.s[end]) { + p.pos += 4 + return "", true // null → empty string + } + } + + if p.peek() == '"' || p.peek() == '\'' { + return p.parseStringLiteral() + } + + return "", false +} + +func (p *exprParser) parseIdent() string { + start := p.pos + for p.pos < len(p.s) && isIdentByte(p.s[p.pos]) { + p.pos++ + } + return p.s[start:p.pos] +} + +func (p *exprParser) parseStringLiteral() (string, bool) { + quote := p.s[p.pos] + p.pos++ // consume opening quote + var sb strings.Builder + for p.pos < len(p.s) { + b := p.s[p.pos] + if b == quote { + p.pos++ // consume closing quote + return sb.String(), true + } + if b == '\\' && p.pos+1 < len(p.s) { + p.pos++ + sb.WriteByte(p.s[p.pos]) + } else { + sb.WriteByte(b) + } + p.pos++ + } + return "", false // unterminated string +} + +func (p *exprParser) parseRegexLiteral() (string, bool) { + if p.peek() != '/' { + return "", false + } + p.pos++ // consume opening '/' + var sb strings.Builder + for p.pos < len(p.s) { + b := p.s[p.pos] + if b == '/' { + p.pos++ // consume closing '/' + return sb.String(), true + } + if b == '\\' && p.pos+1 < len(p.s) { + p.pos++ + sb.WriteByte('\\') + sb.WriteByte(p.s[p.pos]) + } else { + sb.WriteByte(b) + } + p.pos++ + } + return "", false // unterminated regex +} + +func isIdentByte(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' +} diff --git a/internal/cicontext/eval_test.go b/internal/cicontext/eval_test.go new file mode 100644 index 0000000..81f40d8 --- /dev/null +++ b/internal/cicontext/eval_test.go @@ -0,0 +1,85 @@ +package cicontext + +import "testing" + +func TestEvalIf(t *testing.T) { + vars := func(key string) string { + m := map[string]string{ + "CI_COMMIT_BRANCH": "develop", + "CI_COMMIT_TAG": "", + "CI_PIPELINE_SOURCE": "push", + "DEPLOY_ENV": "staging", + } + return m[key] + } + + tests := []struct { + name string + expr string + want bool + }{ + // ── Equality ────────────────────────────────────────────────────────── + {"eq match", `$CI_COMMIT_BRANCH == "develop"`, true}, + {"eq no match", `$CI_COMMIT_BRANCH == "main"`, false}, + {"eq single-quote", `$CI_COMMIT_BRANCH == 'develop'`, true}, + {"neq match", `$CI_COMMIT_BRANCH != "main"`, true}, + {"neq no match", `$CI_COMMIT_BRANCH != "develop"`, false}, + + // ── Null checks ─────────────────────────────────────────────────────── + {"tag undefined eq null", `$CI_COMMIT_TAG == null`, true}, + {"tag undefined neq null", `$CI_COMMIT_TAG != null`, false}, + {"branch defined neq null", `$CI_COMMIT_BRANCH != null`, true}, + {"branch defined eq null", `$CI_COMMIT_BRANCH == null`, false}, + {"null eq null", `null == null`, true}, + + // ── Variable truthiness (no operator) ──────────────────────────────── + {"truthy branch", `$CI_COMMIT_BRANCH`, true}, + {"falsy tag", `$CI_COMMIT_TAG`, false}, + + // ── Logical NOT ─────────────────────────────────────────────────────── + {"not false", `!$CI_COMMIT_TAG`, true}, + {"not true", `!$CI_COMMIT_BRANCH`, false}, + {"double not", `!!$CI_COMMIT_BRANCH`, true}, + + // ── Boolean AND ─────────────────────────────────────────────────────── + {"and both true", `$CI_COMMIT_BRANCH == "develop" && $CI_PIPELINE_SOURCE == "push"`, true}, + {"and left false", `$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"`, false}, + {"and right false", `$CI_COMMIT_BRANCH == "develop" && $CI_PIPELINE_SOURCE == "schedule"`, false}, + {"and both false", `$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "schedule"`, false}, + + // ── Boolean OR ──────────────────────────────────────────────────────── + {"or both true", `$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "main"`, true}, + {"or left true", `$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "nope"`, true}, + {"or right true", `$CI_COMMIT_BRANCH == "nope" || $CI_COMMIT_BRANCH == "develop"`, true}, + {"or both false", `$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "nope"`, false}, + + // ── Parentheses ─────────────────────────────────────────────────────── + {"paren or+and", `($CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "main") && $CI_COMMIT_TAG == null`, true}, + {"paren or+and false", `($CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "nope") && $CI_COMMIT_TAG == null`, false}, + + // ── Regex match ─────────────────────────────────────────────────────── + {"regex match", `$CI_COMMIT_BRANCH =~ /^dev/`, true}, + {"regex no match", `$CI_COMMIT_BRANCH =~ /^main/`, false}, + {"regex not match", `$CI_COMMIT_BRANCH !~ /^main/`, true}, + {"regex not no match", `$CI_COMMIT_BRANCH !~ /^dev/`, false}, + {"regex case sensitive", `$CI_COMMIT_BRANCH =~ /^DEV/`, false}, + {"regex feat branch", `$CI_COMMIT_BRANCH =~ /^feat\//`, false}, + + // ── Extra whitespace ────────────────────────────────────────────────── + {"extra spaces", ` $CI_COMMIT_BRANCH == "develop" `, true}, + {"tabs", "$CI_COMMIT_BRANCH\t==\t\"develop\"", true}, + + // ── Permissive fallback ─────────────────────────────────────────────── + {"unparseable returns true", `this is not valid syntax %%%`, true}, + {"empty expr returns true", ``, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := EvalIf(tc.expr, vars) + if got != tc.want { + t.Errorf("EvalIf(%q) = %v, want %v", tc.expr, got, tc.want) + } + }) + } +} diff --git a/internal/cicontext/reachability.go b/internal/cicontext/reachability.go new file mode 100644 index 0000000..e9d96bf --- /dev/null +++ b/internal/cicontext/reachability.go @@ -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 +} diff --git a/testdata/context_rules.yml b/testdata/context_rules.yml new file mode 100644 index 0000000..5e4086c --- /dev/null +++ b/testdata/context_rules.yml @@ -0,0 +1,58 @@ +# context_rules.yml +# Pipeline exercising context-aware rules evaluation. +# Expected behaviour per context: +# (no context) → lints clean, all jobs visible +# --branch main → build, test, deploy-prod active; deploy-staging/release-notes skipped +# --branch develop → build, test, deploy-staging active; deploy-prod/release-notes skipped +# --branch feat/my-feat → build, test active; deploy-staging manual; deploy-prod/release-notes skipped +# --tag v1.0.0 → build, test, deploy-prod, release-notes active; deploy-staging skipped + +stages: + - build + - test + - deploy + +build: + stage: build + script: + - make build + rules: + - when: always + +test: + stage: test + script: + - make test + rules: + - when: always + +deploy-staging: + stage: deploy + script: + - make deploy ENV=staging + rules: + - if: $CI_COMMIT_BRANCH == "develop" + when: on_success + - if: $CI_COMMIT_BRANCH =~ /^feat\// + when: manual + - when: never + +deploy-prod: + stage: deploy + script: + - make deploy ENV=production + rules: + - if: $CI_COMMIT_BRANCH == "main" + when: on_success + - if: $CI_COMMIT_TAG != null + when: on_success + - when: never + +release-notes: + stage: deploy + script: + - make release + rules: + - if: $CI_COMMIT_TAG != null + when: on_success + - when: never