minitest-mergify 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.
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Mergify
6
+ module Minitest
7
+ # The run's tests, as the session asks Test Selection about them, and the
8
+ # order they run in: on a merge-queue bisection step, Fail First puts
9
+ # first the tests that failed in the step above.
10
+ #
11
+ # Minitest has no collection of its own: every test file is loaded before
12
+ # it runs, so the run's tests are each class's test methods, once
13
+ # Minitest's filters (`-n`, `-e`, Rails' `file:line`) left them, which is
14
+ # what Minitest itself runs. Each class then runs what is narrowed here.
15
+ class Selection
16
+ # A test the run collected: its class, its method, and the name Mergify
17
+ # knows it by.
18
+ Test = Struct.new(:klass, :method_name, :name)
19
+
20
+ # Every test the run collected, as Minitest's filters left them.
21
+ attr_reader :tests
22
+
23
+ def initialize(tests)
24
+ @tests = tests
25
+ @first = Set.new
26
+ end
27
+
28
+ # How many tests the run is to run: every one it collected.
29
+ def to_run
30
+ @tests.size
31
+ end
32
+
33
+ # The tests `runnables` hold once Minitest's filters, in `options`, left
34
+ # them, each named by `names`.
35
+ def self.collect(runnables, names, options)
36
+ tests = runnables.flat_map do |klass|
37
+ filtered(klass, options).filter_map do |method|
38
+ file = klass.instance_method(method).source_location&.first
39
+ Test.new(klass, method, names.of(klass.name, method, file).name) if file
40
+ end
41
+ end
42
+ new(tests)
43
+ end
44
+
45
+ # Minitest 6 names its filters `include` and `exclude`, and filters a
46
+ # class's tests in `filter_runnable_methods`; Minitest 5 does the same
47
+ # inline, `include` under the name `filter`.
48
+ def self.filtered(klass, options)
49
+ return klass.filter_runnable_methods(options) if klass.respond_to?(:filter_runnable_methods)
50
+
51
+ include = pattern(options[:filter])
52
+ exclude = pattern(options[:exclude])
53
+ klass.runnable_methods
54
+ .select { |method| !include || matches?(include, klass, method) }
55
+ .reject { |method| exclude && matches?(exclude, klass, method) }
56
+ end
57
+
58
+ # Whether `filter` takes the test, as Minitest asks it: by its method,
59
+ # or by `Class#method`. With `===`, which a Regexp, a String and Rails'
60
+ # `file:line` filter each answer their own way.
61
+ def self.matches?(filter, klass, method)
62
+ filter === method || filter === "#{klass}##{method}" # rubocop:disable Style/CaseEquality
63
+ end
64
+
65
+ # A filter given as `/.../` is a regular expression, as Minitest reads it.
66
+ def self.pattern(filter)
67
+ filter.is_a?(String) && filter =~ %r{/(.*)/} ? Regexp.new(Regexp.last_match(1)) : filter
68
+ end
69
+
70
+ # Hand the session what the run collected, and put first what Fail
71
+ # First puts first, when Mergify answered the run is a bisection step.
72
+ def apply(test_run)
73
+ test_run.select(@tests.map(&:name))
74
+ fail_first = test_run.fail_first
75
+ put_first(fail_first) if fail_first
76
+ end
77
+
78
+ # The names of the tests the run is to run: every one it collected.
79
+ def names_to_run
80
+ @tests.to_set(&:name)
81
+ end
82
+
83
+ # The tests of `klass`, of `methods`, that run now.
84
+ def narrow(klass, methods)
85
+ case @pass
86
+ when :first then methods.select { |method| @first.include?([klass, method]) }
87
+ when :rest then methods.reject { |method| @first.include?([klass, method]) }
88
+ else methods
89
+ end
90
+ end
91
+
92
+ # Run the suites Minitest runs in `run_suites`: the tests put first,
93
+ # then, unless the recorder says to stop once all of them recorded, the
94
+ # rest.
95
+ def run(recorder, &run_suites)
96
+ return run_suites.call if @first.empty?
97
+
98
+ @pass = :first
99
+ run_suites.call
100
+ # They may run in Minitest's threads or Rails' workers: their results
101
+ # come back after the suites were handed over.
102
+ recorder.wait_for(@first_names)
103
+ return if recorder.stopping?
104
+
105
+ @pass = :rest
106
+ run_suites.call
107
+ ensure
108
+ @pass = nil
109
+ end
110
+
111
+ private
112
+
113
+ def put_first(fail_first)
114
+ names = fail_first.put_first(@tests.map { |test| [test.name, test.name] }).to_set
115
+ first = @tests.select { |test| names.include?(test.name) }
116
+ @first = first.to_set { |test| [test.klass, test.method_name] }
117
+ @first_names = names
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest'
4
+ require 'mergify/ci/core'
5
+ require_relative 'version'
6
+
7
+ module Mergify
8
+ module Minitest
9
+ # The run's session: what this process reads once (the CI context, the
10
+ # trace a caller handed down, the run's id), and what the run reports.
11
+ class TestRun
12
+ # What Minitest is, as the session takes it: what it calls a test; what
13
+ # it can do about Fail First; and Minitest itself, which the trace
14
+ # names.
15
+ FRAMEWORK = {
16
+ test_noun: 'test',
17
+ # Minitest runs tests, the ones put first in a pass of their own that
18
+ # the main process waits for, wherever its workers run them: it sees
19
+ # every result and stops the run.
20
+ fail_first: { unit: 'test', waits_for_all: true, workers_stop_the_run: false },
21
+ # Flaky detection reruns a test after the suite, as many times as
22
+ # every other one it reruns, and nothing retries a failure.
23
+ reruns: { plans_repeats: true, retries_failures: false },
24
+ framework: { name: 'minitest', version: ::Minitest::VERSION, language: 'ruby' }
25
+ }.freeze
26
+
27
+ CI_ENABLE_ENV = 'MERGIFY_CI_ENABLE'
28
+ CI_DEBUG_ENV = 'MERGIFY_CI_DEBUG'
29
+
30
+ attr_reader :session
31
+
32
+ # `doubles` has the session play Mergify in memory, answering and
33
+ # keeping what it would send (the session's test doubles, by port, as a
34
+ # Hash: `trace`, `record`). It is for the gem's own specs, which install
35
+ # their TestRun before the run starts. The one the plugin builds takes
36
+ # none, and the gem reads no switch for them.
37
+ def initialize(doubles: nil)
38
+ @session = Mergify::CI::Core::Native::Session.start(
39
+ VERSION, output, doubles, enabled?, client: 'minitest-mergify', **FRAMEWORK
40
+ )
41
+ # What a run that reports loads before its tests: the quarantine list
42
+ # for the branch they target. Without one (no branch, a failed fetch,
43
+ # no token) nothing is quarantined, and its block says why.
44
+ @quarantine = @session.load_before_tests if reports?
45
+ end
46
+
47
+ # Whether the run reports to Mergify at all: in CI, or turned on with
48
+ # MERGIFY_CI_ENABLE. The session decides.
49
+ def reports?
50
+ @session.reports?
51
+ end
52
+
53
+ # Hand the session what this run is about to execute, by name, once
54
+ # Minitest applied its filters: in a job that opted in, it asks Mergify
55
+ # whether the run is a merge-queue bisection step, and the collection's
56
+ # fingerprint travels on the verdict and the trace.
57
+ def select(names)
58
+ @session.select(names)
59
+ nil
60
+ end
61
+
62
+ # The run's Fail First, which the session hands out when Mergify
63
+ # answered `failure_first` and the run runs it; nil otherwise.
64
+ def fail_first
65
+ @session.fail_first
66
+ end
67
+
68
+ # Record a test's final status (`passed`, `failed`, `skipped` or
69
+ # `quarantined_failed`) and how long it took, for the session's verdict.
70
+ def record_test(name, status, run_time)
71
+ @session.record_test(name, status, run_time.to_f)
72
+ end
73
+
74
+ # How many distinct tests reached a final status.
75
+ def executed_count
76
+ @session.executed_count
77
+ end
78
+
79
+ # Write what this session concluded to Mergify, before the trace goes:
80
+ # the session decides whether there is a verdict to send -- only a run
81
+ # that asked for a selection has one -- builds it from what it recorded
82
+ # and from how the run ended (`not_run` tests it was to run never ran;
83
+ # `told_to_stop`, Fail First stopped it), and sends it. A verdict that
84
+ # did not land is a line of the summary.
85
+ def report_verdict(not_run:, told_to_stop:)
86
+ @session.end_run(not_run, told_to_stop)
87
+ @session.report_verdict(false)
88
+ end
89
+
90
+ # Whether flaky detection runs in this run: Mergify answered, and the
91
+ # repository opted in.
92
+ def flaky_detection_runs?
93
+ @session.flaky_detection_runs?
94
+ end
95
+
96
+ # Whether flaky detection found a new test flaky, which fails the run.
97
+ def flaky_detection_fails_the_run?
98
+ @session.flaky_detection_fails_the_run?
99
+ end
100
+
101
+ # Prepare flaky detection for the tests the run ran, by name, each also
102
+ # known by its former names (`former_names`, a list by name), which
103
+ # Mergify's lists may still hold it under: the run's reruns, or nil
104
+ # when nothing is to rerun. Which tests it reruns, and how many times, are the
105
+ # session's.
106
+ def prepare_reruns(names, former_names)
107
+ @session.state_former_names(former_names)
108
+ @session.prepare_reruns(names)
109
+ end
110
+
111
+ # An attempt at a test, ended with `outcome` (`'passed'` or `'failed'`)
112
+ # after `duration_ms`: its first one, then each rerun.
113
+ def record_attempt(name, outcome, duration_ms)
114
+ @session.record_attempt(name, outcome, duration_ms)
115
+ end
116
+
117
+ # Account for a test that ended with `outcome` (`'passed'`, `'failed'`
118
+ # or `'skipped'`), under each of its `names`, as `{ 'quarantined' =>
119
+ # bool, 'absorbed' => bool }`: an absorbed failure fails nothing.
120
+ def record_quarantine(names, outcome)
121
+ return { 'quarantined' => false, 'absorbed' => false } unless @quarantine
122
+
123
+ @quarantine.record(names, outcome)
124
+ end
125
+
126
+ # Hand the session a test once it finished (see
127
+ # `Mergify::CI::Core::Native::Session#record_case`): its span joins the
128
+ # run's trace.
129
+ def record_case(test_case)
130
+ @session.record_case(test_case)
131
+ end
132
+
133
+ # Have the session send the run's trace, once, at the end, under a
134
+ # session span that fails with the run. An upload that did not land is
135
+ # a line of the summary, never a failure of the suite.
136
+ def report_trace(failed:)
137
+ @session.report_trace(failed) if reports?
138
+ end
139
+
140
+ # What the session says once the run is over, each line a `[severity,
141
+ # text]` pair. A process parallel_tests started ran a share of the
142
+ # suite only: it sets TEST_ENV_NUMBER, to an empty string for its first.
143
+ def summary
144
+ @session.summary(ENV.key?('TEST_ENV_NUMBER'))
145
+ end
146
+
147
+ private
148
+
149
+ # Where the session sends the trace: shown in debug mode, sent
150
+ # otherwise -- to Mergify, or to the test doubles a spec handed over.
151
+ def output
152
+ Mergify::CI::Core::Utils.env_truthy?(CI_DEBUG_ENV) ? 'debug' : 'send'
153
+ end
154
+
155
+ # The gem's enable switch: the run then reports outside CI too.
156
+ def enabled?
157
+ Mergify::CI::Core::Utils.env_truthy?(CI_ENABLE_ENV)
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mergify
4
+ module Minitest
5
+ # Placeholder: the release workflow stamps the real version from the git
6
+ # tag at build time, as it does for every client in this repository.
7
+ VERSION = '2026.10.8'
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'minitest/version'
4
+
5
+ module Mergify
6
+ module Minitest
7
+ class << self
8
+ # The run's TestRun: the plugin builds it, unless one is installed
9
+ # before the run starts, as the gem's own specs install theirs, with
10
+ # the session's test doubles.
11
+ attr_accessor :test_run
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest'
4
+ require 'minitest/spec'
5
+ require_relative 'mergify_plugin'
6
+ require_relative '../mergify/minitest/declarations'
7
+
8
+ # What a suite requires to report to Mergify, before its tests are defined.
9
+ # Minitest 6 loads only the plugins it is asked for, so this registers ours;
10
+ # Minitest 5 registers every installed plugin by itself, ours again, and the
11
+ # plugin sets itself up once. Either way, only a suite that requires it gives
12
+ # a test with no body the place it is declared (see Declarations); requiring
13
+ # the spec DSL for that does what `minitest/autorun` does anyway.
14
+ Minitest.extensions << 'mergify' unless Minitest.extensions.include?('mergify')
15
+ Mergify::Minitest::Declarations.install
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mergify/minitest/plugin'
4
+
5
+ # Minitest's plugin entry point. Minitest 5 finds this file in every installed
6
+ # gem and loads it by itself; Minitest 6 loads it when the suite requires
7
+ # `minitest/mergify`.
8
+ module Minitest
9
+ # Called once Minitest has parsed its options, before any test runs.
10
+ def self.plugin_mergify_init(options)
11
+ Mergify::Minitest::Plugin.init(options)
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: minitest-mergify
3
+ version: !ruby/object:Gem::Version
4
+ version: 2026.10.8
5
+ platform: ruby
6
+ authors:
7
+ - Mergify
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '5.19'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '7'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '5.19'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '7'
32
+ - !ruby/object:Gem::Dependency
33
+ name: mergify-ci-core
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - '='
37
+ - !ruby/object:Gem::Version
38
+ version: 2026.10.8
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - '='
44
+ - !ruby/object:Gem::Version
45
+ version: 2026.10.8
46
+ description: 'Minitest integration for Mergify Test Engine: the run reported to CI
47
+ Insights, test quarantine, Fail First on merge-queue bisection steps, and flaky
48
+ test detection.'
49
+ email:
50
+ - support@mergify.com
51
+ executables: []
52
+ extensions: []
53
+ extra_rdoc_files: []
54
+ files:
55
+ - LICENSE
56
+ - README.md
57
+ - THIRD_PARTY_LICENSES
58
+ - lib/mergify/minitest.rb
59
+ - lib/mergify/minitest/declarations.rb
60
+ - lib/mergify/minitest/names.rb
61
+ - lib/mergify/minitest/plugin.rb
62
+ - lib/mergify/minitest/recorder.rb
63
+ - lib/mergify/minitest/reruns.rb
64
+ - lib/mergify/minitest/results.rb
65
+ - lib/mergify/minitest/selection.rb
66
+ - lib/mergify/minitest/test_run.rb
67
+ - lib/mergify/minitest/version.rb
68
+ - lib/minitest/mergify.rb
69
+ - lib/minitest/mergify_plugin.rb
70
+ homepage: https://docs.mergify.com/test-engine/test-frameworks/minitest/
71
+ licenses:
72
+ - LicenseRef-Mergify-Proprietary
73
+ metadata:
74
+ homepage_uri: https://docs.mergify.com/test-engine/test-frameworks/minitest/
75
+ bug_tracker_uri: https://github.com/Mergifyio/mergify-cli/issues
76
+ rubygems_mfa_required: 'true'
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '3.1'
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 4.0.20
92
+ specification_version: 4
93
+ summary: Minitest plugin for Mergify Test Engine
94
+ test_files: []