diff --git a/.ci/changed-file-filters.yml b/.ci/changed-file-filters.yml new file mode 100644 index 00000000..0b5c2983 --- /dev/null +++ b/.ci/changed-file-filters.yml @@ -0,0 +1,43 @@ +# Shared changed-file filters for workflows and pipelines that should ignore docs-only changes. +# Negative patterns need `predicate-quantifier: every` in dorny/paths-filter workflow steps. +android: + - 'platforms/android/**' + - &exclude-markdown '!**/*.md' + - &exclude-docs-directory '!**/docs/**' +swift: + - 'platforms/swift/**' + - *exclude-markdown + - *exclude-docs-directory +reactNative: + - 'platforms/react-native/**' + - *exclude-markdown + - *exclude-docs-directory +web: + - 'platforms/web/**' + - *exclude-markdown + - *exclude-docs-directory +protocol: + - 'protocol/**' + - *exclude-markdown + - *exclude-docs-directory +protocolTypescript: + - 'protocol/languages/typescript/**' + - *exclude-markdown + - *exclude-docs-directory +protocolSwift: + - 'protocol/languages/swift/**' + - *exclude-markdown + - *exclude-docs-directory +protocolKotlin: + - 'protocol/languages/kotlin/**' + - *exclude-markdown + - *exclude-docs-directory +packageSwift: + - 'Package.swift' + - 'Package.resolved' +e2e: + - 'e2e/**' + - *exclude-markdown + - *exclude-docs-directory +ciFilters: + - '.ci/changed-file-filters.yml' diff --git a/.github/filters/platform-non-docs.yml b/.github/filters/platform-non-docs.yml deleted file mode 100644 index f8104e1d..00000000 --- a/.github/filters/platform-non-docs.yml +++ /dev/null @@ -1,27 +0,0 @@ -# Shared source-tree filters for workflows that should ignore docs-only changes. -# Negative patterns need `predicate-quantifier: every` in the workflow step that -# reads this file. -android: - - 'platforms/android/**' - - &exclude-markdown '!**/*.md' - - &exclude-docs-directory '!**/docs/**' -swift: - - 'platforms/swift/**' - - *exclude-markdown - - *exclude-docs-directory -reactNative: - - 'platforms/react-native/**' - - *exclude-markdown - - *exclude-docs-directory -web: - - 'platforms/web/**' - - *exclude-markdown - - *exclude-docs-directory -protocol: - - 'protocol/**' - - *exclude-markdown - - *exclude-docs-directory -protocolTypescript: - - 'protocol/languages/typescript/**' - - *exclude-markdown - - *exclude-docs-directory diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37257b9e..f3f8d5e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: id: platform with: predicate-quantifier: every - filters: .github/filters/platform-non-docs.yml + filters: .ci/changed-file-filters.yml - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: infra @@ -55,7 +55,7 @@ jobs: - '.github/workflows/breaking-changes.yml' - '.github/workflows/release.yml' - '.github/scripts/validate-release-version' - - '.github/filters/**' + - '.ci/changed-file-filters.yml' - '.github/workflows/ci.yml' swift: - 'Package.swift' @@ -65,7 +65,7 @@ jobs: - '.github/workflows/swift-test-workflow.yml' - '.github/workflows/swift-lint.yml' - '.github/workflows/breaking-changes.yml' - - '.github/filters/**' + - '.ci/changed-file-filters.yml' - '.github/workflows/ci.yml' # Common React Native infra: JS-level jobs (jest, lint, packed # files) and shared config that should trigger all RN jobs. @@ -78,7 +78,7 @@ jobs: - '.github/scripts/package-json-version' - '.github/scripts/validate-release-version' - '.github/actions/setup/**' - - '.github/filters/**' + - '.ci/changed-file-filters.yml' - '.github/workflows/breaking-changes.yml' - '.github/workflows/ci.yml' # iOS-only RN infra: gates the macOS build/test jobs. @@ -94,7 +94,7 @@ jobs: web: - '.github/workflows/web.yml' - '.github/actions/setup/**' - - '.github/filters/**' + - '.ci/changed-file-filters.yml' - '.github/workflows/ci.yml' protocol: - '.github/workflows/protocol-test.yml' diff --git a/.github/workflows/package-size.yml b/.github/workflows/package-size.yml index 9dd66edc..450b0da4 100644 --- a/.github/workflows/package-size.yml +++ b/.github/workflows/package-size.yml @@ -28,7 +28,7 @@ jobs: id: package with: predicate-quantifier: every - filters: .github/filters/platform-non-docs.yml + filters: .ci/changed-file-filters.yml - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: infra @@ -36,17 +36,17 @@ jobs: filters: | android: - '.github/scripts/measure-package-size' - - '.github/filters/**' + - '.ci/changed-file-filters.yml' - '.github/workflows/package-size.yml' reactNative: - '.github/actions/setup/**' - '.github/scripts/measure-package-size' - - '.github/filters/**' + - '.ci/changed-file-filters.yml' - '.github/workflows/package-size.yml' web: - '.github/actions/setup/**' - '.github/scripts/measure-package-size' - - '.github/filters/**' + - '.ci/changed-file-filters.yml' - '.github/workflows/package-size.yml' measure: diff --git a/e2e/BITRISE.md b/e2e/BITRISE.md index d29e2cd6..f0073ea4 100644 --- a/e2e/BITRISE.md +++ b/e2e/BITRISE.md @@ -42,7 +42,13 @@ bitrise validate -c e2e/bitrise.yml ## PR trigger -`e2e/bitrise.yml` maps pull requests to the `e2e` pipeline with `trigger_map`. The GitHub checks are kept non-required while `E2E_STRICT=false`. +`e2e/bitrise.yml` maps pull requests to the `e2e` pipeline with `trigger_map`. The trigger uses Bitrise `changed_files.regex` as a coarse source-tree gate for Checkout Kit platform, protocol, shared filter, package, and E2E paths. Bitrise does not support the same named include/exclude filter objects as GitHub Actions, so app-level filtering is enforced by `e2e/config/matrix.yml` and `e2e/scripts/e2e_matrix_to_browserstack_run_plan` after the pipeline starts. + +Shared changed-file filter groups live in `.ci/changed-file-filters.yml` and are consumed by both GitHub Actions and Bitrise E2E. Each application in `e2e/config/matrix.yml` declares `changed_files_filters` by shared group name. The run-plan producer fetches the PR file list from GitHub, applies those groups, emits only matching application rows into the BrowserStack run plan, and shares `E2E_BUILD_*` variables that gate downstream Bitrise build workflows with `run_if`. + +Markdown and `docs/` changes are excluded by the app-level filters. A docs-only change under a coarse Bitrise trigger path can still start the lightweight run-plan workflow, but it will produce an empty run plan and skip app build, BrowserStack execution, and reporting workflows. + +The GitHub checks are kept non-required while `E2E_STRICT=false`. ## Duplicate PR build cancellation diff --git a/e2e/bitrise.yml b/e2e/bitrise.yml index fc36eaf4..55c92865 100644 --- a/e2e/bitrise.yml +++ b/e2e/bitrise.yml @@ -21,6 +21,8 @@ tools: trigger_map: - pull_request_source_branch: "*" draft_pull_request_enabled: false + changed_files: + regex: '^(platforms/(swift|android|react-native)|protocol|e2e|\.ci)/.*|^Package\.(swift|resolved)$' pipeline: e2e pipelines: @@ -28,18 +30,26 @@ pipelines: workflows: e2e-produce-browserstack-run-plan: {} e2e-build-react-native-ios: + run_if: + expression: env "E2E_BUILD_REACT_NATIVE_IOS" | eq "true" depends_on: - e2e-produce-browserstack-run-plan e2e-build-react-native-android: + run_if: + expression: env "E2E_BUILD_REACT_NATIVE_ANDROID" | eq "true" depends_on: - e2e-produce-browserstack-run-plan e2e-execute-browserstack-run: + run_if: + expression: env "E2E_HAS_E2E_RUNS" | eq "true" depends_on: - e2e-produce-browserstack-run-plan - e2e-build-react-native-ios - e2e-build-react-native-android - parallel: $E2E_BROWSERSTACK_RUN_PLAN_COUNT + parallel: $E2E_BROWSERSTACK_RUN_PLAN_PARALLEL_COUNT e2e-report: + run_if: + expression: env "E2E_HAS_E2E_RUNS" | eq "true" depends_on: - e2e-execute-browserstack-run should_always_run: workflow @@ -111,11 +121,20 @@ workflows: - content: |- set -euo pipefail source e2e/scripts/bitrise_ci_helpers - ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan validate + changed_files="$(e2e_deploy_dir)/changed-files.txt" + e2e_log "Fetching pull request changed files" + if [ -n "${BITRISE_PULL_REQUEST:-}" ]; then + ruby e2e/scripts/github_pr_changed_files > "$changed_files" + else + git diff --name-only "${BITRISE_GIT_BRANCH_DEST:-origin/main}...HEAD" > "$changed_files" + fi + ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan validate --changed-files-file "$changed_files" e2e_log "Producing BrowserStack run plan" - ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan expand > "$BITRISE_DEPLOY_DIR/browserstack-run-plan.json" - e2e_log "Publishing BrowserStack run plan count" - envman add --key E2E_BROWSERSTACK_RUN_PLAN_COUNT --value "$(ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan count)" + ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan expand --changed-files-file "$changed_files" > "$BITRISE_DEPLOY_DIR/browserstack-run-plan.json" + e2e_log "Publishing BrowserStack run plan environment" + while IFS='=' read -r key value; do + envman add --key "$key" --value "$value" + done < <(ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan bitrise-env --changed-files-file "$changed_files") e2e_log "Zipping E2E tests" tests_zip="$(e2e_deploy_dir)/e2e-tests.zip" e2e/scripts/zip_e2e_tests --output "$tests_zip" @@ -123,7 +142,12 @@ workflows: envman add --key E2E_TESTS_ZIP --value "$tests_zip" - share-pipeline-variable@1: inputs: - - variables: E2E_BROWSERSTACK_RUN_PLAN_COUNT + - variables: |- + E2E_BROWSERSTACK_RUN_PLAN_COUNT + E2E_BROWSERSTACK_RUN_PLAN_PARALLEL_COUNT + E2E_HAS_E2E_RUNS + E2E_BUILD_REACT_NATIVE_IOS + E2E_BUILD_REACT_NATIVE_ANDROID - deploy-to-bitrise-io@2: is_always_run: false inputs: @@ -196,7 +220,7 @@ workflows: - git-clone@8: {} - pull-intermediate-files@1: inputs: - - artifact_sources: e2e-produce-browserstack-run-plan,e2e-build-react-native-.* + - artifact_sources: e2e-produce-browserstack-run-plan,e2e-build-.* - script@1: title: Execute BrowserStack run timeout: 2700 @@ -215,8 +239,8 @@ workflows: : "${BROWSERSTACK_ACCESS_KEY:?BROWSERSTACK_ACCESS_KEY is required}" : "${E2E_TESTS_ZIP:?E2E_TESTS_ZIP is required. Check e2e-execute-browserstack-run pull-intermediate-files artifact_sources.}" : "${E2E_BROWSERSTACK_RUN_PLAN_JSON:?E2E_BROWSERSTACK_RUN_PLAN_JSON is required. Check e2e-execute-browserstack-run pull-intermediate-files artifact_sources.}" - : "${E2E_REACT_NATIVE_IOS_APP_PATH:?E2E_REACT_NATIVE_IOS_APP_PATH is required. Check e2e-execute-browserstack-run pull-intermediate-files artifact_sources.}" - : "${E2E_REACT_NATIVE_ANDROID_APP_PATH:?E2E_REACT_NATIVE_ANDROID_APP_PATH is required. Check e2e-execute-browserstack-run pull-intermediate-files artifact_sources.}" + run_artifact_env="$(ruby -rjson -e 'run_plan = JSON.parse(File.read(ARGV.fetch(0))); puts run_plan.fetch(Integer(ARGV.fetch(1))).fetch("artifact_env")' "$E2E_BROWSERSTACK_RUN_PLAN_JSON" "$run_index")" + : "${!run_artifact_env:?${run_artifact_env} is required. Check e2e-execute-browserstack-run pull-intermediate-files artifact_sources.}" e2e_log "Executing BrowserStack run plan row ${run_index}" E2E_STRICT="${E2E_STRICT:-false}" e2e/scripts/execute_browserstack_run \ --index "$run_index" \ diff --git a/e2e/config/matrix.yml b/e2e/config/matrix.yml index 53608764..09acb8a1 100644 --- a/e2e/config/matrix.yml +++ b/e2e/config/matrix.yml @@ -1,4 +1,5 @@ version: 1 +changed_file_filters: .ci/changed-file-filters.yml applications: - id: react-native-ios target: react-native @@ -6,12 +7,22 @@ applications: app_id: com.shopify.checkoutkit.reactnativedemo artifact_env: E2E_REACT_NATIVE_IOS_APP_PATH ready_marker: checkout-kit-sample-ready + changed_files_filters: + - reactNative + - protocolTypescript + - e2e + - ciFilters - id: react-native-android target: react-native platform: android app_id: com.shopify.checkoutkit.reactnativedemo artifact_env: E2E_REACT_NATIVE_ANDROID_APP_PATH ready_marker: checkout-kit-sample-ready + changed_files_filters: + - reactNative + - protocolTypescript + - e2e + - ciFilters os_version_tags: - latest suites: diff --git a/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb b/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb index 6dd8b5f0..a65a72b9 100644 --- a/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb +++ b/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true +require "pathname" require "yaml" +require_relative "../../scripts/lib/changed_file_filters" # Transforms the compact E2E matrix in config/matrix.yml into the BrowserStack # run plan consumed by the parallel Bitrise execution workflow. @@ -16,19 +18,20 @@ # every application x suite across that OS, turning an additive config change into # a multiplicative set of runs without hand-writing each one. class E2EMatrixToBrowserStackRunPlan - attr_reader :config_path + attr_reader :config_path, :changed_files - def self.load(config_path) - new(config_path, YAML.safe_load_file(config_path)) + def self.load(config_path, changed_files: nil) + new(config_path, YAML.safe_load_file(config_path, aliases: true), changed_files: changed_files) end - def initialize(config_path, config) + def initialize(config_path, config, changed_files: nil) @config_path = config_path @config = config || {} + @changed_files = changed_files&.map(&:to_s) end def expand - applications.flat_map do |application| + selected_applications.flat_map do |application| os_version_tags.flat_map do |os_version_tag| suites.map do |suite| build_run(application, os_version_tag, suite) @@ -50,12 +53,32 @@ def count expand.length end + def selected_applications + return applications if changed_files.nil? + + applications.select { |application| application_matches_changed_files?(application) } + end + + def bitrise_env + selected_ids = selected_applications.map { |application| application.fetch("id") } + env = { + "E2E_BROWSERSTACK_RUN_PLAN_COUNT" => count.to_s, + "E2E_BROWSERSTACK_RUN_PLAN_PARALLEL_COUNT" => [count, 1].max.to_s, + "E2E_HAS_E2E_RUNS" => count.positive?.to_s + } + applications.each do |application| + env[build_env_key(application.fetch("id"))] = selected_ids.include?(application.fetch("id")).to_s + end + env + end + def validation_errors errors = [] errors << "version must be 1" unless @config.fetch("version", nil) == 1 validate_collection(errors, "applications", applications) validate_collection(errors, "os_version_tags", os_version_tags) validate_collection(errors, "suites", suites) + validate_changed_file_filters(errors) validate_applications(errors) validate_os_version_tags(errors) validate_suites(errors) @@ -85,6 +108,25 @@ def build_run(application, os_version_tag, suite) } end + def application_matches_changed_files?(application) + filter_names = application_changed_file_filter_names(application) + return true if filter_names.empty? + + changed_file_filters.match?(filter_names, changed_files) + end + + def application_changed_file_filter_names(application) + filters = application.fetch("changed_files_filters", nil) + return filters if filters + + filter = application.fetch("changed_files_filter", "") + filter.empty? ? [] : [filter] + end + + def build_env_key(application_id) + "E2E_BUILD_#{application_id.upcase.gsub(/[^A-Z0-9]+/, "_")}" + end + def device_selector(platform, os_version_tag) return os_version_tag.fetch("device_selector") if os_version_tag.is_a?(Hash) && os_version_tag.key?("device_selector") @@ -99,6 +141,22 @@ def applications @config.fetch("applications", []) || [] end + def changed_file_filters + @changed_file_filters ||= ChangedFileFilters.load(changed_file_filters_path) + end + + def changed_file_filters_path + path = @config.fetch("changed_file_filters", nil) + return nil if path.to_s.empty? + return path if Pathname.new(path).absolute? + + File.expand_path(path, repo_root) + end + + def repo_root + File.expand_path("..", e2e_root) + end + def os_version_tags @config.fetch("os_version_tags", []) || [] end @@ -111,6 +169,22 @@ def validate_collection(errors, name, collection) errors << "#{name} must be a non-empty array" unless collection.is_a?(Array) && !collection.empty? end + def validate_changed_file_filters(errors) + if changed_file_filters_path.nil? + errors << "changed_file_filters must point to a shared filter file" + return + end + + unless File.exist?(changed_file_filters_path) + errors << "changed_file_filters file does not exist: #{changed_file_filters_path}" + return + end + + errors.concat(changed_file_filters.validation_errors) + rescue Psych::Exception => error + errors << "changed_file_filters could not be parsed: #{error.message}" + end + def validate_applications(errors) return unless applications.is_a?(Array) @@ -120,11 +194,29 @@ def validate_applications(errors) required_application_keys.each do |key| errors << "application #{id} missing #{key}" if application.fetch(key, "").to_s.empty? end + validate_application_changed_files_filters(errors, application) platform = application.fetch("platform", nil) errors << "application #{id} platform must be ios or android" unless ["ios", "android"].include?(platform) end end + def validate_application_changed_files_filters(errors, application) + id = application.fetch("id", "") + filter_names = application_changed_file_filter_names(application) + if filter_names.empty? + errors << "application #{id} missing changed_files_filters" + return + end + + filter_names.each do |filter_name| + next if changed_file_filters.filters.key?(filter_name) + + errors << "application #{id} references unknown changed file filter #{filter_name}" + end + rescue StandardError + nil + end + def validate_os_version_tags(errors) return unless os_version_tags.is_a?(Array) diff --git a/e2e/scripts/e2e_matrix_to_browserstack_run_plan b/e2e/scripts/e2e_matrix_to_browserstack_run_plan index daf67271..837a4b62 100755 --- a/e2e/scripts/e2e_matrix_to_browserstack_run_plan +++ b/e2e/scripts/e2e_matrix_to_browserstack_run_plan @@ -10,20 +10,27 @@ options = { } parser = OptionParser.new do |opts| - opts.banner = "Usage: e2e/scripts/e2e_matrix_to_browserstack_run_plan validate|expand|count [options]" + opts.banner = "Usage: e2e/scripts/e2e_matrix_to_browserstack_run_plan validate|expand|count|bitrise-env [options]" opts.on("--config PATH") { |path| options[:config] = path } opts.on("--index INDEX", Integer) { |index| options[:index] = index } + opts.on("--changed-files-file PATH") { |path| options[:changed_files_file] = path } + opts.on("--changed-file PATH") { |path| (options[:changed_files] ||= []) << path } end command = ARGV.shift parser.parse!(ARGV) -unless ["validate", "expand", "count"].include?(command) +unless ["validate", "expand", "count", "bitrise-env"].include?(command) warn parser exit 1 end -run_plan = E2EMatrixToBrowserStackRunPlan.load(options.fetch(:config)) +changed_files = [] +changed_files.concat(File.readlines(options.fetch(:changed_files_file), chomp: true)) if options.key?(:changed_files_file) +changed_files.concat(options.fetch(:changed_files, [])) +changed_files = nil unless options.key?(:changed_files_file) || options.key?(:changed_files) + +run_plan = E2EMatrixToBrowserStackRunPlan.load(options.fetch(:config), changed_files: changed_files) case command when "validate" @@ -56,4 +63,14 @@ when "count" end puts run_plan.count +when "bitrise-env" + errors = run_plan.validation_errors + unless errors.empty? + warn errors.join("\n") + exit 1 + end + + run_plan.bitrise_env.each do |key, value| + puts "#{key}=#{value}" + end end diff --git a/e2e/scripts/github_pr_changed_files b/e2e/scripts/github_pr_changed_files new file mode 100755 index 00000000..f0228389 --- /dev/null +++ b/e2e/scripts/github_pr_changed_files @@ -0,0 +1,30 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" +require "net/http" +require "uri" + +repository = ENV.fetch("GITHUB_REPOSITORY", "Shopify/checkout-kit") +pull_request = ENV.fetch("BITRISE_PULL_REQUEST") +token = ENV["GITHUB_TOKEN"] || ENV["GIT_HTTP_PASSWORD"] +raise "GITHUB_TOKEN or GIT_HTTP_PASSWORD is required to fetch PR changed files" if token.to_s.empty? + +page = 1 +loop do + uri = URI("https://api.github.com/repos/#{repository}/pulls/#{pull_request}/files") + uri.query = URI.encode_www_form(per_page: 100, page: page) + request = Net::HTTP::Get.new(uri) + request["Accept"] = "application/vnd.github+json" + request["Authorization"] = "Bearer #{token}" + response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) } + body = response.body.to_s.empty? ? [] : JSON.parse(response.body) + raise "GitHub PR files request failed #{response.code}: #{body}" unless response.is_a?(Net::HTTPSuccess) + + break if body.empty? + + body.each { |file| puts file.fetch("filename") } + break if body.length < 100 + + page += 1 +end diff --git a/scripts/changed_file_filters b/scripts/changed_file_filters new file mode 100755 index 00000000..a26d2e1f --- /dev/null +++ b/scripts/changed_file_filters @@ -0,0 +1,41 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "optparse" +require_relative "lib/changed_file_filters" + +options = { + filters: ".ci/changed-file-filters.yml" +} + +parser = OptionParser.new do |opts| + opts.banner = "Usage: scripts/changed_file_filters match|list [FILTER ...] [options]" + opts.on("--filters PATH") { |path| options[:filters] = path } + opts.on("--changed-files-file PATH") { |path| options[:changed_files_file] = path } + opts.on("--changed-file PATH") { |path| (options[:changed_files] ||= []) << path } +end + +command = ARGV.shift +parser.parse!(ARGV) + +unless ["match", "list"].include?(command) + warn parser + exit 1 +end + +changed_files = [] +changed_files.concat(File.readlines(options.fetch(:changed_files_file), chomp: true)) if options.key?(:changed_files_file) +changed_files.concat(options.fetch(:changed_files, [])) +filters = ChangedFileFilters.load(options.fetch(:filters)) +errors = filters.validation_errors +unless errors.empty? + warn errors.join("\n") + exit 1 +end + +case command +when "match" + exit(filters.match?(ARGV, changed_files) ? 0 : 1) +when "list" + puts filters.matching_filters(changed_files) +end diff --git a/scripts/lib/changed_file_filters.rb b/scripts/lib/changed_file_filters.rb new file mode 100644 index 00000000..76e84304 --- /dev/null +++ b/scripts/lib/changed_file_filters.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "yaml" + +class ChangedFileFilters + MATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB + + attr_reader :filters + + def self.load(path) + new(YAML.safe_load_file(path, aliases: true) || {}) + end + + def initialize(filters) + @filters = filters || {} + end + + def match?(filter_names, changed_files) + names = Array(filter_names).map(&:to_s) + files = Array(changed_files).map(&:to_s) + names.any? do |name| + patterns = filters.fetch(name) + files.any? { |path| path_matches_filter?(path, patterns) } + end + end + + def matching_filters(changed_files) + filters.keys.select { |name| match?([name], changed_files) } + end + + def validation_errors + errors = [] + unless filters.is_a?(Hash) + errors << "changed file filters must be a map" + return errors + end + + filters.each do |name, patterns| + if !patterns.is_a?(Array) || patterns.empty? + errors << "changed file filter #{name} must be a non-empty array" + next + end + + patterns.each do |pattern| + errors << "changed file filter #{name} patterns must be non-empty strings" if pattern.to_s.empty? + end + end + errors + end + + private + + def path_matches_filter?(path, patterns) + positives, negatives = patterns.partition { |pattern| !pattern.start_with?("!") } + positives.any? { |pattern| pattern_matches_path?(pattern, path) } && + negatives.none? { |pattern| pattern_matches_path?(pattern.delete_prefix("!"), path) } + end + + def pattern_matches_path?(pattern, path) + if pattern.end_with?("/**") + prefix = pattern.delete_suffix("/**") + return path == prefix || path.start_with?("#{prefix}/") + end + + File.fnmatch?(pattern, path, MATCH_FLAGS) + end +end