rspec-mergify 2026.10.8 → 2026.10.9

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: e5715b89995827b7f5d3840e0c3c1d94288f511fedcac9f3bdd58433c1908228
4
- data.tar.gz: d70082926c396a3ac69ae778f9024be9d3cde6570cd84ef498d1e489ae9164d2
3
+ metadata.gz: 82681fca0d5d6710db90d1efb22a5ae4d23b175e55519bf8fbf91e44d48d386f
4
+ data.tar.gz: fdae1d6d362f960d5c93f27991227f59d751f27547c3534361de4ca35ad20aca
5
5
  SHA512:
6
- metadata.gz: 036d1307e6752c12f0c4a88a5f4625528a8ef6eb7d0247b23100127d42a1842c2a3ddadcffc2439f27bb73f49f71d478346fb51b4a1524725c6812f232e10168
7
- data.tar.gz: d3e25b1c4dda733655435cd3e28016a30127c82ed04e8d5b8e07a8e0e7c7374d572491bf8559e3f6c9262c814abe5130e4b65781b0c93cd7f91f2a72f5496de8
6
+ metadata.gz: 85e472d4c201ed2a59ab570d045c71a3d3c275fd87b7442ebe14b3a2cf7ba5989dec8a3bcef7fa3d0ee996c9c17d9052a8e5308db83d284949ad2db0e1a90e74
7
+ data.tar.gz: 1ff736c8192c6b9ca2e8775bceaf39478c88f56df5e5604801424933e4fe9ecb209c5d819b3352fdb483800fdc9eb303007260b51ffea7aa91c343289b03ec6c
data/README.md CHANGED
@@ -6,6 +6,7 @@ RSpec plugin for [Mergify Test Engine](https://docs.mergify.com/test-engine/).
6
6
 
7
7
  - **Test tracing** — Sends OpenTelemetry traces for every test to Mergify's API
8
8
  - **Flaky test detection** — Intelligently reruns tests to detect flakiness with budget constraints
9
+ - **Test retry** — On the default branch, runs a failing example again, once rspec-retry's tries failed, within a time budget Mergify sets
9
10
  - **Test quarantine** — Quarantines failing tests so they don't block CI
10
11
  - **Test selection** — Runs first the examples a merge-queue bisection step failed in the step above, and stops once one fails again
11
12
 
@@ -53,6 +54,7 @@ The plugin activates automatically when running in CI (detected via the `CI` env
53
54
  | `MERGIFY_TRACEPARENT` | W3C distributed trace context | — |
54
55
  | `MERGIFY_TEST_JOB_NAME` | Mergify test job name | — |
55
56
  | `MERGIFY_FAIL_FIRST_ENABLE` | Set to `false` to never run first what a merge-queue bisection step of this job failed before | `true` |
57
+ | `MERGIFY_TEST_RETRY_ENABLE` | Set to `false` to have this job retry no failing test, whatever the repository sets | `true` |
56
58
 
57
59
  The names these replace, `RSPEC_MERGIFY_ENABLE` and `RSPEC_MERGIFY_DEBUG`, are
58
60
  deprecated: they still work when the new name is not set, and the summary says
@@ -143,7 +145,7 @@ test.
143
145
 
144
146
  [parallel_tests](https://github.com/grosser/parallel_tests) and [turbo_tests](https://github.com/serpapi/turbo_tests) need no setup. Each worker is a separate RSpec process, and each reports to Mergify as its own session:
145
147
 
146
- - flaky detection sizes each worker's rerun budget from the tests that worker runs;
148
+ - flaky detection sizes each worker's rerun budget from the tests that worker runs, and test retry gives each worker a budget of its own;
147
149
  - each worker prints its own Mergify report, so a report covers only that worker's tests.
148
150
 
149
151
  Suites split across CI jobs (knapsack, `circleci tests split`, Buildkite `parallelism`) behave the same way, one session per job.
data/THIRD_PARTY_LICENSES CHANGED
@@ -8,8 +8,8 @@ The source code of each component is available from the repository listed
8
8
  next to it, as the Mozilla Public License 2.0 requires for the components
9
9
  under it.
10
10
 
11
- Apache License 2.0 (230)
12
- MIT License (73)
11
+ Apache License 2.0 (233)
12
+ MIT License (74)
13
13
  Unicode License v3 (19)
14
14
  ISC License (5)
15
15
  BSD 3-Clause "New" or "Revised" License (4)
@@ -23,7 +23,7 @@ Apache License 2.0 (Apache-2.0)
23
23
 
24
24
  Used by:
25
25
  - cmov 0.5.4 (https://github.com/RustCrypto/utils)
26
- - ctutils 0.4.3 (https://github.com/RustCrypto/utils)
26
+ - ctutils 0.4.6 (https://github.com/RustCrypto/utils)
27
27
  - encode_unicode 1.0.0 (https://github.com/tormol/encode_unicode)
28
28
  - encoding_rs 0.8.42 (https://github.com/hsivonen/encoding_rs)
29
29
  - multiversion_no_op 1.0.0 (https://github.com/hsivonen/multiversion_no_op)
@@ -4882,6 +4882,7 @@ Apache License 2.0 (Apache-2.0)
4882
4882
  Used by:
4883
4883
  - ahash 0.8.12 (https://github.com/tkaitchuck/ahash)
4884
4884
  - async-channel 2.5.0 (https://github.com/smol-rs/async-channel)
4885
+ - async-compression 0.4.51 (https://github.com/Nullus157/async-compression)
4885
4886
  - async-executor 1.14.0 (https://github.com/smol-rs/async-executor)
4886
4887
  - async-io 2.6.0 (https://github.com/smol-rs/async-io)
4887
4888
  - async-lock 3.4.2 (https://github.com/smol-rs/async-lock)
@@ -4894,6 +4895,8 @@ Used by:
4894
4895
  - bitflags 2.13.1 (https://github.com/bitflags/bitflags)
4895
4896
  - blocking 1.7.0 (https://github.com/smol-rs/blocking)
4896
4897
  - cfg-if 1.0.4 (https://github.com/rust-lang/cfg-if)
4898
+ - compression-codecs 0.4.46 (https://github.com/Nullus157/async-compression)
4899
+ - compression-core 0.4.34 (https://github.com/Nullus157/async-compression)
4897
4900
  - concurrent-queue 2.5.0 (https://github.com/smol-rs/concurrent-queue)
4898
4901
  - core-foundation-sys 0.8.7 (https://github.com/servo/core-foundation-rs)
4899
4902
  - core-foundation 0.10.1 (https://github.com/servo/core-foundation-rs)
@@ -8791,7 +8794,7 @@ THIS SOFTWARE.
8791
8794
  ISC License (ISC)
8792
8795
 
8793
8796
  Used by:
8794
- - rustls-webpki 0.103.15 (https://github.com/rustls/webpki)
8797
+ - rustls-webpki 0.103.16 (https://github.com/rustls/webpki)
8795
8798
 
8796
8799
  Except as otherwise noted, this project is licensed under the following
8797
8800
  (ISC-style) terms:
@@ -9826,10 +9829,10 @@ SOFTWARE.
9826
9829
  MIT License (MIT)
9827
9830
 
9828
9831
  Used by:
9829
- - jsonschema-regex 0.60.0 (https://github.com/Stranger6667/jsonschema)
9830
- - jsonschema-value 0.60.0 (https://github.com/Stranger6667/jsonschema)
9831
- - jsonschema 0.60.0 (https://github.com/Stranger6667/jsonschema)
9832
- - referencing 0.60.0 (https://github.com/Stranger6667/jsonschema)
9832
+ - jsonschema-regex 0.60.1 (https://github.com/Stranger6667/jsonschema)
9833
+ - jsonschema-value 0.60.1 (https://github.com/Stranger6667/jsonschema)
9834
+ - jsonschema 0.60.1 (https://github.com/Stranger6667/jsonschema)
9835
+ - referencing 0.60.1 (https://github.com/Stranger6667/jsonschema)
9833
9836
 
9834
9837
  MIT License
9835
9838
 
@@ -10083,6 +10086,7 @@ USE OR OTHER DEALINGS IN THE SOFTWARE.
10083
10086
  MIT License (MIT)
10084
10087
 
10085
10088
  Used by:
10089
+ - tokio-util 0.7.19 (https://github.com/tokio-rs/tokio)
10086
10090
  - tokio 1.54.1 (https://github.com/tokio-rs/tokio)
10087
10091
 
10088
10092
  MIT License
@@ -146,44 +146,87 @@ module Mergify
146
146
  # engine around `example.run`, and its outcome read off the exception it
147
147
  # left behind. Its first run is the example's first attempt, and the
148
148
  # tries a retry plugin makes after it, inside that run; flaky detection's
149
- # reruns follow, each a try alone.
149
+ # reruns follow, or test retry's, each a try alone.
150
150
  def run_example(example, test_run)
151
151
  name = test_run.names.of(example)
152
152
  detector = test_run.flaky_detector
153
- tries = run_first(example, name, test_run, detector)
154
- return unless detector&.rerunning_test?(name)
153
+ retrier = test_run.test_retrier
154
+ retrier = nil unless retrier&.answers_for?(name)
155
+ tries = run_first(example, name, test_run, detector, retrier)
156
+ if detector&.rerunning_test?(name)
157
+ detect_flakiness(example, name, detector, retrier, tries)
158
+ elsif retrier
159
+ retry_failure(example, name, retrier, tries)
160
+ end
161
+ end
155
162
 
163
+ # Flaky detection reruns the example, within its budget. Where test
164
+ # retry answers for the example, the reruns answer its failure too.
165
+ def detect_flakiness(example, name, detector, retrier, tries)
156
166
  initial_exception = example.exception
157
167
  first_failure = tries.failure || initial_exception
158
- first_failure = rerun(example, name, detector, first_failure) if detector.start_reruns(name)
168
+ first_failure = rerun(example, name, detector, retrier, first_failure) if detector.start_reruns(name)
159
169
 
160
170
  # Which attempt decides the result is the session's rule: in 'new'
161
171
  # mode any failure fails the test, the retry plugin's tries included,
162
172
  # so a new flaky test cannot be merged; in 'unhealthy' mode the reruns
163
- # only learn, and the example's own result stands.
173
+ # only learn, and the example's own result stands, unless test retry
174
+ # says a rerun rescued it.
164
175
  final_exception = detector.rerun_failure_fails_the_test? ? first_failure : initial_exception
176
+ final_exception = nil if retrier&.rescued?(name)
165
177
  example.example.instance_variable_set(:@exception, final_exception)
166
178
  end
167
179
 
180
+ # Test retry runs the failed example again, each time the session grants
181
+ # one more attempt, until one passes: the example then passes, as a pass
182
+ # of a retry plugin's would. Otherwise its first run's failure stands.
183
+ # Each attempt is one try, as flaky detection's reruns are.
184
+ def retry_failure(example, name, retrier, tries)
185
+ failure = example.exception
186
+ return unless failure
187
+
188
+ without_retries(example) { run_retries(example, name, retrier, tries.last_ms) }
189
+ example.example.instance_variable_set(:@exception, retrier.rescued?(name) ? nil : failure)
190
+ end
191
+
192
+ # Each attempt the session grants the example, once the one before it
193
+ # failed after `attempt_ms`.
194
+ def run_retries(example, name, retrier, attempt_ms)
195
+ while retrier.grant?(name, attempt_ms)
196
+ reset_for_rerun(example)
197
+ attempt_ms = run_tries(example).last_ms
198
+ retrier.record(name, example.exception ? :failed : :passed, attempt_ms)
199
+ end
200
+ end
201
+
168
202
  # The first run of the example named `name`: its first attempt, timed by
169
203
  # the engine up to the second try where a retry plugin made one, then
170
204
  # each try after it, handed to the session once the first reached flaky
171
- # detection. What the first attempt was is kept for its span.
172
- def run_first(example, name, test_run, detector)
205
+ # detection and test retry. What the first attempt was is kept for its
206
+ # span.
207
+ def run_first(example, name, test_run, detector, retrier)
173
208
  detector&.attempt_started(name)
174
209
  tries = run_tries(example) { detector&.attempt_stopped(name) }
175
- detector&.attempt_ended(name, first_failed?(example, tries) ? :failed : :passed)
210
+ first_attempt_ended(example, name, tries, detector, retrier)
176
211
  tries.later.each { |try| test_run.record_retry(name, try.status, try.duration_ms) }
177
212
  example.metadata[:mergify_first_attempt] = { try: tries.first, ended_at: tries.first_ended_at } if tries.first
178
213
  tries
179
214
  end
180
215
 
216
+ # The example's first attempt is over: flaky detection and test retry
217
+ # take it before the tries after it.
218
+ def first_attempt_ended(example, name, tries, detector, retrier)
219
+ status = first_failed?(example, tries) ? :failed : :passed
220
+ detector&.attempt_ended(name, status)
221
+ retrier&.record(name, status, tries.first_ms)
222
+ end
223
+
181
224
  # Run the example once, noting each try a retry plugin makes of it; the
182
225
  # block is called as a second one starts.
183
226
  def run_tries(example, &)
184
227
  tries = Tries.new(&)
185
228
  example.metadata[:mergify_tries] = tries
186
- example.run
229
+ tries.time { example.run }
187
230
  tries
188
231
  ensure
189
232
  example.metadata.delete(:mergify_tries)
@@ -215,16 +258,16 @@ module Mergify
215
258
  end
216
259
 
217
260
  # Which attempt decides the result is the session's rule (see
218
- # `run_example`): answers the first failure, the reruns' included.
261
+ # `detect_flakiness`): answers the first failure, the reruns' included.
219
262
  # Each rerun is one try: a retry plugin's tries would hide its failure
220
263
  # behind the one that passed.
221
- def rerun(example, name, detector, first_failure)
264
+ def rerun(example, name, detector, retrier, first_failure)
222
265
  without_retries(example) do
223
266
  until example.metadata[:is_last_rerun]
224
267
  example.metadata[:is_last_rerun] = detector.last_rerun_for_test?(name)
225
268
  reset_for_rerun(example)
226
269
 
227
- run_attempt(example, name, detector)
270
+ run_attempt(example, name, detector, retrier)
228
271
  first_failure ||= example.exception
229
272
  end
230
273
  end
@@ -260,13 +303,15 @@ module Mergify
260
303
  end
261
304
 
262
305
  # Runs what the around hook wraps -- the before and after hooks included
263
- # -- timed by the session, and answers its outcome.
264
- def run_attempt(example, name, detector)
306
+ # -- timed by the session, and answers its outcome; test retry, where it
307
+ # answers for the example, is told of it too.
308
+ def run_attempt(example, name, detector, retrier)
265
309
  detector.attempt_started(name)
266
- example.run
310
+ attempt = run_tries(example)
267
311
  failed = example.exception || example.metadata.delete(:mergify_quarantined_failure)
268
312
  status = failed ? :failed : :passed
269
313
  detector.attempt_ended(name, status)
314
+ retrier&.record(name, status, attempt.last_ms)
270
315
  status
271
316
  end
272
317
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mergify
4
+ module RSpec
5
+ # Test retry for one RSpec run, as its session prepared it.
6
+ #
7
+ # Which failures it answers for, whether an example runs once more, and
8
+ # whether a retry rescued it are the session's: on a push to the default
9
+ # branch, every failing example but the ones the quarantine holds and the
10
+ # ones Mergify classifies as broken, up to the repository's retries, within
11
+ # one budget for the run. What is left here is how RSpec runs an attempt,
12
+ # which `Configuration#retry_failure` does.
13
+ class TestRetrier
14
+ # `reruns` is the session's.
15
+ def initialize(reruns)
16
+ @reruns = reruns
17
+ end
18
+
19
+ # Whether a failure of the example named `name` is test retry's to
20
+ # answer for: false where it does not run.
21
+ def answers_for?(name)
22
+ @reruns.retry_answers_for?(name)
23
+ end
24
+
25
+ # An attempt at the example ended with `status` (`:passed` or
26
+ # `:failed`) after `duration_ms`: its first, a rerun of flaky
27
+ # detection's, or one of retry's. Its first reaches flaky detection
28
+ # through its phases, so test retry is told apart.
29
+ def record(name, status, duration_ms)
30
+ @reruns.record_retry_attempt(name, status.to_s, duration_ms)
31
+ end
32
+
33
+ # Whether the example, whose attempt just failed after `attempt_ms`,
34
+ # runs once more. Asked once that attempt is recorded, and once the
35
+ # retry plugin's tries of it are over.
36
+ def grant?(name, attempt_ms)
37
+ @reruns.grant_retry(name, attempt_ms)
38
+ end
39
+
40
+ # Whether an attempt after the example's first passed, rescuing it.
41
+ def rescued?(name)
42
+ @reruns.rescued?(name)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative 'utils'
4
4
  require 'mergify/ci/core'
5
5
  require_relative 'flaky_detection'
6
+ require_relative 'test_retry'
6
7
  require_relative 'names'
7
8
  require 'json'
8
9
  require 'rspec/core/version'
@@ -22,14 +23,14 @@ module Mergify
22
23
  # RSpec runs examples, in one process that can wait for every one put
23
24
  # first.
24
25
  fail_first: { unit: 'test', waits_for_all: true, workers_stop_the_run: false },
25
- # RSpec reruns an example as it goes, and retries no failure, in one
26
- # process that plans the run's budget once.
27
- reruns: { plans_repeats: false, retries_failures: false },
26
+ # RSpec reruns an example as it goes, and retries a failed one in
27
+ # place, in one process that plans the run's budgets once.
28
+ reruns: { plans_repeats: false, retries_failures: true },
28
29
  framework: { name: 'rspec', version: ::RSpec::Core::Version::STRING, language: 'ruby' }
29
30
  }.freeze
30
31
 
31
32
  attr_reader :session, :repo_name, :test_run_id,
32
- :flaky_detector, :quarantined_tests,
33
+ :flaky_detector, :test_retrier, :quarantined_tests,
33
34
  :captured_session_verdict, :names
34
35
 
35
36
  # `doubles` has the session play Mergify in memory, answering and keeping
@@ -51,6 +52,7 @@ module Mergify
51
52
  @test_run_id = @session.run_id
52
53
  @reruns = nil
53
54
  @flaky_detector = nil
55
+ @test_retrier = nil
54
56
  @quarantined_tests = nil
55
57
  @captured_session_verdict = nil
56
58
  @examples_to_run = nil
@@ -150,15 +152,17 @@ module Mergify
150
152
  @session.summary(Utils.parallel_worker?)
151
153
  end
152
154
 
153
- # Prepare flaky detection for the examples this run is about to run, by
154
- # their names, each one Mergify's lists may still hold under its former
155
- # names (`former_names`, a list by name). Which of them it reruns, and whether
156
- # it runs at all, are the session's.
155
+ # Prepare flaky detection and test retry for the examples this run is
156
+ # about to run, by their names, each one Mergify's lists may still hold
157
+ # under its former names (`former_names`, a list by name). Which of them
158
+ # each reruns, and whether it runs at all, are the session's. No example
159
+ # opts out of test retry: RSpec's metadata has no key of Mergify's.
157
160
  def prepare_reruns(names, former_names)
158
161
  @session.state_former_names(former_names)
159
162
  @reruns = @session.prepare_reruns(names)
160
163
  detection = @reruns&.detection
161
164
  @flaky_detector = detection && FlakyDetector.new(@reruns, detection)
165
+ @test_retrier = @reruns && TestRetrier.new(@reruns)
162
166
  end
163
167
 
164
168
  # Account for the real outcome of the example known by `keys` against
@@ -25,6 +25,15 @@ module Mergify
25
25
  @on_second = on_second
26
26
  @started_ms = nil
27
27
  @first_ended_at = nil
28
+ @run_ms = 0.0
29
+ end
30
+
31
+ # Run the block, the run of the example its tries are made in, timed.
32
+ def time
33
+ started_ms = now_ms
34
+ yield
35
+ ensure
36
+ @run_ms = now_ms - started_ms
28
37
  end
29
38
 
30
39
  def start
@@ -53,6 +62,18 @@ module Mergify
53
62
  @ended.drop(1)
54
63
  end
55
64
 
65
+ # How long the first try took; the whole run where no try started (a
66
+ # skipped example, an `around` hook that raised before running it).
67
+ def first_ms
68
+ first ? first.duration_ms : @run_ms
69
+ end
70
+
71
+ # How long the last try took, as `first_ms`: what the next attempt is
72
+ # expected to take.
73
+ def last_ms
74
+ @ended.empty? ? @run_ms : @ended.last.duration_ms
75
+ end
76
+
56
77
  # The first failure a try left on the example, the quarantine's aside.
57
78
  def failure
58
79
  @ended.find(&:exception)&.exception
@@ -8,6 +8,6 @@ module Mergify
8
8
  # which resolved against whatever repository happened to be the working
9
9
  # directory at load time -- the monorepo's namespaced tags here, the user's
10
10
  # own tags once the gem was installed.
11
- VERSION = '2026.10.8'
11
+ VERSION = '2026.10.9'
12
12
  end
13
13
  end
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: 2026.10.8
4
+ version: 2026.10.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mergify
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - '='
31
31
  - !ruby/object:Gem::Version
32
- version: 2026.10.8
32
+ version: 2026.10.9
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - '='
38
38
  - !ruby/object:Gem::Version
39
- version: 2026.10.8
39
+ version: 2026.10.9
40
40
  description: 'RSpec integration for Mergify Test Engine: OpenTelemetry tracing, flaky
41
41
  test detection, and test quarantine.'
42
42
  email:
@@ -56,6 +56,7 @@ files:
56
56
  - lib/mergify/rspec/formatter.rb
57
57
  - lib/mergify/rspec/names.rb
58
58
  - lib/mergify/rspec/quarantine.rb
59
+ - lib/mergify/rspec/test_retry.rb
59
60
  - lib/mergify/rspec/test_run.rb
60
61
  - lib/mergify/rspec/tries.rb
61
62
  - lib/mergify/rspec/utils.rb