rspec-mergify 0.2.0-aarch64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require_relative 'utils'
5
+ require_relative 'native'
6
+ require_relative 'version'
7
+
8
+ module Mergify
9
+ module RSpec
10
+ # Signals that flaky detection must not run for this session, and that this
11
+ # is expected rather than a failure: the repository has not opted in (the
12
+ # server responds with 404) or there is no baseline of recorded tests yet.
13
+ # Callers skip silently instead of surfacing an error banner.
14
+ class FlakyDetectionDisabledError < StandardError; end
15
+
16
+ # Manages intelligent test rerunning with budget constraints for flaky detection.
17
+ # rubocop:disable-next Metrics/ClassLength
18
+ class FlakyDetector
19
+ # Per-test tracking metrics.
20
+ class TestMetrics
21
+ attr_accessor :initial_setup_duration, :initial_call_duration, :initial_teardown_duration,
22
+ :rerun_count, :deadline, :prevented_timeout, :total_duration
23
+
24
+ def initialize
25
+ @initial_setup_duration = 0.0
26
+ @initial_call_duration = 0.0
27
+ @initial_teardown_duration = 0.0
28
+ @rerun_count = 0
29
+ @deadline = nil
30
+ @prevented_timeout = false
31
+ @total_duration = 0.0
32
+ end
33
+
34
+ def initial_duration
35
+ @initial_setup_duration + @initial_call_duration + @initial_teardown_duration
36
+ end
37
+
38
+ def remaining_time
39
+ return 0.0 if @deadline.nil?
40
+
41
+ [(@deadline - Time.now.to_f), 0.0].max
42
+ end
43
+
44
+ def will_exceed_deadline?
45
+ return false if @deadline.nil?
46
+
47
+ (Time.now.to_f + initial_duration) >= @deadline
48
+ end
49
+
50
+ def fill_from_report(phase, duration, _status)
51
+ case phase
52
+ when 'setup'
53
+ @initial_setup_duration = duration if @initial_setup_duration.zero?
54
+ when 'call'
55
+ @initial_call_duration = duration if @initial_call_duration.zero?
56
+ @rerun_count += 1
57
+ when 'teardown'
58
+ @initial_teardown_duration = duration if @initial_teardown_duration.zero?
59
+ end
60
+ @total_duration += duration
61
+ end
62
+ end
63
+
64
+ attr_reader :tests_to_process, :budget, :mode
65
+
66
+ def initialize(token:, url:, full_repository_name:, mode:)
67
+ @token = token
68
+ @url = url
69
+ @full_repository_name = full_repository_name
70
+ @mode = mode
71
+ @metrics = {}
72
+ @over_length_tests = Set.new
73
+ @tests_to_process = []
74
+ @budget = 0.0
75
+
76
+ @context = fetch_context
77
+ raise FlakyDetectionDisabledError unless Native::Budget.should_run(@context, @mode)
78
+ end
79
+
80
+ # Which tests this session reruns, and how long it may spend doing it,
81
+ # both come from the shared budget engine -- so a Ruby suite and a Python
82
+ # one facing the same context spend the same time.
83
+ #
84
+ # The engine sizes the budget from the existing tests *in this session*,
85
+ # where this class counted every existing test the context knew about. A
86
+ # session running part of a suite was handed the whole suite's budget; it
87
+ # now gets its own.
88
+ def prepare_for_session(test_ids)
89
+ plan = Native::Budget.compute(@context, @mode, test_ids, [])
90
+
91
+ @tests_to_process = plan['tests_to_process']
92
+ # The engine works in milliseconds; everything downstream compares
93
+ # against RSpec's durations, which are seconds.
94
+ @budget = plan['available_budget_ms'] / 1000.0
95
+ end
96
+
97
+ # rubocop:disable-next Metrics/MethodLength
98
+ def fill_metrics_from_report(test_id, phase, duration, status)
99
+ if status == :skipped
100
+ @metrics.delete(test_id)
101
+ return
102
+ end
103
+
104
+ return unless @tests_to_process.include?(test_id)
105
+
106
+ if test_id.length > @context['max_test_name_length']
107
+ @over_length_tests.add(test_id)
108
+ return
109
+ end
110
+
111
+ # Only initialize metrics when the first phase is "setup"
112
+ return if !@metrics.key?(test_id) && phase != 'setup'
113
+
114
+ @metrics[test_id] ||= TestMetrics.new
115
+ @metrics[test_id].fill_from_report(phase, duration, status)
116
+ end
117
+
118
+ def rerunning_test?(test_id)
119
+ @metrics.key?(test_id) && @metrics[test_id].rerun_count >= 1
120
+ end
121
+
122
+ def test_rerun?(test_id)
123
+ @metrics.key?(test_id) && @metrics[test_id].rerun_count > 1
124
+ end
125
+
126
+ def set_test_deadline(test_id, timeout: nil)
127
+ return unless @metrics.key?(test_id)
128
+
129
+ remaining_tests = [remaining_tests_count, 1].max
130
+ per_test_budget = remaining_budget / remaining_tests
131
+
132
+ allocated =
133
+ if timeout
134
+ [per_test_budget, timeout * 0.9].min
135
+ else
136
+ per_test_budget
137
+ end
138
+
139
+ @metrics[test_id].deadline = Time.now.to_f + allocated
140
+ end
141
+
142
+ def test_too_slow?(test_id)
143
+ return false unless @metrics.key?(test_id)
144
+
145
+ metrics = @metrics[test_id]
146
+ min_exec = @context['min_test_execution_count']
147
+ (metrics.initial_duration * min_exec) > metrics.remaining_time
148
+ end
149
+
150
+ def last_rerun_for_test?(test_id)
151
+ return false unless @metrics.key?(test_id)
152
+
153
+ metrics = @metrics[test_id]
154
+ metrics.will_exceed_deadline? || metrics.rerun_count >= @context['max_test_execution_count']
155
+ end
156
+
157
+ def test_metrics(test_id)
158
+ @metrics[test_id]
159
+ end
160
+
161
+ # rubocop:disable-next Metrics/MethodLength,Metrics/AbcSize
162
+ def make_report
163
+ lines = []
164
+ lines << 'Mergify Flaky Detection Report'
165
+ lines << " Mode : #{@mode}"
166
+ lines << " Budget : #{format('%.2f', @budget)}s"
167
+ lines << " Budget used : #{format('%.2f', budget_used)}s"
168
+ lines << " Tests tracked: #{@metrics.size}"
169
+ lines << ''
170
+
171
+ @metrics.each do |test_id, m|
172
+ lines << " #{test_id}"
173
+ lines << " Reruns : #{m.rerun_count}"
174
+ lines << " Initial dur : #{format('%.3f', m.initial_duration)}s"
175
+ lines << " Total dur : #{format('%.3f', m.total_duration)}s"
176
+ lines << " Timeout warn : #{m.prevented_timeout}" if m.prevented_timeout
177
+ end
178
+
179
+ lines << '' unless @over_length_tests.empty?
180
+ @over_length_tests.each do |id|
181
+ lines << " WARNING: test name too long (skipped): #{id[0, 80]}..."
182
+ end
183
+
184
+ lines.join("\n")
185
+ end
186
+
187
+ private
188
+
189
+ # A nil context means the repository has not opted into flaky detection,
190
+ # which is the expected default rather than a failure.
191
+ def fetch_context
192
+ raise FlakyDetectionDisabledError unless Native.available?
193
+
194
+ owner, repo = Utils.split_full_repo_name(@full_repository_name)
195
+ context = Native::Client.new(@url, @token, owner, repo, VERSION).fetch_flaky_context
196
+ raise FlakyDetectionDisabledError if context.nil?
197
+
198
+ context
199
+ end
200
+
201
+ def remaining_budget
202
+ used = budget_used
203
+ [@budget - used, 0.0].max
204
+ end
205
+
206
+ def budget_used
207
+ @metrics.sum { |_, m| m.total_duration }
208
+ end
209
+
210
+ def remaining_tests_count
211
+ @tests_to_process.count { |id| !@metrics.key?(id) || @metrics[id].deadline.nil? }
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core/formatters/base_formatter'
4
+ require 'opentelemetry-sdk'
5
+
6
+ module Mergify
7
+ module RSpec
8
+ # RSpec formatter that creates OpenTelemetry spans for Mergify Test Insights and
9
+ # prints a terminal report. It is purely observational and does not modify
10
+ # test execution.
11
+ # rubocop:disable-next Metrics/ClassLength
12
+ class Formatter < ::RSpec::Core::Formatters::BaseFormatter
13
+ ::RSpec::Core::Formatters.register self,
14
+ :start,
15
+ :example_started,
16
+ :example_finished,
17
+ :example_pending,
18
+ :stop
19
+
20
+ # rubocop:disable-next Metrics/MethodLength
21
+ def start(notification)
22
+ super
23
+
24
+ @ci_insights = Mergify::RSpec.ci_insights
25
+ return unless @ci_insights&.tracer
26
+
27
+ extract_distributed_trace_context
28
+
29
+ @session_span = @ci_insights.tracer.start_span(
30
+ 'rspec session start',
31
+ with_parent: @parent_context,
32
+ attributes: { 'test.scope' => 'session' }
33
+ )
34
+ @has_error = false
35
+ @example_spans = {}
36
+ end
37
+
38
+ def example_started(notification)
39
+ return unless @ci_insights&.tracer && @session_span
40
+
41
+ example = notification.example
42
+ parent_context = OpenTelemetry::Trace.context_with_span(@session_span)
43
+ quarantined = @ci_insights.mark_test_as_quarantined_if_needed(example.id)
44
+
45
+ span = @ci_insights.tracer.start_span(
46
+ example.id,
47
+ with_parent: parent_context,
48
+ attributes: build_example_attributes(example, quarantined)
49
+ )
50
+ @example_spans[example.id] = span
51
+ end
52
+
53
+ # rubocop:disable-next Metrics/MethodLength
54
+ def example_finished(notification)
55
+ return unless @example_spans
56
+
57
+ example = notification.example
58
+ span = @example_spans.delete(example.id)
59
+ return unless span
60
+
61
+ result = example.execution_result
62
+ status = result.status.to_s
63
+ span.set_attribute('test.case.result.status', status)
64
+ set_flaky_attributes(span, example)
65
+
66
+ if result.status == :failed
67
+ set_error_attributes(span, result.exception)
68
+ @has_error = true
69
+ else
70
+ span.status = OpenTelemetry::Trace::Status.ok
71
+ end
72
+
73
+ span.finish
74
+ end
75
+
76
+ def example_pending(notification)
77
+ return unless @example_spans
78
+
79
+ example = notification.example
80
+ span = @example_spans.delete(example.id)
81
+ return unless span
82
+
83
+ span.set_attribute('test.case.result.status', 'skipped')
84
+ span.finish
85
+ end
86
+
87
+ def stop(_notification)
88
+ finish_session_span
89
+ print_report
90
+ flush_and_shutdown
91
+ end
92
+
93
+ private
94
+
95
+ def extract_distributed_trace_context
96
+ traceparent = ENV.fetch('MERGIFY_TRACEPARENT', nil)
97
+ @parent_context = if traceparent
98
+ propagator = OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator.new
99
+ propagator.extract({ 'traceparent' => traceparent })
100
+ end
101
+ end
102
+
103
+ def build_example_attributes(example, quarantined)
104
+ {
105
+ 'test.scope' => 'case',
106
+ 'code.filepath' => example.metadata[:file_path].delete_prefix('./'),
107
+ 'code.function' => example.description,
108
+ 'code.lineno' => example.metadata[:line_number] || 0,
109
+ 'code.namespace' => example.example_group.description,
110
+ 'code.file.path' => File.expand_path(example.metadata[:file_path]),
111
+ 'code.line.number' => example.metadata[:line_number] || 0,
112
+ 'cicd.test.quarantined' => quarantined
113
+ }
114
+ end
115
+
116
+ def set_flaky_attributes(span, example)
117
+ meta = example.metadata
118
+
119
+ rerun_count = meta[:mergify_rerun_count]
120
+ span.set_attribute('cicd.test.rerun_count', rerun_count) unless rerun_count.nil?
121
+
122
+ flaky = meta[:mergify_flaky]
123
+ span.set_attribute('cicd.test.flaky', flaky) unless flaky.nil?
124
+
125
+ flaky_detection = meta[:mergify_flaky_detection]
126
+ span.set_attribute('cicd.test.flaky_detection', flaky_detection) unless flaky_detection.nil?
127
+
128
+ new_test = meta[:mergify_new_test]
129
+ span.set_attribute('cicd.test.new', new_test) unless new_test.nil?
130
+ end
131
+
132
+ def set_error_attributes(span, exception)
133
+ span.set_attribute('exception.type', exception.class.to_s)
134
+ span.set_attribute('exception.message', exception.message)
135
+ span.set_attribute('exception.stacktrace', exception.backtrace&.join("\n") || '')
136
+ span.status = OpenTelemetry::Trace::Status.error(exception.message)
137
+ end
138
+
139
+ def finish_session_span
140
+ return unless @session_span
141
+
142
+ @session_span.status = if @has_error
143
+ OpenTelemetry::Trace::Status.error('One or more tests failed')
144
+ else
145
+ OpenTelemetry::Trace::Status.ok
146
+ end
147
+ @session_span.finish
148
+ end
149
+
150
+ # rubocop:disable-next Metrics/MethodLength
151
+ def print_report
152
+ output.puts ''
153
+ output.puts '--- Mergify CI ---'
154
+
155
+ unless @ci_insights
156
+ output.puts 'Mergify Test Insights is not configured.'
157
+ return
158
+ end
159
+
160
+ print_configuration_warnings
161
+ print_flaky_report
162
+ print_quarantine_report
163
+ output.puts "MERGIFY_TEST_RUN_ID=#{@ci_insights.test_run_id}"
164
+ output.puts '------------------'
165
+ end
166
+
167
+ def print_configuration_warnings
168
+ output.puts 'WARNING: MERGIFY_TOKEN is not set. Traces will not be sent to Mergify.' unless @ci_insights.token
169
+
170
+ return if @ci_insights.repo_name
171
+
172
+ output.puts 'WARNING: Could not detect repository name. ' \
173
+ 'Please set GITHUB_REPOSITORY or configure a git remote.'
174
+ end
175
+
176
+ def print_flaky_report
177
+ return unless @ci_insights.flaky_detector.respond_to?(:make_report)
178
+
179
+ report = @ci_insights.flaky_detector.make_report
180
+ output.puts report if report
181
+ end
182
+
183
+ def print_quarantine_report
184
+ return unless @ci_insights.quarantined_tests.respond_to?(:report)
185
+
186
+ report = @ci_insights.quarantined_tests.report
187
+ output.puts report if report
188
+ end
189
+
190
+ def flush_and_shutdown # rubocop:disable Metrics/MethodLength
191
+ return unless @ci_insights&.tracer_provider
192
+
193
+ begin
194
+ @ci_insights.tracer_provider.force_flush
195
+ rescue StandardError => e
196
+ print_export_error(e)
197
+ end
198
+
199
+ begin
200
+ @ci_insights.tracer_provider.shutdown
201
+ rescue StandardError => e
202
+ output.puts "Error while shutting down the tracer: #{e.message}"
203
+ end
204
+ end
205
+
206
+ def print_export_error(error)
207
+ output.puts "Error while exporting traces: #{error.message}"
208
+ output.puts ''
209
+ output.puts 'Common issues:'
210
+ output.puts ' - Your MERGIFY_TOKEN might not be set or could be invalid'
211
+ output.puts ' - Mergify Test Insights might not be enabled for this repository'
212
+ output.puts ' - There might be a network connectivity issue with the Mergify API'
213
+ output.puts ''
214
+ output.puts 'Documentation: https://docs.mergify.com/ci-insights/test-frameworks/'
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mergify
4
+ module RSpec
5
+ # The Rust extension over mergify-ci-core: CI detection shared with the
6
+ # pytest and TypeScript clients.
7
+ #
8
+ # Loading is best-effort by design. A precompiled gem carries one extension
9
+ # per Ruby under `<ruby>/`, the source gem compiles one alongside this file,
10
+ # and a platform we publish neither for gets neither. Detection is one input
11
+ # to telemetry, not the point of the gem, so an absent extension degrades
12
+ # what we can report rather than breaking the suite under test -- the same
13
+ # fail-open posture the napi binding takes when a platform has no prebuilt
14
+ # binary. `load_error` keeps the reason, for callers that want to say so.
15
+ module Native
16
+ # Raised when a Mergify API call fails outright.
17
+ #
18
+ # Distinct from StandardError on purpose: callers degrade on an API
19
+ # failure, and a bare rescue there would swallow genuine bugs in the
20
+ # binding as though the backend were down.
21
+ class ApiError < StandardError; end
22
+
23
+ class << self
24
+ # The LoadError that prevented the extension loading, or nil.
25
+ attr_accessor :load_error
26
+
27
+ def available?
28
+ load_error.nil?
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ begin
36
+ # Precompiled gems ship lib/mergify/rspec/<ruby>/mergify_ci.<dlext>.
37
+ require_relative "#{RUBY_VERSION.to_f}/mergify_ci"
38
+ rescue LoadError
39
+ begin
40
+ # Source gem, and any local `rake compile`.
41
+ require_relative 'mergify_ci'
42
+ rescue LoadError => e
43
+ Mergify::RSpec::Native.load_error = e
44
+ end
45
+ end
46
+
47
+ unless Mergify::RSpec::Native.available?
48
+ # Stand in for the extension so callers can just ask, and get the same answer
49
+ # they would get from a machine that is not in CI. Branching on availability
50
+ # at every call site would only spread the same nil back through the caller.
51
+ module Mergify
52
+ module RSpec
53
+ module Native
54
+ class << self
55
+ def detect_provider
56
+ nil
57
+ end
58
+
59
+ def detect_repository_name
60
+ nil
61
+ end
62
+
63
+ def detect_attributes
64
+ {}
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require_relative 'utils'
5
+ require_relative 'native'
6
+ require_relative 'version'
7
+
8
+ module Mergify
9
+ module RSpec
10
+ # Fetches quarantined test names from the Mergify API and tracks which are used.
11
+ #
12
+ # The fetch itself -- pagination, the RFC 8288 `next` links, the status
13
+ # codes that mean "not subscribed" rather than "broken" -- belongs to the
14
+ # shared Rust client now, so every Mergify test client reads a quarantine
15
+ # list the same way. What stays here is what RSpec cares about: which of
16
+ # those tests this session actually ran, and the report at the end.
17
+ class Quarantine
18
+ attr_reader :quarantined_tests, :init_error_msg
19
+
20
+ def initialize(api_url:, token:, repo_name:, branch_name:)
21
+ @repo_name = repo_name
22
+ @branch_name = branch_name
23
+ @quarantined_tests = []
24
+ @used_tests = Set.new
25
+ @init_error_msg = nil
26
+
27
+ fetch(api_url, token, branch_name)
28
+ end
29
+
30
+ def include?(example_id)
31
+ @quarantined_tests.include?(example_id)
32
+ end
33
+
34
+ def mark_as_used(example_id)
35
+ @used_tests.add(example_id)
36
+ end
37
+
38
+ # rubocop:disable-next Metrics/MethodLength,Metrics/AbcSize
39
+ def report
40
+ used, unused = @quarantined_tests.partition { |t| @used_tests.include?(t) }
41
+
42
+ lines = []
43
+ lines << 'Mergify Quarantine Report'
44
+ lines << " Repository : #{@repo_name}"
45
+ lines << " Branch : #{@branch_name}"
46
+ lines << " Quarantined tests from API: #{@quarantined_tests.size}"
47
+ lines << ''
48
+ lines << " Quarantined tests run (#{used.size}):"
49
+ used.each { |t| lines << " - #{t}" }
50
+ lines << ''
51
+ lines << " Unused quarantined tests (#{unused.size}):"
52
+ unused.each { |t| lines << " - #{t}" }
53
+ lines.join("\n")
54
+ end
55
+
56
+ private
57
+
58
+ # A nil list means the repository has no quarantine subscription, which is
59
+ # not an error: the session simply quarantines nothing. Anything that went
60
+ # genuinely wrong is recorded and the suite carries on -- this plugin has
61
+ # never let the backend fail a test run.
62
+ def fetch(api_url, token, branch_name)
63
+ unless Native.available?
64
+ @init_error_msg = "Mergify native extension unavailable: #{Native.load_error}"
65
+ return
66
+ end
67
+
68
+ owner, repo = Utils.split_full_repo_name(@repo_name)
69
+ client = Native::Client.new(api_url, token, owner, repo, VERSION)
70
+ @quarantined_tests = client.fetch_quarantine(branch_name) || []
71
+ rescue Utils::InvalidRepositoryFullNameError, Native::ApiError => e
72
+ @init_error_msg = e.message
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'opentelemetry-sdk'
4
+ require 'rspec/core/version'
5
+
6
+ module Mergify
7
+ module RSpec
8
+ module Resources
9
+ # Detects OpenTelemetry Resource attributes for RSpec.
10
+ module RSpec
11
+ module_function
12
+
13
+ def detect
14
+ OpenTelemetry::SDK::Resources::Resource.create(
15
+ 'test.framework' => 'rspec',
16
+ 'test.framework.version' => ::RSpec::Core::Version::STRING
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'opentelemetry-sdk'
4
+
5
+ module Mergify
6
+ module RSpec
7
+ class ExportError < StandardError; end
8
+
9
+ # A span processor that queues spans in memory and exports them all in one
10
+ # batch when force_flush is called. This avoids HTTP requests during test
11
+ # execution.
12
+ class SynchronousBatchSpanProcessor < OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor
13
+ def initialize(exporter)
14
+ super
15
+ @queue = []
16
+ end
17
+
18
+ def on_finish(span)
19
+ return unless span.context.trace_flags.sampled?
20
+
21
+ @queue << span.to_span_data
22
+ end
23
+
24
+ def force_flush(timeout: nil) # rubocop:disable Lint/UnusedMethodArgument
25
+ spans = @queue.dup
26
+ @queue.clear
27
+ result = @span_exporter.export(spans)
28
+ raise ExportError, 'Failed to export traces' unless result == OpenTelemetry::SDK::Trace::Export::SUCCESS
29
+
30
+ OpenTelemetry::SDK::Trace::Export::SUCCESS
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mergify
4
+ module RSpec
5
+ # Utility methods shared across the rspec-mergify gem.
6
+ #
7
+ # CI detection used to live here -- the provider table, the git shelling
8
+ # out, the per-provider environment mappings. It is the Rust core's now, via
9
+ # the extension in Mergify::RSpec::Native, so that every Mergify test client
10
+ # detects identically. What is left is what is genuinely Ruby's: parsing a
11
+ # repository name the API needs split, and deciding whether the plugin
12
+ # should switch itself on at all.
13
+ module Utils
14
+ module_function
15
+
16
+ # Raised when a repository full name (owner/repo) is malformed.
17
+ class InvalidRepositoryFullNameError < StandardError; end
18
+
19
+ TRUTHY_STRINGS = %w[y yes t true on 1].freeze
20
+ FALSY_STRINGS = %w[n no f false off 0].freeze
21
+
22
+ # Convert a string to a boolean.
23
+ # Truthy: y yes t true on 1
24
+ # Falsy: n no f false off 0
25
+ # Raises ArgumentError for anything else.
26
+ def strtobool(string)
27
+ return true if TRUTHY_STRINGS.include?(string.downcase)
28
+ return false if FALSY_STRINGS.include?(string.downcase)
29
+
30
+ raise ArgumentError, "Could not convert '#{string}' to boolean"
31
+ end
32
+
33
+ # Returns true when the named environment variable holds a truthy value.
34
+ def env_truthy?(key)
35
+ TRUTHY_STRINGS.include?(ENV.fetch(key, '').downcase)
36
+ end
37
+
38
+ # Returns true when the suite is running inside CI or when
39
+ # RSPEC_MERGIFY_ENABLE is set to a truthy value.
40
+ #
41
+ # Deliberately not the core's provider detection: this asks whether the
42
+ # plugin should run, which an unrecognised CI or a developer setting
43
+ # RSPEC_MERGIFY_ENABLE both answer yes to.
44
+ def in_ci?
45
+ env_truthy?('CI') || env_truthy?('RSPEC_MERGIFY_ENABLE')
46
+ end
47
+
48
+ # Split "owner/repo" into [owner, repo].
49
+ # Raises InvalidRepositoryFullNameError when the format is wrong.
50
+ def split_full_repo_name(full_repo_name)
51
+ parts = full_repo_name.split('/')
52
+ return parts if parts.size == 2
53
+
54
+ raise InvalidRepositoryFullNameError, "Invalid repository name: #{full_repo_name}"
55
+ end
56
+ end
57
+ end
58
+ end