rspec-mergify 2026.10.7 → 2026.10.8

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: e5715b89995827b7f5d3840e0c3c1d94288f511fedcac9f3bdd58433c1908228
4
+ data.tar.gz: d70082926c396a3ac69ae778f9024be9d3cde6570cd84ef498d1e489ae9164d2
5
5
  SHA512:
6
- metadata.gz: e9c95b28c61e098fc580b9a854f3bbc3d8f9dc94d5084bd98d5fd0fe3bcf7d090711c3e993651739501bfb5fa11ef091a92e5959cbb78098883bfad9b5ce8882
7
- data.tar.gz: 0cb4388da6ad71ae24ca0315fb29dde8e7995c031b79f29c37a177da5c77df548ffe8216c5b8af5931f810eb39999eab5ad5145dc0c2b20022588629906c45c6
6
+ metadata.gz: 036d1307e6752c12f0c4a88a5f4625528a8ef6eb7d0247b23100127d42a1842c2a3ddadcffc2439f27bb73f49f71d478346fb51b4a1524725c6812f232e10168
7
+ data.tar.gz: d3e25b1c4dda733655435cd3e28016a30127c82ed04e8d5b8e07a8e0e7c7374d572491bf8559e3f6c9262c814abe5130e4b65781b0c93cd7f91f2a72f5496de8
data/README.md CHANGED
@@ -21,7 +21,7 @@ Then run `bundle install`.
21
21
 
22
22
  ### Supported platforms
23
23
 
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:
24
+ 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
25
 
26
26
  | Platform | Requirement |
27
27
  |---|---|
@@ -52,7 +52,7 @@ The plugin activates automatically when running in CI (detected via the `CI` env
52
52
  | `MERGIFY_CI_DEBUG` | Print the session verdict and the spans instead of sending them, and upload nothing | `false` |
53
53
  | `MERGIFY_TRACEPARENT` | W3C distributed trace context | — |
54
54
  | `MERGIFY_TEST_JOB_NAME` | Mergify test job name | — |
55
- | `MERGIFY_TEST_SELECTION_ENABLE` | Opt this job into test selection (see below) | `false` |
55
+ | `MERGIFY_FAIL_FIRST_ENABLE` | Set to `false` to never run first what a merge-queue bisection step of this job failed before | `true` |
56
56
 
57
57
  The names these replace, `RSPEC_MERGIFY_ENABLE` and `RSPEC_MERGIFY_DEBUG`, are
58
58
  deprecated: they still work when the new name is not set, and the summary says
@@ -78,18 +78,32 @@ as usual. No example is ever skipped, so a green step still comes from a full
78
78
  suite. A quarantined failure does not stop the run; a `--fail-fast` of your own
79
79
  still stops at the first failure.
80
80
 
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:
81
+ **It is on by default.** A job that must always run its suite in its usual
82
+ order sets `MERGIFY_FAIL_FIRST_ENABLE=false`, and never asks. Only a recognised
83
+ no (`false`, `no`, `off`, `0`) turns it off. `MERGIFY_TEST_SELECTION_ENABLE`,
84
+ its former name, is still read while the new one is unset or empty, and the
85
+ summary at the end of the run says to switch. It is also enabled per
86
+ organization on Mergify's side: until yours has it, every run is served the
87
+ full suite, says nothing about it, and still sends
88
+ its results, which show what Fail First would do there.
89
+
90
+ It only ever asks in a build of a merge-queue batch, the only builds a
91
+ bisection step can be: on GitHub Actions, the event of the draft pull request
92
+ the queue opened; on any other build, the queue's git note on the branch, one
93
+ `git ls-remote` against `origin` that runs while the tests are being collected.
94
+ Every other build asks nothing and sends nothing. Of a batch's builds, only
95
+ those below a failed batch, and not retrying their own checks, ask: the
96
+ others cannot be a bisection step, and send their results only when the
97
+ batch holds two pull requests or more, so a bisection step may read them, or
98
+ when they failed. A build where git cannot answer (no `origin`, credentials
99
+ the test step does not hold) asks, as it would without this.
100
+
101
+ When it runs:
88
102
 
89
103
  - the order applies **after** RSpec's own filters — file and line arguments,
90
104
  `--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;
105
+ - examples are matched by the name Mergify knows them by (see
106
+ [Test names](#test-names));
93
107
  - any error, timeout, or unrecognised answer runs the full suite in its usual
94
108
  order;
95
109
  - at the end of the run the gem sends Mergify what it concluded (the counts
@@ -100,6 +114,31 @@ Once opted in:
100
114
  Each parallel_tests worker, and each CI job handed a slice of the spec files,
101
115
  reports its own collection, by the examples it collects.
102
116
 
117
+ ### Test names
118
+
119
+ Mergify knows each example by its file, its groups and its description:
120
+ `spec/models/user_spec.rb > User > #full_name > joins first and last`. Adding,
121
+ moving or removing an example renames no other. An example written without a
122
+ description (`it { is_expected.to validate_presence_of(:email) }`) is named by
123
+ its code, and examples a file names alike get their place after the name
124
+ (`[1:2]`), as RSpec numbers them.
125
+
126
+ Earlier releases named an example by its place instead
127
+ (`./spec/models/user_spec.rb[1:2]`). A quarantine entry or a flaky-detection
128
+ history under such a name still matches, but each example's history on the
129
+ dashboard starts over under its new name. Quarantine an example again under
130
+ its new name: an entry under its old one follows the place, so the example
131
+ that holds it changes when one is added above.
132
+
133
+ A suite that reported through
134
+ [rspec_junit_formatter](https://github.com/sj26/rspec_junit_formatter) and
135
+ `mergify ci junit-process` keeps its quarantine entries and flaky-detection
136
+ history the same way: an example is still matched under the name those
137
+ uploads gave it (`spec.models.user_spec.User#full_name joins first and last`).
138
+ An example written without a description gets that name only once it ran, so
139
+ on the pull request that switches, flaky detection may take it for a new
140
+ test.
141
+
103
142
  ### Parallel runs
104
143
 
105
144
  [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:
@@ -131,8 +170,10 @@ bundle install
131
170
  bundle exec rake test
132
171
  ```
133
172
 
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.
173
+ `rake test` compiles `mergify-ci-core`'s extension in Cargo's dev profile
174
+ first, with that gem's own bundle (`clients/mergify-ci-core`, `bundle install`
175
+ there too): running `rspec` alone skips every binding example when the
176
+ extension isn't built.
136
177
 
137
178
  ### Linting
138
179
 
data/THIRD_PARTY_LICENSES CHANGED
@@ -8357,7 +8357,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
8357
8357
  Community Data License Agreement Permissive 2.0 (CDLA-Permissive-2.0)
8358
8358
 
8359
8359
  Used by:
8360
- - webpki-root-certs 1.0.10 (https://github.com/rustls/webpki-roots)
8360
+ - webpki-root-certs 1.0.11 (https://github.com/rustls/webpki-roots)
8361
8361
 
8362
8362
  # Community Data License Agreement - Permissive - Version 2.0
8363
8363
 
@@ -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
 
@@ -145,13 +148,14 @@ module Mergify
145
148
  # tries a retry plugin makes after it, inside that run; flaky detection's
146
149
  # reruns follow, 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
+ tries = run_first(example, name, test_run, detector)
154
+ return unless detector&.rerunning_test?(name)
151
155
 
152
156
  initial_exception = example.exception
153
157
  first_failure = tries.failure || initial_exception
154
- first_failure = rerun(example, detector, first_failure) if detector.start_reruns(example.id)
158
+ first_failure = rerun(example, name, detector, first_failure) if detector.start_reruns(name)
155
159
 
156
160
  # Which attempt decides the result is the session's rule: in 'new'
157
161
  # mode any failure fails the test, the retry plugin's tries included,
@@ -161,16 +165,15 @@ module Mergify
161
165
  example.example.instance_variable_set(:@exception, final_exception)
162
166
  end
163
167
 
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) }
168
+ # The first run of the example named `name`: its first attempt, timed by
169
+ # the engine up to the second try where a retry plugin made one, then
170
+ # 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)
173
+ detector&.attempt_started(name)
174
+ tries = run_tries(example) { detector&.attempt_stopped(name) }
175
+ detector&.attempt_ended(name, first_failed?(example, tries) ? :failed : :passed)
176
+ tries.later.each { |try| test_run.record_retry(name, try.status, try.duration_ms) }
174
177
  example.metadata[:mergify_first_attempt] = { try: tries.first, ended_at: tries.first_ended_at } if tries.first
175
178
  tries
176
179
  end
@@ -215,13 +218,13 @@ module Mergify
215
218
  # `run_example`): answers the first failure, the reruns' included.
216
219
  # Each rerun is one try: a retry plugin's tries would hide its failure
217
220
  # behind the one that passed.
218
- def rerun(example, detector, first_failure)
221
+ def rerun(example, name, detector, first_failure)
219
222
  without_retries(example) do
220
223
  until example.metadata[:is_last_rerun]
221
- example.metadata[:is_last_rerun] = detector.last_rerun_for_test?(example.id)
224
+ example.metadata[:is_last_rerun] = detector.last_rerun_for_test?(name)
222
225
  reset_for_rerun(example)
223
226
 
224
- run_attempt(example, detector)
227
+ run_attempt(example, name, detector)
225
228
  first_failure ||= example.exception
226
229
  end
227
230
  end
@@ -258,12 +261,12 @@ module Mergify
258
261
 
259
262
  # Runs what the around hook wraps -- the before and after hooks included
260
263
  # -- timed by the session, and answers its outcome.
261
- def run_attempt(example, detector)
262
- detector.attempt_started(example.id)
264
+ def run_attempt(example, name, detector)
265
+ detector.attempt_started(name)
263
266
  example.run
264
267
  failed = example.exception || example.metadata.delete(:mergify_quarantined_failure)
265
268
  status = failed ? :failed : :passed
266
- detector.attempt_ended(example.id, status)
269
+ detector.attempt_ended(name, status)
267
270
  status
268
271
  end
269
272
 
@@ -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
@@ -1,8 +1,9 @@
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 'names'
6
7
  require 'json'
7
8
  require 'rspec/core/version'
8
9
  require 'set'
@@ -29,7 +30,7 @@ module Mergify
29
30
 
30
31
  attr_reader :session, :repo_name, :test_run_id,
31
32
  :flaky_detector, :quarantined_tests,
32
- :captured_session_verdict
33
+ :captured_session_verdict, :names
33
34
 
34
35
  # `doubles` has the session play Mergify in memory, answering and keeping
35
36
  # what it would send (the session's test doubles, by port, as a Hash:
@@ -37,12 +38,15 @@ module Mergify
37
38
  # build their TestRun themselves -- or, for a run in another process,
38
39
  # install it from a file required before `rspec_mergify`. The instance
39
40
  # the gem builds takes none, and the gem reads no switch for them.
41
+ # rubocop:disable-next Metrics/MethodLength
40
42
  def initialize(doubles: nil)
41
43
  # What this process reads once: the CI context, the trace a caller
42
44
  # handed down, and the run's id; and the deprecated names the run
43
45
  # 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)
46
+ @session = Mergify::CI::Core::Native::Session.start(
47
+ Mergify::RSpec::VERSION, output, doubles, enabled?, client: 'rspec-mergify', **FRAMEWORK,
48
+ deprecated_variables: Utils.deprecated_env_names_in_use
49
+ )
46
50
  @repo_name = @session.repository_name
47
51
  @test_run_id = @session.run_id
48
52
  @reruns = nil
@@ -50,6 +54,7 @@ module Mergify
50
54
  @quarantined_tests = nil
51
55
  @captured_session_verdict = nil
52
56
  @examples_to_run = nil
57
+ @names = Names.new(Dir.pwd)
53
58
 
54
59
  set_up if reports?
55
60
  end
@@ -63,8 +68,8 @@ module Mergify
63
68
 
64
69
  # Hand the session what this run is about to execute, and answer how it
65
70
  # executes it, as the session decided: a Hash whose `outcome` is `full`
66
- # or `failure_first` (see `Native::Session#select`). Every example
67
- # runs.
71
+ # or `failure_first` (see `Mergify::CI::Core::Native::Session#select`).
72
+ # Every example runs.
68
73
  #
69
74
  # Called once RSpec has loaded the spec files and applied every filter
70
75
  # of its own -- file and line arguments, `--tag`, `-e`,
@@ -74,11 +79,11 @@ module Mergify
74
79
  # own identity: the engine matches each against the previous attempt of
75
80
  # the same slice. It travels on the verdict and the trace of a run that
76
81
  # asked for the selection.
77
- def select(example_ids)
82
+ def select(names)
78
83
  return unless reports?
79
84
 
80
- @examples_to_run = example_ids.size
81
- @session.select(example_ids)
85
+ @examples_to_run = names.size
86
+ @session.select(names)
82
87
  end
83
88
 
84
89
  # The run's Fail First, which the session hands out when Mergify
@@ -87,10 +92,10 @@ module Mergify
87
92
  @session.fail_first
88
93
  end
89
94
 
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)
95
+ # Record the final status of the example named `name`, as RSpec
96
+ # reported it, for the session's verdict.
97
+ def record_verdict(name, status, run_time)
98
+ @session.record_test(name, status, run_time.to_f)
94
99
  end
95
100
 
96
101
  # Write what this session concluded to Mergify, before the spans go.
@@ -112,19 +117,19 @@ module Mergify
112
117
  end
113
118
 
114
119
  # 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
120
+ # it (see `Mergify::CI::Core::Native::Session#record_case`): its span joins the run's
116
121
  # trace.
117
122
  def record_case(example)
118
123
  @session.record_case(example)
119
124
  end
120
125
 
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)
126
+ # A retry plugin (rspec-retry) tried the example named `name` again
127
+ # after its first attempt, which ended with `status` (`'passed'` or
128
+ # `'failed'`) after `duration_ms`: the session counts it on the
129
+ # example's span, and as one of its runs where flaky detection runs,
130
+ # never against its budget or its execution cap.
131
+ def record_retry(name, status, duration_ms)
132
+ @session.record_retry(name, status, duration_ms)
128
133
  end
129
134
 
130
135
  # Have the session send the run's trace, once, at the end: the examples
@@ -145,20 +150,23 @@ module Mergify
145
150
  @session.summary(Utils.parallel_worker?)
146
151
  end
147
152
 
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)
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.
157
+ def prepare_reruns(names, former_names)
158
+ @session.state_former_names(former_names)
159
+ @reruns = @session.prepare_reruns(names)
153
160
  detection = @reruns&.detection
154
161
  @flaky_detector = detection && FlakyDetector.new(@reruns, detection)
155
162
  end
156
163
 
157
- # Account for an example's real outcome against the quarantine list.
158
- def record_quarantine(example_id, outcome)
164
+ # Account for the real outcome of the example known by `keys` against
165
+ # the quarantine list.
166
+ def record_quarantine(keys, outcome)
159
167
  return { 'quarantined' => false, 'absorbed' => false } unless @quarantined_tests
160
168
 
161
- @quarantined_tests.record(example_id, outcome)
169
+ @quarantined_tests.record(keys, outcome)
162
170
  end
163
171
 
164
172
  private
@@ -188,7 +196,7 @@ module Mergify
188
196
  # be on whatever its value, so that `false` silently stopped the upload;
189
197
  # both names now take a boolean.
190
198
  def debug_mode?
191
- Utils.env_truthy?(Utils.renamed_env(Utils::CI_DEBUG_ENV))
199
+ Mergify::CI::Core::Utils.env_truthy?(Utils.renamed_env(Utils::CI_DEBUG_ENV))
192
200
  end
193
201
 
194
202
  # Where the session sends the verdict and the trace: shown in debug mode,
@@ -200,7 +208,7 @@ module Mergify
200
208
  # The gem's enable switch, under every name it has had: the run then
201
209
  # reports outside CI too.
202
210
  def enabled?
203
- Utils.env_truthy?(Utils.renamed_env(Utils::CI_ENABLE_ENV))
211
+ Mergify::CI::Core::Utils.env_truthy?(Utils.renamed_env(Utils::CI_ENABLE_ENV))
204
212
  end
205
213
  end
206
214
  end
@@ -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.8'
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.8
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.8
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.8
26
40
  description: 'RSpec integration for Mergify Test Engine: OpenTelemetry tracing, flaky
27
41
  test detection, and test quarantine.'
28
42
  email:
@@ -40,7 +54,7 @@ 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
45
59
  - lib/mergify/rspec/test_run.rb
46
60
  - lib/mergify/rspec/tries.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