rspec-mergify 0.1.4 → 0.3.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3b9e1b83fe9ad90add227b5618ac7b4594883cf39c0eb58ab150feb0de9d916
4
- data.tar.gz: e801b0a101413dea6c0d723e58d79c4a978cffdf252ca35332a64e47c4b75493
3
+ metadata.gz: 0e962d0dafd4d52189d682226bdfee4729bab091553a4b0ab261c1f371003b5c
4
+ data.tar.gz: 98b9017bf4067a6f4bc42a90a7ee957e3fb82899c368704c17c8b150b6234e03
5
5
  SHA512:
6
- metadata.gz: 1a30b290d0860751b9a8be05cc18615415c0f3dc8ae16fbdd9ea877b54688767625e4d931e88f901ecbf8ca2dddc2e02eee457958ca31e7fb8fb305088912b66
7
- data.tar.gz: 643aa383dbaa5324534aea467d8220642f3240182e16d57447d73e4dd44e4a0337d4de9514233b13498802057638d4574311bb046d57cd93898ddf1747361b00
6
+ metadata.gz: 6020c63eb271ebf4da6dcfaa1e7b6ef4f7b2a8e61741dc6840cc934021250eabb5592dcad2d57cbd41950285eb55370deff9c7bb3aa37aef8c1bf689f0ccf380
7
+ data.tar.gz: a50947dbb468eca3b1deec17218e15fd08c80a051101ad11c71e3f4af9b45488a1f3bf4ebc026e6b6ae0869b6834110b0caabda45794d990350923c4a70802ae
data/README.md CHANGED
@@ -18,6 +18,23 @@ gem 'rspec-mergify'
18
18
 
19
19
  Then run `bundle install`.
20
20
 
21
+ ### Supported platforms
22
+
23
+ The gem ships precompiled, so nothing is built on your machine. Each platform gem carries an extension for Ruby 3.1 through 4.0:
24
+
25
+ | Platform | Requirement |
26
+ |---|---|
27
+ | Linux x86_64 / aarch64 (glibc) | **glibc 2.30 or newer** — Debian 11+, Ubuntu 20.04+, RHEL 9+, Amazon Linux 2023 |
28
+ | Linux x86_64 / aarch64 (musl) | Alpine; on Ruby 3.1 also `apk add libgcc` |
29
+ | macOS arm64 / x86_64 | — |
30
+ | Windows x64 (UCRT) | — |
31
+
32
+ Anywhere else, and on glibc older than 2.30 — RHEL, Rocky, AlmaLinux and Oracle Linux 8, Amazon Linux 2, CentOS 7 — the extension cannot load. The gem still installs and your suite still runs, but nothing is reported to Mergify. Pin the last pure-Ruby release on those systems:
33
+
34
+ ```ruby
35
+ gem 'rspec-mergify', '0.1.4'
36
+ ```
37
+
21
38
  ## Configuration
22
39
 
23
40
  Set the `MERGIFY_TOKEN` environment variable with your Mergify API token.
@@ -1,37 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
- require 'opentelemetry-sdk'
4
+ require_relative 'trace'
5
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/buildkite'
12
- require_relative 'resources/mergify'
6
+ require_relative 'native'
13
7
  require_relative 'resources/rspec'
14
8
 
15
9
  module Mergify
16
10
  module RSpec
17
- # Central orchestrator for Mergify Test Insights: sets up OpenTelemetry tracing,
18
- # manages the tracer provider, and coordinates flaky detection and quarantine.
19
- # rubocop:disable-next Metrics/ClassLength
11
+ # Central orchestrator for Mergify Test Insights: records the run's spans,
12
+ # uploads them, and coordinates flaky detection and quarantine.
20
13
  class CIInsights
21
14
  attr_reader :token, :repo_name, :api_url, :test_run_id,
22
- :tracer_provider, :tracer, :exporter,
15
+ :recorder,
23
16
  :branch_name,
24
17
  :flaky_detector, :flaky_detector_error_message, :quarantined_tests
25
18
 
26
19
  # rubocop:disable-next Metrics/MethodLength
27
20
  def initialize
28
21
  @token = ENV.fetch('MERGIFY_TOKEN', nil)
29
- @repo_name = Utils.repository_name
22
+ @repo_name = Native.detect_repository_name
30
23
  @api_url = ENV.fetch('MERGIFY_API_URL', 'https://api.mergify.com')
31
24
  @test_run_id = SecureRandom.hex(8)
32
- @tracer_provider = nil
33
- @tracer = nil
34
- @exporter = nil
25
+ @recorder = nil
26
+ @uploads = false
35
27
  @branch_name = nil
36
28
  @flaky_detector = nil
37
29
  @flaky_detector_error_message = nil
@@ -40,6 +32,21 @@ module Mergify
40
32
  setup_tracing if Utils.in_ci?
41
33
  end
42
34
 
35
+ # Send the run, once, at the end. Failing to report a run is worth
36
+ # saying out loud but never worth failing a suite that just passed,
37
+ # so this answers with a message instead of raising.
38
+ def flush
39
+ return nil unless @recorder && @uploads
40
+ return nil if @recorder.finished_spans.empty?
41
+
42
+ owner, repo = Utils.split_full_repo_name(@repo_name)
43
+ client = Native::Client.new(@api_url, @token, owner, repo, Mergify::RSpec::VERSION)
44
+ client.upload_trace(@recorder.resource_attributes, @recorder.finished_spans.map(&:to_h))
45
+ nil
46
+ rescue Native::ApiError, Utils::InvalidRepositoryFullNameError => e
47
+ e.message
48
+ end
49
+
43
50
  def mark_test_as_quarantined_if_needed(example_id) # rubocop:disable Naming/PredicateMethod
44
51
  return false unless @quarantined_tests&.include?(example_id)
45
52
 
@@ -49,30 +56,25 @@ module Mergify
49
56
 
50
57
  private
51
58
 
59
+ # Recording is unconditional; whether the run is *uploaded* is what the
60
+ # token and repository decide. Debug and test runs keep their spans and
61
+ # send nothing, which is what they always did -- the difference is that
62
+ # the collector is the same object either way instead of two processors.
52
63
  def setup_tracing
53
- processor, exp = build_processor
54
- return unless processor
55
-
56
- @exporter = exp
57
64
  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)
65
+ @recorder = Trace::Recorder.new(resource_attributes: resource,
66
+ traceparent: ENV.fetch('MERGIFY_TRACEPARENT', nil))
67
+ @uploads = uploadable?
68
+ # Only a pull request has a base branch, and that is what puts flaky
69
+ # detection in 'new' mode. GitHub Actions still sets GITHUB_BASE_REF on
70
+ # every other event, to an empty string, so empty counts as absent.
71
+ base_branch_name = resource['vcs.ref.base.name']
72
+ @base_branch_name = base_branch_name unless base_branch_name.to_s.empty?
73
+ @branch_name = @base_branch_name || resource['vcs.ref.head.name']
62
74
  load_flaky_detector
63
75
  load_quarantine
64
76
  end
65
77
 
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
78
  def debug_mode?
77
79
  ENV.key?('RSPEC_MERGIFY_DEBUG')
78
80
  end
@@ -81,62 +83,23 @@ module Mergify
81
83
  ENV['_RSPEC_MERGIFY_TEST'] == 'true'
82
84
  end
83
85
 
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
86
+ # A run is uploaded when there is somewhere to upload it to and nothing
87
+ # asking us not to: debug and test runs record and keep.
88
+ def uploadable?
89
+ return false if debug_mode? || test_mode?
90
+ return false unless @token && @repo_name && Native.available?
89
91
 
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]
92
+ true
96
93
  end
97
94
 
98
- # rubocop:disable-next Metrics/MethodLength
95
+ # The cicd.* and vcs.* attributes come from the Rust core, which every
96
+ # Mergify test client shares, so a CI provider added there reaches every
97
+ # client at once. What stays here is what only Ruby knows: the test
98
+ # framework and its language, and the id this run invented for itself.
99
99
  def build_resource
100
- resources = [
101
- Resources::CI.detect,
102
- Resources::Git.detect,
103
- Resources::GitHubActions.detect,
104
- Resources::Jenkins.detect,
105
- Resources::Buildkite.detect,
106
- Resources::Mergify.detect,
107
- Resources::RSpec.detect
108
- ]
109
- base = resources.reduce(OpenTelemetry::SDK::Resources::Resource.create({})) do |merged, r|
110
- merged.merge(r)
111
- end
112
- run_id_resource = OpenTelemetry::SDK::Resources::Resource.create('test.run.id' => @test_run_id)
113
- base.merge(run_id_resource)
114
- end
115
-
116
- def extract_branch_name(resource)
117
- attrs = resource.attribute_enumerator.to_h
118
- @base_branch_name = attrs['vcs.ref.base.name']
119
- @base_branch_name || attrs['vcs.ref.head.name']
120
- end
121
-
122
- # rubocop:disable-next Metrics/MethodLength
123
- def create_otlp_exporter(endpoint)
124
- require 'opentelemetry-exporter-otlp'
125
- original_env = ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil)
126
- ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] = endpoint
127
- begin
128
- OpenTelemetry::Exporter::OTLP::Exporter.new(
129
- endpoint: endpoint,
130
- headers: { 'Authorization' => "Bearer #{@token}" },
131
- compression: 'gzip'
132
- )
133
- ensure
134
- if original_env
135
- ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] = original_env
136
- else
137
- ENV.delete('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT')
138
- end
139
- end
100
+ Native.detect_attributes
101
+ .merge(Resources::RSpec.detect)
102
+ .merge('test.run.id' => @test_run_id)
140
103
  end
141
104
 
142
105
  # rubocop:disable-next Metrics/MethodLength
@@ -1,10 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'net/http'
4
- require 'json'
5
- require 'uri'
6
3
  require 'set'
7
4
  require_relative 'utils'
5
+ require_relative 'native'
6
+ require_relative 'version'
8
7
 
9
8
  module Mergify
10
9
  module RSpec
@@ -74,34 +73,25 @@ module Mergify
74
73
  @tests_to_process = []
75
74
  @budget = 0.0
76
75
 
77
- fetch_context
78
- validate!
76
+ @context = fetch_context
77
+ raise FlakyDetectionDisabledError unless Native::Budget.should_run(@context, @mode)
79
78
  end
80
79
 
81
- # rubocop:disable-next Metrics/MethodLength,Metrics/AbcSize
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.
82
88
  def prepare_for_session(test_ids)
83
- existing = Set.new(@context[:existing_test_names])
84
- unhealthy = Set.new(@context[:unhealthy_test_names])
85
-
86
- @tests_to_process =
87
- if @mode == 'new'
88
- test_ids.reject { |id| existing.include?(id) }
89
- else
90
- test_ids.select { |id| unhealthy.include?(id) }
91
- end
92
-
93
- budget_ratio = if @mode == 'new'
94
- @context[:budget_ratio_for_new_tests]
95
- else
96
- @context[:budget_ratio_for_unhealthy_tests]
97
- end
89
+ plan = Native::Budget.compute(@context, @mode, test_ids, [])
98
90
 
99
- mean_duration_s = @context[:existing_tests_mean_duration_ms] / 1000.0
100
- existing_count = @context[:existing_test_names].size
101
- min_budget_s = @context[:min_budget_duration_ms] / 1000.0
102
-
103
- ratio_budget = budget_ratio * mean_duration_s * existing_count
104
- @budget = [ratio_budget, min_budget_s].max
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
105
95
  end
106
96
 
107
97
  # rubocop:disable-next Metrics/MethodLength
@@ -113,7 +103,7 @@ module Mergify
113
103
 
114
104
  return unless @tests_to_process.include?(test_id)
115
105
 
116
- if test_id.length > @context[:max_test_name_length]
106
+ if test_id.length > @context['max_test_name_length']
117
107
  @over_length_tests.add(test_id)
118
108
  return
119
109
  end
@@ -136,8 +126,7 @@ module Mergify
136
126
  def set_test_deadline(test_id, timeout: nil)
137
127
  return unless @metrics.key?(test_id)
138
128
 
139
- remaining_tests = [remaining_tests_count, 1].max
140
- per_test_budget = remaining_budget / remaining_tests
129
+ per_test_budget = next_test_share
141
130
 
142
131
  allocated =
143
132
  if timeout
@@ -153,7 +142,7 @@ module Mergify
153
142
  return false unless @metrics.key?(test_id)
154
143
 
155
144
  metrics = @metrics[test_id]
156
- min_exec = @context[:min_test_execution_count]
145
+ min_exec = @context['min_test_execution_count']
157
146
  (metrics.initial_duration * min_exec) > metrics.remaining_time
158
147
  end
159
148
 
@@ -161,7 +150,7 @@ module Mergify
161
150
  return false unless @metrics.key?(test_id)
162
151
 
163
152
  metrics = @metrics[test_id]
164
- metrics.will_exceed_deadline? || metrics.rerun_count >= @context[:max_test_execution_count]
153
+ metrics.will_exceed_deadline? || metrics.rerun_count >= @context['max_test_execution_count']
165
154
  end
166
155
 
167
156
  def test_metrics(test_id)
@@ -196,67 +185,33 @@ module Mergify
196
185
 
197
186
  private
198
187
 
199
- # rubocop:disable-next Metrics/AbcSize,Metrics/MethodLength
188
+ # A nil context means the repository has not opted into flaky detection,
189
+ # which is the expected default rather than a failure.
200
190
  def fetch_context
201
- owner, repo = Utils.split_full_repo_name(@full_repository_name)
202
- uri = URI("#{@url}/v1/ci/#{owner}/repositories/#{repo}/flaky-detection-context")
203
-
204
- http = Net::HTTP.new(uri.host, uri.port)
205
- http.use_ssl = uri.scheme == 'https'
206
- http.open_timeout = 10
207
- http.read_timeout = 10
208
-
209
- request = Net::HTTP::Get.new(uri)
210
- request['Authorization'] = "Bearer #{@token}"
211
-
212
- response = http.request(request)
213
- case response.code.to_i
214
- when 200
215
- parse_context(response.body)
216
- when 404
217
- # A 404 means the repository has not opted into flaky detection; this
218
- # is the expected default, not an error.
219
- raise FlakyDetectionDisabledError
220
- else
221
- raise "Mergify API returned HTTP #{response.code}"
222
- end
223
- end
191
+ raise FlakyDetectionDisabledError unless Native.available?
224
192
 
225
- # rubocop:disable-next Metrics/MethodLength,Metrics/AbcSize
226
- def parse_context(body)
227
- data = JSON.parse(body, symbolize_names: true)
228
- @context = {
229
- budget_ratio_for_new_tests: data[:budget_ratio_for_new_tests].to_f,
230
- budget_ratio_for_unhealthy_tests: data[:budget_ratio_for_unhealthy_tests].to_f,
231
- existing_test_names: Array(data[:existing_test_names]),
232
- existing_tests_mean_duration_ms: data[:existing_tests_mean_duration_ms].to_f,
233
- unhealthy_test_names: Array(data[:unhealthy_test_names]),
234
- max_test_execution_count: data[:max_test_execution_count].to_i,
235
- max_test_name_length: data[:max_test_name_length].to_i,
236
- min_budget_duration_ms: data[:min_budget_duration_ms].to_f,
237
- min_test_execution_count: data[:min_test_execution_count].to_i
238
- }
239
- end
240
-
241
- def validate!
242
- return unless @mode == 'new' && @context[:existing_test_names].empty?
243
-
244
- # Without a baseline, `new` mode would treat every test as new and rerun
245
- # the whole suite. Skip instead of surfacing an error.
246
- raise FlakyDetectionDisabledError
247
- end
193
+ owner, repo = Utils.split_full_repo_name(@full_repository_name)
194
+ context = Native::Client.new(@url, @token, owner, repo, VERSION).fetch_flaky_context
195
+ raise FlakyDetectionDisabledError if context.nil?
248
196
 
249
- def remaining_budget
250
- used = budget_used
251
- [@budget - used, 0.0].max
197
+ context
252
198
  end
253
199
 
254
200
  def budget_used
255
201
  @metrics.sum { |_, m| m.total_duration }
256
202
  end
257
203
 
258
- def remaining_tests_count
259
- @tests_to_process.count { |id| !@metrics.key?(id) || @metrics[id].deadline.nil? }
204
+ # What is left of the budget, divided over the tests still waiting for a
205
+ # share of it. The engine works in milliseconds; RSpec durations are
206
+ # seconds.
207
+ def next_test_share
208
+ Native::Budget.dynamic_share_ms(
209
+ @budget * 1000, budget_used * 1000, @tests_to_process.size, processed_tests_count
210
+ ) / 1000.0
211
+ end
212
+
213
+ def processed_tests_count
214
+ @tests_to_process.count { |id| @metrics.key?(id) && !@metrics[id].deadline.nil? }
260
215
  end
261
216
  end
262
217
  end
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rspec/core/formatters/base_formatter'
4
- require 'opentelemetry-sdk'
4
+ require_relative 'trace'
5
+
6
+ require 'mergify/rspec/native'
5
7
 
6
8
  module Mergify
7
9
  module RSpec
8
- # RSpec formatter that creates OpenTelemetry spans for Mergify Test Insights and
10
+ # RSpec formatter that records spans for Mergify Test Insights and
9
11
  # prints a terminal report. It is purely observational and does not modify
10
12
  # test execution.
11
13
  # rubocop:disable-next Metrics/ClassLength
@@ -17,18 +19,14 @@ module Mergify
17
19
  :example_pending,
18
20
  :stop
19
21
 
20
- # rubocop:disable-next Metrics/MethodLength
21
22
  def start(notification)
22
23
  super
23
24
 
24
25
  @ci_insights = Mergify::RSpec.ci_insights
25
- return unless @ci_insights&.tracer
26
-
27
- extract_distributed_trace_context
26
+ return unless @ci_insights&.recorder
28
27
 
29
- @session_span = @ci_insights.tracer.start_span(
28
+ @session_span = @ci_insights.recorder.start_span(
30
29
  'rspec session start',
31
- with_parent: @parent_context,
32
30
  attributes: { 'test.scope' => 'session' }
33
31
  )
34
32
  @has_error = false
@@ -36,15 +34,14 @@ module Mergify
36
34
  end
37
35
 
38
36
  def example_started(notification)
39
- return unless @ci_insights&.tracer && @session_span
37
+ return unless @ci_insights&.recorder && @session_span
40
38
 
41
39
  example = notification.example
42
- parent_context = OpenTelemetry::Trace.context_with_span(@session_span)
43
40
  quarantined = @ci_insights.mark_test_as_quarantined_if_needed(example.id)
44
41
 
45
- span = @ci_insights.tracer.start_span(
42
+ span = @ci_insights.recorder.start_span(
46
43
  example.id,
47
- with_parent: parent_context,
44
+ parent: @session_span,
48
45
  attributes: build_example_attributes(example, quarantined)
49
46
  )
50
47
  @example_spans[example.id] = span
@@ -67,10 +64,10 @@ module Mergify
67
64
  set_error_attributes(span, result.exception)
68
65
  @has_error = true
69
66
  else
70
- span.status = OpenTelemetry::Trace::Status.ok
67
+ span.ok!
71
68
  end
72
69
 
73
- span.finish
70
+ @ci_insights.recorder.record(span)
74
71
  end
75
72
 
76
73
  def example_pending(notification)
@@ -81,7 +78,7 @@ module Mergify
81
78
  return unless span
82
79
 
83
80
  span.set_attribute('test.case.result.status', 'skipped')
84
- span.finish
81
+ @ci_insights.recorder.record(span)
85
82
  end
86
83
 
87
84
  def stop(_notification)
@@ -92,14 +89,6 @@ module Mergify
92
89
 
93
90
  private
94
91
 
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
92
  def build_example_attributes(example, quarantined)
104
93
  {
105
94
  'test.scope' => 'case',
@@ -133,18 +122,18 @@ module Mergify
133
122
  span.set_attribute('exception.type', exception.class.to_s)
134
123
  span.set_attribute('exception.message', exception.message)
135
124
  span.set_attribute('exception.stacktrace', exception.backtrace&.join("\n") || '')
136
- span.status = OpenTelemetry::Trace::Status.error(exception.message)
125
+ span.error!(exception.message)
137
126
  end
138
127
 
139
128
  def finish_session_span
140
129
  return unless @session_span
141
130
 
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
131
+ if @has_error
132
+ @session_span.error!('One or more tests failed')
133
+ else
134
+ @session_span.ok!
135
+ end
136
+ @ci_insights.recorder.record(@session_span)
148
137
  end
149
138
 
150
139
  # rubocop:disable-next Metrics/MethodLength
@@ -167,12 +156,21 @@ module Mergify
167
156
  def print_configuration_warnings
168
157
  output.puts 'WARNING: MERGIFY_TOKEN is not set. Traces will not be sent to Mergify.' unless @ci_insights.token
169
158
 
159
+ return print_native_warning unless Mergify::RSpec::Native.available?
170
160
  return if @ci_insights.repo_name
171
161
 
172
162
  output.puts 'WARNING: Could not detect repository name. ' \
173
163
  'Please set GITHUB_REPOSITORY or configure a git remote.'
174
164
  end
175
165
 
166
+ # The extension is what detects CI, so when it did not load there is no
167
+ # repository name either, and blaming that would send people hunting for a
168
+ # git remote that is fine. Say what actually happened instead.
169
+ def print_native_warning
170
+ output.puts 'WARNING: the Mergify native extension could not be loaded, so this run reports nothing: ' \
171
+ "#{Mergify::RSpec::Native.load_error&.message}"
172
+ end
173
+
176
174
  def print_flaky_report
177
175
  return unless @ci_insights.flaky_detector.respond_to?(:make_report)
178
176
 
@@ -187,20 +185,13 @@ module Mergify
187
185
  output.puts report if report
188
186
  end
189
187
 
190
- def flush_and_shutdown # rubocop:disable Metrics/MethodLength
191
- return unless @ci_insights&.tracer_provider
188
+ # One upload, at the end. There is nothing to shut down any more: the
189
+ # recorder holds spans in memory and the client owns the connection.
190
+ def flush_and_shutdown
191
+ return unless @ci_insights&.recorder
192
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
193
+ error = @ci_insights.flush
194
+ print_export_error(StandardError.new(error)) if error
204
195
  end
205
196
 
206
197
  def print_export_error(error)
@@ -0,0 +1,91 @@
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
+
31
+ # Which of the two failed requires actually explains the absence.
32
+ #
33
+ # A precompiled gem ships lib/mergify/rspec/<ruby>/mergify_ci.<dlext>,
34
+ # so when one is packed for this Ruby that require is the real attempt:
35
+ # it fails with something like "version `GLIBC_2.29' not found", and the
36
+ # fallback that follows only ever adds "cannot load such file", which
37
+ # sends the reader looking for the wrong thing. With no extension for
38
+ # this Ruby -- the source gem, or a platform we publish no gem for --
39
+ # it is the other way round.
40
+ # Require a file next to this one, returning the LoadError instead of
41
+ # raising it. Two attempts read better as values than as nested rescues.
42
+ def attempt_require(path)
43
+ require_relative path
44
+ nil
45
+ rescue LoadError => e
46
+ e
47
+ end
48
+
49
+ def load_failure_reason(versioned, fallback)
50
+ packed = Dir.glob(File.join(__dir__, RUBY_VERSION.to_f.to_s, 'mergify_ci.*')).any?
51
+ packed ? versioned : fallback
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ # Precompiled gems ship lib/mergify/rspec/<ruby>/mergify_ci.<dlext>; the source
59
+ # gem and any local `rake compile` put one beside this file instead.
60
+ versioned_error = Mergify::RSpec::Native.attempt_require("#{RUBY_VERSION.to_f}/mergify_ci")
61
+ fallback_error = versioned_error && Mergify::RSpec::Native.attempt_require('mergify_ci')
62
+
63
+ if fallback_error
64
+ Mergify::RSpec::Native.load_error =
65
+ Mergify::RSpec::Native.load_failure_reason(versioned_error, fallback_error)
66
+ end
67
+
68
+ unless Mergify::RSpec::Native.available?
69
+ # Stand in for the extension so callers can just ask, and get the same answer
70
+ # they would get from a machine that is not in CI. Branching on availability
71
+ # at every call site would only spread the same nil back through the caller.
72
+ module Mergify
73
+ module RSpec
74
+ module Native
75
+ class << self
76
+ def detect_provider
77
+ nil
78
+ end
79
+
80
+ def detect_repository_name
81
+ nil
82
+ end
83
+
84
+ def detect_attributes
85
+ {}
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end