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 +4 -4
- data/README.md +57 -14
- data/THIRD_PARTY_LICENSES +13 -9
- data/lib/mergify/rspec/configuration.rb +91 -43
- data/lib/mergify/rspec/failure_first.rb +8 -7
- data/lib/mergify/rspec/formatter.rb +7 -6
- data/lib/mergify/rspec/names.rb +110 -0
- data/lib/mergify/rspec/quarantine.rb +10 -7
- data/lib/mergify/rspec/test_retry.rb +46 -0
- data/lib/mergify/rspec/test_run.rb +47 -35
- data/lib/mergify/rspec/tries.rb +21 -0
- data/lib/mergify/rspec/utils.rb +4 -11
- data/lib/mergify/rspec/version.rb +1 -1
- metadata +17 -2
- data/lib/mergify/rspec/native.rb +0 -130
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 82681fca0d5d6710db90d1efb22a5ae4d23b175e55519bf8fbf91e44d48d386f
|
|
4
|
+
data.tar.gz: fdae1d6d362f960d5c93f27991227f59d751f27547c3534361de4ca35ad20aca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
| `
|
|
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
|
-
**
|
|
82
|
-
`
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
92
|
-
|
|
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
|
|
135
|
-
|
|
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 (
|
|
12
|
-
MIT License (
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
9830
|
-
- jsonschema-value 0.60.
|
|
9831
|
-
- jsonschema 0.60.
|
|
9832
|
-
- referencing 0.60.
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
ci.
|
|
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.
|
|
85
|
-
#
|
|
86
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
150
|
-
|
|
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(
|
|
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
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
168
|
-
def
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
# `
|
|
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?(
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
#
|
|
100
|
-
|
|
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
|
|
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/
|
|
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.
|
|
38
|
-
|
|
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
|
|
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::
|
|
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::
|
|
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
|
-
|
|
20
|
-
|
|
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`
|
|
24
|
-
# `'failed'` or `'skipped'`, before the quarantine turned a
|
|
25
|
-
# `pending`), as `{ 'quarantined' => bool, 'absorbed' =>
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
25
|
-
# process that plans the run's
|
|
26
|
-
reruns: { plans_repeats: false, retries_failures:
|
|
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(
|
|
45
|
-
|
|
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`).
|
|
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(
|
|
84
|
+
def select(names)
|
|
78
85
|
return unless reports?
|
|
79
86
|
|
|
80
|
-
@examples_to_run =
|
|
81
|
-
@session.select(
|
|
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
|
|
91
|
-
# session's verdict.
|
|
92
|
-
def record_verdict(
|
|
93
|
-
@session.record_test(
|
|
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
|
|
122
|
-
# attempt, which ended with `status` (`'passed'` or
|
|
123
|
-
# `duration_ms`: the session counts it on the
|
|
124
|
-
# of its runs where flaky detection runs,
|
|
125
|
-
# its execution cap.
|
|
126
|
-
def record_retry(
|
|
127
|
-
@session.record_retry(
|
|
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
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
158
|
-
|
|
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(
|
|
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
|
data/lib/mergify/rspec/tries.rb
CHANGED
|
@@ -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
|
data/lib/mergify/rspec/utils.rb
CHANGED
|
@@ -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::
|
|
10
|
-
# detects identically. What is left is what is genuinely Ruby's:
|
|
11
|
-
# repository name the API needs split, and reading the plugin's
|
|
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.
|
|
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.
|
|
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/
|
|
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
|
data/lib/mergify/rspec/native.rb
DELETED
|
@@ -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
|