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 +4 -4
- data/README.md +17 -0
- data/lib/mergify/rspec/ci_insights.rb +49 -86
- data/lib/mergify/rspec/flaky_detection.rb +39 -84
- data/lib/mergify/rspec/formatter.rb +34 -43
- data/lib/mergify/rspec/native.rb +91 -0
- data/lib/mergify/rspec/quarantine.rb +22 -79
- data/lib/mergify/rspec/resources/rspec.rb +9 -5
- data/lib/mergify/rspec/trace.rb +145 -0
- data/lib/mergify/rspec/utils.rb +11 -93
- data/lib/mergify/rspec/version.rb +1 -1
- metadata +4 -37
- data/lib/mergify/rspec/resources/buildkite.rb +0 -54
- data/lib/mergify/rspec/resources/ci.rb +0 -24
- data/lib/mergify/rspec/resources/git.rb +0 -35
- data/lib/mergify/rspec/resources/github_actions.rb +0 -60
- data/lib/mergify/rspec/resources/jenkins.rb +0 -55
- data/lib/mergify/rspec/resources/mergify.rb +0 -24
- data/lib/mergify/rspec/synchronous_batch_span_processor.rb +0 -34
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0e962d0dafd4d52189d682226bdfee4729bab091553a4b0ab261c1f371003b5c
|
|
4
|
+
data.tar.gz: 98b9017bf4067a6f4bc42a90a7ee957e3fb82899c368704c17c8b150b6234e03
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
4
|
+
require_relative 'trace'
|
|
5
5
|
require_relative 'utils'
|
|
6
|
-
require_relative '
|
|
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:
|
|
18
|
-
#
|
|
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
|
-
:
|
|
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 =
|
|
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
|
-
@
|
|
33
|
-
@
|
|
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
|
-
@
|
|
59
|
-
|
|
60
|
-
@
|
|
61
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
76
|
+
@context = fetch_context
|
|
77
|
+
raise FlakyDetectionDisabledError unless Native::Budget.should_run(@context, @mode)
|
|
79
78
|
end
|
|
80
79
|
|
|
81
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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[
|
|
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
|
-
|
|
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[
|
|
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[
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
4
|
+
require_relative 'trace'
|
|
5
|
+
|
|
6
|
+
require 'mergify/rspec/native'
|
|
5
7
|
|
|
6
8
|
module Mergify
|
|
7
9
|
module RSpec
|
|
8
|
-
# RSpec formatter that
|
|
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&.
|
|
26
|
-
|
|
27
|
-
extract_distributed_trace_context
|
|
26
|
+
return unless @ci_insights&.recorder
|
|
28
27
|
|
|
29
|
-
@session_span = @ci_insights.
|
|
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&.
|
|
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.
|
|
42
|
+
span = @ci_insights.recorder.start_span(
|
|
46
43
|
example.id,
|
|
47
|
-
|
|
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.
|
|
67
|
+
span.ok!
|
|
71
68
|
end
|
|
72
69
|
|
|
73
|
-
span
|
|
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
|
|
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.
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
@session_span
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|