From 48e63101a0892267fee29d71b6745606447bf6f7 Mon Sep 17 00:00:00 2001 From: Laura Montemayor Date: Mon, 29 Sep 2025 19:13:06 +0200 Subject: [PATCH 1/2] Add inputs lexeme --- .../ci/pipeline/expression/lexeme/input.rb | 31 +++++++ .../expression/lexeme/logical_operator.rb | 2 + lib/gitlab/ci/pipeline/expression/lexer.rb | 1 + .../ci/pipeline/expression/statement.rb | 20 ++++ .../pipeline/expression/lexeme/input_spec.rb | 91 +++++++++++++++++++ .../ci/pipeline/expression/statement_spec.rb | 52 +++++++++++ 6 files changed, 197 insertions(+) create mode 100644 lib/gitlab/ci/pipeline/expression/lexeme/input.rb create mode 100644 spec/lib/gitlab/ci/pipeline/expression/lexeme/input_spec.rb diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/input.rb b/lib/gitlab/ci/pipeline/expression/lexeme/input.rb new file mode 100644 index 00000000000000..add063f71776de --- /dev/null +++ b/lib/gitlab/ci/pipeline/expression/lexeme/input.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Expression + module Lexeme + class Input < Lexeme::Value + PATTERN = /\$\[\[\s*inputs\.(?\w+)\s*\]\]/ + + def self.build(string) + new(string.match(PATTERN)[:name]) + end + + def evaluate(variables = {}) + inputs = variables[:inputs] || {} + + inputs = inputs.with_indifferent_access unless inputs.is_a?(ActiveSupport::HashWithIndifferentAccess) + + inputs.fetch(@value, nil) + end + + def inspect + "$[[ inputs.#{@value} ]]" + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/logical_operator.rb b/lib/gitlab/ci/pipeline/expression/lexeme/logical_operator.rb index 05d5043c06ea3a..554c82627290e4 100644 --- a/lib/gitlab/ci/pipeline/expression/lexeme/logical_operator.rb +++ b/lib/gitlab/ci/pipeline/expression/lexeme/logical_operator.rb @@ -12,6 +12,8 @@ class LogicalOperator < Lexeme::Operator # implement an Operator that takes a greater or lesser number of arguments, a # structural change or additional Operator superclass will likely be needed. + attr_reader :left, :right + def initialize(left, right) raise OperatorError, 'Invalid left operand' unless left.respond_to? :evaluate raise OperatorError, 'Invalid right operand' unless right.respond_to? :evaluate diff --git a/lib/gitlab/ci/pipeline/expression/lexer.rb b/lib/gitlab/ci/pipeline/expression/lexer.rb index ac03ef79ccbb39..85ff5b32244551 100644 --- a/lib/gitlab/ci/pipeline/expression/lexer.rb +++ b/lib/gitlab/ci/pipeline/expression/lexer.rb @@ -12,6 +12,7 @@ class Lexer LEXEMES = [ Expression::Lexeme::ParenthesisOpen, Expression::Lexeme::ParenthesisClose, + Expression::Lexeme::Input, Expression::Lexeme::Variable, Expression::Lexeme::String, Expression::Lexeme::Pattern, diff --git a/lib/gitlab/ci/pipeline/expression/statement.rb b/lib/gitlab/ci/pipeline/expression/statement.rb index 4b13cae792ed9b..b84fe26b5b122e 100644 --- a/lib/gitlab/ci/pipeline/expression/statement.rb +++ b/lib/gitlab/ci/pipeline/expression/statement.rb @@ -34,6 +34,26 @@ def valid? rescue Expression::ExpressionError false end + + def input_names + collect_input_names(parse_tree).uniq + rescue Expression::ExpressionError + [] + end + + private + + def collect_input_names(node) + return [] unless node + + if node.is_a?(Lexeme::Input) + [node.value] + elsif node.is_a?(Lexeme::LogicalOperator) + collect_input_names(node.left) + collect_input_names(node.right) + else + [] + end + end end end end diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexeme/input_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexeme/input_spec.rb new file mode 100644 index 00000000000000..62c3f18a1365dc --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/expression/lexeme/input_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Ci::Pipeline::Expression::Lexeme::Input, feature_category: :pipeline_composition do + describe '.build' do + it 'extracts the input name' do + lexeme = described_class.build('$[[ inputs.environment ]]') + + expect(lexeme.value).to eq('environment') + end + + it 'handles input names with whitespace in brackets' do + lexeme = described_class.build('$[[ inputs.environment ]]') + + expect(lexeme.value).to eq('environment') + end + end + + describe '.type' do + it 'is a value' do + expect(described_class.type).to eq :value + end + end + + describe '#evaluate' do + let(:lexeme) { described_class.new('environment') } + + it 'returns input value if it is defined' do + expect(lexeme.evaluate(inputs: { 'environment' => 'production' })).to eq 'production' + end + + it 'allows to use a symbol as an input key too' do + expect(lexeme.evaluate(inputs: { environment: 'production' })).to eq 'production' + end + + it 'returns nil if it is not defined' do + expect(lexeme.evaluate(inputs: { 'region' => 'us-west' })).to be_nil + expect(lexeme.evaluate(inputs: { region: 'us-west' })).to be_nil + end + + it 'returns nil when no inputs are provided' do + expect(lexeme.evaluate({})).to be_nil + expect(lexeme.evaluate).to be_nil + end + + it 'does not call with_indifferent_access unnecessarily' do + inputs_hash = { inputs: { environment: 'production' }.with_indifferent_access } + + expect(inputs_hash[:inputs]).not_to receive(:with_indifferent_access) + expect(lexeme.evaluate(inputs_hash)).to eq 'production' + end + end + + describe '#inspect' do + let(:lexeme) { described_class.new('environment') } + + it 'returns the input reference format' do + expect(lexeme.inspect).to eq '$[[ inputs.environment ]]' + end + end + + describe 'integration with Expression::Statement' do + it 'evaluates input references in expressions' do + statement = Gitlab::Ci::Pipeline::Expression::Statement.new( + '$[[ inputs.cloud_provider ]] == "aws"', + inputs: { 'cloud_provider' => 'aws' } + ) + + expect(statement.evaluate).to be(true) + end + + it 'evaluates complex expressions with multiple inputs' do + statement = Gitlab::Ci::Pipeline::Expression::Statement.new( + '$[[ inputs.cloud_provider ]] == "aws" && $[[ inputs.environment ]] == "production"', + inputs: { 'cloud_provider' => 'aws', 'environment' => 'production' } + ) + + expect(statement.evaluate).to be(true) + end + + it 'returns false when input values do not match' do + statement = Gitlab::Ci::Pipeline::Expression::Statement.new( + '$[[ inputs.cloud_provider ]] == "gcp"', + inputs: { 'cloud_provider' => 'aws' } + ) + + expect(statement.evaluate).to be(false) + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb index acaec07f95b9c9..aaaf4a0f2486c7 100644 --- a/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb @@ -193,4 +193,56 @@ end end end + + describe '#input_names' do + subject(:input_names) { statement.input_names } + + context 'with simple input expression' do + let(:text) { '$[[ inputs.cloud_provider ]] == "aws"' } + + it { is_expected.to eq(['cloud_provider']) } + end + + context 'with multiple inputs using AND' do + let(:text) { '$[[ inputs.a ]] == "x" && $[[ inputs.b ]] == "y"' } + + it { is_expected.to contain_exactly('a', 'b') } + end + + context 'with multiple inputs using OR' do + let(:text) { '$[[ inputs.env ]] == "prod" || $[[ inputs.env ]] == "stage"' } + + it 'deduplicates repeated inputs' do + expect(input_names).to eq(['env']) + end + end + + context 'with nested expressions' do + let(:text) { '($[[ inputs.a ]] == "x" && $[[ inputs.b ]] == "y") || $[[ inputs.c ]] == "z"' } + + it { is_expected.to contain_exactly('a', 'b', 'c') } + end + + context 'with inputs and variables mixed' do + let(:text) { '$[[ inputs.region ]] == "us" && $CI_COMMIT_BRANCH == "main"' } + + it 'returns only inputs, not variables' do + expect(input_names).to eq(['region']) + end + end + + context 'with no inputs' do + let(:text) { '$PRESENT_VARIABLE == "value"' } + + it { is_expected.to be_empty } + end + + context 'with invalid expression' do + let(:text) { 'invalid &&' } + + it 'returns empty array on error' do + expect(input_names).to be_empty + end + end + end end -- GitLab From 4436a1f62a7c00752eba12a5018dca84bea1ab98 Mon Sep 17 00:00:00 2001 From: Laura Montemayor Date: Fri, 17 Oct 2025 17:01:57 +0200 Subject: [PATCH 2/2] Move specs --- .../pipeline/expression/lexeme/input_spec.rb | 29 ------------------- .../ci/pipeline/expression/statement_spec.rb | 18 ++++++++++++ 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexeme/input_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexeme/input_spec.rb index 62c3f18a1365dc..47b97c8342a626 100644 --- a/spec/lib/gitlab/ci/pipeline/expression/lexeme/input_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/expression/lexeme/input_spec.rb @@ -59,33 +59,4 @@ expect(lexeme.inspect).to eq '$[[ inputs.environment ]]' end end - - describe 'integration with Expression::Statement' do - it 'evaluates input references in expressions' do - statement = Gitlab::Ci::Pipeline::Expression::Statement.new( - '$[[ inputs.cloud_provider ]] == "aws"', - inputs: { 'cloud_provider' => 'aws' } - ) - - expect(statement.evaluate).to be(true) - end - - it 'evaluates complex expressions with multiple inputs' do - statement = Gitlab::Ci::Pipeline::Expression::Statement.new( - '$[[ inputs.cloud_provider ]] == "aws" && $[[ inputs.environment ]] == "production"', - inputs: { 'cloud_provider' => 'aws', 'environment' => 'production' } - ) - - expect(statement.evaluate).to be(true) - end - - it 'returns false when input values do not match' do - statement = Gitlab::Ci::Pipeline::Expression::Statement.new( - '$[[ inputs.cloud_provider ]] == "gcp"', - inputs: { 'cloud_provider' => 'aws' } - ) - - expect(statement.evaluate).to be(false) - end - end end diff --git a/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb index aaaf4a0f2486c7..59ada634cd1dd1 100644 --- a/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb @@ -123,6 +123,24 @@ '("string" == ("string" || (("1" == "1") && ("2" == "3"))))' | true end + context 'with input references' do + let(:variables) { { inputs: { 'cloud_provider' => 'aws', 'environment' => 'production' } } } + + where(:expression, :value) do + '$[[ inputs.cloud_provider ]] == "aws"' | true + '$[[ inputs.cloud_provider ]] == "gcp"' | false + '$[[ inputs.cloud_provider ]] == "aws" && $[[ inputs.environment ]] == "production"' | true + end + + with_them do + let(:text) { expression } + + it "evaluates to `#{params[:value].inspect}`" do + expect(evaluate).to eq(value) + end + end + end + with_them do let(:text) { expression } -- GitLab