rspec-mergify 0.0.0.dev

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.
data/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # rspec-mergify
2
+
3
+ RSpec plugin for [Mergify CI Insights](https://docs.mergify.com/ci-insights/).
4
+
5
+ ## Features
6
+
7
+ - **Test tracing** — Sends OpenTelemetry traces for every test to Mergify's API
8
+ - **Flaky test detection** — Intelligently reruns tests to detect flakiness with budget constraints
9
+ - **Test quarantine** — Quarantines failing tests so they don't block CI
10
+
11
+ ## Installation
12
+
13
+ Add to your Gemfile:
14
+
15
+ ```ruby
16
+ gem 'rspec-mergify'
17
+ ```
18
+
19
+ Then run `bundle install`.
20
+
21
+ ## Configuration
22
+
23
+ Set the `MERGIFY_TOKEN` environment variable with your Mergify API token.
24
+
25
+ The plugin activates automatically when running in CI (detected via the `CI` environment variable). To enable outside CI, set `RSPEC_MERGIFY_ENABLE=true`.
26
+
27
+ ### Environment Variables
28
+
29
+ | Variable | Description | Default |
30
+ |---|---|---|
31
+ | `MERGIFY_TOKEN` | Mergify API authentication token | (required) |
32
+ | `MERGIFY_API_URL` | Mergify API endpoint | `https://api.mergify.com` |
33
+ | `RSPEC_MERGIFY_ENABLE` | Force-enable outside CI | `false` |
34
+ | `RSPEC_MERGIFY_DEBUG` | Print spans to console | `false` |
35
+ | `MERGIFY_TRACEPARENT` | W3C distributed trace context | — |
36
+ | `MERGIFY_TEST_JOB_NAME` | Mergify test job name | — |
37
+
38
+ ## Development
39
+
40
+ ### Prerequisites
41
+
42
+ - Ruby >= 3.1 (`.ruby-version` pins to 3.4.4 — use [rbenv](https://github.com/rbenv/rbenv) or [mise](https://mise.jdx.dev/) to install it)
43
+ - Bundler
44
+
45
+ ### Setup
46
+
47
+ ```bash
48
+ rbenv install # install the Ruby version from .ruby-version (if needed)
49
+ bundle install
50
+ ```
51
+
52
+ ### Running Tests
53
+
54
+ ```bash
55
+ bundle exec rspec
56
+ ```
57
+
58
+ ### Linting
59
+
60
+ ```bash
61
+ bundle exec rubocop
62
+ ```
63
+
64
+ ## License
65
+
66
+ GPL-3.0-only
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'opentelemetry-sdk'
5
+ require_relative 'utils'
6
+ require_relative 'synchronous_batch_span_processor'
7
+ require_relative 'resources/ci'
8
+ require_relative 'resources/git'
9
+ require_relative 'resources/github_actions'
10
+ require_relative 'resources/jenkins'
11
+ require_relative 'resources/mergify'
12
+ require_relative 'resources/rspec'
13
+
14
+ module Mergify
15
+ module RSpec
16
+ # Central orchestrator for CI Insights: sets up OpenTelemetry tracing,
17
+ # manages the tracer provider, and coordinates flaky detection and quarantine.
18
+ # rubocop:disable Metrics/ClassLength
19
+ class CIInsights
20
+ attr_reader :token, :repo_name, :api_url, :test_run_id,
21
+ :tracer_provider, :tracer, :exporter,
22
+ :branch_name,
23
+ :flaky_detector, :flaky_detector_error_message, :quarantined_tests
24
+
25
+ # rubocop:disable Metrics/MethodLength
26
+ def initialize
27
+ @token = ENV.fetch('MERGIFY_TOKEN', nil)
28
+ @repo_name = Utils.repository_name
29
+ @api_url = ENV.fetch('MERGIFY_API_URL', 'https://api.mergify.com')
30
+ @test_run_id = SecureRandom.hex(8)
31
+ @tracer_provider = nil
32
+ @tracer = nil
33
+ @exporter = nil
34
+ @branch_name = nil
35
+ @flaky_detector = nil
36
+ @flaky_detector_error_message = nil
37
+ @quarantined_tests = nil
38
+
39
+ setup_tracing if Utils.in_ci?
40
+ end
41
+ # rubocop:enable Metrics/MethodLength
42
+
43
+ def mark_test_as_quarantined_if_needed(example_id) # rubocop:disable Naming/PredicateMethod
44
+ return false unless @quarantined_tests&.include?(example_id)
45
+
46
+ @quarantined_tests.mark_as_used(example_id)
47
+ true
48
+ end
49
+
50
+ private
51
+
52
+ def setup_tracing
53
+ processor, exp = build_processor
54
+ return unless processor
55
+
56
+ @exporter = exp
57
+ resource = build_resource
58
+ @tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new(resource: resource)
59
+ @tracer_provider.add_span_processor(processor)
60
+ @tracer = @tracer_provider.tracer('rspec-mergify', Mergify::RSpec::VERSION)
61
+ @branch_name = extract_branch_name(resource)
62
+ load_flaky_detector
63
+ load_quarantine
64
+ end
65
+
66
+ def build_processor
67
+ if debug_mode? || test_mode?
68
+ build_in_memory_processor
69
+ elsif @token && @repo_name
70
+ build_otlp_processor
71
+ else
72
+ [nil, nil]
73
+ end
74
+ end
75
+
76
+ def debug_mode?
77
+ ENV.key?('RSPEC_MERGIFY_DEBUG')
78
+ end
79
+
80
+ def test_mode?
81
+ ENV['_RSPEC_MERGIFY_TEST'] == 'true'
82
+ end
83
+
84
+ def build_in_memory_processor
85
+ exp = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new
86
+ processor = OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exp)
87
+ [processor, exp]
88
+ end
89
+
90
+ def build_otlp_processor
91
+ owner, repo = Utils.split_full_repo_name(@repo_name)
92
+ endpoint = "#{@api_url}/v1/ci/#{owner}/repositories/#{repo}/traces"
93
+ exp = create_otlp_exporter(endpoint)
94
+ processor = SynchronousBatchSpanProcessor.new(exp)
95
+ [processor, exp]
96
+ end
97
+
98
+ # rubocop:disable Metrics/MethodLength
99
+ def build_resource
100
+ resources = [
101
+ Resources::CI.detect,
102
+ Resources::Git.detect,
103
+ Resources::GitHubActions.detect,
104
+ Resources::Jenkins.detect,
105
+ Resources::Mergify.detect,
106
+ Resources::RSpec.detect
107
+ ]
108
+ base = resources.reduce(OpenTelemetry::SDK::Resources::Resource.create({})) do |merged, r|
109
+ merged.merge(r)
110
+ end
111
+ run_id_resource = OpenTelemetry::SDK::Resources::Resource.create('test.run.id' => @test_run_id)
112
+ base.merge(run_id_resource)
113
+ end
114
+ # rubocop:enable Metrics/MethodLength
115
+
116
+ def extract_branch_name(resource)
117
+ attrs = resource.attribute_enumerator.to_h
118
+ attrs['vcs.ref.base.name'] || attrs['vcs.ref.head.name']
119
+ end
120
+
121
+ # rubocop:disable Metrics/MethodLength
122
+ def create_otlp_exporter(endpoint)
123
+ require 'opentelemetry-exporter-otlp'
124
+ original_env = ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil)
125
+ ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] = endpoint
126
+ begin
127
+ OpenTelemetry::Exporter::OTLP::Exporter.new(
128
+ endpoint: endpoint,
129
+ headers: { 'Authorization' => "Bearer #{@token}" },
130
+ compression: 'gzip'
131
+ )
132
+ ensure
133
+ if original_env
134
+ ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] = original_env
135
+ else
136
+ ENV.delete('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT')
137
+ end
138
+ end
139
+ end
140
+ # rubocop:enable Metrics/MethodLength
141
+
142
+ # rubocop:disable Metrics/MethodLength
143
+ def load_flaky_detector
144
+ return unless @token && @repo_name
145
+ return unless Utils.env_truthy?('_MERGIFY_TEST_NEW_FLAKY_DETECTION')
146
+
147
+ require_relative 'flaky_detection'
148
+ mode = @branch_name ? 'new' : 'unhealthy'
149
+ @flaky_detector = FlakyDetector.new(
150
+ token: @token,
151
+ url: @api_url,
152
+ full_repository_name: @repo_name,
153
+ mode: mode
154
+ )
155
+ rescue StandardError => e
156
+ @flaky_detector_error_message = "Could not load flaky detector: #{e.message}"
157
+ end
158
+ # rubocop:enable Metrics/MethodLength
159
+
160
+ def load_quarantine
161
+ return unless @token && @repo_name && @branch_name
162
+
163
+ require_relative 'quarantine'
164
+ @quarantined_tests = Quarantine.new(
165
+ api_url: @api_url,
166
+ token: @token,
167
+ repo_name: @repo_name,
168
+ branch_name: @branch_name
169
+ )
170
+ end
171
+ end
172
+ # rubocop:enable Metrics/ClassLength
173
+ end
174
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Mergify
6
+ module RSpec
7
+ # Registers RSpec hooks for quarantine and flaky detection, and adds the
8
+ # CI Insights formatter when running inside CI.
9
+ module Configuration
10
+ module_function
11
+
12
+ # rubocop:disable Metrics/MethodLength,Metrics/BlockLength,Metrics/AbcSize
13
+ # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
14
+ def setup!
15
+ ::RSpec.configure do |config|
16
+ # Add formatter when in CI
17
+ config.add_formatter(Mergify::RSpec::Formatter) if Utils.in_ci?
18
+
19
+ # Flaky detection: prepare session with all example IDs
20
+ config.before(:suite) do
21
+ ci = Mergify::RSpec.ci_insights
22
+ fd = ci&.flaky_detector
23
+ if fd
24
+ all_ids = ::RSpec.world.example_groups.flat_map(&:descendants).flat_map(&:examples).map(&:id)
25
+ fd.prepare_for_session(all_ids)
26
+ end
27
+ end
28
+
29
+ # Quarantine: mark tests before execution
30
+ config.before(:each) do |example|
31
+ ci = Mergify::RSpec.ci_insights
32
+ next unless ci&.quarantined_tests&.include?(example.id)
33
+
34
+ ci.quarantined_tests.mark_as_used(example.id)
35
+ example.metadata[:mergify_quarantined] = true
36
+ end
37
+
38
+ # Flaky detection: rerun tests within budget
39
+ config.around(:each) do |example|
40
+ ci = Mergify::RSpec.ci_insights
41
+ fd = ci&.flaky_detector
42
+
43
+ example.run
44
+
45
+ # Feed metrics from the initial run so the detector can evaluate
46
+ if fd
47
+ run_time = example.execution_result.run_time || 0.0
48
+ status = example.execution_result.status
49
+ fd.fill_metrics_from_report(example.id, 'setup', 0.0, status)
50
+ fd.fill_metrics_from_report(example.id, 'call', run_time, status)
51
+ fd.fill_metrics_from_report(example.id, 'teardown', 0.0, status)
52
+ end
53
+
54
+ next unless fd&.rerunning_test?(example.id)
55
+
56
+ fd.set_test_deadline(example.id)
57
+ next if fd.test_too_slow?(example.id)
58
+
59
+ example.metadata[:mergify_flaky_detection] = true
60
+ example.metadata[:mergify_new_test] = true if fd.mode == 'new'
61
+
62
+ distinct_outcomes = Set.new
63
+ distinct_outcomes.add(example.execution_result.status) if example.execution_result.status
64
+
65
+ rerun_count = 0
66
+ until example.metadata[:is_last_rerun]
67
+ example.metadata[:is_last_rerun] = fd.last_rerun_for_test?(example.id)
68
+
69
+ # Reset example state for rerun
70
+ example.instance_variable_set(:@exception, nil)
71
+ if example.example_group_instance
72
+ memoized = example.example_group_instance.instance_variable_get(:@__memoized)
73
+ memoized&.clear
74
+ end
75
+
76
+ example.run
77
+ distinct_outcomes.add(example.execution_result.status)
78
+ rerun_count += 1
79
+ end
80
+
81
+ is_flaky = distinct_outcomes.include?(:passed) &&
82
+ distinct_outcomes.include?(:failed)
83
+ example.metadata[:mergify_flaky] = true if is_flaky
84
+ example.metadata[:mergify_rerun_count] = rerun_count
85
+ end
86
+
87
+ # Quarantine: override failed quarantined test results
88
+ config.after(:each) do |example|
89
+ next unless example.metadata[:mergify_quarantined] && example.exception
90
+
91
+ example.instance_variable_set(:@exception, nil)
92
+ example.execution_result.status = :pending
93
+ example.execution_result.pending_message = 'Test is quarantined from Mergify CI Insights'
94
+ end
95
+ end
96
+ end
97
+ # rubocop:enable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
98
+ # rubocop:enable Metrics/MethodLength,Metrics/BlockLength,Metrics/AbcSize
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+ require 'set'
7
+ require_relative 'utils'
8
+
9
+ module Mergify
10
+ module RSpec
11
+ # Manages intelligent test rerunning with budget constraints for flaky detection.
12
+ # rubocop:disable Metrics/ClassLength
13
+ class FlakyDetector
14
+ # Per-test tracking metrics.
15
+ class TestMetrics
16
+ attr_accessor :initial_setup_duration, :initial_call_duration, :initial_teardown_duration,
17
+ :rerun_count, :deadline, :prevented_timeout, :total_duration
18
+
19
+ def initialize
20
+ @initial_setup_duration = 0.0
21
+ @initial_call_duration = 0.0
22
+ @initial_teardown_duration = 0.0
23
+ @rerun_count = 0
24
+ @deadline = nil
25
+ @prevented_timeout = false
26
+ @total_duration = 0.0
27
+ end
28
+
29
+ def initial_duration
30
+ @initial_setup_duration + @initial_call_duration + @initial_teardown_duration
31
+ end
32
+
33
+ def remaining_time
34
+ return 0.0 if @deadline.nil?
35
+
36
+ [(@deadline - Time.now.to_f), 0.0].max
37
+ end
38
+
39
+ def will_exceed_deadline?
40
+ return false if @deadline.nil?
41
+
42
+ (Time.now.to_f + initial_duration) >= @deadline
43
+ end
44
+
45
+ def fill_from_report(phase, duration, _status)
46
+ case phase
47
+ when 'setup'
48
+ @initial_setup_duration = duration if @initial_setup_duration.zero?
49
+ when 'call'
50
+ @initial_call_duration = duration if @initial_call_duration.zero?
51
+ @rerun_count += 1
52
+ when 'teardown'
53
+ @initial_teardown_duration = duration if @initial_teardown_duration.zero?
54
+ end
55
+ @total_duration += duration
56
+ end
57
+ end
58
+
59
+ attr_reader :tests_to_process, :budget, :mode
60
+
61
+ def initialize(token:, url:, full_repository_name:, mode:)
62
+ @token = token
63
+ @url = url
64
+ @full_repository_name = full_repository_name
65
+ @mode = mode
66
+ @metrics = {}
67
+ @over_length_tests = Set.new
68
+ @tests_to_process = []
69
+ @budget = 0.0
70
+
71
+ fetch_context
72
+ validate!
73
+ end
74
+
75
+ # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
76
+ def prepare_for_session(test_ids)
77
+ existing = Set.new(@context[:existing_test_names])
78
+ unhealthy = Set.new(@context[:unhealthy_test_names])
79
+
80
+ @tests_to_process =
81
+ if @mode == 'new'
82
+ test_ids.reject { |id| existing.include?(id) }
83
+ else
84
+ test_ids.select { |id| unhealthy.include?(id) }
85
+ end
86
+
87
+ budget_ratio = if @mode == 'new'
88
+ @context[:budget_ratio_for_new_tests]
89
+ else
90
+ @context[:budget_ratio_for_unhealthy_tests]
91
+ end
92
+
93
+ mean_duration_s = @context[:existing_tests_mean_duration_ms] / 1000.0
94
+ existing_count = @context[:existing_test_names].size
95
+ min_budget_s = @context[:min_budget_duration_ms] / 1000.0
96
+
97
+ ratio_budget = budget_ratio * mean_duration_s * existing_count
98
+ @budget = [ratio_budget, min_budget_s].max
99
+ end
100
+ # rubocop:enable Metrics/MethodLength,Metrics/AbcSize
101
+
102
+ # rubocop:disable Metrics/MethodLength
103
+ def fill_metrics_from_report(test_id, phase, duration, status)
104
+ if status == :skipped
105
+ @metrics.delete(test_id)
106
+ return
107
+ end
108
+
109
+ return unless @tests_to_process.include?(test_id)
110
+
111
+ if test_id.length > @context[:max_test_name_length]
112
+ @over_length_tests.add(test_id)
113
+ return
114
+ end
115
+
116
+ # Only initialize metrics when the first phase is "setup"
117
+ return if !@metrics.key?(test_id) && phase != 'setup'
118
+
119
+ @metrics[test_id] ||= TestMetrics.new
120
+ @metrics[test_id].fill_from_report(phase, duration, status)
121
+ end
122
+ # rubocop:enable Metrics/MethodLength
123
+
124
+ def rerunning_test?(test_id)
125
+ @metrics.key?(test_id) && @metrics[test_id].rerun_count >= 1
126
+ end
127
+
128
+ def test_rerun?(test_id)
129
+ @metrics.key?(test_id) && @metrics[test_id].rerun_count > 1
130
+ end
131
+
132
+ def set_test_deadline(test_id, timeout: nil)
133
+ return unless @metrics.key?(test_id)
134
+
135
+ remaining_tests = [remaining_tests_count, 1].max
136
+ per_test_budget = remaining_budget / remaining_tests
137
+
138
+ allocated =
139
+ if timeout
140
+ [per_test_budget, timeout * 0.9].min
141
+ else
142
+ per_test_budget
143
+ end
144
+
145
+ @metrics[test_id].deadline = Time.now.to_f + allocated
146
+ end
147
+
148
+ def test_too_slow?(test_id)
149
+ return false unless @metrics.key?(test_id)
150
+
151
+ metrics = @metrics[test_id]
152
+ min_exec = @context[:min_test_execution_count]
153
+ (metrics.initial_duration * min_exec) > metrics.remaining_time
154
+ end
155
+
156
+ def last_rerun_for_test?(test_id)
157
+ return false unless @metrics.key?(test_id)
158
+
159
+ metrics = @metrics[test_id]
160
+ metrics.will_exceed_deadline? || metrics.rerun_count >= @context[:max_test_execution_count]
161
+ end
162
+
163
+ def test_metrics(test_id)
164
+ @metrics[test_id]
165
+ end
166
+
167
+ # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
168
+ def make_report
169
+ lines = []
170
+ lines << 'Mergify Flaky Detection Report'
171
+ lines << " Mode : #{@mode}"
172
+ lines << " Budget : #{format('%.2f', @budget)}s"
173
+ lines << " Budget used : #{format('%.2f', budget_used)}s"
174
+ lines << " Tests tracked: #{@metrics.size}"
175
+ lines << ''
176
+
177
+ @metrics.each do |test_id, m|
178
+ lines << " #{test_id}"
179
+ lines << " Reruns : #{m.rerun_count}"
180
+ lines << " Initial dur : #{format('%.3f', m.initial_duration)}s"
181
+ lines << " Total dur : #{format('%.3f', m.total_duration)}s"
182
+ lines << " Timeout warn : #{m.prevented_timeout}" if m.prevented_timeout
183
+ end
184
+
185
+ lines << '' unless @over_length_tests.empty?
186
+ @over_length_tests.each do |id|
187
+ lines << " WARNING: test name too long (skipped): #{id[0, 80]}..."
188
+ end
189
+
190
+ lines.join("\n")
191
+ end
192
+ # rubocop:enable Metrics/MethodLength,Metrics/AbcSize
193
+
194
+ private
195
+
196
+ # rubocop:disable Metrics/AbcSize
197
+ def fetch_context
198
+ owner, repo = Utils.split_full_repo_name(@full_repository_name)
199
+ uri = URI("#{@url}/v1/ci/#{owner}/repositories/#{repo}/flaky-detection-context")
200
+
201
+ http = Net::HTTP.new(uri.host, uri.port)
202
+ http.use_ssl = uri.scheme == 'https'
203
+ http.open_timeout = 10
204
+ http.read_timeout = 10
205
+
206
+ request = Net::HTTP::Get.new(uri)
207
+ request['Authorization'] = "Bearer #{@token}"
208
+
209
+ response = http.request(request)
210
+ parse_context(response.body)
211
+ end
212
+ # rubocop:enable Metrics/AbcSize
213
+
214
+ # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
215
+ def parse_context(body)
216
+ data = JSON.parse(body, symbolize_names: true)
217
+ @context = {
218
+ budget_ratio_for_new_tests: data[:budget_ratio_for_new_tests].to_f,
219
+ budget_ratio_for_unhealthy_tests: data[:budget_ratio_for_unhealthy_tests].to_f,
220
+ existing_test_names: Array(data[:existing_test_names]),
221
+ existing_tests_mean_duration_ms: data[:existing_tests_mean_duration_ms].to_f,
222
+ unhealthy_test_names: Array(data[:unhealthy_test_names]),
223
+ max_test_execution_count: data[:max_test_execution_count].to_i,
224
+ max_test_name_length: data[:max_test_name_length].to_i,
225
+ min_budget_duration_ms: data[:min_budget_duration_ms].to_f,
226
+ min_test_execution_count: data[:min_test_execution_count].to_i
227
+ }
228
+ end
229
+ # rubocop:enable Metrics/MethodLength,Metrics/AbcSize
230
+
231
+ def validate!
232
+ return unless @mode == 'new' && @context[:existing_test_names].empty?
233
+
234
+ raise 'Cannot use "new" mode without existing test names in the context'
235
+ end
236
+
237
+ def remaining_budget
238
+ used = budget_used
239
+ [@budget - used, 0.0].max
240
+ end
241
+
242
+ def budget_used
243
+ @metrics.sum { |_, m| m.total_duration }
244
+ end
245
+
246
+ def remaining_tests_count
247
+ @tests_to_process.count { |id| !@metrics.key?(id) || @metrics[id].deadline.nil? }
248
+ end
249
+ end
250
+ # rubocop:enable Metrics/ClassLength
251
+ end
252
+ end