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 +4 -4
- data/ci_runner.gemspec +1 -0
- data/lib/ci_runner/check/base.rb +63 -0
- data/lib/ci_runner/check/buildkite.rb +88 -0
- data/lib/ci_runner/check/circle_ci.rb +128 -0
- data/lib/ci_runner/check/concurrent_download.rb +57 -0
- data/lib/ci_runner/check/github.rb +40 -0
- data/lib/ci_runner/check/unsupported.rb +33 -0
- data/lib/ci_runner/cli.rb +116 -24
- data/lib/ci_runner/client/authenticated_buildkite.rb +67 -0
- data/lib/ci_runner/client/base.rb +78 -0
- data/lib/ci_runner/client/buildkite.rb +60 -0
- data/lib/ci_runner/client/circle_ci.rb +58 -0
- data/lib/ci_runner/client/error.rb +27 -0
- data/lib/ci_runner/client/github.rb +88 -0
- data/lib/ci_runner/configuration/user.rb +48 -0
- data/lib/ci_runner/log_downloader.rb +20 -20
- data/lib/ci_runner/runners/base.rb +6 -4
- data/lib/ci_runner/runners/minitest_runner.rb +1 -1
- data/lib/ci_runner/runners/rspec.rb +1 -1
- data/lib/ci_runner/test_failure.rb +3 -0
- data/lib/ci_runner/test_run_finder.rb +81 -18
- data/lib/ci_runner/version.rb +1 -1
- data/lib/ci_runner/version_verifier.rb +53 -0
- data/lib/ci_runner.rb +22 -6
- metadata +17 -4
- data/lib/ci_runner/github_client.rb +0 -105
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f7834106a8db1f0770e5184dcf31fbfc892a92890b52f4709ab900ccd560f1b2
|
4
|
+
data.tar.gz: 69c9c0533ff5a52a1f97cb1f4374e012f56004a06a584b2b60ab80e4c9897517
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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(
|
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
|
55
|
-
|
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
|
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 =
|
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
|
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 [
|
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
|
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(
|
133
|
-
log = LogDownloader.new(
|
134
|
-
puts(<<~EOM)
|
135
|
-
Couldn't fetch the CI log. The
|
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
|
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 [
|
222
|
+
# @param ci_checks [Array<Check::Base>] (See #fetch_ci_checks)
|
151
223
|
#
|
152
|
-
# @return [
|
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
|
-
|
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
|
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
|
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
|