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.
- checksums.yaml +7 -0
- data/LICENSE +176 -0
- data/README.md +68 -0
- data/lib/mergify/rspec/3.1/mergify_ci.so +0 -0
- data/lib/mergify/rspec/3.2/mergify_ci.so +0 -0
- data/lib/mergify/rspec/3.3/mergify_ci.so +0 -0
- data/lib/mergify/rspec/3.4/mergify_ci.so +0 -0
- data/lib/mergify/rspec/4.0/mergify_ci.so +0 -0
- data/lib/mergify/rspec/ci_insights.rb +167 -0
- data/lib/mergify/rspec/configuration.rb +117 -0
- data/lib/mergify/rspec/flaky_detection.rb +215 -0
- data/lib/mergify/rspec/formatter.rb +218 -0
- data/lib/mergify/rspec/native.rb +70 -0
- data/lib/mergify/rspec/quarantine.rb +76 -0
- data/lib/mergify/rspec/resources/rspec.rb +22 -0
- data/lib/mergify/rspec/synchronous_batch_span_processor.rb +34 -0
- data/lib/mergify/rspec/utils.rb +58 -0
- data/lib/mergify/rspec/version.rb +13 -0
- data/lib/mergify/rspec.rb +11 -0
- data/lib/rspec_mergify.rb +12 -0
- metadata +111 -0
|
@@ -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
|