From 4f6855b9abc139c51947f3d1bb16f46ab2d2bb2f Mon Sep 17 00:00:00 2001 From: k3nny Date: Fri, 26 Jun 2026 23:30:25 +0200 Subject: [PATCH] feat(cli): inject GitLab predefined variables into the simulation context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two categories of predefined variables are now injected automatically: 1. Always-available (CI=true, GITLAB_CI=true): set at lowest priority for every non-empty context so that rules:if: expressions like '$CI == "true"' evaluate correctly without requiring --var. 2. MR-specific (CI_MERGE_REQUEST_IID, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME, CI_MERGE_REQUEST_TARGET_BRANCH_NAME, …): injected as placeholder values when --source merge_request_event is given, so MR-gated jobs evaluate as active rather than silently skipped. CI_MERGE_REQUEST_SOURCE_BRANCH_NAME is derived from --branch when provided. All injected defaults are non-pinned: --var overrides them, and pipeline variables: blocks can also override via Inject(). The empty context (no flags at all) is unchanged — no predefined vars are injected. Co-Authored-By: Claude Sonnet 4.6 --- cmd/glint/main.go | 12 +++++- internal/cicontext/context.go | 53 ++++++++++++++++++++++++-- internal/cicontext/context_test.go | 60 ++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 6 deletions(-) diff --git a/cmd/glint/main.go b/cmd/glint/main.go index 0af4b1f..88ff48f 100644 --- a/cmd/glint/main.go +++ b/cmd/glint/main.go @@ -258,7 +258,10 @@ Note: when none of --branch, --tag, --source, or --var are given, glint detects the current git branch automatically and uses it as the default (falling back to 'main' when not inside a git repository or in detached-HEAD state). --source defaults to 'push' so that rules:if: expressions are always -evaluated. +evaluated. GitLab's always-available predefined variables (CI=true, +GITLAB_CI=true) are injected automatically; MR-specific variables +(CI_MERGE_REQUEST_IID, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME, …) are injected +when --source merge_request_event is given. Override any with --var. Examples: glint check .gitlab-ci.yml @@ -269,6 +272,8 @@ Examples: glint check --branch develop .gitlab-ci.yml glint check --tag v1.0.0 .gitlab-ci.yml glint check --source merge_request_event .gitlab-ci.yml + glint check --source merge_request_event --branch feature/my-branch .gitlab-ci.yml + glint check --source merge_request_event --var CI_MERGE_REQUEST_IID=42 .gitlab-ci.yml glint check --list-vars .gitlab-ci.yml GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml @@ -619,7 +624,10 @@ Note: when none of --branch, --tag, --source, or --var are given, glint detects the current git branch automatically and uses it as the default (falling back to 'main' when not inside a git repository or in detached-HEAD state). --source defaults to 'push' so that rules:if: expressions are always -evaluated. +evaluated. GitLab's always-available predefined variables (CI=true, +GITLAB_CI=true) are injected automatically; MR-specific variables +(CI_MERGE_REQUEST_IID, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME, …) are injected +when --source merge_request_event is given. Override any with --var. Examples: glint graph .gitlab-ci.yml diff --git a/internal/cicontext/context.go b/internal/cicontext/context.go index ce82394..cde9341 100644 --- a/internal/cicontext/context.go +++ b/internal/cicontext/context.go @@ -14,6 +14,32 @@ type Context struct { changedFiles []string // nil = not provided (permissive); non-nil = known set of changed files } +// gitlabAlwaysVars are GitLab predefined variables that are constant across +// every pipeline, instance, and project. Injected at the lowest priority so +// that pipeline variables: blocks and --var overrides can still win. +var gitlabAlwaysVars = map[string]string{ + "CI": "true", + "GITLAB_CI": "true", +} + +// gitlabMRVars are predefined variables GitLab injects only for +// CI_PIPELINE_SOURCE=merge_request_event pipelines. Placeholder values are +// used so that rules:if: expressions that gate on MR context evaluate +// correctly. All can be overridden with --var. +// +// CI_MERGE_REQUEST_SOURCE_BRANCH_NAME is left empty here and filled from +// CI_COMMIT_BRANCH (the --branch flag) when available. +var gitlabMRVars = map[string]string{ + "CI_MERGE_REQUEST_ID": "1", + "CI_MERGE_REQUEST_IID": "1", + "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME": "", + "CI_MERGE_REQUEST_TARGET_BRANCH_NAME": "main", + "CI_MERGE_REQUEST_PROJECT_PATH": "namespace/project", + "CI_MERGE_REQUEST_TITLE": "Draft: placeholder", + "CI_MERGE_REQUEST_LABELS": "", + "CI_OPEN_MERGE_REQUESTS": "namespace/project!1", +} + // 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. @@ -21,15 +47,18 @@ type Context struct { // 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. -// Both shortcut-derived and extraVar variables are pinned — they will not be -// overwritten by Inject (used for pipeline-level and workflow-rule variables). +// Override priority (highest wins): extraVars > branch/tag/source shortcuts > +// pipeline variables (via Inject) > GitLab predefined defaults. func New(branch, tag, source string, extraVars []string) *Context { if branch == "" && tag == "" && source == "" && len(extraVars) == 0 { return &Context{} } - vars := make(map[string]string) + // Seed with always-available GitLab predefined variables at lowest priority. + vars := make(map[string]string, len(gitlabAlwaysVars)+8) + for k, v := range gitlabAlwaysVars { + vars[k] = v + } pinned := make(map[string]bool) pin := func(k, v string) { @@ -62,6 +91,22 @@ func New(branch, tag, source string, extraVars []string) *Context { vars["CI_DEFAULT_BRANCH"] = "main" } + // For MR pipelines, inject placeholder values for the predefined MR + // variables so that rules:if: expressions like '$CI_MERGE_REQUEST_IID' + // evaluate as non-empty (truthy). These are non-pinned so --var overrides + // them and pipeline variables: can also override via Inject. + if source == "merge_request_event" { + for k, v := range gitlabMRVars { + if !pinned[k] { + vars[k] = v + } + } + // Derive source branch from --branch when available. + if branch != "" && vars["CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"] == "" { + vars["CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"] = branch + } + } + // KEY=VALUE overrides win over shortcuts and everything else. for _, kv := range extraVars { k, v, ok := strings.Cut(kv, "=") diff --git a/internal/cicontext/context_test.go b/internal/cicontext/context_test.go index 137b025..3871f59 100644 --- a/internal/cicontext/context_test.go +++ b/internal/cicontext/context_test.go @@ -78,6 +78,66 @@ func TestNew_DefaultBranch(t *testing.T) { } } +func TestNew_PredefinedAlwaysVars(t *testing.T) { + ctx := New("main", "", "", nil) + if ctx.Get("CI") != "true" { + t.Errorf("CI should be 'true', got %q", ctx.Get("CI")) + } + if ctx.Get("GITLAB_CI") != "true" { + t.Errorf("GITLAB_CI should be 'true', got %q", ctx.Get("GITLAB_CI")) + } +} + +func TestNew_PredefinedAlwaysVars_EmptyContext(t *testing.T) { + // Always-vars must NOT be injected when the context is empty (no flags). + ctx := New("", "", "", nil) + if ctx.Get("CI") != "" { + t.Errorf("CI should be empty in empty context, got %q", ctx.Get("CI")) + } +} + +func TestNew_PredefinedAlwaysVars_OverridableByVar(t *testing.T) { + ctx := New("main", "", "", []string{"CI=false"}) + if ctx.Get("CI") != "false" { + t.Errorf("--var should override CI, got %q", ctx.Get("CI")) + } +} + +func TestNew_PredefinedAlwaysVars_OverridableByInject(t *testing.T) { + ctx := New("main", "", "", nil) + ctx.Inject("CI", "custom") + if ctx.Get("CI") != "custom" { + t.Errorf("Inject should override non-pinned CI, got %q", ctx.Get("CI")) + } +} + +func TestNew_MRVars(t *testing.T) { + ctx := New("feature/my-branch", "", "merge_request_event", nil) + if ctx.Get("CI_MERGE_REQUEST_IID") != "1" { + t.Errorf("CI_MERGE_REQUEST_IID should be '1', got %q", ctx.Get("CI_MERGE_REQUEST_IID")) + } + if ctx.Get("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME") != "feature/my-branch" { + t.Errorf("source branch should match --branch, got %q", ctx.Get("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME")) + } + if ctx.Get("CI_MERGE_REQUEST_TARGET_BRANCH_NAME") != "main" { + t.Errorf("target branch should be 'main', got %q", ctx.Get("CI_MERGE_REQUEST_TARGET_BRANCH_NAME")) + } +} + +func TestNew_MRVars_NotInjectedForNonMR(t *testing.T) { + ctx := New("main", "", "push", nil) + if ctx.Get("CI_MERGE_REQUEST_IID") != "" { + t.Errorf("CI_MERGE_REQUEST_IID should be empty for push pipeline, got %q", ctx.Get("CI_MERGE_REQUEST_IID")) + } +} + +func TestNew_MRVars_OverridableByVar(t *testing.T) { + ctx := New("", "", "merge_request_event", []string{"CI_MERGE_REQUEST_IID=42"}) + if ctx.Get("CI_MERGE_REQUEST_IID") != "42" { + t.Errorf("--var should override CI_MERGE_REQUEST_IID, got %q", ctx.Get("CI_MERGE_REQUEST_IID")) + } +} + func TestInject(t *testing.T) { ctx := New("main", "", "", nil) // pinned var should not be overwritten