minitest-mergify 2026.10.8 → 2026.10.10
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 +17 -1
- data/THIRD_PARTY_LICENSES +20 -20
- data/lib/mergify/minitest/plugin.rb +14 -5
- data/lib/mergify/minitest/recorder.rb +82 -31
- data/lib/mergify/minitest/reruns.rb +43 -30
- data/lib/mergify/minitest/retries.rb +131 -0
- data/lib/mergify/minitest/selection.rb +12 -1
- data/lib/mergify/minitest/test_run.rb +16 -8
- data/lib/mergify/minitest/version.rb +1 -1
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0ed7de1c1d2ed7a6d5de874d3b9afbd42144b62f3db77e395943b98e16f1a6ce
|
|
4
|
+
data.tar.gz: 71a773ec460d13ae629703b569f6de02e46787bf0d78b17c6c85a149e6b55756
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b1be6af5d90daee9af0f99b3e603d43c9c464907c4b606c68b1a4b23462cd4ae897f22a384a16f5b1c4c4a5bb9fb0ce8ebcf6ea6837682eed6e087643424066d
|
|
7
|
+
data.tar.gz: 8a93ea8e43e3a63ccb9ed21754e10fc94280618e656f8b810de43dd8350d01148eddea5d241fee269884fda53126f73cb67da7304ba89caf76e86f3ec9021325
|
data/README.md
CHANGED
|
@@ -8,6 +8,7 @@ Minitest plugin for [Mergify Test Engine](https://docs.mergify.com/test-engine/)
|
|
|
8
8
|
- **Test quarantine** — Quarantines failing tests so they don't block CI
|
|
9
9
|
- **Test selection** — Runs first the tests that failed in a merge-queue bisection step, and stops once one fails again
|
|
10
10
|
- **Flaky test detection** — Reruns new tests to catch flaky ones before they merge
|
|
11
|
+
- **Test retry** — On the default branch, runs a failing test again, within a time budget Mergify sets
|
|
11
12
|
|
|
12
13
|
## Installation
|
|
13
14
|
|
|
@@ -34,7 +35,7 @@ It supports Minitest 5.19 and later, on Ruby 3.1 and later, and Minitest 6 on
|
|
|
34
35
|
Ruby 3.2 and later: tests in classes and in `describe` blocks, run in one
|
|
35
36
|
process, in Minitest's threads (`parallelize_me!`) or in Rails' parallel
|
|
36
37
|
workers (`parallelize`). It runs on `mergify-ci-core`, which Bundler installs
|
|
37
|
-
with it and which ships precompiled for Linux (glibc 2.
|
|
38
|
+
with it and which ships precompiled for Linux (glibc 2.28 or newer, and musl),
|
|
38
39
|
macOS and Windows.
|
|
39
40
|
|
|
40
41
|
## Configuration
|
|
@@ -55,6 +56,7 @@ environment variable). To enable outside CI, set `MERGIFY_CI_ENABLE=true`.
|
|
|
55
56
|
| `MERGIFY_TRACEPARENT` | W3C distributed trace context | — |
|
|
56
57
|
| `MERGIFY_TEST_JOB_NAME` | Mergify test job name | — |
|
|
57
58
|
| `MERGIFY_FAIL_FIRST_ENABLE` | Set to `false` to never run first what a merge-queue bisection step of this job failed before (see below) | `true` |
|
|
59
|
+
| `MERGIFY_TEST_RETRY_ENABLE` | Set to `false` to have this job retry no failing test, whatever the repository sets | `true` |
|
|
58
60
|
|
|
59
61
|
### Quarantine
|
|
60
62
|
|
|
@@ -105,6 +107,20 @@ without failing the job. The reruns go through Minitest itself, so they run
|
|
|
105
107
|
where the test ran: in one process, in Minitest's threads, or in Rails'
|
|
106
108
|
parallel workers.
|
|
107
109
|
|
|
110
|
+
### Test retry
|
|
111
|
+
|
|
112
|
+
On a push to the repository's default branch, when a test fails, the plugin
|
|
113
|
+
runs it again, up to the number of retries the repository sets, within a time
|
|
114
|
+
budget for the whole run. If a retry passes, so does the test: Minitest's own
|
|
115
|
+
reporters see the retry that passed instead of the failure, and Mergify still
|
|
116
|
+
learns the test failed first. A quarantined test, and one Mergify classifies
|
|
117
|
+
as broken, are not retried; pull requests, merge-queue runs and other branches
|
|
118
|
+
never are. The retries go through Minitest itself, as flaky detection's reruns
|
|
119
|
+
do.
|
|
120
|
+
|
|
121
|
+
A job that sets `MERGIFY_TEST_RETRY_ENABLE=false` retries nothing, whatever
|
|
122
|
+
the repository sets.
|
|
123
|
+
|
|
108
124
|
### Test names
|
|
109
125
|
|
|
110
126
|
A test in a class is named as Minitest names it, `UserTest#test_valid`. A test
|
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 (229)
|
|
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)
|
|
@@ -474,11 +474,7 @@ Used by:
|
|
|
474
474
|
- windows-link 0.2.1 (https://github.com/microsoft/windows-rs)
|
|
475
475
|
- windows-result 0.4.1 (https://github.com/microsoft/windows-rs)
|
|
476
476
|
- windows-strings 0.5.1 (https://github.com/microsoft/windows-rs)
|
|
477
|
-
- windows-sys 0.52.0 (https://github.com/microsoft/windows-rs)
|
|
478
477
|
- windows-sys 0.61.2 (https://github.com/microsoft/windows-rs)
|
|
479
|
-
- windows-targets 0.52.6 (https://github.com/microsoft/windows-rs)
|
|
480
|
-
- windows_x86_64_gnu 0.52.6 (https://github.com/microsoft/windows-rs)
|
|
481
|
-
- windows_x86_64_msvc 0.52.6 (https://github.com/microsoft/windows-rs)
|
|
482
478
|
|
|
483
479
|
Apache License
|
|
484
480
|
Version 2.0, January 2004
|
|
@@ -2571,14 +2567,14 @@ limitations under the License.
|
|
|
2571
2567
|
Apache License 2.0 (Apache-2.0)
|
|
2572
2568
|
|
|
2573
2569
|
Used by:
|
|
2574
|
-
- futures-channel 0.3.
|
|
2575
|
-
- futures-core 0.3.
|
|
2570
|
+
- futures-channel 0.3.34 (https://github.com/rust-lang/futures-rs)
|
|
2571
|
+
- futures-core 0.3.34 (https://github.com/rust-lang/futures-rs)
|
|
2576
2572
|
- futures-executor 0.3.33 (https://github.com/rust-lang/futures-rs)
|
|
2577
|
-
- futures-io 0.3.
|
|
2578
|
-
- futures-macro 0.3.
|
|
2579
|
-
- futures-sink 0.3.
|
|
2580
|
-
- futures-task 0.3.
|
|
2581
|
-
- futures-util 0.3.
|
|
2573
|
+
- futures-io 0.3.34 (https://github.com/rust-lang/futures-rs)
|
|
2574
|
+
- futures-macro 0.3.34 (https://github.com/rust-lang/futures-rs)
|
|
2575
|
+
- futures-sink 0.3.34 (https://github.com/rust-lang/futures-rs)
|
|
2576
|
+
- futures-task 0.3.34 (https://github.com/rust-lang/futures-rs)
|
|
2577
|
+
- futures-util 0.3.34 (https://github.com/rust-lang/futures-rs)
|
|
2582
2578
|
- futures 0.3.33 (https://github.com/rust-lang/futures-rs)
|
|
2583
2579
|
|
|
2584
2580
|
Apache License
|
|
@@ -4882,6 +4878,7 @@ Apache License 2.0 (Apache-2.0)
|
|
|
4882
4878
|
Used by:
|
|
4883
4879
|
- ahash 0.8.12 (https://github.com/tkaitchuck/ahash)
|
|
4884
4880
|
- async-channel 2.5.0 (https://github.com/smol-rs/async-channel)
|
|
4881
|
+
- async-compression 0.4.51 (https://github.com/Nullus157/async-compression)
|
|
4885
4882
|
- async-executor 1.14.0 (https://github.com/smol-rs/async-executor)
|
|
4886
4883
|
- async-io 2.6.0 (https://github.com/smol-rs/async-io)
|
|
4887
4884
|
- async-lock 3.4.2 (https://github.com/smol-rs/async-lock)
|
|
@@ -4894,6 +4891,8 @@ Used by:
|
|
|
4894
4891
|
- bitflags 2.13.1 (https://github.com/bitflags/bitflags)
|
|
4895
4892
|
- blocking 1.7.0 (https://github.com/smol-rs/blocking)
|
|
4896
4893
|
- cfg-if 1.0.4 (https://github.com/rust-lang/cfg-if)
|
|
4894
|
+
- compression-codecs 0.4.46 (https://github.com/Nullus157/async-compression)
|
|
4895
|
+
- compression-core 0.4.34 (https://github.com/Nullus157/async-compression)
|
|
4897
4896
|
- concurrent-queue 2.5.0 (https://github.com/smol-rs/concurrent-queue)
|
|
4898
4897
|
- core-foundation-sys 0.8.7 (https://github.com/servo/core-foundation-rs)
|
|
4899
4898
|
- core-foundation 0.10.1 (https://github.com/servo/core-foundation-rs)
|
|
@@ -4951,7 +4950,7 @@ Used by:
|
|
|
4951
4950
|
- secret-service 5.2.0 (https://github.com/hwchen/secret-service-rs.git)
|
|
4952
4951
|
- security-framework-sys 2.17.0 (https://github.com/kornelski/rust-security-framework)
|
|
4953
4952
|
- security-framework 3.7.0 (https://github.com/kornelski/rust-security-framework)
|
|
4954
|
-
- self-replace 1.5.
|
|
4953
|
+
- self-replace 1.5.1 (https://github.com/mitsuhiko/self-replace)
|
|
4955
4954
|
- signal-hook-registry 1.4.8 (https://github.com/vorner/signal-hook)
|
|
4956
4955
|
- smallvec 1.15.2 (https://github.com/servo/rust-smallvec)
|
|
4957
4956
|
- socket2 0.6.5 (https://github.com/rust-lang/socket2)
|
|
@@ -8791,7 +8790,7 @@ THIS SOFTWARE.
|
|
|
8791
8790
|
ISC License (ISC)
|
|
8792
8791
|
|
|
8793
8792
|
Used by:
|
|
8794
|
-
- rustls-webpki 0.103.
|
|
8793
|
+
- rustls-webpki 0.103.16 (https://github.com/rustls/webpki)
|
|
8795
8794
|
|
|
8796
8795
|
Except as otherwise noted, this project is licensed under the following
|
|
8797
8796
|
(ISC-style) terms:
|
|
@@ -9826,10 +9825,10 @@ SOFTWARE.
|
|
|
9826
9825
|
MIT License (MIT)
|
|
9827
9826
|
|
|
9828
9827
|
Used by:
|
|
9829
|
-
- jsonschema-regex 0.60.
|
|
9830
|
-
- jsonschema-value 0.60.
|
|
9831
|
-
- jsonschema 0.60.
|
|
9832
|
-
- referencing 0.60.
|
|
9828
|
+
- jsonschema-regex 0.60.2 (https://github.com/Stranger6667/jsonschema)
|
|
9829
|
+
- jsonschema-value 0.60.2 (https://github.com/Stranger6667/jsonschema)
|
|
9830
|
+
- jsonschema 0.60.2 (https://github.com/Stranger6667/jsonschema)
|
|
9831
|
+
- referencing 0.60.2 (https://github.com/Stranger6667/jsonschema)
|
|
9833
9832
|
|
|
9834
9833
|
MIT License
|
|
9835
9834
|
|
|
@@ -10083,6 +10082,7 @@ USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
|
10083
10082
|
MIT License (MIT)
|
|
10084
10083
|
|
|
10085
10084
|
Used by:
|
|
10085
|
+
- tokio-util 0.7.19 (https://github.com/tokio-rs/tokio)
|
|
10086
10086
|
- tokio 1.54.1 (https://github.com/tokio-rs/tokio)
|
|
10087
10087
|
|
|
10088
10088
|
MIT License
|
|
@@ -7,6 +7,7 @@ require_relative 'test_run'
|
|
|
7
7
|
require_relative 'names'
|
|
8
8
|
require_relative 'recorder'
|
|
9
9
|
require_relative 'reruns'
|
|
10
|
+
require_relative 'retries'
|
|
10
11
|
require_relative 'selection'
|
|
11
12
|
|
|
12
13
|
module Mergify
|
|
@@ -60,14 +61,21 @@ module Mergify
|
|
|
60
61
|
start(reporter, test_run, options)
|
|
61
62
|
end
|
|
62
63
|
|
|
63
|
-
# The run's suites, as the selection runs them,
|
|
64
|
-
#
|
|
65
|
-
#
|
|
64
|
+
# The run's suites, as the selection runs them, once the session
|
|
65
|
+
# prepared its reruns; then test retry's retries and flaky
|
|
66
|
+
# detection's reruns -- not after a stop Fail First made, whose whole
|
|
67
|
+
# point is to end the job sooner -- and last the failures test retry
|
|
68
|
+
# held back for flaky detection's reruns.
|
|
66
69
|
def run_suites(reporter, &)
|
|
67
70
|
return yield unless @selection
|
|
68
71
|
|
|
72
|
+
@reruns.prepare
|
|
69
73
|
@selection.run(@recorder, &)
|
|
70
|
-
|
|
74
|
+
unless @recorder.stopping?
|
|
75
|
+
@retries.settle(@recorder, @selection.names_to_run)
|
|
76
|
+
@reruns.run(reporter, @recorder)
|
|
77
|
+
end
|
|
78
|
+
@recorder.release_held(reporter)
|
|
71
79
|
end
|
|
72
80
|
|
|
73
81
|
private
|
|
@@ -79,7 +87,8 @@ module Mergify
|
|
|
79
87
|
@selection = nil
|
|
80
88
|
@selection = select(test_run, names, options)
|
|
81
89
|
@reruns = @selection && Reruns.new(test_run, @selection)
|
|
82
|
-
@
|
|
90
|
+
@retries = @selection && Retries.new(test_run, @selection, @reruns)
|
|
91
|
+
@recorder = Recorder.new(test_run, names, options[:io] || $stdout, @selection, @reruns, @retries)
|
|
83
92
|
@recorder.attach(reporter)
|
|
84
93
|
install
|
|
85
94
|
end
|
|
@@ -7,8 +7,8 @@ require_relative 'results'
|
|
|
7
7
|
module Mergify
|
|
8
8
|
module Minitest
|
|
9
9
|
# Hands each finished test to the session, for the run's trace, its
|
|
10
|
-
# verdict, the quarantine, Fail First
|
|
11
|
-
# the session says once the run is over.
|
|
10
|
+
# verdict, the quarantine, Fail First, flaky detection and test retry, and
|
|
11
|
+
# prints what the session says once the run is over.
|
|
12
12
|
#
|
|
13
13
|
# It wraps the reporter Minitest sends every result through, rather than
|
|
14
14
|
# joining the reporters inside it: a plugin can replace those, and
|
|
@@ -28,16 +28,23 @@ module Mergify
|
|
|
28
28
|
attr_reader :first_attempts
|
|
29
29
|
|
|
30
30
|
# `selection` is the run's, when it asked Test Selection; nil when it
|
|
31
|
-
# does not report. `reruns`,
|
|
32
|
-
# tests
|
|
33
|
-
|
|
31
|
+
# does not report. `reruns`, the session's, takes the results of the
|
|
32
|
+
# tests flaky detection reruns, and `retries` those of the tests test
|
|
33
|
+
# retry retries.
|
|
34
|
+
# rubocop:disable-next Metrics/MethodLength,Metrics/ParameterLists
|
|
35
|
+
def initialize(test_run, names, io, selection, reruns, retries)
|
|
34
36
|
@test_run = test_run
|
|
35
37
|
@names = names
|
|
36
38
|
@io = io
|
|
37
39
|
@selection = selection
|
|
38
40
|
@reruns = reruns
|
|
41
|
+
@retries = retries
|
|
39
42
|
@fail_first = selection && test_run.fail_first
|
|
40
43
|
@first_attempts = {}
|
|
44
|
+
# The results Minitest's own reporters get as they are: the failures
|
|
45
|
+
# test retry held back for flaky detection's reruns, once they are
|
|
46
|
+
# over.
|
|
47
|
+
@released = {}.compare_by_identity
|
|
41
48
|
@lock = Mutex.new
|
|
42
49
|
@changed = ConditionVariable.new
|
|
43
50
|
@stopping = false
|
|
@@ -48,30 +55,35 @@ module Mergify
|
|
|
48
55
|
reporter.singleton_class.prepend(hooks)
|
|
49
56
|
end
|
|
50
57
|
|
|
51
|
-
#
|
|
58
|
+
# What Minitest's own reporters get of `result`, which `reporter`
|
|
59
|
+
# records, before they count it; nil for nothing. A failure the
|
|
52
60
|
# quarantine absorbs reaches them as a skip, so it fails nothing, and
|
|
53
|
-
# Mergify still sees it fail.
|
|
54
|
-
|
|
55
|
-
|
|
61
|
+
# Mergify still sees it fail. A rerun of flaky detection's never does. A
|
|
62
|
+
# failure test retry answers for is held back until it answered, then
|
|
63
|
+
# reaches them as the retry that passed, or as itself.
|
|
64
|
+
def pass_on(result, reporter)
|
|
65
|
+
return result if @released.delete(result) || !@test_run.reports?
|
|
66
|
+
return if rerun_taken?(result)
|
|
67
|
+
return retry_taken(result, reporter) if @retries&.retrying?(result.klass, result.name)
|
|
56
68
|
|
|
57
|
-
|
|
58
|
-
status = Results.status_of(result)
|
|
59
|
-
absorbed = trace(result, named, status)
|
|
60
|
-
conclude(named.name, status, absorbed, result.time)
|
|
69
|
+
record(result, reporter)
|
|
61
70
|
end
|
|
62
71
|
|
|
63
|
-
# Whether
|
|
64
|
-
#
|
|
65
|
-
def
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
@reruns.take(result, named(result).name)
|
|
72
|
+
# Whether Minitest is about to report a rerun or a retry of `method` of
|
|
73
|
+
# `klass` (a class, or what Rails' workers report its name as).
|
|
74
|
+
def rerun?(klass, method)
|
|
75
|
+
klass = klass.respond_to?(:name) ? klass.name : klass.to_s
|
|
76
|
+
@reruns&.rerun?(klass, method) || @retries&.retrying?(klass, method) || false
|
|
69
77
|
end
|
|
70
78
|
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
def
|
|
74
|
-
@
|
|
79
|
+
# Hand Minitest's own reporters, through `reporter`, the failures test
|
|
80
|
+
# retry held back for flaky detection's reruns, once they are over.
|
|
81
|
+
def release_held(reporter)
|
|
82
|
+
@retries&.answered&.each do |name, result|
|
|
83
|
+
conclude(name, Results.status_of(result), false, result.time)
|
|
84
|
+
@released[result] = true
|
|
85
|
+
reporter.record(result)
|
|
86
|
+
end
|
|
75
87
|
end
|
|
76
88
|
|
|
77
89
|
# Whether flaky detection found a new test flaky, in reruns Minitest's
|
|
@@ -106,6 +118,43 @@ module Mergify
|
|
|
106
118
|
|
|
107
119
|
private
|
|
108
120
|
|
|
121
|
+
# A test's first result: its span, the quarantine, the session's
|
|
122
|
+
# reruns, then its final status, unless test retry holds its failure
|
|
123
|
+
# back. Answers the result Minitest's own reporters get, nil while held
|
|
124
|
+
# back.
|
|
125
|
+
def record(result, reporter)
|
|
126
|
+
named = named(result)
|
|
127
|
+
name = named.name
|
|
128
|
+
status = Results.status_of(result)
|
|
129
|
+
absorbed = trace(result, named, status)
|
|
130
|
+
@reruns&.first_attempt(name, status, result.time * 1000)
|
|
131
|
+
held = !absorbed && @retries&.hold?(result, name, reporter)
|
|
132
|
+
conclude(name, status, absorbed, result.time) unless held
|
|
133
|
+
recorded(name, status, result.time)
|
|
134
|
+
result unless held
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Whether `result` is a rerun's, which flaky detection takes as an
|
|
138
|
+
# attempt, and test retry too where it holds the test's failure back.
|
|
139
|
+
def rerun_taken?(result)
|
|
140
|
+
return false unless @reruns&.rerun?(result.klass, result.name)
|
|
141
|
+
|
|
142
|
+
name = named(result).name
|
|
143
|
+
return false unless @reruns.take(result, name)
|
|
144
|
+
|
|
145
|
+
@retries&.rerun_ended(name, result)
|
|
146
|
+
true
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A retry's result, which test retry takes: once it answered, the
|
|
150
|
+
# result Minitest's own reporters get, whose status is the test's.
|
|
151
|
+
def retry_taken(result, reporter)
|
|
152
|
+
name = named(result).name
|
|
153
|
+
released = @retries.take(result, name, reporter)
|
|
154
|
+
conclude(name, Results.status_of(released), false, released.time) if released
|
|
155
|
+
released
|
|
156
|
+
end
|
|
157
|
+
|
|
109
158
|
# The test's span, as the quarantine accounts for it, and the failure
|
|
110
159
|
# it absorbs: whether it did.
|
|
111
160
|
def trace(result, named, status)
|
|
@@ -119,16 +168,20 @@ module Mergify
|
|
|
119
168
|
@names.of(result.klass, result.name, result.source_location.first)
|
|
120
169
|
end
|
|
121
170
|
|
|
122
|
-
# The test's
|
|
123
|
-
#
|
|
124
|
-
# absorbed is none.
|
|
171
|
+
# The test's final status, for the verdict and Fail First: a failure
|
|
172
|
+
# the quarantine absorbed is none.
|
|
125
173
|
def conclude(name, status, absorbed, time)
|
|
126
174
|
final = absorbed ? 'quarantined_failed' : status
|
|
127
175
|
@test_run.record_test(name, final, time)
|
|
128
176
|
stop = @fail_first&.unit_done(name, final == 'failed')
|
|
177
|
+
@lock.synchronize { @stopping ||= stop }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The test's first attempt was recorded, after its final status unless
|
|
181
|
+
# test retry held it back.
|
|
182
|
+
def recorded(name, status, time)
|
|
129
183
|
@lock.synchronize do
|
|
130
184
|
@first_attempts[name] ||= [status, time * 1000]
|
|
131
|
-
@stopping ||= stop
|
|
132
185
|
@changed.broadcast
|
|
133
186
|
end
|
|
134
187
|
end
|
|
@@ -152,10 +205,8 @@ module Mergify
|
|
|
152
205
|
end
|
|
153
206
|
|
|
154
207
|
define_method(:record) do |result|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
recorder.record(result)
|
|
158
|
-
super(result)
|
|
208
|
+
passed_on = recorder.pass_on(result, self)
|
|
209
|
+
super(passed_on) if passed_on
|
|
159
210
|
end
|
|
160
211
|
|
|
161
212
|
define_method(:passed?) do
|
|
@@ -5,22 +5,45 @@ require_relative 'results'
|
|
|
5
5
|
|
|
6
6
|
module Mergify
|
|
7
7
|
module Minitest
|
|
8
|
-
#
|
|
9
|
-
# many times to rerun the tests it
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# reporters never see.
|
|
8
|
+
# The session's reruns for the run, and flaky detection's, once the run's
|
|
9
|
+
# tests are over: the session plans how many times to rerun the tests it
|
|
10
|
+
# targets, from their first attempts, and each rerun goes through
|
|
11
|
+
# Minitest's own dispatch, so it runs where the test ran -- inline, in
|
|
12
|
+
# Minitest's threads, or in Rails' workers -- and its result comes back to
|
|
13
|
+
# the reporter Minitest records results through. There, each is an
|
|
14
|
+
# attempt for the session, which Minitest's own reporters never see.
|
|
15
15
|
class Reruns
|
|
16
|
+
# The session's reruns, once prepared: nil before, and when neither
|
|
17
|
+
# flaky detection nor test retry runs.
|
|
18
|
+
attr_reader :prepared
|
|
19
|
+
|
|
16
20
|
def initialize(test_run, selection)
|
|
17
21
|
@test_run = test_run
|
|
18
22
|
@selection = selection
|
|
23
|
+
@prepared = nil
|
|
19
24
|
@pending = Hash.new(0)
|
|
20
25
|
@lock = Mutex.new
|
|
21
26
|
@changed = ConditionVariable.new
|
|
22
27
|
end
|
|
23
28
|
|
|
29
|
+
# Prepare the session's reruns before any test runs, for every test the
|
|
30
|
+
# run is to run, where test retry retries: it answers each failure as
|
|
31
|
+
# it comes. Flaky detection alone waits for the run's tests to be over.
|
|
32
|
+
def prepare
|
|
33
|
+
prepare_for(@selection.tests.map(&:name)) if @test_run.test_retry_runs?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The first attempt at the test named `name` ended with `outcome` after
|
|
37
|
+
# `duration_ms`: the session takes it, once the reruns are prepared,
|
|
38
|
+
# where flaky detection targets the test or test retry answers for it,
|
|
39
|
+
# as it passed or failed.
|
|
40
|
+
def first_attempt(name, outcome, duration_ms)
|
|
41
|
+
return unless @prepared && %w[passed failed].include?(outcome)
|
|
42
|
+
return unless @prepared.tracks?(name) || @prepared.retry_answers_for?(name)
|
|
43
|
+
|
|
44
|
+
@test_run.record_attempt(name, outcome, duration_ms)
|
|
45
|
+
end
|
|
46
|
+
|
|
24
47
|
# Rerun what flaky detection targets, once `recorder` recorded every test
|
|
25
48
|
# the run was to run: a run in Minitest's threads or Rails' workers
|
|
26
49
|
# hands its results back after its suites were handed over.
|
|
@@ -28,8 +51,8 @@ module Mergify
|
|
|
28
51
|
return unless @test_run.flaky_detection_runs?
|
|
29
52
|
|
|
30
53
|
recorder.wait_for(@selection.names_to_run)
|
|
31
|
-
|
|
32
|
-
plan =
|
|
54
|
+
prepare_after(recorder.first_attempts) unless @prepared
|
|
55
|
+
plan = @prepared&.shared_repeats
|
|
33
56
|
rerun(plan, reporter) if plan
|
|
34
57
|
end
|
|
35
58
|
|
|
@@ -55,18 +78,18 @@ module Mergify
|
|
|
55
78
|
|
|
56
79
|
private
|
|
57
80
|
|
|
58
|
-
# The session's reruns for
|
|
59
|
-
# former name too
|
|
60
|
-
|
|
61
|
-
def prepare(first_attempts)
|
|
81
|
+
# The session's reruns for the tests named `names`, each known by its
|
|
82
|
+
# former name too.
|
|
83
|
+
def prepare_for(names)
|
|
62
84
|
former_names = @selection.tests.to_h { |test| [test.name, ["#{test.klass.name}.#{test.method_name}"]] }
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
next unless reruns&.tracks?(name) && %w[passed failed].include?(outcome)
|
|
85
|
+
@prepared = @test_run.prepare_reruns(names, former_names)
|
|
86
|
+
end
|
|
66
87
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
88
|
+
# The session's reruns once the run's tests are over, for every test it
|
|
89
|
+
# ran, with the first attempt of each.
|
|
90
|
+
def prepare_after(first_attempts)
|
|
91
|
+
prepare_for(first_attempts.keys)
|
|
92
|
+
first_attempts.each { |name, (outcome, duration_ms)| first_attempt(name, outcome, duration_ms) }
|
|
70
93
|
end
|
|
71
94
|
|
|
72
95
|
# Each test of `plan` again, as many times as it says, round after
|
|
@@ -74,7 +97,7 @@ module Mergify
|
|
|
74
97
|
def rerun(plan, reporter)
|
|
75
98
|
tests = @selection.tests.select { |test| plan['tests'].include?(test.name) }
|
|
76
99
|
expect(tests, plan['repeats'])
|
|
77
|
-
plan['repeats'].times { tests.each { |test| dispatch(
|
|
100
|
+
plan['repeats'].times { tests.each { |test| test.dispatch(reporter) } }
|
|
78
101
|
@lock.synchronize { @changed.wait(@lock) until @pending.values.all?(&:zero?) }
|
|
79
102
|
end
|
|
80
103
|
|
|
@@ -82,16 +105,6 @@ module Mergify
|
|
|
82
105
|
def expect(tests, repeats)
|
|
83
106
|
@lock.synchronize { tests.each { |test| @pending[[test.klass.name, test.method_name]] += repeats } }
|
|
84
107
|
end
|
|
85
|
-
|
|
86
|
-
# As Minitest runs a test of a class: inline and reported, or handed to
|
|
87
|
-
# its parallel executor, which reports it once it ran.
|
|
88
|
-
def dispatch(test, reporter)
|
|
89
|
-
if ::Minitest::Runnable.respond_to?(:run_suite)
|
|
90
|
-
test.klass.run(test.klass, test.method_name, reporter)
|
|
91
|
-
else
|
|
92
|
-
test.klass.run_one_method(test.klass, test.method_name, reporter)
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
108
|
end
|
|
96
109
|
end
|
|
97
110
|
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'minitest'
|
|
4
|
+
require 'set'
|
|
5
|
+
require_relative 'results'
|
|
6
|
+
|
|
7
|
+
module Mergify
|
|
8
|
+
module Minitest
|
|
9
|
+
# Test retry: a test whose first attempt failed runs again, through
|
|
10
|
+
# Minitest's own dispatch, as long as the session grants it one more
|
|
11
|
+
# attempt, so it runs where it ran -- inline, in Minitest's threads, or in
|
|
12
|
+
# Rails' workers -- and each result comes back to the reporter Minitest
|
|
13
|
+
# records results through, in the main process, which asks the session
|
|
14
|
+
# for the next. Its failure is held back from Minitest's own reporters
|
|
15
|
+
# until retry answered: a retry that passed reaches them in its place,
|
|
16
|
+
# and the failure itself otherwise.
|
|
17
|
+
#
|
|
18
|
+
# Which failures retry answers for, how many retries a test gets and
|
|
19
|
+
# within which budget are the session's. A test flaky detection reruns is
|
|
20
|
+
# retry's to answer for with those reruns, once the suite is over: its
|
|
21
|
+
# failure is held back until then.
|
|
22
|
+
class Retries
|
|
23
|
+
def initialize(test_run, selection, reruns)
|
|
24
|
+
@test_run = test_run
|
|
25
|
+
@tests = selection.tests.to_h { |test| [test.name, test] }
|
|
26
|
+
@reruns = reruns
|
|
27
|
+
# The failures held back, by name, until retry answered.
|
|
28
|
+
@held = {}
|
|
29
|
+
# The tests retried, by class name and method, until retry answered.
|
|
30
|
+
@retrying = Set.new
|
|
31
|
+
# A passing rerun of flaky detection's, by name, for a failure held
|
|
32
|
+
# back until its reruns are over.
|
|
33
|
+
@passed = {}
|
|
34
|
+
@lock = Mutex.new
|
|
35
|
+
@changed = ConditionVariable.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The first attempt at the test named `name` ended with `result`,
|
|
39
|
+
# which the session took: whether retry holds it back, a failure it
|
|
40
|
+
# answers for. A test the session grants a retry runs again; one flaky
|
|
41
|
+
# detection reruns waits for its reruns.
|
|
42
|
+
def hold?(result, name, reporter)
|
|
43
|
+
prepared = @reruns.prepared
|
|
44
|
+
return false unless Results.status_of(result) == 'failed' && prepared&.retry_answers_for?(name)
|
|
45
|
+
|
|
46
|
+
if prepared.grant_retry(name, result.time * 1000)
|
|
47
|
+
retry_test(result, name, reporter)
|
|
48
|
+
elsif prepared.tracks?(name)
|
|
49
|
+
@lock.synchronize { @held[name] = result }
|
|
50
|
+
else
|
|
51
|
+
return false
|
|
52
|
+
end
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Whether Minitest is about to report a retry of `method` of the class
|
|
57
|
+
# named `klass`, which the reporter keeps from Minitest's own.
|
|
58
|
+
def retrying?(klass, method)
|
|
59
|
+
@lock.synchronize { @retrying.include?([klass, method]) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The result of a retry of the test named `name`, as an attempt: the
|
|
63
|
+
# result Minitest's own reporters get once retry answered, the one that
|
|
64
|
+
# passed or the failure held back; nil while the test runs once more.
|
|
65
|
+
def take(result, name, reporter)
|
|
66
|
+
status = Results.status_of(result)
|
|
67
|
+
duration_ms = result.time * 1000
|
|
68
|
+
@test_run.record_attempt(name, status, duration_ms)
|
|
69
|
+
return answer(result, name) unless status == 'failed' && @reruns.prepared.grant_retry(name, duration_ms)
|
|
70
|
+
|
|
71
|
+
@tests.fetch(name).dispatch(reporter)
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A rerun of flaky detection's of the test named `name` ended with
|
|
76
|
+
# `result`.
|
|
77
|
+
def rerun_ended(name, result)
|
|
78
|
+
@lock.synchronize do
|
|
79
|
+
@passed[name] ||= result if @held.key?(name) && Results.status_of(result) == 'passed'
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Wait, once the run's suites were handed over, until `recorder`
|
|
84
|
+
# recorded every test named `names`, and every retry they were granted
|
|
85
|
+
# ended, where test retry answers for a failure of any of them:
|
|
86
|
+
# Minitest's threads and Rails' workers stop once the suites are over.
|
|
87
|
+
def settle(recorder, names)
|
|
88
|
+
prepared = @reruns.prepared
|
|
89
|
+
return unless prepared && names.any? { |name| prepared.retry_answers_for?(name) }
|
|
90
|
+
|
|
91
|
+
recorder.wait_for(names)
|
|
92
|
+
@lock.synchronize { @changed.wait(@lock) until @retrying.empty? }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The failures held back for flaky detection's reruns, once they are
|
|
96
|
+
# over, by name: the result Minitest's own reporters get for each, a
|
|
97
|
+
# rerun that passed where it rescued the test, the failure otherwise.
|
|
98
|
+
def answered
|
|
99
|
+
held = @lock.synchronize { @held.dup.tap { @held.clear } }
|
|
100
|
+
held.to_h do |name, failure|
|
|
101
|
+
passed = @passed[name]
|
|
102
|
+
[name, passed && @reruns.prepared.rescued?(name) ? passed : failure]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# Hold back the failure `result` of the test named `name`, and run the
|
|
109
|
+
# test again.
|
|
110
|
+
def retry_test(result, name, reporter)
|
|
111
|
+
@lock.synchronize do
|
|
112
|
+
@held[name] = result
|
|
113
|
+
@retrying << [result.klass, result.name]
|
|
114
|
+
end
|
|
115
|
+
@tests.fetch(name).dispatch(reporter)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Retry answered the failure of the test named `name`, its last retry
|
|
119
|
+
# ended with `result`: what Minitest's own reporters get, `result`
|
|
120
|
+
# where a retry rescued the test, the failure held back otherwise.
|
|
121
|
+
def answer(result, name)
|
|
122
|
+
held = @lock.synchronize do
|
|
123
|
+
@retrying.delete([result.klass, result.name])
|
|
124
|
+
@changed.broadcast
|
|
125
|
+
@held.delete(name)
|
|
126
|
+
end
|
|
127
|
+
@reruns.prepared.rescued?(name) ? result : held
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -15,7 +15,18 @@ module Mergify
|
|
|
15
15
|
class Selection
|
|
16
16
|
# A test the run collected: its class, its method, and the name Mergify
|
|
17
17
|
# knows it by.
|
|
18
|
-
Test = Struct.new(:klass, :method_name, :name)
|
|
18
|
+
Test = Struct.new(:klass, :method_name, :name) do
|
|
19
|
+
# Run it again, as Minitest runs a test of a class: inline and
|
|
20
|
+
# reported, or handed to its parallel executor, which reports it once
|
|
21
|
+
# it ran.
|
|
22
|
+
def dispatch(reporter)
|
|
23
|
+
if ::Minitest::Runnable.respond_to?(:run_suite)
|
|
24
|
+
klass.run(klass, method_name, reporter)
|
|
25
|
+
else
|
|
26
|
+
klass.run_one_method(klass, method_name, reporter)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
19
30
|
|
|
20
31
|
# Every test the run collected, as Minitest's filters left them.
|
|
21
32
|
attr_reader :tests
|
|
@@ -19,8 +19,9 @@ module Mergify
|
|
|
19
19
|
# every result and stops the run.
|
|
20
20
|
fail_first: { unit: 'test', waits_for_all: true, workers_stop_the_run: false },
|
|
21
21
|
# Flaky detection reruns a test after the suite, as many times as
|
|
22
|
-
# every other one it reruns
|
|
23
|
-
|
|
22
|
+
# every other one it reruns; test retry runs a failed one again as it
|
|
23
|
+
# fails.
|
|
24
|
+
reruns: { plans_repeats: true, retries_failures: true },
|
|
24
25
|
framework: { name: 'minitest', version: ::Minitest::VERSION, language: 'ruby' }
|
|
25
26
|
}.freeze
|
|
26
27
|
|
|
@@ -93,23 +94,30 @@ module Mergify
|
|
|
93
94
|
@session.flaky_detection_runs?
|
|
94
95
|
end
|
|
95
96
|
|
|
97
|
+
# Whether test retry retries in this run: the repository has it, and
|
|
98
|
+
# the run builds its default branch.
|
|
99
|
+
def test_retry_runs?
|
|
100
|
+
@session.test_retry_runs?
|
|
101
|
+
end
|
|
102
|
+
|
|
96
103
|
# Whether flaky detection found a new test flaky, which fails the run.
|
|
97
104
|
def flaky_detection_fails_the_run?
|
|
98
105
|
@session.flaky_detection_fails_the_run?
|
|
99
106
|
end
|
|
100
107
|
|
|
101
|
-
# Prepare flaky detection for the tests
|
|
102
|
-
# known by its former names (`former_names`, a list by name),
|
|
103
|
-
# Mergify's lists may still hold it under: the run's reruns, or
|
|
104
|
-
# when
|
|
105
|
-
# session's.
|
|
108
|
+
# Prepare flaky detection and test retry for the tests named `names`,
|
|
109
|
+
# each also known by its former names (`former_names`, a list by name),
|
|
110
|
+
# which Mergify's lists may still hold it under: the run's reruns, or
|
|
111
|
+
# nil when neither runs. Which tests each reruns, and how many times,
|
|
112
|
+
# are the session's. No test opts out of test retry: Minitest has no
|
|
113
|
+
# annotation of Mergify's.
|
|
106
114
|
def prepare_reruns(names, former_names)
|
|
107
115
|
@session.state_former_names(former_names)
|
|
108
116
|
@session.prepare_reruns(names)
|
|
109
117
|
end
|
|
110
118
|
|
|
111
119
|
# An attempt at a test, ended with `outcome` (`'passed'` or `'failed'`)
|
|
112
|
-
# after `duration_ms`: its first one, then each rerun.
|
|
120
|
+
# after `duration_ms`: its first one, then each rerun or retry.
|
|
113
121
|
def record_attempt(name, outcome, duration_ms)
|
|
114
122
|
@session.record_attempt(name, outcome, duration_ms)
|
|
115
123
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: minitest-mergify
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2026.10.
|
|
4
|
+
version: 2026.10.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mergify
|
|
@@ -35,14 +35,14 @@ dependencies:
|
|
|
35
35
|
requirements:
|
|
36
36
|
- - '='
|
|
37
37
|
- !ruby/object:Gem::Version
|
|
38
|
-
version: 2026.10.
|
|
38
|
+
version: 2026.10.10
|
|
39
39
|
type: :runtime
|
|
40
40
|
prerelease: false
|
|
41
41
|
version_requirements: !ruby/object:Gem::Requirement
|
|
42
42
|
requirements:
|
|
43
43
|
- - '='
|
|
44
44
|
- !ruby/object:Gem::Version
|
|
45
|
-
version: 2026.10.
|
|
45
|
+
version: 2026.10.10
|
|
46
46
|
description: 'Minitest integration for Mergify Test Engine: the run reported to CI
|
|
47
47
|
Insights, test quarantine, Fail First on merge-queue bisection steps, and flaky
|
|
48
48
|
test detection.'
|
|
@@ -62,6 +62,7 @@ files:
|
|
|
62
62
|
- lib/mergify/minitest/recorder.rb
|
|
63
63
|
- lib/mergify/minitest/reruns.rb
|
|
64
64
|
- lib/mergify/minitest/results.rb
|
|
65
|
+
- lib/mergify/minitest/retries.rb
|
|
65
66
|
- lib/mergify/minitest/selection.rb
|
|
66
67
|
- lib/mergify/minitest/test_run.rb
|
|
67
68
|
- lib/mergify/minitest/version.rb
|