ci_runner 0.1.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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