ci_runner 0.1.0 → 0.2.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 +61 -0
- data/lib/ci_runner/check/circle_ci.rb +165 -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 +67 -24
- data/lib/ci_runner/client/base.rb +71 -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 +77 -0
- data/lib/ci_runner/configuration/user.rb +18 -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_run_finder.rb +79 -18
- data/lib/ci_runner/version.rb +1 -1
- data/lib/ci_runner.rb +12 -1
- metadata +12 -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: fb83fde059747332cee33b4a0d313d2eaefb735ec3fe0e291141e8a18a535fa3
|
4
|
+
data.tar.gz: 6dd4960bce217bbebba6a22b6e1863d0baaf7190c5835c1fc63de9d0cc86e94e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f1e747da3d2101445f77645cc9f4d37f1f36b53702a85b735aecac605576b0d060037b56a54fcd3269c65a4313ebc3a3b00cab05d4acfbfd0785f3138911d51e
|
7
|
+
data.tar.gz: 65a90464a386601f2bd6caaaa61ec6ea6188bd65688ddf9e32db0ee7006aff914bc3d3104271d5a4607f0252a1936514ee301b16bb3a708e1d32b7a12bc75c9d
|
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,61 @@
|
|
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
|
+
def failed?
|
57
|
+
@status == "failure"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,165 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base"
|
4
|
+
require "uri"
|
5
|
+
require "open-uri"
|
6
|
+
require "json"
|
7
|
+
require "tempfile"
|
8
|
+
|
9
|
+
module CIRunner
|
10
|
+
module Check
|
11
|
+
# A Step object represents a CircleCI step.
|
12
|
+
# This Struct has +eql?+ and +hash+ implemented in order to check if two steps are the same and remove
|
13
|
+
# the duplicates.
|
14
|
+
#
|
15
|
+
# Two steps are considered the same if their names are equal and both are successful.
|
16
|
+
# The reason this is implemented like this is to avoid downloading too many of the same logfiles.
|
17
|
+
#
|
18
|
+
# Project on CircleCI can be configured to run in parallel, the number of steps and therefore log output
|
19
|
+
# we have to download increases exponentially.
|
20
|
+
#
|
21
|
+
# As an example, imagine this CircleCI configuration:
|
22
|
+
#
|
23
|
+
# 'Minitest':
|
24
|
+
# executor: ruby/default
|
25
|
+
# parallelism: 16
|
26
|
+
# steps:
|
27
|
+
# - setup-ruby
|
28
|
+
# - bundle install
|
29
|
+
# - bin/rails test
|
30
|
+
#
|
31
|
+
# CircleCI will create 48 steps (and 48 log download link). Downloading those 48 log, don't make sense
|
32
|
+
# since they will be all similar. Unless they failed, in which case we download the log for that step.
|
33
|
+
#
|
34
|
+
# @see https://circleci.com/docs/configuration-reference#steps
|
35
|
+
Step = Struct.new(:name, :output_url, :failed) do
|
36
|
+
# Used in conjuction with +hash+ for unique comparison.
|
37
|
+
#
|
38
|
+
# @param other [Object]
|
39
|
+
#
|
40
|
+
# @return [Boolean]
|
41
|
+
def eql?(other)
|
42
|
+
return false if failed || other.failed
|
43
|
+
|
44
|
+
name == other.name
|
45
|
+
end
|
46
|
+
|
47
|
+
# Used for unique comparison.
|
48
|
+
#
|
49
|
+
# @return [String]
|
50
|
+
def hash
|
51
|
+
[self.class, name, failed].hash
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Check class used when a project is configured to run its CI using CircleCI.
|
56
|
+
class CircleCI < Base
|
57
|
+
attr_reader :url # :private:
|
58
|
+
|
59
|
+
# @param args (See Base#initialize)
|
60
|
+
# @param url [String] The html URL pointing to the CircleCI build.
|
61
|
+
def initialize(*args, url)
|
62
|
+
super(*args)
|
63
|
+
|
64
|
+
@url = url
|
65
|
+
@queue = Queue.new
|
66
|
+
@tempfile = Tempfile.new
|
67
|
+
end
|
68
|
+
|
69
|
+
# Used to tell the user which CI provider we are downloading the log output from.
|
70
|
+
#
|
71
|
+
# @return [String]
|
72
|
+
def provider
|
73
|
+
"CircleCI"
|
74
|
+
end
|
75
|
+
|
76
|
+
# Download the CI logs for this CI build.
|
77
|
+
#
|
78
|
+
# CircleCI doesn't have an API to download a single log file for the whole build. Instead, we have
|
79
|
+
# to download a log output for each steps. Depending on the number of steps configured on a project, and
|
80
|
+
# whether it uses parallelism, the number of log files to download might be quite important.
|
81
|
+
#
|
82
|
+
# The log for each steps are small in size, so downloading them in parallel to make things much faster.
|
83
|
+
#
|
84
|
+
# @return [Tempfile]
|
85
|
+
def download_log
|
86
|
+
client = Client::CircleCI.new(Configuration::User.instance.circle_ci_token)
|
87
|
+
job = client.job(repository, build_number)
|
88
|
+
steps = []
|
89
|
+
|
90
|
+
job["steps"].each do |step|
|
91
|
+
step["actions"].each do |parallel|
|
92
|
+
next unless parallel["has_output"]
|
93
|
+
|
94
|
+
steps << Step.new(*parallel.values_at("name", "output_url", "failed"))
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
steps.uniq!
|
99
|
+
|
100
|
+
steps.each do |step|
|
101
|
+
@queue << step
|
102
|
+
end
|
103
|
+
|
104
|
+
process_queue
|
105
|
+
|
106
|
+
@tempfile.tap(&:flush)
|
107
|
+
end
|
108
|
+
|
109
|
+
# @return [Boolean]
|
110
|
+
#
|
111
|
+
# @see https://docs.github.com/en/rest/commits/statuses#get-the-combined-status-for-a-specific-reference
|
112
|
+
def failed?
|
113
|
+
["error", "failure"].include?(status)
|
114
|
+
end
|
115
|
+
|
116
|
+
private
|
117
|
+
|
118
|
+
# Implement a queuing system in order to download log files in parallel.
|
119
|
+
#
|
120
|
+
# @return [void]
|
121
|
+
def process_queue
|
122
|
+
max_threads = 6
|
123
|
+
threads = []
|
124
|
+
|
125
|
+
max_threads.times do
|
126
|
+
threads << Thread.new do
|
127
|
+
while (element = dequeue)
|
128
|
+
process(element)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
threads.each(&:join)
|
134
|
+
end
|
135
|
+
|
136
|
+
# @param step [Step]
|
137
|
+
#
|
138
|
+
# @return [void]
|
139
|
+
def process(step)
|
140
|
+
response = URI.open(step.output_url)
|
141
|
+
parsed_response = JSON.parse(response.read)
|
142
|
+
log_output = parsed_response.map! { |res| res["message"] }.join
|
143
|
+
|
144
|
+
@tempfile.write(log_output)
|
145
|
+
end
|
146
|
+
|
147
|
+
# Dequeue a CircleCI Step from the queue.
|
148
|
+
#
|
149
|
+
# @return [Step, nil]
|
150
|
+
def dequeue
|
151
|
+
@queue.pop(true)
|
152
|
+
rescue ThreadError
|
153
|
+
nil
|
154
|
+
end
|
155
|
+
|
156
|
+
# The URL on the commit status will look something like: https://circleci.com/gh/owner/repo/1234?query_string.
|
157
|
+
# We want the `1234` which is the builder number.
|
158
|
+
#
|
159
|
+
# @return [Integer]
|
160
|
+
def build_number
|
161
|
+
URI(@url.to_s).path.split("/").last
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
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
@@ -47,14 +47,12 @@ module CIRunner
|
|
47
47
|
run_name = options[:run_name] || ask_for_name(ci_checks)
|
48
48
|
check_run = TestRunFinder.find(ci_checks, run_name)
|
49
49
|
|
50
|
-
ci_log = fetch_ci_log(
|
51
|
-
runner = TestRunFinder.detect_runner(ci_log)
|
50
|
+
ci_log = fetch_ci_log(check_run)
|
51
|
+
runner = TestRunFinder.detect_runner(ci_log.read)
|
52
52
|
runner.parse!
|
53
53
|
|
54
|
-
if runner.failures.count
|
55
|
-
|
56
|
-
end
|
57
|
-
rescue GithubClient::Error, Error => e
|
54
|
+
no_failure_error(ci_log, runner) if runner.failures.count.zero?
|
55
|
+
rescue Client::Error, Error => e
|
58
56
|
::CLI::UI.puts("\n{{red:#{e.message}}}", frame_color: :red)
|
59
57
|
|
60
58
|
exit(false)
|
@@ -71,13 +69,13 @@ module CIRunner
|
|
71
69
|
Save a personal access GitHub token in the ~/.ci_runner/config.yml file.
|
72
70
|
The GitHub token is required to fetch CI checks and download logs from repositories.
|
73
71
|
|
74
|
-
You can get a token from GitHub by following this link: https://github.com/settings/tokens/new?description=CI+Runner&scopes=repo
|
72
|
+
You can get a token from GitHub by following this link: https://github.com/settings/tokens/new?description=CI+Runner&scopes=repo
|
75
73
|
EOM
|
76
74
|
def github_token(token)
|
77
75
|
::CLI::UI::StdoutRouter.enable
|
78
76
|
|
79
77
|
::CLI::UI.frame("Saving GitHub Token") do
|
80
|
-
user =
|
78
|
+
user = Client::Github.new(token).me
|
81
79
|
Configuration::User.instance.save_github_token(token)
|
82
80
|
|
83
81
|
::CLI::UI.puts(<<~EOM)
|
@@ -85,13 +83,40 @@ module CIRunner
|
|
85
83
|
|
86
84
|
{{info:The token has been saved in this file: #{Configuration::User.instance.config_file}}}
|
87
85
|
EOM
|
88
|
-
rescue
|
86
|
+
rescue Client::Error => e
|
89
87
|
::CLI::UI.puts("{{red:\nYour token doesn't seem to be valid. The response from GitHub was: #{e.message}}}")
|
90
88
|
|
91
89
|
exit(false)
|
92
90
|
end
|
93
91
|
end
|
94
92
|
|
93
|
+
desc "circle_ci_token TOKEN", "Save a Circle CI token in your config."
|
94
|
+
long_desc <<~EOM
|
95
|
+
Save a personal access Circle CI token in the ~/.ci_runner/config.yml file.
|
96
|
+
If one of your project uses Circle CI as its CI provider and the project is set to private,
|
97
|
+
CI Runner won't be able to fetch the logs unless you provide a token.
|
98
|
+
|
99
|
+
You can get a token from Circle CI by following this link: https://app.circleci.com/settings/user/tokens
|
100
|
+
EOM
|
101
|
+
def circle_ci_token(token)
|
102
|
+
::CLI::UI::StdoutRouter.enable
|
103
|
+
|
104
|
+
::CLI::UI.frame("Saving CircleCI Token") do
|
105
|
+
user = Client::CircleCI.new(token).me
|
106
|
+
Configuration::User.instance.save_circle_ci_token(token)
|
107
|
+
|
108
|
+
::CLI::UI.puts(<<~EOM)
|
109
|
+
Hello {{warning:#{user["login"]}}}! {{success:Your token is valid!}}
|
110
|
+
|
111
|
+
{{info:The token has been saved in this file: #{Configuration::User.instance.config_file}}}
|
112
|
+
EOM
|
113
|
+
rescue Client::Error => e
|
114
|
+
::CLI::UI.puts("{{red:\nYour token doesn't seem to be valid. The response from Circle CI was: #{e.message}}}")
|
115
|
+
|
116
|
+
exit(false)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
95
120
|
private
|
96
121
|
|
97
122
|
# Retrieve all the GitHub CI checks for a given commit. Will be used to interactively prompt
|
@@ -100,7 +125,7 @@ module CIRunner
|
|
100
125
|
# @param repository [String] The full repository name including the owner (rails/rails).
|
101
126
|
# @param commit [String] A Git commit that has been pushed to GitHub and for which CI failed.
|
102
127
|
#
|
103
|
-
# @return [
|
128
|
+
# @return [Array<Check::Base>] Array filled with Check::Base subclasses.
|
104
129
|
#
|
105
130
|
# @raise [SystemExit] Early exit the process if the CI checks can't be retrieved.
|
106
131
|
#
|
@@ -120,19 +145,17 @@ module CIRunner
|
|
120
145
|
# Download and cache the log for the GitHub check. Downloading the log allows CI Runner to parse it and detect
|
121
146
|
# which test failed in order to run uniquely those on the user machine.
|
122
147
|
#
|
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.
|
148
|
+
# @param check_run [Check::Base] The GitHub Check that failed. See #fetch_ci_checks.
|
126
149
|
#
|
127
150
|
# @return [String] The content of the CI log.
|
128
151
|
#
|
129
152
|
# @raise [SystemExit] Early exit the process if the CI checks can't be retrieved.
|
130
153
|
#
|
131
154
|
# @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
|
155
|
+
def fetch_ci_log(check_run)
|
156
|
+
log = LogDownloader.new(check_run).fetch do |error|
|
157
|
+
::CLI::UI.puts(<<~EOM)
|
158
|
+
Couldn't fetch the CI log. The error was:
|
136
159
|
|
137
160
|
#{error.message}
|
138
161
|
EOM
|
@@ -140,27 +163,26 @@ module CIRunner
|
|
140
163
|
exit(false)
|
141
164
|
end
|
142
165
|
|
143
|
-
log
|
166
|
+
log
|
144
167
|
end
|
145
168
|
|
146
169
|
# Interatively ask the user which CI check to rerun in the case a commit has multiple failing checks.
|
147
170
|
# This method only runs if the user has not passed the '--run-name' flag to ci_runner.
|
148
171
|
# Will automatically select a check in the case where there is only one failing check.
|
149
172
|
#
|
150
|
-
# @param ci_checks [
|
173
|
+
# @param ci_checks [Array<Check::Base>] (See #fetch_ci_checks)
|
151
174
|
#
|
152
|
-
# @return [
|
175
|
+
# @return [Check::Base] A single Check, the one that the user selected.
|
153
176
|
#
|
154
177
|
# @raise [CIRunner::Error] In case all the CI checks on this commit were successfull. In such case
|
155
178
|
# there is no need to proceed as there should be no failing tests to rerun.
|
156
179
|
def ask_for_name(ci_checks)
|
157
|
-
|
158
|
-
failed_runs = check_runs.reject { |check_run| check_run["conclusion"] == "success" }
|
180
|
+
failed_runs = ci_checks.select(&:failed?)
|
159
181
|
|
160
182
|
if failed_runs.count == 0
|
161
183
|
raise(Error, "No CI checks failed on this commit.")
|
162
184
|
elsif failed_runs.count == 1
|
163
|
-
check_run = failed_runs.first
|
185
|
+
check_run = failed_runs.first.name
|
164
186
|
|
165
187
|
::CLI::UI.puts(<<~EOM)
|
166
188
|
{{warning:Automatically selected the CI check #{check_run} because it's the only one failing.}}
|
@@ -170,9 +192,30 @@ module CIRunner
|
|
170
192
|
else
|
171
193
|
::CLI::UI.ask(
|
172
194
|
"Multiple CI checks failed for this commit. Please choose the one you wish to re-run.",
|
173
|
-
options: failed_runs.map
|
195
|
+
options: failed_runs.map(&:name),
|
174
196
|
)
|
175
197
|
end
|
176
198
|
end
|
199
|
+
|
200
|
+
# Raise an error in the case where CI Runner can't detect test failures from the logs.
|
201
|
+
# Can happen for a couple of reasons, outlined in the error message below.
|
202
|
+
#
|
203
|
+
# @param ci_log [Pathname]
|
204
|
+
# @param runner [Runners::Minitest, Runners::RSpec]
|
205
|
+
#
|
206
|
+
# @raise [Error]
|
207
|
+
def no_failure_error(ci_log, runner)
|
208
|
+
raise(Error, <<~EOM)
|
209
|
+
Couldn't detect any {{warning:#{runner.name}}} test failures from the log output. This can be either because:
|
210
|
+
|
211
|
+
{{warning:- The selected CI is not running #{runner.name} tests.}}
|
212
|
+
{{warning:- CIRunner default set of regexes failed to match the failures.}}
|
213
|
+
|
214
|
+
If your application is using custom reporters, you'll need to configure CI Runner.
|
215
|
+
{{info:Have a look at the wiki to know how: https://github.com/Edouard-chin/ci_runner/wiki}}
|
216
|
+
|
217
|
+
The CI log output has been downloaded to {{underline:#{ci_log}}} if you need to inspect it.
|
218
|
+
EOM
|
219
|
+
end
|
177
220
|
end
|
178
221
|
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "net/http"
|
5
|
+
require "openssl"
|
6
|
+
|
7
|
+
module CIRunner
|
8
|
+
module Client
|
9
|
+
class Base
|
10
|
+
# @return [Net::HTTP] An instance of Net:HTTP configured to make requests to the GitHub API endpoint.
|
11
|
+
def self.default_client
|
12
|
+
Net::HTTP.new(self::API_ENDPOINT, 443).tap do |http|
|
13
|
+
http.use_ssl = true
|
14
|
+
http.read_timeout = 3
|
15
|
+
http.write_timeout = 3
|
16
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# @param access_token [String] The access token with "repo" scope.
|
21
|
+
# @param client [Net::HTTP]
|
22
|
+
def initialize(access_token = nil, client = self.class.default_client)
|
23
|
+
@access_token = access_token
|
24
|
+
@client = client
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
# Add authentication before making the request.
|
30
|
+
#
|
31
|
+
# @param request [Net::HTTPRequest] A subclass of Net::HTTPRequest.
|
32
|
+
#
|
33
|
+
# @return [void]
|
34
|
+
def authentication(request)
|
35
|
+
raise(NotImplementedError, "Subclass responsability")
|
36
|
+
end
|
37
|
+
|
38
|
+
# Perform an authenticated GET request.
|
39
|
+
#
|
40
|
+
# @param path [String] The resource to access.
|
41
|
+
#
|
42
|
+
# @return (See #request)
|
43
|
+
def get(path)
|
44
|
+
request(Net::HTTP::Get, path)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Perform an authenticated request.
|
48
|
+
#
|
49
|
+
# @param verb_class [Net::HTTPRequest] A subclass of Net::HTTPRequest.
|
50
|
+
# @param path [String] The resource to access.
|
51
|
+
#
|
52
|
+
# @return [Hash, String] A decoded JSON response or a String pointing to the Location redirection.
|
53
|
+
def request(verb_class, path)
|
54
|
+
req = verb_class.new(path)
|
55
|
+
req["Accept"] = "application/json"
|
56
|
+
authentication(req)
|
57
|
+
|
58
|
+
response = @client.request(req)
|
59
|
+
|
60
|
+
case response.code.to_i
|
61
|
+
when 200..204
|
62
|
+
response.content_type == "application/json" ? JSON.parse(response.body) : response.body
|
63
|
+
when 302
|
64
|
+
response["Location"]
|
65
|
+
else
|
66
|
+
raise(Error.new(response.code, response.body, self.class.name.split("::").last))
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base"
|
4
|
+
|
5
|
+
module CIRunner
|
6
|
+
module Client
|
7
|
+
class CircleCI < Base
|
8
|
+
API_ENDPOINT = "circleci.com"
|
9
|
+
|
10
|
+
# Make an API request to get the authenticated user. Used to verify if the access token
|
11
|
+
# the user has stored in its config is valid.
|
12
|
+
#
|
13
|
+
# @return [Hash] See Circle CI documentation.
|
14
|
+
#
|
15
|
+
# @see https://circleci.com/docs/api/v1/index.html#user
|
16
|
+
def me
|
17
|
+
get("/api/v1.1/me")
|
18
|
+
end
|
19
|
+
|
20
|
+
# @param repository [String] The full repository name including the owner (rails/rails).
|
21
|
+
# @param build_number [Integer] The CircleCI build number.
|
22
|
+
#
|
23
|
+
# @see https://circleci.com/docs/api/v1/index.html#single-job
|
24
|
+
def job(repository, build_number)
|
25
|
+
get("/api/v1.1/project/github/#{repository}/#{build_number}")
|
26
|
+
rescue Error => e
|
27
|
+
reraise_with_reason(e)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
# Add authentication before making the request.
|
33
|
+
#
|
34
|
+
# @param request [Net::HTTPRequest] A subclass of Net::HTTPRequest.
|
35
|
+
#
|
36
|
+
# @return [void]
|
37
|
+
def authentication(request)
|
38
|
+
request.basic_auth(@access_token, "") if @access_token
|
39
|
+
end
|
40
|
+
|
41
|
+
# @param error [Client::Error]
|
42
|
+
#
|
43
|
+
# @raise [Client::Error] A better error message in case of a 404.
|
44
|
+
def reraise_with_reason(error)
|
45
|
+
if @access_token.nil? && error.error_code == 404
|
46
|
+
raise(error, <<~EOM.rstrip)
|
47
|
+
404 while trying to fetch the CircleCI build.
|
48
|
+
|
49
|
+
{{warning:Please save a CircleCI token in your configuration.}}
|
50
|
+
{{command:ci_runner help circle_ci_token}}
|
51
|
+
EOM
|
52
|
+
else
|
53
|
+
raise(error)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CIRunner
|
4
|
+
module Client
|
5
|
+
class Error < StandardError
|
6
|
+
attr_reader :error_code
|
7
|
+
|
8
|
+
# @param error_code [String] The HTTP status code.
|
9
|
+
# @param error_body [String] The response from the provider.
|
10
|
+
# @param provider [String] The name of the CI provider.
|
11
|
+
# @param message [String, nil]
|
12
|
+
def initialize(error_code, error_body, provider, message = nil)
|
13
|
+
@error_code = error_code.to_i
|
14
|
+
|
15
|
+
if message
|
16
|
+
super(message)
|
17
|
+
else
|
18
|
+
super(<<~EOM.rstrip)
|
19
|
+
Error while making a request to #{provider}. Code: #{error_code}
|
20
|
+
|
21
|
+
The response was: #{error_body}
|
22
|
+
EOM
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base"
|
4
|
+
require "open-uri"
|
5
|
+
|
6
|
+
module CIRunner
|
7
|
+
module Client
|
8
|
+
# A simple client to interact the GitHub API.
|
9
|
+
#
|
10
|
+
# @example Using the client
|
11
|
+
# Github.new("access_token").me
|
12
|
+
class Github < Base
|
13
|
+
API_ENDPOINT = "api.github.com"
|
14
|
+
|
15
|
+
# Make an API request to get the authenticated user. Used to verify if the access token
|
16
|
+
# the user has stored in its config is valid.
|
17
|
+
#
|
18
|
+
# @return [Hash] See GitHub documentation.
|
19
|
+
#
|
20
|
+
# @see https://docs.github.com/en/rest/users/users#get-the-authenticated-user
|
21
|
+
def me
|
22
|
+
get("/user")
|
23
|
+
end
|
24
|
+
|
25
|
+
# Makes an API request to get the CI checks for the +commit+.
|
26
|
+
#
|
27
|
+
# @param repository [String] The full repository name, including the owner (rails/rails)
|
28
|
+
# @param commit [String] The Git commit that has been pushed to GitHub.
|
29
|
+
#
|
30
|
+
# @return [Hash] See GitHub documentation.
|
31
|
+
#
|
32
|
+
# @see https://docs.github.com/en/rest/checks/runs#list-check-runs-for-a-git-reference
|
33
|
+
def check_runs(repository, commit)
|
34
|
+
get("/repos/#{repository}/commits/#{commit}/check-runs")
|
35
|
+
end
|
36
|
+
|
37
|
+
# Makes an API request to get the Commit statuses for the +commit+.
|
38
|
+
#
|
39
|
+
# @param repository [String] The full repository name, including the owner (rails/rails)
|
40
|
+
# @param commit [String] The Git commit that has been pushed to GitHub.
|
41
|
+
#
|
42
|
+
# @return [Hash] See GitHub documentation.
|
43
|
+
#
|
44
|
+
# @see https://docs.github.com/en/rest/commits/statuses#list-commit-statuses-for-a-reference
|
45
|
+
def commit_statuses(repository, commit)
|
46
|
+
get("/repos/#{repository}/commits/#{commit}/statuses")
|
47
|
+
end
|
48
|
+
|
49
|
+
# Makes two requests to get the CI log for a check run.
|
50
|
+
# The first request returns a 302 containing a Location header poiting to a short lived url to download the log.
|
51
|
+
# The second request is to actually download the log.
|
52
|
+
#
|
53
|
+
# @param repository [String] The full repository name, including the owner (rails/rails)
|
54
|
+
# @param check_run_id [Integer] The GitHub ID of the check run.
|
55
|
+
#
|
56
|
+
# @return [Tempfile, IO] Depending on the size of the response. Quirk of URI.open.
|
57
|
+
#
|
58
|
+
# @see https://docs.github.com/en/rest/actions/workflow-jobs#download-job-logs-for-a-workflow-run
|
59
|
+
def download_log(repository, check_run_id)
|
60
|
+
download_url = get("/repos/#{repository}/actions/jobs/#{check_run_id}/logs")
|
61
|
+
|
62
|
+
URI.open(download_url)
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
# Add authentication before making the request.
|
68
|
+
#
|
69
|
+
# @param request [Net::HTTPRequest] A subclass of Net::HTTPRequest.
|
70
|
+
#
|
71
|
+
# @return [void]
|
72
|
+
def authentication(request)
|
73
|
+
request.basic_auth("user", @access_token)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -39,6 +39,13 @@ module CIRunner
|
|
39
39
|
@yaml_config.dig("github", "token")
|
40
40
|
end
|
41
41
|
|
42
|
+
# Retrieve the stored CircleCI access token of the user.
|
43
|
+
#
|
44
|
+
# @return [String, nil] Depending if the user ran the `ci_runner circle_ci_token TOKEN` command.
|
45
|
+
def circle_ci_token
|
46
|
+
@yaml_config.dig("circle_ci", "token")
|
47
|
+
end
|
48
|
+
|
42
49
|
# Write the GitHub token to the user configuration file
|
43
50
|
#
|
44
51
|
# @param token [String] A valid GitHub access token.
|
@@ -50,6 +57,17 @@ module CIRunner
|
|
50
57
|
save!(@yaml_config)
|
51
58
|
end
|
52
59
|
|
60
|
+
# Write the Circle CI token to the user configuration file
|
61
|
+
#
|
62
|
+
# @param token [String] A valid Circle CI access token.
|
63
|
+
#
|
64
|
+
# @return [void]
|
65
|
+
def save_circle_ci_token(token)
|
66
|
+
@yaml_config["circle_ci"] = { "token" => token }
|
67
|
+
|
68
|
+
save!(@yaml_config)
|
69
|
+
end
|
70
|
+
|
53
71
|
# @return [Pathname] The path of the configuration file.
|
54
72
|
#
|
55
73
|
# @example
|
@@ -8,44 +8,44 @@ module CIRunner
|
|
8
8
|
# A PORO to help download and cache a GitHub CI log.
|
9
9
|
#
|
10
10
|
# @example Using the service
|
11
|
-
# log_dl = LogDownloader.new(
|
11
|
+
# log_dl = LogDownloader.new(
|
12
|
+
# CICheck::GitHub.new(
|
13
|
+
# "catanacorp/catana",
|
14
|
+
# "commit_sha",
|
15
|
+
# "Tests Ruby 2.7",
|
16
|
+
# "failed",
|
17
|
+
# 12345,
|
18
|
+
# )
|
19
|
+
# )
|
12
20
|
# log_file = log_dl.fetch
|
13
21
|
# puts log_file # => File
|
14
|
-
#
|
15
|
-
# @see https://docs.github.com/en/rest/actions/workflow-jobs#download-job-logs-for-a-workflow-run
|
16
22
|
class LogDownloader
|
17
|
-
# @param
|
18
|
-
|
19
|
-
# @param check_run [Hash] A GitHub CI check for which we want to download the log.
|
20
|
-
def initialize(commit, repository, check_run)
|
21
|
-
@commit = commit
|
22
|
-
@repository = repository
|
23
|
+
# @param check_run [Check::Base] A Base::Check subclass for which we want to download the log.
|
24
|
+
def initialize(check_run)
|
23
25
|
@check_run = check_run
|
24
26
|
end
|
25
27
|
|
26
|
-
#
|
28
|
+
# Ask the +@check_run+ to download the log from its CI or retrieve it from disk in case we previously downloaded it.
|
27
29
|
#
|
28
30
|
# @param block [Proc, Lambda] A proc that gets called if fetching the logs from GitHub fails. Allows the CLI to
|
29
31
|
# prematurely exit while cleaning up the CLI::UI frame.
|
30
32
|
#
|
31
|
-
# @return [
|
33
|
+
# @return [Pathname] The path to the log file.
|
32
34
|
def fetch(&block)
|
33
35
|
return cached_log if cached_log
|
34
36
|
|
35
|
-
github_client = GithubClient.new(Configuration::User.instance.github_token)
|
36
37
|
error = nil
|
37
38
|
|
38
|
-
::CLI::UI.spinner("Downloading CI logs from
|
39
|
-
|
40
|
-
|
41
|
-
cache_log(logfile)
|
42
|
-
rescue GithubClient::Error => e
|
39
|
+
::CLI::UI.spinner("Downloading CI logs from #{@check_run.provider}", auto_debrief: false) do
|
40
|
+
cache_log(@check_run.download_log)
|
41
|
+
rescue Client::Error, Error => e
|
43
42
|
error = e
|
44
43
|
|
45
44
|
::CLI::UI::Spinner::TASK_FAILED
|
46
45
|
end
|
47
46
|
|
48
47
|
block.call(error) if error
|
48
|
+
|
49
49
|
cached_log
|
50
50
|
end
|
51
51
|
|
@@ -74,14 +74,14 @@ module CIRunner
|
|
74
74
|
# @example Given a repository "rails/rails". A CI check called "Ruby 3.0". A commit "abcdef".
|
75
75
|
# puts computed_filed_path # ==> /var/tmpdir/T/.../rails/rails/log-abcdef-Ruby 3.0
|
76
76
|
def computed_file_path
|
77
|
-
normalized_run_name = @check_run
|
77
|
+
normalized_run_name = @check_run.name.tr("/", "_")
|
78
78
|
|
79
|
-
log_folder.join("log-#{@commit[0..12]}-#{normalized_run_name}.log")
|
79
|
+
log_folder.join("log-#{@check_run.commit[0..12]}-#{normalized_run_name}.log")
|
80
80
|
end
|
81
81
|
|
82
82
|
# @return [Pathname]
|
83
83
|
def log_folder
|
84
|
-
Pathname(Dir.tmpdir).join(@repository)
|
84
|
+
Pathname(Dir.tmpdir).join(@check_run.repository)
|
85
85
|
end
|
86
86
|
|
87
87
|
# @return [Pathname, false] Depending if the log has been downloaded before.
|
@@ -43,13 +43,15 @@ module CIRunner
|
|
43
43
|
# @return [void]
|
44
44
|
def parse!
|
45
45
|
@ci_log.each_line do |line|
|
46
|
-
|
46
|
+
line_no_ansi_color = line.gsub(/\e\[\d+m/, "")
|
47
|
+
|
48
|
+
case line_no_ansi_color
|
47
49
|
when seed_regex
|
48
50
|
@seed = first_matching_group(Regexp.last_match)
|
49
51
|
when ruby_detection_regex
|
50
52
|
@ruby_version = first_matching_group(Regexp.last_match)
|
51
53
|
|
52
|
-
@buffer <<
|
54
|
+
@buffer << line_no_ansi_color if buffering?
|
53
55
|
when gemfile_detection_regex
|
54
56
|
@gemfile = first_matching_group(Regexp.last_match)
|
55
57
|
when buffer_detection_regex
|
@@ -58,9 +60,9 @@ module CIRunner
|
|
58
60
|
@buffer.clear
|
59
61
|
end
|
60
62
|
|
61
|
-
@buffer <<
|
63
|
+
@buffer << line_no_ansi_color
|
62
64
|
else
|
63
|
-
@buffer <<
|
65
|
+
@buffer << line_no_ansi_color if buffering?
|
64
66
|
end
|
65
67
|
end
|
66
68
|
|
@@ -40,7 +40,7 @@ module CIRunner
|
|
40
40
|
def self.match?(ci_log)
|
41
41
|
default_reporter = %r{(Finished in) \d+\.\d{6}s, \d+\.\d{4} runs/s, \d+\.\d{4} assertions/s\.}
|
42
42
|
|
43
|
-
Regexp.union(default_reporter, SEED_REGEX
|
43
|
+
Regexp.union(default_reporter, SEED_REGEX).match?(ci_log)
|
44
44
|
end
|
45
45
|
|
46
46
|
# @return [String] See Runners::Base#report
|
@@ -2,6 +2,8 @@
|
|
2
2
|
|
3
3
|
module CIRunner
|
4
4
|
module TestRunFinder
|
5
|
+
GITHUB_ACTION = "github-actions"
|
6
|
+
|
5
7
|
extend self
|
6
8
|
|
7
9
|
# Makes a request to GitHub to retrieve the checks for a commit. Display a nice UI with
|
@@ -12,18 +14,16 @@ module CIRunner
|
|
12
14
|
# @param block [Proc, Lambda] A proc that will be called in case we can't retrieve the CI Checks.
|
13
15
|
# This allows the CLI to prematurely exit and let the CLI::UI closes its frame.
|
14
16
|
#
|
15
|
-
# @return [
|
16
|
-
#
|
17
|
-
# @see https://docs.github.com/en/rest/checks/runs#list-check-runs-for-a-git-reference
|
17
|
+
# @return [Array<Check::Base>] Array filled with Check::Base subclasses.
|
18
18
|
def fetch_ci_checks(repository, commit, &block)
|
19
|
-
github_client = GithubClient.new(Configuration::User.instance.github_token)
|
20
|
-
ci_checks = {}
|
21
19
|
error = nil
|
22
|
-
|
20
|
+
ci_checks = []
|
23
21
|
title = "Fetching failed CI checks from GitHub for commit {{info:#{commit[..12]}}}"
|
22
|
+
|
24
23
|
::CLI::UI.spinner(title, auto_debrief: false) do
|
25
|
-
ci_checks =
|
26
|
-
|
24
|
+
ci_checks = github_ci(repository, commit)
|
25
|
+
ci_checks += other_ci(repository, commit)
|
26
|
+
rescue Client::Error, StandardError => e
|
27
27
|
error = e
|
28
28
|
|
29
29
|
::CLI::UI::Spinner::TASK_FAILED
|
@@ -34,21 +34,58 @@ module CIRunner
|
|
34
34
|
ci_checks
|
35
35
|
end
|
36
36
|
|
37
|
+
# Download the GitHub checks. This is used in case a project uses GitHub itself as its CI provider.
|
38
|
+
#
|
39
|
+
# @param repository [String] The full repository name, including the owner (i.e. rails/rails)
|
40
|
+
# @param commit [String] The Git commit that has been pushed to GitHub and for which we'll retrieve the CI checks.
|
41
|
+
#
|
42
|
+
# @return [Array<Check::Github>]
|
43
|
+
#
|
44
|
+
# @see https://docs.github.com/en/rest/checks/runs#list-check-runs-for-a-git-reference
|
45
|
+
def github_ci(repository, commit)
|
46
|
+
github_client = Client::Github.new(Configuration::User.instance.github_token)
|
47
|
+
ci_checks = github_client.check_runs(repository, commit)["check_runs"]
|
48
|
+
|
49
|
+
ci_checks.filter_map do |check_run|
|
50
|
+
next unless check_run.dig("app", "slug") == GITHUB_ACTION
|
51
|
+
|
52
|
+
Check::Github.new(repository, commit, *check_run.values_at("name", "conclusion", "id"))
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Download the Commit Statuses for this commit. Some CI provider (like GitHub or Buildkite), doesn't use
|
57
|
+
# the GitHub Check API, but instead this API.
|
58
|
+
#
|
59
|
+
# @param repository [String] The full repository name, including the owner (i.e. rails/rails)
|
60
|
+
# @param commit [String] The Git commit that has been pushed to GitHub and for which we'll retrieve the CI checks.
|
61
|
+
#
|
62
|
+
# @return [Array<Check::CircleCI, Check::Unsupported>]
|
63
|
+
#
|
64
|
+
# @see https://docs.github.com/en/rest/checks/runs#get-a-check-run
|
65
|
+
def other_ci(repository, commit)
|
66
|
+
github_client = Client::Github.new(Configuration::User.instance.github_token)
|
67
|
+
commit_statuses = github_client.commit_statuses(repository, commit)
|
68
|
+
|
69
|
+
commit_statuses.map do |commit_status|
|
70
|
+
check_class_from_url(commit_status, repository, commit)
|
71
|
+
end.compact
|
72
|
+
end
|
73
|
+
|
37
74
|
# Find the CI check the user requested from the list of upstream checks.
|
38
75
|
# This method is useful only when the user passes the `--run-name` flag to `ci-runner`. This makes
|
39
76
|
# sure the CI check actually exists.
|
40
77
|
#
|
41
|
-
# @param ci_checks [
|
78
|
+
# @param ci_checks [Array<Check::Base>] A list of CI checks.
|
42
79
|
# @param run_name [String] The name of the CI run that the user would like to retry on its machine.
|
43
80
|
#
|
44
|
-
# @return [
|
81
|
+
# @return [Check::Base] A single check run from the list of +ci_checks+
|
45
82
|
#
|
46
83
|
# @raise [Error] If no CI checks with the given +run_name+ could be found.
|
47
84
|
# @raise [Error] If the CI check was successfull. No point to continue as there should be no tests to rerun.
|
48
85
|
def find(ci_checks, run_name)
|
49
|
-
check_run = ci_checks
|
86
|
+
check_run = ci_checks.find { |check| check.name == run_name }
|
50
87
|
raise(Error, no_check_message(ci_checks, run_name)) if check_run.nil?
|
51
|
-
raise(Error, check_succeed(run_name))
|
88
|
+
raise(Error, check_succeed(run_name)) unless check_run.failed?
|
52
89
|
|
53
90
|
check_run
|
54
91
|
end
|
@@ -75,6 +112,28 @@ module CIRunner
|
|
75
112
|
|
76
113
|
private
|
77
114
|
|
115
|
+
# Infer the CI Runner Check class based on the URL pointing to the CI provider's page.
|
116
|
+
#
|
117
|
+
# @param commit_status [Hash] A single commit status previously retrieved from the GitHub API.
|
118
|
+
# @param repository [String] The full repository name, including the owner (i.e. rails/rails)
|
119
|
+
# @param commit [String] The Git commit that has been pushed to GitHub and for which we'll retrieve the CI checks.
|
120
|
+
#
|
121
|
+
# @return [Check::CircleCI, Check::Unsupported] Depending if we could recognize the URL on the commit status
|
122
|
+
# pointing to the CI provider.
|
123
|
+
def check_class_from_url(commit_status, repository, commit)
|
124
|
+
target_url = commit_status["target_url"]
|
125
|
+
return unless target_url
|
126
|
+
|
127
|
+
uri = URI(target_url)
|
128
|
+
|
129
|
+
case uri.host
|
130
|
+
when "circleci.com"
|
131
|
+
Check::CircleCI.new(repository, commit, *commit_status.values_at("context", "state", "target_url"))
|
132
|
+
else
|
133
|
+
Check::Unsupported.new(repository, commit, *commit_status.values_at("context", "state"))
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
78
137
|
# @param [String] run_name The name of the CI check input or chosen by the user.
|
79
138
|
#
|
80
139
|
# @return [String] A error message to display.
|
@@ -82,16 +141,18 @@ module CIRunner
|
|
82
141
|
"The CI check '#{run_name}' was successfull. There should be no failing tests to rerun."
|
83
142
|
end
|
84
143
|
|
85
|
-
# @param [
|
86
|
-
# @param [String] run_name The name of the CI check input or chosen by the user.
|
144
|
+
# @param ci_checks [Array<Check::Base>] The list of CI checks previously by the +fetch_ci_checks+ method.
|
145
|
+
# @param run_name [String] run_name The name of the CI check input or chosen by the user.
|
87
146
|
#
|
88
147
|
# @return [String] A error message letting the user know why CI Runner couldn't continue.
|
89
148
|
def no_check_message(ci_checks, run_name)
|
90
|
-
possible_checks = ci_checks
|
91
|
-
if check_run
|
92
|
-
"#{::CLI::UI::Glyph.lookup("v")} #{check_run
|
149
|
+
possible_checks = ci_checks.filter_map do |check_run|
|
150
|
+
if check_run.success?
|
151
|
+
"#{::CLI::UI::Glyph.lookup("v")} #{check_run.name}"
|
152
|
+
elsif check_run.failed?
|
153
|
+
"#{::CLI::UI::Glyph.lookup("x")} #{check_run.name}"
|
93
154
|
else
|
94
|
-
|
155
|
+
next
|
95
156
|
end
|
96
157
|
end
|
97
158
|
|
data/lib/ci_runner/version.rb
CHANGED
data/lib/ci_runner.rb
CHANGED
@@ -7,12 +7,23 @@ module CIRunner
|
|
7
7
|
Error = Class.new(StandardError)
|
8
8
|
|
9
9
|
autoload :CLI, "ci_runner/cli"
|
10
|
-
autoload :GithubClient, "ci_runner/github_client"
|
11
10
|
autoload :GitHelper, "ci_runner/git_helper"
|
12
11
|
autoload :TestRunFinder, "ci_runner/test_run_finder"
|
13
12
|
autoload :LogDownloader, "ci_runner/log_downloader"
|
14
13
|
autoload :TestFailure, "ci_runner/test_failure"
|
15
14
|
|
15
|
+
module Check
|
16
|
+
autoload :Github, "ci_runner/check/github"
|
17
|
+
autoload :CircleCI, "ci_runner/check/circle_ci"
|
18
|
+
autoload :Unsupported, "ci_runner/check/unsupported"
|
19
|
+
end
|
20
|
+
|
21
|
+
module Client
|
22
|
+
autoload :Error, "ci_runner/client/error"
|
23
|
+
autoload :Github, "ci_runner/client/github"
|
24
|
+
autoload :CircleCI, "ci_runner/client/circle_ci"
|
25
|
+
end
|
26
|
+
|
16
27
|
module Configuration
|
17
28
|
autoload :User, "ci_runner/configuration/user"
|
18
29
|
autoload :Project, "ci_runner/configuration/project"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ci_runner
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Edouard Chin
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-08-
|
11
|
+
date: 2022-08-22 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: cli-ui
|
@@ -112,11 +112,18 @@ files:
|
|
112
112
|
- ci_runner.gemspec
|
113
113
|
- exe/ci_runner
|
114
114
|
- lib/ci_runner.rb
|
115
|
+
- lib/ci_runner/check/base.rb
|
116
|
+
- lib/ci_runner/check/circle_ci.rb
|
117
|
+
- lib/ci_runner/check/github.rb
|
118
|
+
- lib/ci_runner/check/unsupported.rb
|
115
119
|
- lib/ci_runner/cli.rb
|
120
|
+
- lib/ci_runner/client/base.rb
|
121
|
+
- lib/ci_runner/client/circle_ci.rb
|
122
|
+
- lib/ci_runner/client/error.rb
|
123
|
+
- lib/ci_runner/client/github.rb
|
116
124
|
- lib/ci_runner/configuration/project.rb
|
117
125
|
- lib/ci_runner/configuration/user.rb
|
118
126
|
- lib/ci_runner/git_helper.rb
|
119
|
-
- lib/ci_runner/github_client.rb
|
120
127
|
- lib/ci_runner/log_downloader.rb
|
121
128
|
- lib/ci_runner/runners/base.rb
|
122
129
|
- lib/ci_runner/runners/minitest_runner.rb
|
@@ -131,6 +138,7 @@ licenses:
|
|
131
138
|
metadata:
|
132
139
|
homepage_uri: https://github.com/Edouard-chin/ci_runner
|
133
140
|
source_code_uri: https://github.com/Edouard-chin/ci_runner
|
141
|
+
changelog_uri: https://github.com/Edouard-chin/ci_runner/blob/main/CHANGELOG.md
|
134
142
|
allowed_push_host: https://rubygems.org
|
135
143
|
rubygems_mfa_required: 'true'
|
136
144
|
post_install_message:
|
@@ -148,7 +156,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
148
156
|
- !ruby/object:Gem::Version
|
149
157
|
version: '0'
|
150
158
|
requirements: []
|
151
|
-
rubygems_version: 3.3.
|
159
|
+
rubygems_version: 3.3.19
|
152
160
|
signing_key:
|
153
161
|
specification_version: 4
|
154
162
|
summary: Re-run failing tests from CI on your local machine without copy/pasting.
|
@@ -1,105 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "net/http"
|
4
|
-
require "openssl"
|
5
|
-
require "json"
|
6
|
-
require "open-uri"
|
7
|
-
|
8
|
-
module CIRunner
|
9
|
-
# A simple client to interact the GitHub API.
|
10
|
-
#
|
11
|
-
# @example Using the client
|
12
|
-
# GithubClient.new("access_token").me
|
13
|
-
class GithubClient
|
14
|
-
Error = Class.new(StandardError)
|
15
|
-
|
16
|
-
# @return [Net::HTTP] An instance of Net:HTTP configured to make requests to the GitHub API endpoint.
|
17
|
-
def self.default_client
|
18
|
-
Net::HTTP.new("api.github.com", 443).tap do |http|
|
19
|
-
http.use_ssl = true
|
20
|
-
http.read_timeout = 3
|
21
|
-
http.write_timeout = 3
|
22
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
23
|
-
end
|
24
|
-
end
|
25
|
-
|
26
|
-
# @param access_token [String] The access token with "repo" scope.
|
27
|
-
# @param client [Net::HTTP]
|
28
|
-
def initialize(access_token, client = self.class.default_client)
|
29
|
-
@access_token = access_token
|
30
|
-
@client = client
|
31
|
-
end
|
32
|
-
|
33
|
-
# Make an API request to get the authenticated user. Used to verify if the access token
|
34
|
-
# the user has stored in its config is valid.
|
35
|
-
#
|
36
|
-
# @return [Hash] See GitHub documentation.
|
37
|
-
#
|
38
|
-
# @see https://docs.github.com/en/rest/users/users#get-the-authenticated-user
|
39
|
-
def me
|
40
|
-
get("/user")
|
41
|
-
end
|
42
|
-
|
43
|
-
# Makes an API request to get the CI checks for the +commit+.
|
44
|
-
#
|
45
|
-
# @param repository [String] The full repository name, including the owner (rails/rails)
|
46
|
-
# @param commit [String] The Git commit that has been pushed to GitHub.
|
47
|
-
#
|
48
|
-
# @return [Hash] See GitHub documentation.
|
49
|
-
#
|
50
|
-
# @see https://docs.github.com/en/rest/checks/runs#list-check-runs-for-a-git-reference
|
51
|
-
def check_runs(repository, commit)
|
52
|
-
get("/repos/#{repository}/commits/#{commit}/check-runs")
|
53
|
-
end
|
54
|
-
|
55
|
-
# Makes two requests to get the CI log for a check run.
|
56
|
-
# The first request returns a 302 containing a Location header poiting to a short lived url to download the log.
|
57
|
-
# The second request is to actually download the log.
|
58
|
-
#
|
59
|
-
# @param repository [String] The full repository name, including the owner (rails/rails)
|
60
|
-
# @param check_run_id [Integer] The GitHub ID of the check run.
|
61
|
-
#
|
62
|
-
# @return [Tempfile, IO] Depending on the size of the response. Quirk of URI.open.
|
63
|
-
#
|
64
|
-
# @see https://docs.github.com/en/rest/actions/workflow-jobs#download-job-logs-for-a-workflow-run
|
65
|
-
def download_log(repository, check_run_id)
|
66
|
-
download_url = get("/repos/#{repository}/actions/jobs/#{check_run_id}/logs")
|
67
|
-
|
68
|
-
URI.open(download_url) # rubocop:disable Security/Open
|
69
|
-
end
|
70
|
-
|
71
|
-
private
|
72
|
-
|
73
|
-
# Perform an authenticated GET request.
|
74
|
-
#
|
75
|
-
# @param path [String] The resource to access.
|
76
|
-
#
|
77
|
-
# @return (See #request)
|
78
|
-
def get(path)
|
79
|
-
request(Net::HTTP::Get, path)
|
80
|
-
end
|
81
|
-
|
82
|
-
# Perform an authenticated request.
|
83
|
-
#
|
84
|
-
# @param verb_class [Net::HTTPRequest] A subclass of Net::HTTPRequest.
|
85
|
-
# @param path [String] The resource to access.
|
86
|
-
#
|
87
|
-
# @return [Hash, String] A decoded JSON response or a String pointing to the Location redirection.
|
88
|
-
def request(verb_class, path)
|
89
|
-
req = verb_class.new(path)
|
90
|
-
req["Accept"] = "application/vnd.github+json"
|
91
|
-
req.basic_auth("user", @access_token)
|
92
|
-
|
93
|
-
response = @client.request(req)
|
94
|
-
|
95
|
-
case response.code.to_i
|
96
|
-
when 200..204
|
97
|
-
response.content_type == "application/json" ? JSON.parse(response.body) : response.body
|
98
|
-
when 302
|
99
|
-
response["Location"]
|
100
|
-
else
|
101
|
-
raise(Error, "GitHub response: Status: #{response.code}. Body:\n\n#{response.body}")
|
102
|
-
end
|
103
|
-
end
|
104
|
-
end
|
105
|
-
end
|