ci_runner 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 25a11b4505aff5153e388723711643c4aae5f3a7b64b53b3265e0bd7198ec381
4
- data.tar.gz: 858492f60b87472cf41da22c5808a2371659d56712ef77dad98590cb98063ecf
3
+ metadata.gz: f7834106a8db1f0770e5184dcf31fbfc892a92890b52f4709ab900ccd560f1b2
4
+ data.tar.gz: 69c9c0533ff5a52a1f97cb1f4374e012f56004a06a584b2b60ab80e4c9897517
5
5
  SHA512:
6
- metadata.gz: 51158e9a4b9cd2188f5161320dc140e076009f7c4550206b957ffac03e9b5859533dc99defc644f05aa126713f8d52cf1d2260a4691ba74cba746f779ec3c945
7
- data.tar.gz: 5db9352b2360cec4f5f8b23ce4cf378fcef79c3cc1e8b2f1cb4543fb307a2fc8db1116fea023b766d262d768582dd9f44bb1c48434c2c6171e697962398f0543
6
+ metadata.gz: 67d81327e046862ccb85ead5688aac13d9818d8ffc1487ef92001fba07ff6c6795dee7a537566e410c3c864ed0ee8e3712668310412cae9bbab3eddaeff52114
7
+ data.tar.gz: ac943446f8cf3ebd2a65c5d35e021aebcfee0cca6c5e693bafe7f4ea41b673bc669a3eabe1914eed70a98c646dc12d51dba12d14a066e6b51538a9bfc37318d8
data/ci_runner.gemspec CHANGED
@@ -24,6 +24,7 @@ Gem::Specification.new do |spec|
24
24
 
25
25
  spec.metadata["homepage_uri"] = spec.homepage
26
26
  spec.metadata["source_code_uri"] = "https://github.com/Edouard-chin/ci_runner"
27
+ spec.metadata["changelog_uri"] = "https://github.com/Edouard-chin/ci_runner/blob/main/CHANGELOG.md"
27
28
  spec.metadata["allowed_push_host"] = "https://rubygems.org"
28
29
  spec.metadata["rubygems_mfa_required"] = "true"
29
30
 
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CIRunner
4
+ module Check
5
+ # Base class for a CI check.
6
+ #
7
+ # @see https://docs.github.com/en/rest/checks/runs#get-a-check-run
8
+ # @see https://docs.github.com/en/rest/commits/statuses#list-commit-statuses-for-a-reference
9
+ class Base
10
+ # @return [String] The full repository name, including the owner (i.e. rails/rails)
11
+ attr_reader :repository
12
+
13
+ # @return [String] The Git commit that has been pushed to GitHub and for which we'll retrieve the CI checks.
14
+ attr_reader :commit
15
+
16
+ # @return [String] The name of that check. Should be whatever you had set in your CI configuration cile
17
+ attr_reader :name
18
+
19
+ # @return [String] The status from the GitHub API for this check. Can be a lot of different values.
20
+ # See the GitHub API.
21
+ attr_reader :status
22
+
23
+ # @param repository (See #repository)
24
+ # @param commit (See #commit)
25
+ # @param name (See #name)
26
+ # @param status (See #status)
27
+ def initialize(repository, commit, name, status)
28
+ @repository = repository
29
+ @commit = commit
30
+ @name = name
31
+ @status = status
32
+ end
33
+
34
+ # Subclass have to implement this to download the log(s) output for the build.
35
+ #
36
+ # @raise [NotImplementedError]
37
+ #
38
+ # @return [IO]
39
+ def download_log
40
+ raise(NotImplementedError, "Subclass responsability")
41
+ end
42
+
43
+ # Used to tell the user which CI provider we are downloading the log output from.
44
+ #
45
+ # @return [String]
46
+ def provider
47
+ raise(NotImplementedError, "Subclass responsability")
48
+ end
49
+
50
+ # @return [Boolean]
51
+ def success?
52
+ @status == "success"
53
+ end
54
+
55
+ # @return [Boolean]
56
+ #
57
+ # @see https://docs.github.com/en/rest/commits/statuses#get-the-combined-status-for-a-specific-reference
58
+ def failed?
59
+ ["error", "failure"].include?(status)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require "uri"
5
+
6
+ module CIRunner
7
+ module Check
8
+ # Check class used when a project is configured to run its CI using Buildkite.
9
+ class Buildkite < Base
10
+ include ConcurrentDownload
11
+
12
+ attr_reader :url # :private:
13
+
14
+ # @param args (See Base#initialize)
15
+ # @param url [String] The html URL pointing to the Buildkite build.
16
+ def initialize(*args, url)
17
+ super(*args)
18
+
19
+ @url = url
20
+ end
21
+
22
+ # Used to tell the user which CI provider we are downloading the log output from.
23
+ #
24
+ # @return [String]
25
+ def provider
26
+ "Buildkite"
27
+ end
28
+
29
+ # Download the CI logs for this Buildkite build.
30
+ #
31
+ # The Buildkite API scopes tokens per organizations (token generated for org A can't access
32
+ # resource on org B, even for public resources). This means that for opensource projects using
33
+ # Buildkite, users that are not members of the buildkite org normally can't use CI Runner.
34
+ #
35
+ # To bypass this problem, for builds that are public, CI Runner uses a different API.
36
+ # For private build, CI runner will check if the user had stored a Buildkite token in its config.
37
+ #
38
+ # @return [Tempfile]
39
+ def download_log
40
+ uri = URI(url)
41
+ _, org, pipeline, _, build = uri.path.split("/")
42
+ @client = Client::Buildkite.new
43
+
44
+ unless @client.public_build?(org, pipeline, build)
45
+ token = retrieve_token_from_config(org, url)
46
+ @client = Client::AuthenticatedBuildkite.new(token)
47
+ end
48
+
49
+ @client.job_logs(org, pipeline, build).each do |log_url|
50
+ @queue << log_url
51
+ end
52
+
53
+ process_queue
54
+ end
55
+
56
+ private
57
+
58
+ # @param url [String]
59
+ #
60
+ # @return [void]
61
+ def process(url)
62
+ @client.reset!
63
+ response = @client.download_log(url)
64
+
65
+ @tempfile.write(response.read)
66
+ end
67
+
68
+ # Retrieve a Buildkite token from the user confg.
69
+ #
70
+ # @param organization [String] The organization that owns this buildkite build.
71
+ # @param url [String] The FQDN pointing to the buildkite build.
72
+ #
73
+ # @return [String] The token
74
+ #
75
+ # @raise [Error] If no token for that organization exists in the config.
76
+ def retrieve_token_from_config(organization, url)
77
+ token = Configuration::User.instance.buildkite_token(organization.downcase)
78
+
79
+ token || raise(Error, <<~EOM)
80
+ Can't get the log output from the Buildkite build #{url} because it requires authentication.
81
+
82
+ Please store a Buildkite token scoped to the organization #{organization} and retry.
83
+ See {{command:ci_runner help buildkite_token}}
84
+ EOM
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require "uri"
5
+ require "open-uri"
6
+ require "json"
7
+
8
+ module CIRunner
9
+ module Check
10
+ # A Step object represents a CircleCI step.
11
+ # This Struct has +eql?+ and +hash+ implemented in order to check if two steps are the same and remove
12
+ # the duplicates.
13
+ #
14
+ # Two steps are considered the same if their names are equal and both are successful.
15
+ # The reason this is implemented like this is to avoid downloading too many of the same logfiles.
16
+ #
17
+ # Project on CircleCI can be configured to run in parallel, the number of steps and therefore log output
18
+ # we have to download increases exponentially.
19
+ #
20
+ # As an example, imagine this CircleCI configuration:
21
+ #
22
+ # 'Minitest':
23
+ # executor: ruby/default
24
+ # parallelism: 16
25
+ # steps:
26
+ # - setup-ruby
27
+ # - bundle install
28
+ # - bin/rails test
29
+ #
30
+ # CircleCI will create 48 steps (and 48 log download link). Downloading those 48 log, don't make sense
31
+ # since they will be all similar. Unless they failed, in which case we download the log for that step.
32
+ #
33
+ # @see https://circleci.com/docs/configuration-reference#steps
34
+ Step = Struct.new(:name, :output_url, :failed) do
35
+ # Used in conjuction with +hash+ for unique comparison.
36
+ #
37
+ # @param other [Object]
38
+ #
39
+ # @return [Boolean]
40
+ def eql?(other)
41
+ return false if failed || other.failed
42
+
43
+ name == other.name
44
+ end
45
+
46
+ # Used for unique comparison.
47
+ #
48
+ # @return [String]
49
+ def hash
50
+ [self.class, name, failed].hash
51
+ end
52
+ end
53
+
54
+ # Check class used when a project is configured to run its CI using CircleCI.
55
+ class CircleCI < Base
56
+ include ConcurrentDownload
57
+
58
+ attr_reader :url # :private:
59
+
60
+ # @param args (See Base#initialize)
61
+ # @param url [String] The html URL pointing to the CircleCI build.
62
+ def initialize(*args, url)
63
+ super(*args)
64
+
65
+ @url = url
66
+ end
67
+
68
+ # Used to tell the user which CI provider we are downloading the log output from.
69
+ #
70
+ # @return [String]
71
+ def provider
72
+ "CircleCI"
73
+ end
74
+
75
+ # Download the CI logs for this CI build.
76
+ #
77
+ # CircleCI doesn't have an API to download a single log file for the whole build. Instead, we have
78
+ # to download a log output for each steps. Depending on the number of steps configured on a project, and
79
+ # whether it uses parallelism, the number of log files to download might be quite important.
80
+ #
81
+ # The log for each steps are small in size, so downloading them in parallel to make things much faster.
82
+ #
83
+ # @return [Tempfile]
84
+ def download_log
85
+ client = Client::CircleCI.new(Configuration::User.instance.circle_ci_token)
86
+ job = client.job(repository, build_number)
87
+ steps = []
88
+
89
+ job["steps"].each do |step|
90
+ step["actions"].each do |parallel|
91
+ next unless parallel["has_output"]
92
+
93
+ steps << Step.new(*parallel.values_at("name", "output_url", "failed"))
94
+ end
95
+ end
96
+
97
+ steps.uniq!
98
+
99
+ steps.each do |step|
100
+ @queue << step
101
+ end
102
+
103
+ process_queue
104
+ end
105
+
106
+ private
107
+
108
+ # @param step [Step]
109
+ #
110
+ # @return [void]
111
+ def process(step)
112
+ response = URI.open(step.output_url)
113
+ parsed_response = JSON.parse(response.read)
114
+ log_output = parsed_response.map! { |res| res["message"] }.join
115
+
116
+ @tempfile.write(log_output)
117
+ end
118
+
119
+ # The URL on the commit status will look something like: https://circleci.com/gh/owner/repo/1234?query_string.
120
+ # We want the `1234` which is the builder number.
121
+ #
122
+ # @return [Integer]
123
+ def build_number
124
+ URI(@url.to_s).path.split("/").last
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module CIRunner
6
+ module Check
7
+ # Module used to dowload multiple logfiles in parallel.
8
+ #
9
+ # Some CI providers doesn't have an API to download a single log file for the whole
10
+ # build, and instead one log file is produced per step. CI Runner needs to download
11
+ # the logfile of all steps in the build in order to rerun all test that failed.
12
+ module ConcurrentDownload
13
+ def initialize(...)
14
+ @queue = Queue.new
15
+ @tempfile = Tempfile.new
16
+
17
+ super(...)
18
+ end
19
+
20
+ private
21
+
22
+ # Implement a queuing system in order to download log files in parallel.
23
+ #
24
+ # @return [void]
25
+ def process_queue
26
+ max_threads = 6
27
+ threads = []
28
+
29
+ max_threads.times do
30
+ threads << Thread.new do
31
+ while (element = dequeue)
32
+ process(element)
33
+ end
34
+ end
35
+ end
36
+
37
+ threads.each(&:join)
38
+
39
+ @tempfile.tap(&:flush)
40
+ end
41
+
42
+ # Process item in the queue.
43
+ def process
44
+ raise(NotImplementedError)
45
+ end
46
+
47
+ # Dequeue a CircleCI Step from the queue.
48
+ #
49
+ # @return [Step, nil]
50
+ def dequeue
51
+ @queue.pop(true)
52
+ rescue ThreadError
53
+ nil
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module CIRunner
6
+ module Check
7
+ # Check class used when a project is configured to run its CI using GitHub actions.
8
+ #
9
+ # @see https://docs.github.com/en/rest/actions/workflow-jobs
10
+ class Github < Base
11
+ attr_reader :id # :private:
12
+
13
+ # @param args (See Base#initialize)
14
+ # @param id [Integer] The ID of this check.
15
+ def initialize(*args, id)
16
+ super(*args)
17
+
18
+ @id = id
19
+ end
20
+
21
+ # Used to tell the user which CI provider we are downloading the log output from.
22
+ #
23
+ # @return [String]
24
+ def provider
25
+ "GitHub"
26
+ end
27
+
28
+ # Download the log output for thig GitHub build.
29
+ #
30
+ # @return (See Client::Github#download_log)
31
+ #
32
+ # @see https://docs.github.com/en/rest/actions/workflow-jobs#download-job-logs-for-a-workflow-run
33
+ def download_log
34
+ github_client = Client::Github.new(Configuration::User.instance.github_token)
35
+
36
+ github_client.download_log(@repository, @id)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module CIRunner
6
+ module Check
7
+ # Check class used for any CI provider not (yet) supported by CIRunner.
8
+ #
9
+ # When running the `ci_runner` CLI, those will be selectable but CI runner will bail out
10
+ # if they get selected. Not sure if its a good idea :shrug:.
11
+ class Unsupported < Base
12
+ # @return [String]
13
+ def name
14
+ "#{@name} (Unsupported by CI Runner)"
15
+ end
16
+
17
+ # @return [String]
18
+ def provider
19
+ ""
20
+ end
21
+
22
+ # @raise [Error]
23
+ def download_log
24
+ raise(Error, <<~EOM)
25
+ Aw, snap! This CI is not supported by CI Runner.
26
+ Please open an Issue on GitHub to let me know you are interested:
27
+
28
+ {{info:https://github.com/Edouard-chin/ci_runner/issues/new}}
29
+ EOM
30
+ end
31
+ end
32
+ end
33
+ end
data/lib/ci_runner/cli.rb CHANGED
@@ -35,6 +35,7 @@ module CIRunner
35
35
  def rerun
36
36
  ::CLI::UI::StdoutRouter.enable
37
37
 
38
+ check_for_new_version
38
39
  runner = nil
39
40
 
40
41
  ::CLI::UI.frame("Preparing CI Runner") do
@@ -47,14 +48,12 @@ module CIRunner
47
48
  run_name = options[:run_name] || ask_for_name(ci_checks)
48
49
  check_run = TestRunFinder.find(ci_checks, run_name)
49
50
 
50
- ci_log = fetch_ci_log(repository, commit, check_run)
51
- runner = TestRunFinder.detect_runner(ci_log)
51
+ ci_log = fetch_ci_log(check_run)
52
+ runner = TestRunFinder.detect_runner(ci_log.read)
52
53
  runner.parse!
53
54
 
54
- if runner.failures.count == 0
55
- # Error
56
- end
57
- rescue GithubClient::Error, Error => e
55
+ no_failure_error(ci_log, runner) if runner.failures.count.zero?
56
+ rescue Client::Error, Error => e
58
57
  ::CLI::UI.puts("\n{{red:#{e.message}}}", frame_color: :red)
59
58
 
60
59
  exit(false)
@@ -71,13 +70,13 @@ module CIRunner
71
70
  Save a personal access GitHub token in the ~/.ci_runner/config.yml file.
72
71
  The GitHub token is required to fetch CI checks and download logs from repositories.
73
72
 
74
- You can get a token from GitHub by following this link: https://github.com/settings/tokens/new?description=CI+Runner&scopes=repo # rubocop:disable Layout/LineLength
73
+ You can get a token from GitHub by following this link: https://github.com/settings/tokens/new?description=CI+Runner&scopes=repo
75
74
  EOM
76
75
  def github_token(token)
77
76
  ::CLI::UI::StdoutRouter.enable
78
77
 
79
78
  ::CLI::UI.frame("Saving GitHub Token") do
80
- user = GithubClient.new(token).me
79
+ user = Client::Github.new(token).me
81
80
  Configuration::User.instance.save_github_token(token)
82
81
 
83
82
  ::CLI::UI.puts(<<~EOM)
@@ -85,22 +84,97 @@ module CIRunner
85
84
 
86
85
  {{info:The token has been saved in this file: #{Configuration::User.instance.config_file}}}
87
86
  EOM
88
- rescue GithubClient::Error => e
87
+ rescue Client::Error => e
89
88
  ::CLI::UI.puts("{{red:\nYour token doesn't seem to be valid. The response from GitHub was: #{e.message}}}")
90
89
 
91
90
  exit(false)
92
91
  end
93
92
  end
94
93
 
94
+ desc "circle_ci_token TOKEN", "Save a Circle CI token in your config."
95
+ long_desc <<~EOM
96
+ Save a personal access Circle CI token in the ~/.ci_runner/config.yml file.
97
+ If one of your project uses Circle CI as its CI provider and the project is set to private,
98
+ CI Runner won't be able to fetch the logs unless you provide a token.
99
+
100
+ You can get a token from Circle CI by following this link: https://app.circleci.com/settings/user/tokens
101
+ EOM
102
+ def circle_ci_token(token)
103
+ ::CLI::UI::StdoutRouter.enable
104
+
105
+ ::CLI::UI.frame("Saving CircleCI Token") do
106
+ user = Client::CircleCI.new(token).me
107
+ Configuration::User.instance.save_circle_ci_token(token)
108
+
109
+ ::CLI::UI.puts(<<~EOM)
110
+ Hello {{warning:#{user["login"]}}}! {{success:Your token is valid!}}
111
+
112
+ {{info:The token has been saved in this file: #{Configuration::User.instance.config_file}}}
113
+ EOM
114
+ rescue Client::Error => e
115
+ ::CLI::UI.puts("{{red:\nYour token doesn't seem to be valid. The response from Circle CI was: #{e.message}}}")
116
+
117
+ exit(false)
118
+ end
119
+ end
120
+
121
+ desc "buildkite_token TOKEN ORGANIZATION", "Save a Buildkite token in your config."
122
+ long_desc <<~EOM
123
+ Save a personal access Buildkite token in the ~/.ci_runner/config.yml file.
124
+ Storing a Buildkite token is required to retrieve log from private Buildkite builds.
125
+
126
+ The ORGANIZATION, should be the name of the organization the token has access to.
127
+
128
+ You can get a token from Buildkite by following this link: https://buildkite.com/user/api-access-tokens/new?description=CI%20Runner&scopes[]=read_builds&scopes[]=read_build_logs
129
+ EOM
130
+ def buildkite_token(token, organization)
131
+ ::CLI::UI::StdoutRouter.enable
132
+
133
+ required_scopes = ["read_builds", "read_build_logs"]
134
+ token_scopes = Client::AuthenticatedBuildkite.new(token).access_token["scopes"]
135
+ missing_scopes = required_scopes - token_scopes
136
+
137
+ if missing_scopes.empty?
138
+ Configuration::User.instance.save_buildkite_token(token, organization)
139
+
140
+ ::CLI::UI.puts(<<~EOM)
141
+ {{success:Your token is valid!}}
142
+
143
+ {{info:The token has been saved in this file: #{Configuration::User.instance.config_file}}}
144
+ EOM
145
+ else
146
+ ::CLI::UI.puts("{{red:\nYour token is missing required scope(s): #{missing_scopes.join(",")}")
147
+ end
148
+ rescue Client::Error => e
149
+ ::CLI::UI.puts("{{red:\nYour token doesn't seem to be valid. The response from Buildkite was: #{e.message}}}")
150
+
151
+ exit(false)
152
+ end
153
+
95
154
  private
96
155
 
156
+ # Inform the user of a possible new CI Runner version.
157
+ #
158
+ # @return [void]
159
+ def check_for_new_version
160
+ version_verifier = VersionVerifier.new
161
+ return unless version_verifier.new_ci_runner_version?
162
+
163
+ ::CLI::UI.puts(<<~EOM)
164
+ {{info:A newer version of CI Runner is available (#{version_verifier.upstream_version}).}}
165
+ {{info:You can update CI Runner by running}} {{command:gem update ci_runner}}
166
+ EOM
167
+ rescue StandardError
168
+ nil
169
+ end
170
+
97
171
  # Retrieve all the GitHub CI checks for a given commit. Will be used to interactively prompt
98
172
  # the user which one to rerun.
99
173
  #
100
174
  # @param repository [String] The full repository name including the owner (rails/rails).
101
175
  # @param commit [String] A Git commit that has been pushed to GitHub and for which CI failed.
102
176
  #
103
- # @return [Hash] See the GitHub documentation.
177
+ # @return [Array<Check::Base>] Array filled with Check::Base subclasses.
104
178
  #
105
179
  # @raise [SystemExit] Early exit the process if the CI checks can't be retrieved.
106
180
  #
@@ -120,19 +194,17 @@ module CIRunner
120
194
  # Download and cache the log for the GitHub check. Downloading the log allows CI Runner to parse it and detect
121
195
  # which test failed in order to run uniquely those on the user machine.
122
196
  #
123
- # @param repository [String] The full repository name including the owner (rails/rails).
124
- # @param commit [String] A Git commit that has been pushed to GitHub and for which CI failed.
125
- # @param check_run [Hash] The GitHub Check that failed. See #fetch_ci_checks.
197
+ # @param check_run [Check::Base] The GitHub Check that failed. See #fetch_ci_checks.
126
198
  #
127
199
  # @return [String] The content of the CI log.
128
200
  #
129
201
  # @raise [SystemExit] Early exit the process if the CI checks can't be retrieved.
130
202
  #
131
203
  # @see https://docs.github.com/en/rest/actions/workflow-jobs#download-job-logs-for-a-workflow-run
132
- def fetch_ci_log(repository, commit, check_run)
133
- log = LogDownloader.new(commit, repository, check_run).fetch do |error|
134
- puts(<<~EOM)
135
- Couldn't fetch the CI log. The response from GitHub was:
204
+ def fetch_ci_log(check_run)
205
+ log = LogDownloader.new(check_run).fetch do |error|
206
+ ::CLI::UI.puts(<<~EOM)
207
+ Couldn't fetch the CI log. The error was:
136
208
 
137
209
  #{error.message}
138
210
  EOM
@@ -140,27 +212,26 @@ module CIRunner
140
212
  exit(false)
141
213
  end
142
214
 
143
- log.read
215
+ log
144
216
  end
145
217
 
146
218
  # Interatively ask the user which CI check to rerun in the case a commit has multiple failing checks.
147
219
  # This method only runs if the user has not passed the '--run-name' flag to ci_runner.
148
220
  # Will automatically select a check in the case where there is only one failing check.
149
221
  #
150
- # @param ci_checks [Hash] (See #fetch_ci_checks)
222
+ # @param ci_checks [Array<Check::Base>] (See #fetch_ci_checks)
151
223
  #
152
- # @return [Hash] A single Check Run, the one that the user selected.
224
+ # @return [Check::Base] A single Check, the one that the user selected.
153
225
  #
154
226
  # @raise [CIRunner::Error] In case all the CI checks on this commit were successfull. In such case
155
227
  # there is no need to proceed as there should be no failing tests to rerun.
156
228
  def ask_for_name(ci_checks)
157
- check_runs = ci_checks["check_runs"]
158
- failed_runs = check_runs.reject { |check_run| check_run["conclusion"] == "success" }
229
+ failed_runs = ci_checks.select(&:failed?)
159
230
 
160
231
  if failed_runs.count == 0
161
232
  raise(Error, "No CI checks failed on this commit.")
162
233
  elsif failed_runs.count == 1
163
- check_run = failed_runs.first["name"]
234
+ check_run = failed_runs.first.name
164
235
 
165
236
  ::CLI::UI.puts(<<~EOM)
166
237
  {{warning:Automatically selected the CI check #{check_run} because it's the only one failing.}}
@@ -170,9 +241,30 @@ module CIRunner
170
241
  else
171
242
  ::CLI::UI.ask(
172
243
  "Multiple CI checks failed for this commit. Please choose the one you wish to re-run.",
173
- options: failed_runs.map { |check_run| check_run["name"] },
244
+ options: failed_runs.map(&:name),
174
245
  )
175
246
  end
176
247
  end
248
+
249
+ # Raise an error in the case where CI Runner can't detect test failures from the logs.
250
+ # Can happen for a couple of reasons, outlined in the error message below.
251
+ #
252
+ # @param ci_log [Pathname]
253
+ # @param runner [Runners::Minitest, Runners::RSpec]
254
+ #
255
+ # @raise [Error]
256
+ def no_failure_error(ci_log, runner)
257
+ raise(Error, <<~EOM)
258
+ Couldn't detect any {{warning:#{runner.name}}} test failures from the log output. This can be either because:
259
+
260
+ {{warning:- The selected CI is not running #{runner.name} tests.}}
261
+ {{warning:- CIRunner default set of regexes failed to match the failures.}}
262
+
263
+ If your application is using custom reporters, you'll need to configure CI Runner.
264
+ {{info:Have a look at the wiki to know how: https://github.com/Edouard-chin/ci_runner/wiki}}
265
+
266
+ The CI log output has been downloaded to {{underline:#{ci_log}}} if you need to inspect it.
267
+ EOM
268
+ end
177
269
  end
178
270
  end