ci_runner 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/ci_runner.gemspec +43 -0
- data/exe/ci_runner +7 -0
- data/lib/ci_runner/cli.rb +178 -0
- data/lib/ci_runner/configuration/project.rb +242 -0
- data/lib/ci_runner/configuration/user.rb +97 -0
- data/lib/ci_runner/git_helper.rb +103 -0
- data/lib/ci_runner/github_client.rb +105 -0
- data/lib/ci_runner/log_downloader.rb +94 -0
- data/lib/ci_runner/runners/base.rb +241 -0
- data/lib/ci_runner/runners/minitest_runner.rb +221 -0
- data/lib/ci_runner/runners/rspec.rb +69 -0
- data/lib/ci_runner/test_failure.rb +65 -0
- data/lib/ci_runner/test_run_finder.rb +114 -0
- data/lib/ci_runner/version.rb +5 -0
- data/lib/ci_runner.rb +25 -0
- data/lib/minitest/ci_runner_plugin.rb +32 -0
- metadata +155 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 25a11b4505aff5153e388723711643c4aae5f3a7b64b53b3265e0bd7198ec381
|
4
|
+
data.tar.gz: 858492f60b87472cf41da22c5808a2371659d56712ef77dad98590cb98063ecf
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 51158e9a4b9cd2188f5161320dc140e076009f7c4550206b957ffac03e9b5859533dc99defc644f05aa126713f8d52cf1d2260a4691ba74cba746f779ec3c945
|
7
|
+
data.tar.gz: 5db9352b2360cec4f5f8b23ce4cf378fcef79c3cc1e8b2f1cb4543fb307a2fc8db1116fea023b766d262d768582dd9f44bb1c48434c2c6171e697962398f0543
|
data/ci_runner.gemspec
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/ci_runner/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "ci_runner"
|
7
|
+
spec.version = CIRunner::VERSION
|
8
|
+
spec.authors = ["Edouard Chin"]
|
9
|
+
spec.email = ["chin.edouard@gmail.com"]
|
10
|
+
|
11
|
+
spec.summary = "Re-run failing tests from CI on your local machine without copy/pasting."
|
12
|
+
spec.description = <<~EOM
|
13
|
+
Tired of copying the test suites names from a failed CI?
|
14
|
+
|
15
|
+
This gem will automate this tedious workflow. CI Runner will download the log from your CI
|
16
|
+
provider, parse it, detect failures and rerun exactly the same failing tests on your machine.
|
17
|
+
|
18
|
+
CI Runner can also detect the Ruby version used on your CI as well as which Gemfile and reuse
|
19
|
+
those when starting the run locally.
|
20
|
+
EOM
|
21
|
+
spec.homepage = "https://github.com/Edouard-chin/ci_runner"
|
22
|
+
spec.license = "MIT"
|
23
|
+
spec.required_ruby_version = ">= 2.7.0"
|
24
|
+
|
25
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
26
|
+
spec.metadata["source_code_uri"] = "https://github.com/Edouard-chin/ci_runner"
|
27
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
28
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
29
|
+
|
30
|
+
spec.files = Dir["{lib,exe}/**/*", "ci_runner.gemspec"].select { |f| File.file?(f) }
|
31
|
+
|
32
|
+
spec.bindir = "exe"
|
33
|
+
spec.executables = ["ci_runner"]
|
34
|
+
spec.require_paths = ["lib"]
|
35
|
+
|
36
|
+
spec.add_dependency("cli-ui", "~> 1.5")
|
37
|
+
spec.add_dependency("rake", "~> 13.0")
|
38
|
+
spec.add_dependency("thor", "~> 1.2")
|
39
|
+
|
40
|
+
spec.add_development_dependency("rspec", "~> 3.11")
|
41
|
+
spec.add_development_dependency("rubocop-shopify", "~> 2.8")
|
42
|
+
spec.add_development_dependency("webmock", "~> 3.14")
|
43
|
+
end
|
data/exe/ci_runner
ADDED
@@ -0,0 +1,178 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "thor"
|
4
|
+
|
5
|
+
module CIRunner
|
6
|
+
class CLI < Thor
|
7
|
+
default_command :rerun
|
8
|
+
|
9
|
+
# @return [Boolean]
|
10
|
+
def self.exit_on_failure?
|
11
|
+
true
|
12
|
+
end
|
13
|
+
|
14
|
+
desc "rerun", "Run failing tests from a CI."
|
15
|
+
long_desc <<~EOM
|
16
|
+
Main command of CI Runner. This command is meant to rerun tests that failed on a CI,
|
17
|
+
on your locale machine, without having you copy/paste output from the CI logs onto your terminal.
|
18
|
+
|
19
|
+
The +rerun+ command will do everything from grabbing the CI checks that failed on a GitHub commit,
|
20
|
+
ask which one you'd like to rerun, download and parse the CI log output and run on your
|
21
|
+
machine exactly the same tests from the failing CI.
|
22
|
+
|
23
|
+
CI Runner is meant to replicate what failed on CI exactly the same way. Therefore, the SEED,
|
24
|
+
the Ruby version as well as the Gemfile from the CI run will be used when running the test suite.
|
25
|
+
|
26
|
+
All option on the +rerun+ command are optional. CI Runner will try to infer them from your repository,
|
27
|
+
and if it can't it will let you know.
|
28
|
+
|
29
|
+
Please note that CI Runner will **not** ensure that the Git HEAD of your local repository matches
|
30
|
+
the commit that failed upstream.
|
31
|
+
EOM
|
32
|
+
option :commit, type: :string, desc: "The Git commit that was pushed to GitHub and has a failing CI. The HEAD commit of your local repository will be used by default." # rubocop:disable Layout/LineLength
|
33
|
+
option :repository, type: :string, desc: "The repository on which the CI failed. The repository will be infered from your git remote by default.", banner: "catanacorp/catana" # rubocop:disable Layout/LineLength
|
34
|
+
option :run_name, type: :string, desc: "The CI check you which to rerun in case multiple checks failed for a commit. CI Runner will prompt you by default." # rubocop:disable Layout/LineLength
|
35
|
+
def rerun
|
36
|
+
::CLI::UI::StdoutRouter.enable
|
37
|
+
|
38
|
+
runner = nil
|
39
|
+
|
40
|
+
::CLI::UI.frame("Preparing CI Runner") do
|
41
|
+
Configuration::User.instance.validate_token!
|
42
|
+
|
43
|
+
commit = options[:commit] || GitHelper.head_commit
|
44
|
+
repository = options[:repository] || GitHelper.repository_from_remote
|
45
|
+
ci_checks = fetch_ci_checks(repository, commit)
|
46
|
+
|
47
|
+
run_name = options[:run_name] || ask_for_name(ci_checks)
|
48
|
+
check_run = TestRunFinder.find(ci_checks, run_name)
|
49
|
+
|
50
|
+
ci_log = fetch_ci_log(repository, commit, check_run)
|
51
|
+
runner = TestRunFinder.detect_runner(ci_log)
|
52
|
+
runner.parse!
|
53
|
+
|
54
|
+
if runner.failures.count == 0
|
55
|
+
# Error
|
56
|
+
end
|
57
|
+
rescue GithubClient::Error, Error => e
|
58
|
+
::CLI::UI.puts("\n{{red:#{e.message}}}", frame_color: :red)
|
59
|
+
|
60
|
+
exit(false)
|
61
|
+
end
|
62
|
+
|
63
|
+
::CLI::UI::Frame.open("Your test run is about to start") do
|
64
|
+
runner.report
|
65
|
+
runner.start!
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
desc "github_token TOKEN", "Save a GitHub token in your config."
|
70
|
+
long_desc <<~EOM
|
71
|
+
Save a personal access GitHub token in the ~/.ci_runner/config.yml file.
|
72
|
+
The GitHub token is required to fetch CI checks and download logs from repositories.
|
73
|
+
|
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
|
75
|
+
EOM
|
76
|
+
def github_token(token)
|
77
|
+
::CLI::UI::StdoutRouter.enable
|
78
|
+
|
79
|
+
::CLI::UI.frame("Saving GitHub Token") do
|
80
|
+
user = GithubClient.new(token).me
|
81
|
+
Configuration::User.instance.save_github_token(token)
|
82
|
+
|
83
|
+
::CLI::UI.puts(<<~EOM)
|
84
|
+
Hello {{warning:#{user["login"]}}}! {{success:Your token is valid!}}
|
85
|
+
|
86
|
+
{{info:The token has been saved in this file: #{Configuration::User.instance.config_file}}}
|
87
|
+
EOM
|
88
|
+
rescue GithubClient::Error => e
|
89
|
+
::CLI::UI.puts("{{red:\nYour token doesn't seem to be valid. The response from GitHub was: #{e.message}}}")
|
90
|
+
|
91
|
+
exit(false)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
# Retrieve all the GitHub CI checks for a given commit. Will be used to interactively prompt
|
98
|
+
# the user which one to rerun.
|
99
|
+
#
|
100
|
+
# @param repository [String] The full repository name including the owner (rails/rails).
|
101
|
+
# @param commit [String] A Git commit that has been pushed to GitHub and for which CI failed.
|
102
|
+
#
|
103
|
+
# @return [Hash] See the GitHub documentation.
|
104
|
+
#
|
105
|
+
# @raise [SystemExit] Early exit the process if the CI checks can't be retrieved.
|
106
|
+
#
|
107
|
+
# @see https://docs.github.com/en/rest/checks/runs#list-check-runs-for-a-git-reference
|
108
|
+
def fetch_ci_checks(repository, commit)
|
109
|
+
TestRunFinder.fetch_ci_checks(repository, commit) do |error|
|
110
|
+
puts(<<~EOM)
|
111
|
+
Couldn't fetch the CI checks. The response from GitHub was:
|
112
|
+
|
113
|
+
#{error.message}
|
114
|
+
EOM
|
115
|
+
|
116
|
+
exit(false)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# Download and cache the log for the GitHub check. Downloading the log allows CI Runner to parse it and detect
|
121
|
+
# which test failed in order to run uniquely those on the user machine.
|
122
|
+
#
|
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.
|
126
|
+
#
|
127
|
+
# @return [String] The content of the CI log.
|
128
|
+
#
|
129
|
+
# @raise [SystemExit] Early exit the process if the CI checks can't be retrieved.
|
130
|
+
#
|
131
|
+
# @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:
|
136
|
+
|
137
|
+
#{error.message}
|
138
|
+
EOM
|
139
|
+
|
140
|
+
exit(false)
|
141
|
+
end
|
142
|
+
|
143
|
+
log.read
|
144
|
+
end
|
145
|
+
|
146
|
+
# Interatively ask the user which CI check to rerun in the case a commit has multiple failing checks.
|
147
|
+
# This method only runs if the user has not passed the '--run-name' flag to ci_runner.
|
148
|
+
# Will automatically select a check in the case where there is only one failing check.
|
149
|
+
#
|
150
|
+
# @param ci_checks [Hash] (See #fetch_ci_checks)
|
151
|
+
#
|
152
|
+
# @return [Hash] A single Check Run, the one that the user selected.
|
153
|
+
#
|
154
|
+
# @raise [CIRunner::Error] In case all the CI checks on this commit were successfull. In such case
|
155
|
+
# there is no need to proceed as there should be no failing tests to rerun.
|
156
|
+
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" }
|
159
|
+
|
160
|
+
if failed_runs.count == 0
|
161
|
+
raise(Error, "No CI checks failed on this commit.")
|
162
|
+
elsif failed_runs.count == 1
|
163
|
+
check_run = failed_runs.first["name"]
|
164
|
+
|
165
|
+
::CLI::UI.puts(<<~EOM)
|
166
|
+
{{warning:Automatically selected the CI check #{check_run} because it's the only one failing.}}
|
167
|
+
EOM
|
168
|
+
|
169
|
+
check_run
|
170
|
+
else
|
171
|
+
::CLI::UI.ask(
|
172
|
+
"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"] },
|
174
|
+
)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
@@ -0,0 +1,242 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "pathname"
|
4
|
+
require "yaml"
|
5
|
+
require "singleton"
|
6
|
+
|
7
|
+
module CIRunner
|
8
|
+
module Configuration
|
9
|
+
# A class to interact with a project's Configuration.
|
10
|
+
#
|
11
|
+
# CI Runner tries its best to come out of the box functional. It comes bundled with a set
|
12
|
+
# of regexes that detects variety of output. For instance if your project uses RSpec or Minitest
|
13
|
+
# AND you haven't modified their reporters, CI Runner should just work with no extra setup.
|
14
|
+
#
|
15
|
+
# However, if your application or Gem has custom reporters, CI Runner set of regexes won't work, as the output
|
16
|
+
# expected will change because of those custom reporters.
|
17
|
+
# CI Runner allows to change those regexes thanks to a configuration file you can store on each of your project.
|
18
|
+
#
|
19
|
+
# Note that all *_regex configuration value can be either a String or a Serialized Regexp object.
|
20
|
+
#
|
21
|
+
# @example Using a String
|
22
|
+
# ---
|
23
|
+
# ruby_regex: "Ruby v(\\d\\.\\d\\.\\d)" ==> Note that each backslash has to be escaped!
|
24
|
+
#
|
25
|
+
# @example Using a serialized Regexp
|
26
|
+
# ---
|
27
|
+
# ruby_regex: !ruby/regexp "/Ruby v(\\d\\.\\d\\.\\d)/m" ==> Convenient if you need to have flags on the regex.
|
28
|
+
#
|
29
|
+
# @see https://yaml.org/YAML_for_ruby.html#regexps
|
30
|
+
class Project
|
31
|
+
include Singleton
|
32
|
+
|
33
|
+
CONFIG_PATH = ".github/ci_runner.yml"
|
34
|
+
|
35
|
+
# Singleton class. Shouldn't/Can't be called directly. Call Project.instance instead.
|
36
|
+
#
|
37
|
+
# @return [void]
|
38
|
+
def initialize
|
39
|
+
load!
|
40
|
+
end
|
41
|
+
|
42
|
+
# Load the configuration file from the project into memory.
|
43
|
+
#
|
44
|
+
# @return [void]
|
45
|
+
def load!
|
46
|
+
@yaml_config = config_file.exist? ? YAML.safe_load(config_file.read, permitted_classes: [Regexp]) : {}
|
47
|
+
end
|
48
|
+
|
49
|
+
# This regex is used to detect the Ruby version that was used on a CI. It's quite common to have a CI
|
50
|
+
# testing a gem on different version of Ruby.
|
51
|
+
# If detected, CI Runner will use that same Ruby version from your machine (if it exists) to run the test suite.
|
52
|
+
#
|
53
|
+
# **Important**. Your regex has to contain ONE capturing match, being the Ruby version itself.
|
54
|
+
#
|
55
|
+
# @return [nil, Regexp] Depending if the project has set this. CI Runner default will be used when not set.
|
56
|
+
#
|
57
|
+
# @example Storing this configuration
|
58
|
+
# `cat myproject/.github/ci_runner.yml`
|
59
|
+
#
|
60
|
+
# ---
|
61
|
+
# ruby_regex: "Ruby (.*)"
|
62
|
+
def ruby_detection_regex
|
63
|
+
to_regexp(@yaml_config.dig("ruby_regex"))
|
64
|
+
end
|
65
|
+
|
66
|
+
# This regex is used to detect the Gemfile version that was used on a CI. It's quite common to have a CI
|
67
|
+
# testing a gem with different set of dependencies thanks to multiple Gemfiles.
|
68
|
+
# If detected, CI Runner will use the same Gemfile from your machine (if it exists) to run the test suite.
|
69
|
+
#
|
70
|
+
# **Important**. Your regex has to contain ONE capturing match, being the Gemfile path.
|
71
|
+
#
|
72
|
+
# @return [nil, Regexp] Depending if the project has set this. CI Runner default will be used when not set.
|
73
|
+
#
|
74
|
+
# @example Storing this configuration
|
75
|
+
# `cat myproject/.github/ci_runner.yml`
|
76
|
+
#
|
77
|
+
# ---
|
78
|
+
# gemfile_regex: "Using GEMFILE: (.*)"
|
79
|
+
def gemfile_detection_regex
|
80
|
+
to_regexp(@yaml_config.dig("gemfile_regex"))
|
81
|
+
end
|
82
|
+
|
83
|
+
# This regex is used to detect the SEED on a CI. CI Runner aims to rerun failing tests on your machine
|
84
|
+
# exactly the same as how it ran on CI, therefore in the same order. The SEED is what determine the order.
|
85
|
+
#
|
86
|
+
# **Important**. Your regex has to contain ONE capturing match, being the SEED value.
|
87
|
+
#
|
88
|
+
# @return [nil, Regexp] Depending if the project has set this. CI Runner default will be used when not set.
|
89
|
+
#
|
90
|
+
# @example Storing this configuration
|
91
|
+
# `cat myproject/.github/ci_runner.yml`
|
92
|
+
#
|
93
|
+
# ---
|
94
|
+
# seed_regex: "Running with test options: --seed(.*)"
|
95
|
+
def seed_detection_regex
|
96
|
+
to_regexp(@yaml_config.dig("seed_regex"))
|
97
|
+
end
|
98
|
+
|
99
|
+
# This regex is used to tell CI Runner when to start buffering. The failures will then be matched
|
100
|
+
# agains this buffer rather than the whole log output. An example to better understand:
|
101
|
+
#
|
102
|
+
# @return [nil, Regexp] Depending if the project has set this. CI Runner default will be used when not set.
|
103
|
+
#
|
104
|
+
# @example
|
105
|
+
# An RSpec output looks like this:
|
106
|
+
#
|
107
|
+
# 1.2) Failure/Error: @client.delete_repository(@repo.full_name)
|
108
|
+
#
|
109
|
+
# NoMethodError:
|
110
|
+
# undefined method `full_name' for nil:NilClass
|
111
|
+
# # ./spec/octokit/client/repositories_spec.rb:71:in `block (3 levels) in <top (required)>'
|
112
|
+
# # ./.bundle/gems/ruby/3.0.0/gems/webmock-3.14.0/lib/webmock/rspec.rb:37:in `block (2 levels) in'
|
113
|
+
#
|
114
|
+
# Finished in 26.53 seconds (files took 1.26 seconds to load)
|
115
|
+
# 854 examples, 1 failure
|
116
|
+
#
|
117
|
+
# Failed examples:
|
118
|
+
#
|
119
|
+
# rspec ./spec/octokit/client/repository_spec.rb:75 # Octokit::Client::Repositories.edit_repository is_template
|
120
|
+
#
|
121
|
+
# =====================
|
122
|
+
#
|
123
|
+
# If you have this configuration set to "Failed examples:". CI Runner will start collecting test failures
|
124
|
+
# only after the "Failed examples" line appear.
|
125
|
+
#
|
126
|
+
# @example Storing this configuration
|
127
|
+
# `cat myproject/.github/ci_runner.yml`
|
128
|
+
#
|
129
|
+
# ---
|
130
|
+
# buffer_starts_regex: "Failed examples:"
|
131
|
+
def buffer_starts_regex
|
132
|
+
to_regexp(@yaml_config.dig("buffer_starts_regex"))
|
133
|
+
end
|
134
|
+
|
135
|
+
# This is to be used in conjuction with the +buffer_starts_regex+. It accepts a boolean value.
|
136
|
+
#
|
137
|
+
# This configuration tells CI Runner to process the buffer and find failures each time a new line matching
|
138
|
+
# +buffer_starts_regex+ appears.
|
139
|
+
#
|
140
|
+
# To detect the file path of each failing test, we have to go through the stacktrace of each failing tests.
|
141
|
+
#
|
142
|
+
# When you set the `process_on_new_match` value to true (the default), your regex +test_failure_detection_regex+
|
143
|
+
# will be matched agains each erroring test.
|
144
|
+
#
|
145
|
+
# @return [nil, Regexp] Depending if the project has set this. True by default.
|
146
|
+
#
|
147
|
+
# @example A Minitest failure output looks like this:
|
148
|
+
#
|
149
|
+
# Finished in 0.015397s, 2013.4301 runs/s, 58649.2703 assertions/s.
|
150
|
+
#
|
151
|
+
# Error:
|
152
|
+
#
|
153
|
+
# TestReloading#test_reload_recovers_from_name_errors__w__on_unload_callbacks_:
|
154
|
+
# Tasks: TOP => default => test
|
155
|
+
# NameError: uninitialized constant X
|
156
|
+
# (See full trace by running task with --trace)
|
157
|
+
#
|
158
|
+
# parent.const_get(cname, false)
|
159
|
+
# ^^^^^^^^^^
|
160
|
+
# /Users/runner/work/zeitwerk/zeitwerk/lib/zeitwerk/loader/helpers.rb:118:in
|
161
|
+
# /Users/runner/work/zeitwerk/zeitwerk/lib/zeitwerk/loader/helpers.rb:118:
|
162
|
+
# /Users/runner/work/zeitwerk/zeitwerk/test/lib/zeitwerk/test_reloading.rb:223:in `block (2 levels) in
|
163
|
+
#
|
164
|
+
# Error:
|
165
|
+
#
|
166
|
+
# OtherTest#test_something_else
|
167
|
+
# Tasks: TOP => default => test
|
168
|
+
# NameError: Boom
|
169
|
+
# (See full trace by running task with --trace)
|
170
|
+
#
|
171
|
+
# bla.foo
|
172
|
+
# ^^^
|
173
|
+
# /Users/runner/work/zeitwerk/zeitwerk/test/lib/zeitwerk/other_test.rb:18:in
|
174
|
+
#
|
175
|
+
# @example Storing this configuration
|
176
|
+
# `cat myproject/.github/ci_runner.yml`
|
177
|
+
#
|
178
|
+
# ---
|
179
|
+
# process_on_new_match: false
|
180
|
+
def process_on_new_match?
|
181
|
+
value = @yaml_config.dig("process_on_new_match")
|
182
|
+
|
183
|
+
value.nil? ? true : value
|
184
|
+
end
|
185
|
+
|
186
|
+
# Main detection regex used to detect test failures.
|
187
|
+
#
|
188
|
+
# **Important** This regexp 3 named capture groups. The order doesn't matter.
|
189
|
+
#
|
190
|
+
# - "file_path"
|
191
|
+
# - "test_name"
|
192
|
+
# - "class"
|
193
|
+
#
|
194
|
+
# Your regex should look something like: /(?<file_path>...)(?<test_name>...)(?<class>...)/
|
195
|
+
#
|
196
|
+
# @return [nil, Regexp] Depending if the project has set this. CI Runner default will be used when not set.
|
197
|
+
#
|
198
|
+
# @raise [Error] If the provided doesn't have the 3 capturing group mentioned.
|
199
|
+
#
|
200
|
+
# @example Storing this configuration
|
201
|
+
# `cat myproject/.github/ci_runner.yml`
|
202
|
+
#
|
203
|
+
# ---
|
204
|
+
# failure_regex: "your_regex"
|
205
|
+
def test_failure_detection_regex
|
206
|
+
regexp = to_regexp(@yaml_config.dig("failures_regex"))
|
207
|
+
return unless regexp
|
208
|
+
|
209
|
+
expected_captures = ["file_path", "test_name", "class"]
|
210
|
+
difference = expected_captures - regexp.names
|
211
|
+
|
212
|
+
if difference.any?
|
213
|
+
raise(Error, <<~EOM)
|
214
|
+
The {{warning:failures_regex}} configuration of your project doesn't include expected named captures.
|
215
|
+
CI Runner expects the following Regexp named captures: #{expected_captures.inspect}.
|
216
|
+
|
217
|
+
Your Regex should look something like {{info:/(?<file_path>...)(?<test_name>...)(?<class>...)/}}
|
218
|
+
EOM
|
219
|
+
end
|
220
|
+
|
221
|
+
regexp
|
222
|
+
end
|
223
|
+
|
224
|
+
# @return [Pathname] The path of the configuration file.
|
225
|
+
#
|
226
|
+
# @example
|
227
|
+
# puts Project.instance.config_file # => project/.github/ci_runner.yml
|
228
|
+
def config_file
|
229
|
+
Pathname(File.expand_path(CONFIG_PATH, Dir.pwd))
|
230
|
+
end
|
231
|
+
|
232
|
+
private
|
233
|
+
|
234
|
+
# @param value [String, Regexp, nil]
|
235
|
+
#
|
236
|
+
# @return [Regexp, nil]
|
237
|
+
def to_regexp(value)
|
238
|
+
value ? Regexp.new(value) : value
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "pathname"
|
4
|
+
require "yaml"
|
5
|
+
require "singleton"
|
6
|
+
|
7
|
+
module CIRunner
|
8
|
+
module Configuration
|
9
|
+
# Class to interact with the user's configuration. The configuration is used
|
10
|
+
# to store the GitHub token amonst other things.
|
11
|
+
#
|
12
|
+
# @param Use this configuration
|
13
|
+
# User.instance.github_token
|
14
|
+
class User
|
15
|
+
include Singleton
|
16
|
+
|
17
|
+
USER_CONFIG_PATH = ".ci_runner/config.yml"
|
18
|
+
|
19
|
+
# Singleton class. This should/can't be called directly.
|
20
|
+
#
|
21
|
+
# @return [void]
|
22
|
+
def initialize
|
23
|
+
load!
|
24
|
+
end
|
25
|
+
|
26
|
+
# Load the configuration of the user. If it doesn't exist, write an empty one.
|
27
|
+
#
|
28
|
+
# @return [void]
|
29
|
+
def load!
|
30
|
+
save!({}) unless config_file.exist?
|
31
|
+
|
32
|
+
@yaml_config = YAML.load_file(config_file)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Retrieve the stored GitHub access token of the user.
|
36
|
+
#
|
37
|
+
# @return [String, nil] Depending if the user ran the `ci_runner github_token TOKEN` command.
|
38
|
+
def github_token
|
39
|
+
@yaml_config.dig("github", "token")
|
40
|
+
end
|
41
|
+
|
42
|
+
# Write the GitHub token to the user configuration file
|
43
|
+
#
|
44
|
+
# @param token [String] A valid GitHub access token.
|
45
|
+
#
|
46
|
+
# @return [void]
|
47
|
+
def save_github_token(token)
|
48
|
+
@yaml_config["github"] = { "token" => token }
|
49
|
+
|
50
|
+
save!(@yaml_config)
|
51
|
+
end
|
52
|
+
|
53
|
+
# @return [Pathname] The path of the configuration file.
|
54
|
+
#
|
55
|
+
# @example
|
56
|
+
# puts config_file # ~/.ci_runner/config.yml
|
57
|
+
def config_file
|
58
|
+
Pathname(File.expand_path(USER_CONFIG_PATH, Dir.home))
|
59
|
+
end
|
60
|
+
|
61
|
+
# Ensure the user ran the `ci_runner github_token TOKEN` command prior to using CI Runner.
|
62
|
+
#
|
63
|
+
# Note: Technically, it's possible to access the GitHub API to retrieve checks and download logs on
|
64
|
+
# public repositories, but on private repositories the error GitHub sends back is a 404 which can
|
65
|
+
# be confusing, so I'd rather just make sure the token exists either way.
|
66
|
+
#
|
67
|
+
# @raise [Error] If the user tries to run `ci_runner rerun` before it saved a token in its config file.
|
68
|
+
#
|
69
|
+
# @return [void]
|
70
|
+
def validate_token!
|
71
|
+
return if github_token
|
72
|
+
|
73
|
+
raise(Error, <<~EOM)
|
74
|
+
A GitHub token needs to be saved into your configuration before being able to use CI Runner.
|
75
|
+
|
76
|
+
Have a look at the {{command:ci_runner help github_token}} command.
|
77
|
+
EOM
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
# Dump into yaml and store the new configuration to the +config_file+.
|
83
|
+
#
|
84
|
+
# @param config [Hash] A hash that will be dumped to YAML.
|
85
|
+
#
|
86
|
+
# @raise [Error] In the case where the user's home directory is not writeable (hello nix).
|
87
|
+
def save!(config = {})
|
88
|
+
raise(Error, "Your home directory is not writeable") unless Pathname(Dir.home).writable?
|
89
|
+
|
90
|
+
dir = config_file.dirname
|
91
|
+
dir.mkdir unless dir.exist?
|
92
|
+
|
93
|
+
File.write(config_file, YAML.dump(config))
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "open3"
|
4
|
+
|
5
|
+
module CIRunner
|
6
|
+
# A helper for the `ci_runner rerun` command to infer options automatically.
|
7
|
+
# The goal being to have the user only type `ci_runner rerun` and have things work magically.
|
8
|
+
#
|
9
|
+
# The command line options passed by a user have precedence.
|
10
|
+
module GitHelper
|
11
|
+
extend self
|
12
|
+
|
13
|
+
# Get the HEAD commit of the repository. This assumes the user runs the `ci-runner` command from
|
14
|
+
# a repository.
|
15
|
+
#
|
16
|
+
# @return [String] The HEAD commit of the user's local repository.
|
17
|
+
#
|
18
|
+
# @raise [Error] In case the `git` subprocess returns an error.
|
19
|
+
def head_commit
|
20
|
+
stdout, _, status = Open3.capture3("git rev-parse HEAD")
|
21
|
+
|
22
|
+
if status.success?
|
23
|
+
stdout.rstrip
|
24
|
+
else
|
25
|
+
raise(Error, <<~EOM)
|
26
|
+
Couldn't determine the commit. The commit is required to download the right CI logs.
|
27
|
+
|
28
|
+
Please pass the `--commit` flag (ci_runner --commit <commit>)
|
29
|
+
EOM
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Get the full repository name (including the owner, i.e. rails/rails) thanks to the Git remote.
|
34
|
+
# This allows the user to not have to type `ci-runner rerun --repostitory catanacorp/catana` each time.
|
35
|
+
#
|
36
|
+
# @return [String] The full repository name
|
37
|
+
#
|
38
|
+
# @raise [Error] In case the `git` subprocess returns an error.
|
39
|
+
def repository_from_remote
|
40
|
+
stdout, _, status = Open3.capture3("git remote -v")
|
41
|
+
|
42
|
+
if status.success?
|
43
|
+
process_remotes(stdout)
|
44
|
+
else
|
45
|
+
raise(Error, <<~EOM)
|
46
|
+
Couldn't determine the name of the repository.
|
47
|
+
|
48
|
+
Please pass the `--repository` flag (ci_runner --repository <owner/repository_name>)
|
49
|
+
EOM
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
# Try to get the right repository depending on the remotes. It's quite common to have two remotes when your
|
56
|
+
# work on a forked project. The remote from the source project is regularly called: "remote".
|
57
|
+
#
|
58
|
+
# CI Runner will prioritize remotes with the following order:
|
59
|
+
#
|
60
|
+
# - remote
|
61
|
+
# - origin
|
62
|
+
# - anything else
|
63
|
+
#
|
64
|
+
# @param stdout [String] The output from the `git remote -v` command.
|
65
|
+
#
|
66
|
+
# @return [String] The full repository name.
|
67
|
+
#
|
68
|
+
# @raise [Error] In case there is no GitHub remote. CI Runner currently works with GitHub.
|
69
|
+
#
|
70
|
+
# @example When the remote is preferred
|
71
|
+
# `git remote -v
|
72
|
+
# remote git@github.com:rails/rails.git (fetch)
|
73
|
+
# remote git@github.com:rails/rails.git (push)
|
74
|
+
# origin git@github.com:Edouard-chin/rails.git (fetch)
|
75
|
+
# origin git@github.com:Edouard-chin/rails.git (push)
|
76
|
+
#
|
77
|
+
# rails/rails will be returned.
|
78
|
+
def process_remotes(stdout)
|
79
|
+
stdout.match(/remote#{remote_regex}/) do |match_data|
|
80
|
+
return "#{match_data[1]}/#{match_data[2]}"
|
81
|
+
end
|
82
|
+
|
83
|
+
stdout.match(/origin#{remote_regex}/) do |match_data|
|
84
|
+
return "#{match_data[1]}/#{match_data[2]}"
|
85
|
+
end
|
86
|
+
|
87
|
+
stdout.match(/#{remote_regex}/) do |match_data|
|
88
|
+
return "#{match_data[1]}/#{match_data[2]}"
|
89
|
+
end
|
90
|
+
|
91
|
+
raise(Error, <<~EOM)
|
92
|
+
Couldn't determine the repository name based on the git remote.
|
93
|
+
|
94
|
+
Please pass the `--repository` flag (ci_runner --repository <owner/repository_name>)
|
95
|
+
EOM
|
96
|
+
end
|
97
|
+
|
98
|
+
# return [Regexp] The regex to detect the full repository name.
|
99
|
+
def remote_regex
|
100
|
+
%r{\s+(?:git@|https://)github.com(?::|/)([a-zA-Z0-9\-_\.]+)/([a-zA-Z0-9\-_\.]+?)(?:\.git)?\s+\((?:fetch|push)\)}
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|