rspec-mergify 2026.10.7 → 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: 2c8b41d3c9e361bcea64889951b41d214d237d06a56ee0873d725f1106b9c21a
4
- data.tar.gz: 86f2ccc52676008a737da28ae8877ec49b84f7dc4f484847f0f3d3c83f1496bf
3
+ metadata.gz: 82681fca0d5d6710db90d1efb22a5ae4d23b175e55519bf8fbf91e44d48d386f
4
+ data.tar.gz: fdae1d6d362f960d5c93f27991227f59d751f27547c3534361de4ca35ad20aca
5
5
  SHA512:
6
- metadata.gz: e9c95b28c61e098fc580b9a854f3bbc3d8f9dc94d5084bd98d5fd0fe3bcf7d090711c3e993651739501bfb5fa11ef091a92e5959cbb78098883bfad9b5ce8882
7
- data.tar.gz: 0cb4388da6ad71ae24ca0315fb29dde8e7995c031b79f29c37a177da5c77df548ffe8216c5b8af5931f810eb39999eab5ad5145dc0c2b20022588629906c45c6
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
 
@@ -21,7 +22,7 @@ Then run `bundle install`.
21
22
 
22
23
  ### Supported platforms
23
24
 
24
- The gem ships precompiled, so nothing is built on your machine. Each platform gem carries an extension for Ruby 3.1 through 4.0:
25
+ The gem runs on `mergify-ci-core`, which Bundler installs with it. That gem ships precompiled, so nothing is built on your machine. Each of its platform gems carries an extension for Ruby 3.1 through 4.0:
25
26
 
26
27
  | Platform | Requirement |
27
28
  |---|---|
@@ -52,7 +53,8 @@ The plugin activates automatically when running in CI (detected via the `CI` env
52
53
  | `MERGIFY_CI_DEBUG` | Print the session verdict and the spans instead of sending them, and upload nothing | `false` |
53
54
  | `MERGIFY_TRACEPARENT` | W3C distributed trace context | — |
54
55
  | `MERGIFY_TEST_JOB_NAME` | Mergify test job name | — |
55
- | `MERGIFY_TEST_SELECTION_ENABLE` | Opt this job into test selection (see below) | `false` |
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
@@ -78,18 +80,32 @@ as usual. No example is ever skipped, so a green step still comes from a full
78
80
  suite. A quarantined failure does not stop the run; a `--fail-fast` of your own
79
81
  still stops at the first failure.
80
82
 
81
- **This is off until you turn it on, per job.** Set
82
- `MERGIFY_TEST_SELECTION_ENABLE=true` on the job you want to run its failures
83
- first. Installing the gem is not enough. Anything that is not a recognised yes
84
- (unset, empty, `false`, unparsable) means no, and a job that has not opted in
85
- never asks.
86
-
87
- Once opted in:
83
+ **It is on by default.** A job that must always run its suite in its usual
84
+ order sets `MERGIFY_FAIL_FIRST_ENABLE=false`, and never asks. Only a recognised
85
+ no (`false`, `no`, `off`, `0`) turns it off. `MERGIFY_TEST_SELECTION_ENABLE`,
86
+ its former name, is still read while the new one is unset or empty, and the
87
+ summary at the end of the run says to switch. It is also enabled per
88
+ organization on Mergify's side: until yours has it, every run is served the
89
+ full suite, says nothing about it, and still sends
90
+ its results, which show what Fail First would do there.
91
+
92
+ It only ever asks in a build of a merge-queue batch, the only builds a
93
+ bisection step can be: on GitHub Actions, the event of the draft pull request
94
+ the queue opened; on any other build, the queue's git note on the branch, one
95
+ `git ls-remote` against `origin` that runs while the tests are being collected.
96
+ Every other build asks nothing and sends nothing. Of a batch's builds, only
97
+ those below a failed batch, and not retrying their own checks, ask: the
98
+ others cannot be a bisection step, and send their results only when the
99
+ batch holds two pull requests or more, so a bisection step may read them, or
100
+ when they failed. A build where git cannot answer (no `origin`, credentials
101
+ the test step does not hold) asks, as it would without this.
102
+
103
+ When it runs:
88
104
 
89
105
  - the order applies **after** RSpec's own filters — file and line arguments,
90
106
  `--tag`, `-e`, `--only-failures` all still narrow the run;
91
- - examples are matched by their id (`./spec/models/user_spec.rb[1:2]`), the
92
- identity RSpec itself reruns failures by;
107
+ - examples are matched by the name Mergify knows them by (see
108
+ [Test names](#test-names));
93
109
  - any error, timeout, or unrecognised answer runs the full suite in its usual
94
110
  order;
95
111
  - at the end of the run the gem sends Mergify what it concluded (the counts
@@ -100,11 +116,36 @@ Once opted in:
100
116
  Each parallel_tests worker, and each CI job handed a slice of the spec files,
101
117
  reports its own collection, by the examples it collects.
102
118
 
119
+ ### Test names
120
+
121
+ Mergify knows each example by its file, its groups and its description:
122
+ `spec/models/user_spec.rb > User > #full_name > joins first and last`. Adding,
123
+ moving or removing an example renames no other. An example written without a
124
+ description (`it { is_expected.to validate_presence_of(:email) }`) is named by
125
+ its code, and examples a file names alike get their place after the name
126
+ (`[1:2]`), as RSpec numbers them.
127
+
128
+ Earlier releases named an example by its place instead
129
+ (`./spec/models/user_spec.rb[1:2]`). A quarantine entry or a flaky-detection
130
+ history under such a name still matches, but each example's history on the
131
+ dashboard starts over under its new name. Quarantine an example again under
132
+ its new name: an entry under its old one follows the place, so the example
133
+ that holds it changes when one is added above.
134
+
135
+ A suite that reported through
136
+ [rspec_junit_formatter](https://github.com/sj26/rspec_junit_formatter) and
137
+ `mergify ci junit-process` keeps its quarantine entries and flaky-detection
138
+ history the same way: an example is still matched under the name those
139
+ uploads gave it (`spec.models.user_spec.User#full_name joins first and last`).
140
+ An example written without a description gets that name only once it ran, so
141
+ on the pull request that switches, flaky detection may take it for a new
142
+ test.
143
+
103
144
  ### Parallel runs
104
145
 
105
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:
106
147
 
107
- - 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;
108
149
  - each worker prints its own Mergify report, so a report covers only that worker's tests.
109
150
 
110
151
  Suites split across CI jobs (knapsack, `circleci tests split`, Buildkite `parallelism`) behave the same way, one session per job.
@@ -131,8 +172,10 @@ bundle install
131
172
  bundle exec rake test
132
173
  ```
133
174
 
134
- `rake test` compiles the extension in Cargo's dev profile first: running
135
- `rspec` alone skips every binding example when the extension isn't built.
175
+ `rake test` compiles `mergify-ci-core`'s extension in Cargo's dev profile
176
+ first, with that gem's own bundle (`clients/mergify-ci-core`, `bundle install`
177
+ there too): running `rspec` alone skips every binding example when the
178
+ extension isn't built.
136
179
 
137
180
  ### Linting
138
181
 
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)
@@ -8357,7 +8360,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
8357
8360
  Community Data License Agreement Permissive 2.0 (CDLA-Permissive-2.0)
8358
8361
 
8359
8362
  Used by:
8360
- - webpki-root-certs 1.0.10 (https://github.com/rustls/webpki-roots)
8363
+ - webpki-root-certs 1.0.11 (https://github.com/rustls/webpki-roots)
8361
8364
 
8362
8365
  # Community Data License Agreement - Permissive - Version 2.0
8363
8366
 
@@ -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
@@ -40,25 +40,21 @@ module Mergify
40
40
  # Also when the native extension did not load, which nothing can
41
41
  # tell apart from a run outside CI: the summary then says why it
42
42
  # reports nothing.
43
- config.prepend_before(:suite) { Configuration.attach_formatter(config) } if reports? || !Native.available?
43
+ if reports? || !Mergify::CI::Core::Native.available?
44
+ config.prepend_before(:suite) { Configuration.attach_formatter(config) }
45
+ end
44
46
 
45
47
  # Flaky detection: prepared for every example of the run.
46
48
  config.before(:suite) do
47
49
  ci = Mergify::RSpec.test_run
48
50
  if ci
49
- all_ids = ::RSpec.world.example_groups.flat_map(&:descendants).flat_map(&:examples).map(&:id)
50
- ci.prepare_reruns(all_ids)
51
+ examples = ::RSpec.world.example_groups.flat_map(&:descendants).flat_map(&:examples)
52
+ names = ci.names
53
+ ci.prepare_reruns(examples.map { |example| names.of(example) },
54
+ examples.to_h { |example| [names.of(example), names.formers(example)] })
51
55
  end
52
56
  end
53
57
 
54
- # Quarantine: mark tests before execution
55
- config.before(:each) do |example|
56
- ci = Mergify::RSpec.test_run
57
- next unless ci&.quarantined_tests&.include?(example.id)
58
-
59
- example.metadata[:mergify_quarantined] = true
60
- end
61
-
62
58
  # Each try of an example, which a retry plugin's loop (rspec-retry's)
63
59
  # makes inside the `around` hook below: the first `before` hook every
64
60
  # try runs, and the last `after` one.
@@ -81,12 +77,17 @@ module Mergify
81
77
  test_run&.reports? ? Configuration.run_example(example, test_run) : example.run
82
78
  end
83
79
 
84
- # Quarantine: override failed quarantined test results. The failure is
85
- # noted for flaky detection first, which would otherwise take a
86
- # quarantined attempt that failed for one that passed.
80
+ # Quarantine: override failed quarantined test results. Asked once
81
+ # the example ran: one written without a description has the name a
82
+ # JUnit upload gave it only then, when RSpec has written it one from
83
+ # its matcher. The failure is noted for flaky detection first, which
84
+ # would otherwise take a quarantined attempt that failed for one
85
+ # that passed.
87
86
  config.after(:each) do |example|
88
- next unless example.metadata[:mergify_quarantined] && example.exception
87
+ ci = Mergify::RSpec.test_run
88
+ next unless example.exception && ci&.quarantined_tests&.include?(ci.names.keys(example))
89
89
 
90
+ example.metadata[:mergify_quarantined] = true
90
91
  example.metadata[:mergify_quarantined_failure] = true
91
92
  example.metadata[:mergify_quarantined_exception] = example.exception
92
93
  example.instance_variable_set(:@exception, nil)
@@ -104,23 +105,25 @@ module Mergify
104
105
  # an example group starts. What each group runs is read from
105
106
  # `RSpec.world.filtered_examples`, which RSpec memoized while counting the
106
107
  # run.
108
+ # rubocop:disable-next Metrics/MethodLength
107
109
  def select_examples
108
110
  ci = Mergify::RSpec.test_run
109
111
  return unless ci
110
112
 
111
113
  examples = collected
112
- return unless ci.select(examples.map(&:id))
114
+ names = ci.names
115
+ return unless ci.select(examples.map { |example| names.of(example) })
113
116
 
114
117
  # Ordered, not filtered: a served example this run did not collect is
115
118
  # expected -- a parallel worker holds part of the job's failures.
116
119
  fail_first = ci.fail_first
117
- FailureFirst.apply(fail_first, examples, reporter: ::RSpec.configuration.reporter) if fail_first
120
+ FailureFirst.apply(fail_first, examples, names, reporter: ::RSpec.configuration.reporter) if fail_first
118
121
  rescue StandardError => e
119
122
  # This runs on every CI run, opted in or not, and RSpec would answer a
120
123
  # raise here by running nothing: a fault of this gem must cost the run
121
124
  # its order, never its tests.
122
125
  ::RSpec.configuration.error_stream.puts(
123
- "Mergify Test Selection failed, so the full suite runs: #{e.class}: #{e.message}"
126
+ "Mergify could not apply Fail First, so the full suite runs: #{e.class}: #{e.message}"
124
127
  )
125
128
  end
126
129
 
@@ -143,44 +146,87 @@ module Mergify
143
146
  # engine around `example.run`, and its outcome read off the exception it
144
147
  # left behind. Its first run is the example's first attempt, and the
145
148
  # tries a retry plugin makes after it, inside that run; flaky detection's
146
- # reruns follow, each a try alone.
149
+ # reruns follow, or test retry's, each a try alone.
147
150
  def run_example(example, test_run)
151
+ name = test_run.names.of(example)
148
152
  detector = test_run.flaky_detector
149
- tries = run_first(example, test_run, detector)
150
- return unless detector&.rerunning_test?(example.id)
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
151
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)
152
166
  initial_exception = example.exception
153
167
  first_failure = tries.failure || initial_exception
154
- first_failure = rerun(example, detector, first_failure) if detector.start_reruns(example.id)
168
+ first_failure = rerun(example, name, detector, retrier, first_failure) if detector.start_reruns(name)
155
169
 
156
170
  # Which attempt decides the result is the session's rule: in 'new'
157
171
  # mode any failure fails the test, the retry plugin's tries included,
158
172
  # so a new flaky test cannot be merged; in 'unhealthy' mode the reruns
159
- # 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.
160
175
  final_exception = detector.rerun_failure_fails_the_test? ? first_failure : initial_exception
176
+ final_exception = nil if retrier&.rescued?(name)
161
177
  example.example.instance_variable_set(:@exception, final_exception)
162
178
  end
163
179
 
164
- # The example's first run: its first attempt, timed by the engine up to
165
- # the second try where a retry plugin made one, then each try after it,
166
- # handed to the session once the first reached flaky detection. What the
167
- # first attempt was is kept for its span.
168
- def run_first(example, test_run, detector)
169
- id = example.id
170
- detector&.attempt_started(id)
171
- tries = run_tries(example) { detector&.attempt_stopped(id) }
172
- detector&.attempt_ended(id, first_failed?(example, tries) ? :failed : :passed)
173
- tries.later.each { |try| test_run.record_retry(id, try.status, try.duration_ms) }
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
+
202
+ # The first run of the example named `name`: its first attempt, timed by
203
+ # the engine up to the second try where a retry plugin made one, then
204
+ # each try after it, handed to the session once the first reached flaky
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)
208
+ detector&.attempt_started(name)
209
+ tries = run_tries(example) { detector&.attempt_stopped(name) }
210
+ first_attempt_ended(example, name, tries, detector, retrier)
211
+ tries.later.each { |try| test_run.record_retry(name, try.status, try.duration_ms) }
174
212
  example.metadata[:mergify_first_attempt] = { try: tries.first, ended_at: tries.first_ended_at } if tries.first
175
213
  tries
176
214
  end
177
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
+
178
224
  # Run the example once, noting each try a retry plugin makes of it; the
179
225
  # block is called as a second one starts.
180
226
  def run_tries(example, &)
181
227
  tries = Tries.new(&)
182
228
  example.metadata[:mergify_tries] = tries
183
- example.run
229
+ tries.time { example.run }
184
230
  tries
185
231
  ensure
186
232
  example.metadata.delete(:mergify_tries)
@@ -212,16 +258,16 @@ module Mergify
212
258
  end
213
259
 
214
260
  # Which attempt decides the result is the session's rule (see
215
- # `run_example`): answers the first failure, the reruns' included.
261
+ # `detect_flakiness`): answers the first failure, the reruns' included.
216
262
  # Each rerun is one try: a retry plugin's tries would hide its failure
217
263
  # behind the one that passed.
218
- def rerun(example, detector, first_failure)
264
+ def rerun(example, name, detector, retrier, first_failure)
219
265
  without_retries(example) do
220
266
  until example.metadata[:is_last_rerun]
221
- example.metadata[:is_last_rerun] = detector.last_rerun_for_test?(example.id)
267
+ example.metadata[:is_last_rerun] = detector.last_rerun_for_test?(name)
222
268
  reset_for_rerun(example)
223
269
 
224
- run_attempt(example, detector)
270
+ run_attempt(example, name, detector, retrier)
225
271
  first_failure ||= example.exception
226
272
  end
227
273
  end
@@ -257,13 +303,15 @@ module Mergify
257
303
  end
258
304
 
259
305
  # Runs what the around hook wraps -- the before and after hooks included
260
- # -- timed by the session, and answers its outcome.
261
- def run_attempt(example, detector)
262
- detector.attempt_started(example.id)
263
- example.run
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)
309
+ detector.attempt_started(name)
310
+ attempt = run_tries(example)
264
311
  failed = example.exception || example.metadata.delete(:mergify_quarantined_failure)
265
312
  status = failed ? :failed : :passed
266
- detector.attempt_ended(example.id, status)
313
+ detector.attempt_ended(name, status)
314
+ retrier&.record(name, status, attempt.last_ms)
267
315
  status
268
316
  end
269
317
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'set'
4
- require_relative 'native'
4
+ require 'mergify/ci/core'
5
5
 
6
6
  module Mergify
7
7
  module RSpec
@@ -35,7 +35,7 @@ module Mergify
35
35
  # every example and group not started yet is left out, and the run exits 1.
36
36
  #
37
37
  # What to put first, when to stop, and the terminal block's words are the
38
- # session's (`Native::Session#fail_first`), shared with every plugin: this
38
+ # session's (`Mergify::CI::Core::Native::Session#fail_first`), shared with every plugin: this
39
39
  # class reorders and quits.
40
40
  class FailureFirst
41
41
  class << self
@@ -57,8 +57,8 @@ module Mergify
57
57
 
58
58
  # Put first the examples the session puts first, and stop the run when
59
59
  # it says.
60
- def apply(fail_first, examples, reporter:)
61
- self.current = new(fail_first, examples)
60
+ def apply(fail_first, examples, names, reporter:)
61
+ self.current = new(fail_first, examples, names)
62
62
  current.reorder(top_level) if top_level
63
63
  reporter.register_listener(current, :example_finished)
64
64
  current
@@ -96,10 +96,11 @@ module Mergify
96
96
  end
97
97
  end
98
98
 
99
- # An example's id is also the name Mergify knows it by.
100
- def initialize(fail_first, examples)
99
+ # Each example goes by its id, which RSpec orders and reports it by,
100
+ # and is put first by the name Mergify knows it by (`names`).
101
+ def initialize(fail_first, examples, names)
101
102
  @fail_first = fail_first
102
- @first_ids = fail_first.put_first(examples.map { |example| [example.id, example.id] }).to_set
103
+ @first_ids = fail_first.put_first(examples.map { |example| [example.id, names.of(example)] }).to_set
103
104
  @first_groups = groups_holding(examples.select { |example| @first_ids.include?(example.id) })
104
105
  end
105
106
 
@@ -3,7 +3,7 @@
3
3
  require 'rspec/core/formatters/base_formatter'
4
4
  require_relative 'configuration'
5
5
 
6
- require 'mergify/rspec/native'
6
+ require 'mergify/ci/core'
7
7
 
8
8
  module Mergify
9
9
  module RSpec
@@ -34,8 +34,9 @@ module Mergify
34
34
 
35
35
  example = notification.example
36
36
  result = example.execution_result
37
- @test_run.record_verdict(example.id, verdict_status(example), result.run_time)
38
- recorded = @test_run.record_quarantine(example.id, real_outcome(example))
37
+ names = @test_run.names
38
+ @test_run.record_verdict(names.of(example), verdict_status(example), result.run_time)
39
+ recorded = @test_run.record_quarantine(names.keys(example), real_outcome(example))
39
40
  @has_error = true if result.status == :failed
40
41
  @test_run.record_case(case_of(example, recorded))
41
42
  end
@@ -120,7 +121,7 @@ module Mergify
120
121
  def case_of(example, recorded)
121
122
  first = first_attempt(example, recorded['absorbed'])
122
123
  {
123
- 'name' => example.id,
124
+ 'name' => @test_run.names.of(example),
124
125
  'file' => example.metadata[:file_path].delete_prefix('./'),
125
126
  'function' => example.description,
126
127
  'start_unix_nano' => unix_nano(example.execution_result.started_at),
@@ -201,7 +202,7 @@ module Mergify
201
202
  return ['Mergify Test Engine is not configured.'] unless @test_run
202
203
  # A missing token or repository is the session's to say, with the
203
204
  # trace it could not upload.
204
- return [] if Mergify::RSpec::Native.available?
205
+ return [] if Mergify::CI::Core::Native.available?
205
206
 
206
207
  [native_warning]
207
208
  end
@@ -211,7 +212,7 @@ module Mergify
211
212
  # git remote that is fine. Say what actually happened instead.
212
213
  def native_warning
213
214
  'WARNING: the Mergify native extension could not be loaded, so this run reports nothing: ' \
214
- "#{Mergify::RSpec::Native.load_error&.message}"
215
+ "#{Mergify::CI::Core::Native.load_error&.message}"
215
216
  end
216
217
 
217
218
  # `text` in the colour of `severity` when RSpec colours its output, as
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'mergify/ci/core'
5
+
6
+ module Mergify
7
+ module RSpec
8
+ # The name Mergify knows each example by, which the gem hands the session
9
+ # wherever it speaks of one: the example's span and its verdict, the
10
+ # quarantine, flaky detection and Fail First.
11
+ #
12
+ # An example is named as minitest-mergify names a spec test, by
13
+ # `CI::Core::TestNames`: `<file> > <groups> > <description>`. The file is
14
+ # the one RSpec reruns the example from, so an example a shared group
15
+ # gives a spec is that spec's, relative to the directory the run started
16
+ # in. Nothing in it counts examples: RSpec's own id,
17
+ # `./spec/x_spec.rb[1:2]`, numbers an example by its place in its group,
18
+ # so adding one renamed every one after it, which then took the
19
+ # quarantine entry and the history of the one before. An example written
20
+ # without a description is titled by its block's code: RSpec writes it
21
+ # one from its matcher only once it ran, long after the session asked
22
+ # about it. A name its file repeats is followed by the example's place
23
+ # (`[1:2]`), so those examples stay apart.
24
+ #
25
+ # An example has two former names, which Mergify's lists may still hold
26
+ # it under: its id, which every earlier release named it by, and the
27
+ # name a JUnit upload gave it, for a suite that reported through
28
+ # rspec_junit_formatter and `mergify ci junit-process` before.
29
+ class Names
30
+ # `root` is the directory the run started in.
31
+ def initialize(root)
32
+ @root = root
33
+ end
34
+
35
+ def of(example)
36
+ name_all unless example.metadata.key?(:mergify_name)
37
+ example.metadata.fetch(:mergify_name)
38
+ end
39
+
40
+ # The names the example had before, which Mergify's lists may still
41
+ # hold it under.
42
+ def formers(example)
43
+ [example.id, junit_name(example)].compact
44
+ end
45
+
46
+ # Every name Mergify's lists may hold the example under, which the
47
+ # quarantine is asked with.
48
+ def keys(example)
49
+ [of(example), *formers(example)]
50
+ end
51
+
52
+ private
53
+
54
+ # Name every example RSpec loaded, each in its metadata: whether a name
55
+ # needs the example's place depends on the others of its file.
56
+ def name_all
57
+ names = unplaced_names
58
+ repeated = names.values.compact.tally.select { |_, count| count > 1 }.keys.to_set
59
+ names.each do |example, name|
60
+ example.metadata[:mergify_name] = name.nil? || repeated.include?(name) ? placed(example) : name
61
+ end
62
+ end
63
+
64
+ # Every example RSpec loaded, by its name without its place: nil for
65
+ # one that has no title.
66
+ def unplaced_names
67
+ examples = ::RSpec.world.example_groups.flat_map(&:descendants).flat_map(&:examples)
68
+ examples.to_h do |example|
69
+ title = title(example)
70
+ [example, title && spec_name(example, title)]
71
+ end
72
+ end
73
+
74
+ # Its name with its place after its title, as RSpec writes it: `[1:2]`.
75
+ def placed(example)
76
+ spec_name(example, [title(example), "[#{example.metadata[:scoped_id]}]"].compact.join(' '))
77
+ end
78
+
79
+ # Its description, or the code of its block when it was written without
80
+ # one; nil when there is none to read.
81
+ def title(example)
82
+ description = example.metadata[:description].to_s
83
+ return description unless description.empty?
84
+
85
+ block = example.metadata[:block]
86
+ CI::Core::TestNames.block_text(block) if block
87
+ end
88
+
89
+ # The name `mergify ci junit-process` gave the example from
90
+ # rspec_junit_formatter's report, `<class name>.<name>`: the class name
91
+ # the file of its outermost group, without its extension and with `/`
92
+ # as `.`, and the name its full description. Nil for an example written
93
+ # without a description that has not run yet: RSpec writes it one only
94
+ # once it ran.
95
+ def junit_name(example)
96
+ return if example.metadata[:description].to_s.empty?
97
+
98
+ # The formatter cuts the file's name at its first dot.
99
+ directory, name = File.split(example.example_group.parent_groups.last.metadata[:file_path])
100
+ classname = "#{directory}/#{name.sub(/\..*/, '')}".tr('/', '.').gsub(/\A\.+|\.+\z/, '')
101
+ "#{classname}.#{example.full_description}"
102
+ end
103
+
104
+ def spec_name(example, title)
105
+ groups = example.example_group.parent_groups.reverse.map(&:description)
106
+ CI::Core::TestNames.spec_name(example.metadata[:rerun_file_path], groups, title, @root)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -16,15 +16,18 @@ module Mergify
16
16
  @list = list
17
17
  end
18
18
 
19
- def include?(example_id)
20
- @list.include?([example_id])
19
+ # Whether the list holds an example under any of `keys`, its names (see
20
+ # `Names#keys`).
21
+ def include?(keys)
22
+ @list.include?(keys)
21
23
  end
22
24
 
23
- # Account for an example that ended with `outcome` (`'passed'`,
24
- # `'failed'` or `'skipped'`, before the quarantine turned a failure into
25
- # `pending`), as `{ 'quarantined' => bool, 'absorbed' => bool }`.
26
- def record(example_id, outcome)
27
- @list.record([example_id], outcome)
25
+ # Account for an example known by `keys` that ended with `outcome`
26
+ # (`'passed'`, `'failed'` or `'skipped'`, before the quarantine turned a
27
+ # failure into `pending`), as `{ 'quarantined' => bool, 'absorbed' =>
28
+ # bool }`.
29
+ def record(keys, outcome)
30
+ @list.record(keys, outcome)
28
31
  end
29
32
  end
30
33
  end
@@ -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
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'utils'
4
- require_relative 'native'
4
+ require 'mergify/ci/core'
5
5
  require_relative 'flaky_detection'
6
+ require_relative 'test_retry'
7
+ require_relative 'names'
6
8
  require 'json'
7
9
  require 'rspec/core/version'
8
10
  require 'set'
@@ -21,15 +23,15 @@ module Mergify
21
23
  # RSpec runs examples, in one process that can wait for every one put
22
24
  # first.
23
25
  fail_first: { unit: 'test', waits_for_all: true, workers_stop_the_run: false },
24
- # RSpec reruns an example as it goes, and retries no failure, in one
25
- # process that plans the run's budget once.
26
- 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 },
27
29
  framework: { name: 'rspec', version: ::RSpec::Core::Version::STRING, language: 'ruby' }
28
30
  }.freeze
29
31
 
30
32
  attr_reader :session, :repo_name, :test_run_id,
31
- :flaky_detector, :quarantined_tests,
32
- :captured_session_verdict
33
+ :flaky_detector, :test_retrier, :quarantined_tests,
34
+ :captured_session_verdict, :names
33
35
 
34
36
  # `doubles` has the session play Mergify in memory, answering and keeping
35
37
  # what it would send (the session's test doubles, by port, as a Hash:
@@ -37,19 +39,24 @@ module Mergify
37
39
  # build their TestRun themselves -- or, for a run in another process,
38
40
  # install it from a file required before `rspec_mergify`. The instance
39
41
  # the gem builds takes none, and the gem reads no switch for them.
42
+ # rubocop:disable-next Metrics/MethodLength
40
43
  def initialize(doubles: nil)
41
44
  # What this process reads once: the CI context, the trace a caller
42
45
  # handed down, and the run's id; and the deprecated names the run
43
46
  # sets, which the summary names.
44
- @session = Native::Session.start(Mergify::RSpec::VERSION, output, doubles, enabled?, **FRAMEWORK,
45
- deprecated_variables: Utils.deprecated_env_names_in_use)
47
+ @session = Mergify::CI::Core::Native::Session.start(
48
+ Mergify::RSpec::VERSION, output, doubles, enabled?, client: 'rspec-mergify', **FRAMEWORK,
49
+ deprecated_variables: Utils.deprecated_env_names_in_use
50
+ )
46
51
  @repo_name = @session.repository_name
47
52
  @test_run_id = @session.run_id
48
53
  @reruns = nil
49
54
  @flaky_detector = nil
55
+ @test_retrier = nil
50
56
  @quarantined_tests = nil
51
57
  @captured_session_verdict = nil
52
58
  @examples_to_run = nil
59
+ @names = Names.new(Dir.pwd)
53
60
 
54
61
  set_up if reports?
55
62
  end
@@ -63,8 +70,8 @@ module Mergify
63
70
 
64
71
  # Hand the session what this run is about to execute, and answer how it
65
72
  # executes it, as the session decided: a Hash whose `outcome` is `full`
66
- # or `failure_first` (see `Native::Session#select`). Every example
67
- # runs.
73
+ # or `failure_first` (see `Mergify::CI::Core::Native::Session#select`).
74
+ # Every example runs.
68
75
  #
69
76
  # Called once RSpec has loaded the spec files and applied every filter
70
77
  # of its own -- file and line arguments, `--tag`, `-e`,
@@ -74,11 +81,11 @@ module Mergify
74
81
  # own identity: the engine matches each against the previous attempt of
75
82
  # the same slice. It travels on the verdict and the trace of a run that
76
83
  # asked for the selection.
77
- def select(example_ids)
84
+ def select(names)
78
85
  return unless reports?
79
86
 
80
- @examples_to_run = example_ids.size
81
- @session.select(example_ids)
87
+ @examples_to_run = names.size
88
+ @session.select(names)
82
89
  end
83
90
 
84
91
  # The run's Fail First, which the session hands out when Mergify
@@ -87,10 +94,10 @@ module Mergify
87
94
  @session.fail_first
88
95
  end
89
96
 
90
- # Record an example's final status, as RSpec reported it, for the
91
- # session's verdict.
92
- def record_verdict(example_id, status, run_time)
93
- @session.record_test(example_id, status, run_time.to_f)
97
+ # Record the final status of the example named `name`, as RSpec
98
+ # reported it, for the session's verdict.
99
+ def record_verdict(name, status, run_time)
100
+ @session.record_test(name, status, run_time.to_f)
94
101
  end
95
102
 
96
103
  # Write what this session concluded to Mergify, before the spans go.
@@ -112,19 +119,19 @@ module Mergify
112
119
  end
113
120
 
114
121
  # Hand the session an example once it finished, as the formatter says
115
- # it (see `Native::Session#record_case`): its span joins the run's
122
+ # it (see `Mergify::CI::Core::Native::Session#record_case`): its span joins the run's
116
123
  # trace.
117
124
  def record_case(example)
118
125
  @session.record_case(example)
119
126
  end
120
127
 
121
- # A retry plugin (rspec-retry) tried the example again after its first
122
- # attempt, which ended with `status` (`'passed'` or `'failed'`) after
123
- # `duration_ms`: the session counts it on the example's span, and as one
124
- # of its runs where flaky detection runs, never against its budget or
125
- # its execution cap.
126
- def record_retry(example_id, status, duration_ms)
127
- @session.record_retry(example_id, status, duration_ms)
128
+ # A retry plugin (rspec-retry) tried the example named `name` again
129
+ # after its first attempt, which ended with `status` (`'passed'` or
130
+ # `'failed'`) after `duration_ms`: the session counts it on the
131
+ # example's span, and as one of its runs where flaky detection runs,
132
+ # never against its budget or its execution cap.
133
+ def record_retry(name, status, duration_ms)
134
+ @session.record_retry(name, status, duration_ms)
128
135
  end
129
136
 
130
137
  # Have the session send the run's trace, once, at the end: the examples
@@ -145,20 +152,25 @@ module Mergify
145
152
  @session.summary(Utils.parallel_worker?)
146
153
  end
147
154
 
148
- # Prepare flaky detection for the examples this run is about to run.
149
- # Which of them it reruns, and whether it runs at all, are the
150
- # session's.
151
- def prepare_reruns(example_ids)
152
- @reruns = @session.prepare_reruns(example_ids)
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.
160
+ def prepare_reruns(names, former_names)
161
+ @session.state_former_names(former_names)
162
+ @reruns = @session.prepare_reruns(names)
153
163
  detection = @reruns&.detection
154
164
  @flaky_detector = detection && FlakyDetector.new(@reruns, detection)
165
+ @test_retrier = @reruns && TestRetrier.new(@reruns)
155
166
  end
156
167
 
157
- # Account for an example's real outcome against the quarantine list.
158
- def record_quarantine(example_id, outcome)
168
+ # Account for the real outcome of the example known by `keys` against
169
+ # the quarantine list.
170
+ def record_quarantine(keys, outcome)
159
171
  return { 'quarantined' => false, 'absorbed' => false } unless @quarantined_tests
160
172
 
161
- @quarantined_tests.record(example_id, outcome)
173
+ @quarantined_tests.record(keys, outcome)
162
174
  end
163
175
 
164
176
  private
@@ -188,7 +200,7 @@ module Mergify
188
200
  # be on whatever its value, so that `false` silently stopped the upload;
189
201
  # both names now take a boolean.
190
202
  def debug_mode?
191
- Utils.env_truthy?(Utils.renamed_env(Utils::CI_DEBUG_ENV))
203
+ Mergify::CI::Core::Utils.env_truthy?(Utils.renamed_env(Utils::CI_DEBUG_ENV))
192
204
  end
193
205
 
194
206
  # Where the session sends the verdict and the trace: shown in debug mode,
@@ -200,7 +212,7 @@ module Mergify
200
212
  # The gem's enable switch, under every name it has had: the run then
201
213
  # reports outside CI too.
202
214
  def enabled?
203
- Utils.env_truthy?(Utils.renamed_env(Utils::CI_ENABLE_ENV))
215
+ Mergify::CI::Core::Utils.env_truthy?(Utils.renamed_env(Utils::CI_ENABLE_ENV))
204
216
  end
205
217
  end
206
218
  end
@@ -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
@@ -6,18 +6,16 @@ module Mergify
6
6
  #
7
7
  # CI detection used to live here -- the provider table, the git shelling
8
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 reading the plugin's own
12
- # switches.
9
+ # the extension in Mergify::CI::Core::Native, so that every Mergify test
10
+ # client detects identically. What is left is what is genuinely Ruby's:
11
+ # parsing a repository name the API needs split, and reading the plugin's
12
+ # own switches.
13
13
  module Utils
14
14
  module_function
15
15
 
16
16
  # Raised when a repository full name (owner/repo) is malformed.
17
17
  class InvalidRepositoryFullNameError < StandardError; end
18
18
 
19
- TRUTHY_STRINGS = %w[y yes t true on 1].freeze
20
-
21
19
  CI_ENABLE_ENV = 'MERGIFY_CI_ENABLE'
22
20
  CI_DEBUG_ENV = 'MERGIFY_CI_DEBUG'
23
21
 
@@ -29,11 +27,6 @@ module Mergify
29
27
  CI_DEBUG_ENV => 'RSPEC_MERGIFY_DEBUG'
30
28
  }.freeze
31
29
 
32
- # Returns true when the named environment variable holds a truthy value.
33
- def env_truthy?(key)
34
- TRUTHY_STRINGS.include?(ENV.fetch(key, '').downcase)
35
- end
36
-
37
30
  # The variable to read for a setting in DEPRECATED_ENV_NAMES: its new
38
31
  # name once that is set to something, its deprecated one until then.
39
32
  def renamed_env(name)
@@ -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.7'
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.7
4
+ version: 2026.10.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mergify
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '3.12'
26
+ - !ruby/object:Gem::Dependency
27
+ name: mergify-ci-core
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - '='
31
+ - !ruby/object:Gem::Version
32
+ version: 2026.10.9
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - '='
38
+ - !ruby/object:Gem::Version
39
+ version: 2026.10.9
26
40
  description: 'RSpec integration for Mergify Test Engine: OpenTelemetry tracing, flaky
27
41
  test detection, and test quarantine.'
28
42
  email:
@@ -40,8 +54,9 @@ files:
40
54
  - lib/mergify/rspec/failure_first.rb
41
55
  - lib/mergify/rspec/flaky_detection.rb
42
56
  - lib/mergify/rspec/formatter.rb
43
- - lib/mergify/rspec/native.rb
57
+ - lib/mergify/rspec/names.rb
44
58
  - lib/mergify/rspec/quarantine.rb
59
+ - lib/mergify/rspec/test_retry.rb
45
60
  - lib/mergify/rspec/test_run.rb
46
61
  - lib/mergify/rspec/tries.rb
47
62
  - lib/mergify/rspec/utils.rb
@@ -1,130 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mergify
4
- module RSpec
5
- # The Rust extension over mergify-ci-session: the session 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
- class << self
17
- # The LoadError that prevented the extension loading, or nil.
18
- attr_accessor :load_error
19
-
20
- def available?
21
- load_error.nil?
22
- end
23
-
24
- # Which of the two failed requires actually explains the absence.
25
- #
26
- # A precompiled gem ships lib/mergify/rspec/<ruby>/mergify_ci.<dlext>,
27
- # so when one is packed for this Ruby that require is the real attempt:
28
- # it fails with something like "version `GLIBC_2.29' not found", and the
29
- # fallback that follows only ever adds "cannot load such file", which
30
- # sends the reader looking for the wrong thing. With no extension for
31
- # this Ruby -- the source gem, or a platform we publish no gem for --
32
- # it is the other way round.
33
- # Require a file next to this one, returning the LoadError instead of
34
- # raising it. Two attempts read better as values than as nested rescues.
35
- def attempt_require(path)
36
- require_relative path
37
- nil
38
- rescue LoadError => e
39
- e
40
- end
41
-
42
- def load_failure_reason(versioned, fallback)
43
- packed = Dir.glob(File.join(__dir__, RUBY_VERSION.to_f.to_s, 'mergify_ci.*')).any?
44
- packed ? versioned : fallback
45
- end
46
- end
47
- end
48
- end
49
- end
50
-
51
- # Precompiled gems ship lib/mergify/rspec/<ruby>/mergify_ci.<dlext>; the source
52
- # gem and any local `rake compile` put one beside this file instead.
53
- versioned_error = Mergify::RSpec::Native.attempt_require("#{RUBY_VERSION.to_f}/mergify_ci")
54
- fallback_error = versioned_error && Mergify::RSpec::Native.attempt_require('mergify_ci')
55
-
56
- if fallback_error
57
- Mergify::RSpec::Native.load_error =
58
- Mergify::RSpec::Native.load_failure_reason(versioned_error, fallback_error)
59
- end
60
-
61
- unless Mergify::RSpec::Native.available?
62
- # Stand in for the extension so callers can just ask, and get the same answer
63
- # they would get from a machine that is not in CI. Branching on availability
64
- # at every call site would only spread the same nil back through the caller.
65
- module Mergify
66
- module RSpec
67
- module Native
68
- # A session that detects nothing, as on a machine that is not in CI.
69
- class Session
70
- def self.start(*)
71
- new
72
- end
73
-
74
- # Nothing detected, so nothing to report to.
75
- def reports?
76
- false
77
- end
78
-
79
- def repository_name
80
- nil
81
- end
82
-
83
- # Nothing detected: no run to name.
84
- def run_id
85
- nil
86
- end
87
-
88
- # Nothing to send a verdict with: it neither folds nor reports.
89
- def record_test(_example_id, _status, _run_time); end
90
-
91
- # Nothing to ask with: every example runs.
92
- def select(_example_ids)
93
- { 'outcome' => 'full' }
94
- end
95
-
96
- def executed_count
97
- 0
98
- end
99
-
100
- def report_verdict(_failed_outside_examples); end
101
-
102
- def captured_verdict
103
- nil
104
- end
105
-
106
- # Nothing to send a trace with either.
107
- def record_case(_example); end
108
-
109
- def record_retry(_example_id, _status, _duration_ms); end
110
-
111
- def report_trace(_failed); end
112
-
113
- # Nothing reported: nothing to say.
114
- def summary(_partial)
115
- []
116
- end
117
-
118
- def captured_trace
119
- nil
120
- end
121
-
122
- # Nothing to rerun with.
123
- def prepare_reruns(_example_ids)
124
- nil
125
- end
126
- end
127
- end
128
- end
129
- end
130
- end