rspec-mergify 0.2.0 → 0.4.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 +66 -0
- data/lib/mergify/rspec/ci_insights.rb +246 -76
- data/lib/mergify/rspec/configuration.rb +160 -65
- data/lib/mergify/rspec/flaky_detection.rb +16 -10
- data/lib/mergify/rspec/formatter.rb +90 -47
- data/lib/mergify/rspec/native.rb +31 -10
- data/lib/mergify/rspec/quarantine.rb +7 -1
- data/lib/mergify/rspec/resources/rspec.rb +9 -5
- data/lib/mergify/rspec/session_verdict.rb +86 -0
- data/lib/mergify/rspec/test_selection.rb +299 -0
- data/lib/mergify/rspec/trace.rb +145 -0
- data/lib/mergify/rspec/utils.rb +7 -0
- data/lib/mergify/rspec/version.rb +1 -1
- metadata +5 -31
- data/lib/mergify/rspec/synchronous_batch_span_processor.rb +0 -34
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'opentelemetry-sdk'
|
|
4
3
|
require 'rspec/core/version'
|
|
5
4
|
|
|
6
5
|
module Mergify
|
|
7
6
|
module RSpec
|
|
8
7
|
module Resources
|
|
9
|
-
#
|
|
8
|
+
# The resource attributes only Ruby knows: which framework ran the suite,
|
|
9
|
+
# and in which language.
|
|
10
10
|
module RSpec
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
13
|
def detect
|
|
14
|
-
|
|
14
|
+
{
|
|
15
15
|
'test.framework' => 'rspec',
|
|
16
|
-
'test.framework.version' => ::RSpec::Core::Version::STRING
|
|
17
|
-
|
|
16
|
+
'test.framework.version' => ::RSpec::Core::Version::STRING,
|
|
17
|
+
# Mergify takes a test's language from here when the span does not
|
|
18
|
+
# name one. The OpenTelemetry SDK sets this on its default resource,
|
|
19
|
+
# but this gem never used that resource, so nothing had ever sent it.
|
|
20
|
+
'telemetry.sdk.language' => 'ruby'
|
|
21
|
+
}
|
|
18
22
|
end
|
|
19
23
|
end
|
|
20
24
|
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'test_selection'
|
|
4
|
+
|
|
5
|
+
module Mergify
|
|
6
|
+
module RSpec
|
|
7
|
+
# What the session concluded about each example, folded as RSpec reports
|
|
8
|
+
# it, and written to Mergify when the session ends.
|
|
9
|
+
#
|
|
10
|
+
# Test Selection answers a merge-queue rerun from its predecessor's
|
|
11
|
+
# verdict: which examples failed, whether anything ran at all. The verdict
|
|
12
|
+
# is sent in one request before the trace upload, so the answer is there
|
|
13
|
+
# seconds after the session ends rather than whenever trace ingestion gets
|
|
14
|
+
# to it (INC-2434).
|
|
15
|
+
#
|
|
16
|
+
# One status per example, its FINAL one -- the status RSpec reported once
|
|
17
|
+
# flaky detection's reruns were over, which is what decided the exit code.
|
|
18
|
+
# A verdict that disagreed with the exit code would either replay examples
|
|
19
|
+
# that did not gate, or skip the one that did.
|
|
20
|
+
class SessionVerdict
|
|
21
|
+
# A failure the quarantine absorbed is `quarantined_failed`: it did not
|
|
22
|
+
# gate the job, so a rerun must not replay it, but it did run and fail,
|
|
23
|
+
# so it is not green either.
|
|
24
|
+
PRECEDENCE = { 'passed' => 0, 'skipped' => 1, 'quarantined_failed' => 2, 'failed' => 3 }.freeze
|
|
25
|
+
|
|
26
|
+
# How sending went, for the report. `sent` is false both when nothing
|
|
27
|
+
# had to be sent and when the request failed; `error` tells them apart.
|
|
28
|
+
Result = Struct.new(:sent, :truncated, :error, keyword_init: true) do
|
|
29
|
+
# Said only when the verdict did not reach Mergify whole. Wording
|
|
30
|
+
# validated by Alexandre on 2026-09-15 (MRGFY-9313), pytest-mergify's
|
|
31
|
+
# verbatim; a change here is a product decision.
|
|
32
|
+
def report
|
|
33
|
+
if error
|
|
34
|
+
"#{TestSelection.wrap("Mergify couldn't record this run's results. If this merge-queue batch is " \
|
|
35
|
+
'retried, this job will run its full test suite.')}\nError: #{error}\n"
|
|
36
|
+
elsif truncated
|
|
37
|
+
"#{TestSelection.wrap("Mergify recorded this run's counts but not its failing tests. If this " \
|
|
38
|
+
'merge-queue batch is retried, this job will run its full test suite.')}\n"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize
|
|
44
|
+
@final = {}
|
|
45
|
+
@runtime_seconds = 0.0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def record(example_id, status, run_time)
|
|
49
|
+
@runtime_seconds += run_time.to_f
|
|
50
|
+
previous = @final[example_id]
|
|
51
|
+
@final[example_id] = status if previous.nil? || PRECEDENCE.fetch(status) > PRECEDENCE.fetch(previous)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def total_test_runtime_ms
|
|
55
|
+
(@runtime_seconds * 1000).to_i
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The engine's own definitions: `executed` counts every example that
|
|
59
|
+
# reached a status, a skipped one included, and `failed` includes the
|
|
60
|
+
# quarantined failures.
|
|
61
|
+
def counts
|
|
62
|
+
statuses = @final.values
|
|
63
|
+
{
|
|
64
|
+
'executed_count' => statuses.size,
|
|
65
|
+
'passed_count' => statuses.count('passed'),
|
|
66
|
+
'failed_count' => statuses.count { |status| %w[failed quarantined_failed].include?(status) },
|
|
67
|
+
'skipped_count' => statuses.count('skipped')
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def failing_tests
|
|
72
|
+
ids_with('failed')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def quarantined_failing_tests
|
|
76
|
+
ids_with('quarantined_failed')
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def ids_with(status)
|
|
82
|
+
@final.filter_map { |id, final| id if final == status }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
require_relative 'utils'
|
|
5
|
+
|
|
6
|
+
module Mergify
|
|
7
|
+
module RSpec
|
|
8
|
+
# Raised by `TestSelection#resolve` when Mergify refuses to choose a
|
|
9
|
+
# selection for this run, carrying the explanation to show. The caller
|
|
10
|
+
# stops the run with it.
|
|
11
|
+
class TestSelectionRefused < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Whether this run should execute only part of what it collected.
|
|
14
|
+
#
|
|
15
|
+
# A merge-queue rerun only needs to replay the examples that failed on the
|
|
16
|
+
# previous attempt of the same job. Mergify decides that server-side from
|
|
17
|
+
# the run's own identity AND from the fingerprint of what the run
|
|
18
|
+
# collected, which is why the request leaves only once RSpec has loaded
|
|
19
|
+
# and filtered its examples. The answer is one of:
|
|
20
|
+
#
|
|
21
|
+
# * `full` -- run everything;
|
|
22
|
+
# * `subset` -- run only `tests`;
|
|
23
|
+
# * `empty` -- run nothing: the previous attempt ran these examples and
|
|
24
|
+
# they passed, so the run exits green having executed none;
|
|
25
|
+
# * `refused` -- Mergify holds several candidate sessions for this job and
|
|
26
|
+
# will not guess between them, so the run FAILS with its explanation.
|
|
27
|
+
#
|
|
28
|
+
# Every error, and every answer outside that list, runs the whole suite:
|
|
29
|
+
# the feature can remove work, never correctness. pytest-mergify's
|
|
30
|
+
# `TestSelection`, answer for answer and sentence for sentence.
|
|
31
|
+
#
|
|
32
|
+
# When an answer cannot be honoured the run executes everything and says
|
|
33
|
+
# so in `not_applied_reason`, never by rewriting `selection` or `reason`:
|
|
34
|
+
# those two carry Mergify's word.
|
|
35
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
36
|
+
class TestSelection
|
|
37
|
+
ENABLE_ENV = 'MERGIFY_TEST_SELECTION_ENABLE'
|
|
38
|
+
|
|
39
|
+
CLIENT_NAME = 'rspec-mergify'
|
|
40
|
+
DOCS_URL = 'https://docs.mergify.com/ci-insights/test-frameworks/rspec/'
|
|
41
|
+
|
|
42
|
+
# What a refusal says when the server sent no wording of its own. The
|
|
43
|
+
# copy belongs to the server; this is a fallback, not the message.
|
|
44
|
+
FALLBACK_REFUSAL_MESSAGE = <<~MESSAGE.chomp
|
|
45
|
+
Mergify Test Selection stopped this run.
|
|
46
|
+
|
|
47
|
+
Several runs of this job report to Mergify under the same name, and they run the same tests — so Mergify cannot tell which one this run repeats, and it will not guess which tests to skip.
|
|
48
|
+
|
|
49
|
+
If this job runs more than once (a build matrix, for example), give each run its own name with MERGIFY_TEST_JOB_NAME:
|
|
50
|
+
#{DOCS_URL}
|
|
51
|
+
|
|
52
|
+
If this job only runs once, this is unexpected — please contact Mergify support.
|
|
53
|
+
MESSAGE
|
|
54
|
+
|
|
55
|
+
NOT_APPLIED_SENTENCE =
|
|
56
|
+
"Mergify's answer didn't match the tests this run collected, so the full suite ran."
|
|
57
|
+
|
|
58
|
+
# One sentence per reason the full suite ran. pytest-mergify's
|
|
59
|
+
# `_FULL_RUN_SENTENCES` verbatim (validated by Alexandre on 2026-09-11,
|
|
60
|
+
# MRGFY-8978), naming this gem where they name a client. A change here is
|
|
61
|
+
# a product decision.
|
|
62
|
+
FULL_RUN_SENTENCES = {
|
|
63
|
+
'no_predecessor' => 'First attempt of this batch, so the full suite ran.',
|
|
64
|
+
'not_a_merge_queue_run' => "This job isn't part of a merge queue run, so the full suite ran.",
|
|
65
|
+
'stale_run' => 'The batch branch was updated while this job was running, so the full suite ran.',
|
|
66
|
+
'no_matching_test_session' =>
|
|
67
|
+
"The previous attempt didn't run this exact set of tests, so the full suite ran.",
|
|
68
|
+
'matched_test_session_ran_no_test' => 'The previous attempt executed no tests, so the full suite ran.',
|
|
69
|
+
'predecessor_unknown' => "Mergify couldn't tell which previous run to start from, so the full suite ran.",
|
|
70
|
+
'indeterminate_test_session' =>
|
|
71
|
+
"Mergify couldn't tell which previous run to start from, so the full suite ran.",
|
|
72
|
+
'matched_test_session_partially_processed' =>
|
|
73
|
+
"Mergify didn't have the complete results of the previous attempt, so the full suite ran.",
|
|
74
|
+
'matched_test_session_dropped_cases' =>
|
|
75
|
+
"Mergify didn't have the complete results of the previous attempt, so the full suite ran.",
|
|
76
|
+
'matched_test_session_declaration_unreadable' =>
|
|
77
|
+
"Mergify didn't have the complete results of the previous attempt, so the full suite ran.",
|
|
78
|
+
'matched_test_session_incomplete' =>
|
|
79
|
+
'The previous attempt stopped before running all of its tests, so the full suite ran.',
|
|
80
|
+
'matched_test_session_failures_truncated' =>
|
|
81
|
+
'The previous attempt had too many failures for Mergify to list, so the full suite ran.',
|
|
82
|
+
'no_collection_fingerprint' =>
|
|
83
|
+
"This version of #{CLIENT_NAME} doesn't report what it collected, so the full suite ran. " \
|
|
84
|
+
'Upgrade it to let Mergify reduce reruns.',
|
|
85
|
+
'feature_disabled' => "Test selection isn't enabled for this organization yet, so the full suite ran.",
|
|
86
|
+
'not_requested' => "Test selection isn't available for this repository, so the full suite ran.",
|
|
87
|
+
'unrecognised_selection' =>
|
|
88
|
+
"Mergify answered in a way this version of #{CLIENT_NAME} doesn't understand, so the full suite " \
|
|
89
|
+
'ran. Upgrade it to let Mergify reduce reruns.',
|
|
90
|
+
'subset_served_without_tests' => NOT_APPLIED_SENTENCE,
|
|
91
|
+
'subset_matched_no_collected_test' => NOT_APPLIED_SENTENCE,
|
|
92
|
+
'subset_partly_absent_from_collection' => NOT_APPLIED_SENTENCE
|
|
93
|
+
}.freeze
|
|
94
|
+
|
|
95
|
+
# A newer engine may serve a reason this gem predates. The block must
|
|
96
|
+
# still read as a full run, and must never show the raw identifier.
|
|
97
|
+
UNKNOWN_REASON_SENTENCE = 'Mergify served the full suite.'
|
|
98
|
+
|
|
99
|
+
HEADER = '✂️ Test selection'
|
|
100
|
+
|
|
101
|
+
# How many re-executed examples the subset block lists before counting
|
|
102
|
+
# the rest.
|
|
103
|
+
LISTED_TESTS_MAX = 10
|
|
104
|
+
|
|
105
|
+
# The width the wording was validated at.
|
|
106
|
+
WRAP_WIDTH = 80
|
|
107
|
+
|
|
108
|
+
KNOWN_ANSWERS = %w[full subset empty refused].freeze
|
|
109
|
+
|
|
110
|
+
attr_reader :selection, :reason, :tests, :message, :init_error_msg,
|
|
111
|
+
:not_applied_reason, :kept_count, :deselected_count, :kept_tests
|
|
112
|
+
|
|
113
|
+
class << self
|
|
114
|
+
# Whether this job asked for test selection. Opt-in and per job: a job
|
|
115
|
+
# that has not opted in asks nothing, which is what lets Mergify tell
|
|
116
|
+
# a repository that never opted in from one that did (MRGFY-9172).
|
|
117
|
+
# Trimmed, and anything unrecognised is off -- the direction that runs
|
|
118
|
+
# the whole suite.
|
|
119
|
+
def enabled?
|
|
120
|
+
Utils::TRUTHY_STRINGS.include?(ENV.fetch(ENABLE_ENV, '').strip.downcase)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# What Mergify answered, as the binding hands it over.
|
|
124
|
+
def served(answer)
|
|
125
|
+
new(selection: answer['selection'], reason: answer['reason'], tests: answer['tests'] || [],
|
|
126
|
+
message: answer['message'], served: true)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A run Mergify did not answer: the repository has no such feature, or
|
|
130
|
+
# the request failed. Both run everything, and neither was offered
|
|
131
|
+
# anything, so neither is echoed as an answer.
|
|
132
|
+
def unanswered(init_error_msg: nil)
|
|
133
|
+
new(selection: 'full', reason: 'not_requested', tests: [], message: nil,
|
|
134
|
+
served: false, init_error_msg: init_error_msg)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# rubocop:disable-next Metrics/ParameterLists,Metrics/MethodLength
|
|
139
|
+
def initialize(selection:, reason:, tests:, message:, served:, init_error_msg: nil)
|
|
140
|
+
@selection = selection
|
|
141
|
+
@reason = reason
|
|
142
|
+
@tests = tests
|
|
143
|
+
@message = message
|
|
144
|
+
@served = served
|
|
145
|
+
@init_error_msg = init_error_msg
|
|
146
|
+
@not_applied_reason = nil
|
|
147
|
+
@kept_count = nil
|
|
148
|
+
@deselected_count = 0
|
|
149
|
+
@kept_tests = []
|
|
150
|
+
|
|
151
|
+
# A subset is only honoured with a non-empty list, and an answer this
|
|
152
|
+
# gem predates is never acted on: both run everything, and say so.
|
|
153
|
+
if @selection == 'subset'
|
|
154
|
+
@not_applied_reason = 'subset_served_without_tests' if @tests.empty?
|
|
155
|
+
else
|
|
156
|
+
@not_applied_reason = 'unrecognised_selection' unless KNOWN_ANSWERS.include?(@selection)
|
|
157
|
+
@tests = []
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def served?
|
|
162
|
+
@served
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def refused?
|
|
166
|
+
@not_applied_reason.nil? && @selection == 'refused'
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Decide what the answer leaves of this collection to run: the ids to
|
|
170
|
+
# keep, or nil for all of them. Raises `TestSelectionRefused` on a
|
|
171
|
+
# refusal, carrying the server's explanation.
|
|
172
|
+
#
|
|
173
|
+
# Matching is by exact example id, the identity this gem uploads. A
|
|
174
|
+
# subset is honoured all or not at all: one served id this run did not
|
|
175
|
+
# collect declines the whole answer and runs everything, rather than a
|
|
176
|
+
# reduced run over an arbitrary part of what was asked for.
|
|
177
|
+
# rubocop:disable-next Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
178
|
+
def resolve(ids)
|
|
179
|
+
return nil unless @not_applied_reason.nil?
|
|
180
|
+
|
|
181
|
+
raise TestSelectionRefused, @message.to_s.empty? ? FALLBACK_REFUSAL_MESSAGE : @message if refused?
|
|
182
|
+
|
|
183
|
+
if @selection == 'empty'
|
|
184
|
+
# A collection already empty is left alone: the run is then empty for
|
|
185
|
+
# a reason of its own, and announcing a skip over it would mislead.
|
|
186
|
+
return nil if ids.empty?
|
|
187
|
+
|
|
188
|
+
@deselected_count = ids.size
|
|
189
|
+
return Set.new
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
return nil unless @selection == 'subset'
|
|
193
|
+
|
|
194
|
+
subset = @tests.to_set
|
|
195
|
+
kept = ids.select { |id| subset.include?(id) }
|
|
196
|
+
matched = kept.to_set
|
|
197
|
+
# Identities, not counts, so a repeated id can never stand in for a
|
|
198
|
+
# missing one.
|
|
199
|
+
if matched != subset
|
|
200
|
+
@not_applied_reason =
|
|
201
|
+
matched.empty? ? 'subset_matched_no_collected_test' : 'subset_partly_absent_from_collection'
|
|
202
|
+
return nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
@kept_count = kept.size
|
|
206
|
+
@deselected_count = ids.size - kept.size
|
|
207
|
+
@kept_tests = kept
|
|
208
|
+
subset
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The block printed in the gem's "Mergify CI" report: what reduced this
|
|
212
|
+
# run, whether it was deliberate, and whether its green can be trusted.
|
|
213
|
+
# Prose, and no identifier from the API ever reaches it.
|
|
214
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
215
|
+
def report
|
|
216
|
+
if @init_error_msg
|
|
217
|
+
# The error text on its own line: it is what support will ask for,
|
|
218
|
+
# and it usually carries a URL that wrapping would split.
|
|
219
|
+
return block("Mergify couldn't be asked whether this run could be reduced, so the full suite ran.") +
|
|
220
|
+
"Error: #{@init_error_msg}\n"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
if refused?
|
|
224
|
+
# The server's explanation was printed when the run stopped; the
|
|
225
|
+
# block only says where to look.
|
|
226
|
+
return block('Mergify stopped this run before any test ran; its explanation is in the error above.')
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
return block(FULL_RUN_SENTENCES.fetch(@not_applied_reason, UNKNOWN_REASON_SENTENCE)) if @not_applied_reason
|
|
230
|
+
return empty_block if @selection == 'empty'
|
|
231
|
+
return subset_block if @selection == 'subset' && @kept_count
|
|
232
|
+
|
|
233
|
+
block(FULL_RUN_SENTENCES.fetch(@reason, UNKNOWN_REASON_SENTENCE))
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
private
|
|
237
|
+
|
|
238
|
+
def empty_block
|
|
239
|
+
skipped = @deselected_count
|
|
240
|
+
# Nothing was collected: the block is the title alone, rather than a
|
|
241
|
+
# paragraph about skipping "all 0 tests".
|
|
242
|
+
return "#{HEADER}\n" if skipped.zero?
|
|
243
|
+
|
|
244
|
+
passed, them =
|
|
245
|
+
skipped == 1 ? ['its only test passed back then', 'it'] : ["all #{skipped} tests passed back then", 'them']
|
|
246
|
+
block("The code under test hasn't changed since the previous attempt of this job, and #{passed}. " \
|
|
247
|
+
"Mergify skipped #{them}: the job is green, and no test was executed.")
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
251
|
+
def subset_block
|
|
252
|
+
failed = @kept_count
|
|
253
|
+
skipped = @deselected_count
|
|
254
|
+
sentence =
|
|
255
|
+
if skipped.zero?
|
|
256
|
+
which, them =
|
|
257
|
+
failed == 1 ? ['its only test failed', 'it'] : ["all #{failed} of its tests failed", 'all of them']
|
|
258
|
+
"The code under test hasn't changed since the previous attempt of this job, where #{which}. " \
|
|
259
|
+
"Mergify re-executed #{them}:"
|
|
260
|
+
else
|
|
261
|
+
those = failed == 1 ? 'that one' : "those #{failed}"
|
|
262
|
+
"The code under test hasn't changed since the previous attempt of this job, where #{failed} of its " \
|
|
263
|
+
"#{count_tests(failed + skipped)} failed. Mergify re-executed only #{those} and skipped the " \
|
|
264
|
+
"#{skipped} that had already passed:"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
lines = @kept_tests.first(LISTED_TESTS_MAX).map { |id| " #{id}" }
|
|
268
|
+
remaining = @kept_tests.size - LISTED_TESTS_MAX
|
|
269
|
+
lines << " … and #{remaining} more" if remaining.positive?
|
|
270
|
+
"#{block(sentence)}\n#{lines.join("\n")}\n"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def count_tests(count)
|
|
274
|
+
count == 1 ? '1 test' : "#{count} tests"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def block(text)
|
|
278
|
+
"#{HEADER}\n\n#{TestSelection.wrap(text)}\n"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
class << self
|
|
282
|
+
# Greedy word wrap, the way Python's `textwrap.fill` does it for this
|
|
283
|
+
# prose: words are never split, and a word longer than the width sits
|
|
284
|
+
# on a line of its own.
|
|
285
|
+
def wrap(text, width = WRAP_WIDTH)
|
|
286
|
+
lines = []
|
|
287
|
+
text.split.each do |word|
|
|
288
|
+
if lines.empty? || lines.last.length + 1 + word.length > width
|
|
289
|
+
lines << word.dup
|
|
290
|
+
else
|
|
291
|
+
lines.last << ' ' << word
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
lines.join("\n")
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Mergify
|
|
6
|
+
module RSpec
|
|
7
|
+
# The little of OpenTelemetry this plugin actually used.
|
|
8
|
+
#
|
|
9
|
+
# A test run needs identifiers, a start and an end, some attributes and a
|
|
10
|
+
# status -- and the shared Rust client does the rest: the wire format, the
|
|
11
|
+
# compression, the retries, the size limit. The SDK brought samplers,
|
|
12
|
+
# context propagation machinery, batch processors and an exporter registry
|
|
13
|
+
# to do the part that fits here, and its dependency tree landed in every
|
|
14
|
+
# consumer's bundle. So the spans are assembled directly.
|
|
15
|
+
module Trace
|
|
16
|
+
# W3C traceparent: version-traceid-spanid-flags, all lowercase hex.
|
|
17
|
+
TRACEPARENT = /\A00-(?<trace_id>[0-9a-f]{32})-(?<span_id>[0-9a-f]{16})-[0-9a-f]{2}\z/
|
|
18
|
+
|
|
19
|
+
# A span being recorded, and once finished, the record itself.
|
|
20
|
+
class Span
|
|
21
|
+
attr_reader :name, :trace_id, :span_id, :parent_span_id, :attributes
|
|
22
|
+
attr_accessor :status, :status_message
|
|
23
|
+
|
|
24
|
+
def initialize(name:, trace_id:, span_id:, parent_span_id: nil, attributes: {})
|
|
25
|
+
@name = name
|
|
26
|
+
@trace_id = trace_id
|
|
27
|
+
@span_id = span_id
|
|
28
|
+
@parent_span_id = parent_span_id
|
|
29
|
+
@attributes = attributes.transform_keys(&:to_s)
|
|
30
|
+
@status = 'unset'
|
|
31
|
+
@status_message = nil
|
|
32
|
+
@start_unix_nano = Trace.now_unix_nano
|
|
33
|
+
@end_unix_nano = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Ids travel as bytes and are read as hex -- in a traceparent, in a
|
|
37
|
+
# backend URL, in a log line.
|
|
38
|
+
def hex_trace_id
|
|
39
|
+
@trace_id.unpack1('H*')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def hex_span_id
|
|
43
|
+
@span_id.unpack1('H*')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def set_attribute(key, value)
|
|
47
|
+
@attributes[key.to_s] = value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def error!(message)
|
|
51
|
+
@status = 'error'
|
|
52
|
+
@status_message = message
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ok!
|
|
56
|
+
@status = 'ok'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def finish
|
|
60
|
+
@end_unix_nano ||= Trace.now_unix_nano
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The shape the binding accepts, which is the wire format's own.
|
|
65
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
66
|
+
def to_h
|
|
67
|
+
{
|
|
68
|
+
'name' => @name,
|
|
69
|
+
'trace_id' => @trace_id,
|
|
70
|
+
'span_id' => @span_id,
|
|
71
|
+
'parent_span_id' => @parent_span_id,
|
|
72
|
+
'start_unix_nano' => @start_unix_nano,
|
|
73
|
+
'end_unix_nano' => @end_unix_nano || Trace.now_unix_nano,
|
|
74
|
+
'attributes' => @attributes,
|
|
75
|
+
'status' => @status,
|
|
76
|
+
'status_message' => @status_message
|
|
77
|
+
}.compact
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Collects a run's spans and hands them to the client in one upload.
|
|
82
|
+
#
|
|
83
|
+
# Deliberately not streaming: a suite's spans are worth one request at the
|
|
84
|
+
# end, and exporting mid-run would put HTTP in the middle of the thing
|
|
85
|
+
# being timed. That was already why the gem replaced the SDK's batch
|
|
86
|
+
# processor with its own.
|
|
87
|
+
class Recorder
|
|
88
|
+
attr_reader :resource_attributes, :finished_spans, :trace_id
|
|
89
|
+
|
|
90
|
+
# `traceparent` is the W3C header a caller can hand down to put this
|
|
91
|
+
# run inside a trace it already started; the session span then hangs off
|
|
92
|
+
# that caller's span rather than starting a trace of its own.
|
|
93
|
+
def initialize(resource_attributes: {}, traceparent: nil)
|
|
94
|
+
@resource_attributes = resource_attributes.transform_keys(&:to_s)
|
|
95
|
+
inherited = Trace.parse_traceparent(traceparent)
|
|
96
|
+
@trace_id = inherited ? inherited.first : Trace.generate_trace_id
|
|
97
|
+
@root_parent_span_id = inherited&.last
|
|
98
|
+
@finished_spans = []
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def start_span(name, parent: nil, attributes: {})
|
|
102
|
+
Span.new(
|
|
103
|
+
name: name,
|
|
104
|
+
trace_id: @trace_id,
|
|
105
|
+
span_id: Trace.generate_span_id,
|
|
106
|
+
parent_span_id: parent&.span_id || @root_parent_span_id,
|
|
107
|
+
attributes: attributes
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def record(span)
|
|
112
|
+
@finished_spans << span.finish
|
|
113
|
+
span
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def clear
|
|
117
|
+
@finished_spans = []
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
class << self
|
|
122
|
+
def now_unix_nano
|
|
123
|
+
(Time.now.to_r * 1_000_000_000).to_i
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def generate_trace_id
|
|
127
|
+
SecureRandom.bytes(16)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def generate_span_id
|
|
131
|
+
SecureRandom.bytes(8)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The trace this run belongs to, when a parent handed one down.
|
|
135
|
+
# Returns [trace_id, parent_span_id] as raw bytes, or nil.
|
|
136
|
+
def parse_traceparent(header)
|
|
137
|
+
match = TRACEPARENT.match(header.to_s)
|
|
138
|
+
return nil unless match
|
|
139
|
+
|
|
140
|
+
[[match[:trace_id]].pack('H*'), [match[:span_id]].pack('H*')]
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
data/lib/mergify/rspec/utils.rb
CHANGED
|
@@ -45,6 +45,13 @@ module Mergify
|
|
|
45
45
|
env_truthy?('CI') || env_truthy?('RSPEC_MERGIFY_ENABLE')
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# Returns true inside one of the processes parallel_tests or turbo_tests
|
|
49
|
+
# splits a suite across. Both set TEST_ENV_NUMBER, to an empty string for
|
|
50
|
+
# parallel_tests' first worker, so it is the key that counts.
|
|
51
|
+
def parallel_worker?
|
|
52
|
+
ENV.key?('TEST_ENV_NUMBER')
|
|
53
|
+
end
|
|
54
|
+
|
|
48
55
|
# Split "owner/repo" into [owner, repo].
|
|
49
56
|
# Raises InvalidRepositoryFullNameError when the format is wrong.
|
|
50
57
|
def split_full_repo_name(full_repo_name)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rspec-mergify
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mergify
|
|
@@ -9,34 +9,6 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
-
- !ruby/object:Gem::Dependency
|
|
13
|
-
name: opentelemetry-exporter-otlp
|
|
14
|
-
requirement: !ruby/object:Gem::Requirement
|
|
15
|
-
requirements:
|
|
16
|
-
- - "~>"
|
|
17
|
-
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0.29'
|
|
19
|
-
type: :runtime
|
|
20
|
-
prerelease: false
|
|
21
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
-
requirements:
|
|
23
|
-
- - "~>"
|
|
24
|
-
- !ruby/object:Gem::Version
|
|
25
|
-
version: '0.29'
|
|
26
|
-
- !ruby/object:Gem::Dependency
|
|
27
|
-
name: opentelemetry-sdk
|
|
28
|
-
requirement: !ruby/object:Gem::Requirement
|
|
29
|
-
requirements:
|
|
30
|
-
- - "~>"
|
|
31
|
-
- !ruby/object:Gem::Version
|
|
32
|
-
version: '1.4'
|
|
33
|
-
type: :runtime
|
|
34
|
-
prerelease: false
|
|
35
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
-
requirements:
|
|
37
|
-
- - "~>"
|
|
38
|
-
- !ruby/object:Gem::Version
|
|
39
|
-
version: '1.4'
|
|
40
12
|
- !ruby/object:Gem::Dependency
|
|
41
13
|
name: rspec-core
|
|
42
14
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -69,7 +41,9 @@ files:
|
|
|
69
41
|
- lib/mergify/rspec/native.rb
|
|
70
42
|
- lib/mergify/rspec/quarantine.rb
|
|
71
43
|
- lib/mergify/rspec/resources/rspec.rb
|
|
72
|
-
- lib/mergify/rspec/
|
|
44
|
+
- lib/mergify/rspec/session_verdict.rb
|
|
45
|
+
- lib/mergify/rspec/test_selection.rb
|
|
46
|
+
- lib/mergify/rspec/trace.rb
|
|
73
47
|
- lib/mergify/rspec/utils.rb
|
|
74
48
|
- lib/mergify/rspec/version.rb
|
|
75
49
|
- lib/rspec_mergify.rb
|
|
@@ -94,7 +68,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
94
68
|
- !ruby/object:Gem::Version
|
|
95
69
|
version: '0'
|
|
96
70
|
requirements: []
|
|
97
|
-
rubygems_version: 4.0.
|
|
71
|
+
rubygems_version: 4.0.20
|
|
98
72
|
specification_version: 4
|
|
99
73
|
summary: RSpec plugin for Mergify CI Insights
|
|
100
74
|
test_files: []
|
|
@@ -1,34 +0,0 @@
|
|
|
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
|