ci_runner 0.1.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 +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
|