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
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base"
|
4
|
+
|
5
|
+
module CIRunner
|
6
|
+
module Runners
|
7
|
+
class RSpec < Base
|
8
|
+
SEED_REGEX = /Randomized with seed[[:blank:]]*(\d+)/
|
9
|
+
BUFFER_STARTS = /(Finished in|Failed examples)/
|
10
|
+
|
11
|
+
# @param ci_log [String] The CI log output
|
12
|
+
#
|
13
|
+
# @return [Boolean] Whether this runner detects (and therefore can handle) Minitest from the log output.
|
14
|
+
def self.match?(log)
|
15
|
+
command = /bundle exec rspec/
|
16
|
+
summary = /Failed examples:/
|
17
|
+
|
18
|
+
Regexp.union(command, summary, /rspec/i).match?(log)
|
19
|
+
end
|
20
|
+
|
21
|
+
# @return [String] See Runners::Base#report
|
22
|
+
def name
|
23
|
+
"RSpec"
|
24
|
+
end
|
25
|
+
|
26
|
+
def start!
|
27
|
+
super
|
28
|
+
|
29
|
+
flags = failures.map { |failure| "--example '#{failure.test_name}'" }.join(" ")
|
30
|
+
flags << " --seed #{seed}" if seed
|
31
|
+
|
32
|
+
code = <<~EOM
|
33
|
+
require 'rspec/core/rake_task'
|
34
|
+
|
35
|
+
RSpec::Core::RakeTask.new('__ci_runner_test') do |task|
|
36
|
+
task.pattern = #{failures.map(&:path)}
|
37
|
+
task.rspec_opts = "#{flags}"
|
38
|
+
task.verbose = false
|
39
|
+
end
|
40
|
+
|
41
|
+
Rake::Task[:__ci_runner_test].invoke
|
42
|
+
EOM
|
43
|
+
|
44
|
+
dir = Dir.mktmpdir
|
45
|
+
rakefile_path = File.expand_path("Rakefile", dir)
|
46
|
+
|
47
|
+
File.write(rakefile_path, code)
|
48
|
+
|
49
|
+
env = {}
|
50
|
+
env["RUBY"] = ruby_path.to_s if ruby_path&.exist?
|
51
|
+
env["BUNDLE_GEMFILE"] = gemfile_path.to_s if gemfile_path&.exist?
|
52
|
+
|
53
|
+
execute_within_frame(env, "bundle exec ruby #{rakefile_path}")
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def process_buffer
|
59
|
+
failure_regex = /rspec[[:blank:]]*(?<file_path>.*?):\d+[[:blank:]]*#[[:blank:]]*(?<test_name>.*)/
|
60
|
+
|
61
|
+
@buffer.each_line do |line|
|
62
|
+
line.match(failure_regex) do |match_data|
|
63
|
+
@failures << TestFailure.new(nil, match_data[:test_name].rstrip, match_data[:file_path])
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "pathname"
|
4
|
+
require "drb/drb"
|
5
|
+
|
6
|
+
module CIRunner
|
7
|
+
# A container object to gather test failures as we parse the CI log output.
|
8
|
+
class TestFailure
|
9
|
+
# @see minitest/ci_runner_plugin.rb to understand why we need DRb.
|
10
|
+
include DRbUndumped
|
11
|
+
|
12
|
+
# @return [String] The name of the class that included the failing test.
|
13
|
+
#
|
14
|
+
# @example Given a output log: "TestReloading#test_reload_recovers_from_name_errors__w__on_unload_callbacks".
|
15
|
+
# puts klass # => TestReloading
|
16
|
+
attr_reader :klass
|
17
|
+
|
18
|
+
# @return [String] The name of the test that failed.
|
19
|
+
#
|
20
|
+
# @example Given a output log: "TestReloading#test_reload_recovers_from_name_errors__w__on_unload_callbacks".
|
21
|
+
# puts test_name # => test_reload_recovers_from_name_errors__w__on_unload_callbacks
|
22
|
+
attr_reader :test_name
|
23
|
+
|
24
|
+
# @return [String] The file location where this +klass+ lives.
|
25
|
+
attr_reader :path
|
26
|
+
|
27
|
+
# @param klass (See #klass)
|
28
|
+
# @param test_name (See #test_name)
|
29
|
+
# @param path (See #path)
|
30
|
+
def initialize(klass, test_name, path)
|
31
|
+
@klass = klass
|
32
|
+
@test_name = test_name
|
33
|
+
@path = absolute_path(Pathname(path))
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
# Transform the path parsed from the log to make it absolute. CI Runner will run the tests from a temporary
|
39
|
+
# folder on the user's machine, a relative path wouldn't work.
|
40
|
+
#
|
41
|
+
# Note: This method does another thing which is hacky and try to use the equivalent of `File.relative_path_from`.
|
42
|
+
# See the example below.
|
43
|
+
#
|
44
|
+
# @param path [String] A absolute or relative path.
|
45
|
+
#
|
46
|
+
# @return [String] An absolute path based on where the user ran the `ci_runner` command.
|
47
|
+
#
|
48
|
+
# @example Trying to hack relative_path_from
|
49
|
+
# Given a log output from CI: "BlablaTest#test_abc [/home/runner/work/project/project/test/blabla.rb:7]:"
|
50
|
+
# Minitest outputs absolute path, we need to detect what portion is the home of the CI from the rest.
|
51
|
+
# For now using `test/` as that's usually where the Minitest tests are stored but it's likely that it would fail.
|
52
|
+
def absolute_path(path)
|
53
|
+
if path.relative?
|
54
|
+
return File.expand_path(path, Dir.pwd)
|
55
|
+
end
|
56
|
+
|
57
|
+
regex = %r{.*/?(test/.*?)\Z}
|
58
|
+
unless path.to_s.match?(regex)
|
59
|
+
raise "Can't create a relative path."
|
60
|
+
end
|
61
|
+
|
62
|
+
File.expand_path(path.to_s.sub(regex, '\1'), Dir.pwd)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CIRunner
|
4
|
+
module TestRunFinder
|
5
|
+
extend self
|
6
|
+
|
7
|
+
# Makes a request to GitHub to retrieve the checks for a commit. Display a nice UI with
|
8
|
+
# a spinner while the user wait.
|
9
|
+
#
|
10
|
+
# @param repository [String] The full repository name, including the owner (i.e. rails/rails)
|
11
|
+
# @param commit [String] The Git commit that has been pushed to GitHub and for which we'll retrieve the CI checks.
|
12
|
+
# @param block [Proc, Lambda] A proc that will be called in case we can't retrieve the CI Checks.
|
13
|
+
# This allows the CLI to prematurely exit and let the CLI::UI closes its frame.
|
14
|
+
#
|
15
|
+
# @return [Hash] See GitHub documentation
|
16
|
+
#
|
17
|
+
# @see https://docs.github.com/en/rest/checks/runs#list-check-runs-for-a-git-reference
|
18
|
+
def fetch_ci_checks(repository, commit, &block)
|
19
|
+
github_client = GithubClient.new(Configuration::User.instance.github_token)
|
20
|
+
ci_checks = {}
|
21
|
+
error = nil
|
22
|
+
|
23
|
+
title = "Fetching failed CI checks from GitHub for commit {{info:#{commit[..12]}}}"
|
24
|
+
::CLI::UI.spinner(title, auto_debrief: false) do
|
25
|
+
ci_checks = github_client.check_runs(repository, commit)
|
26
|
+
rescue GithubClient::Error => e
|
27
|
+
error = e
|
28
|
+
|
29
|
+
::CLI::UI::Spinner::TASK_FAILED
|
30
|
+
end
|
31
|
+
|
32
|
+
block.call(error) if error
|
33
|
+
|
34
|
+
ci_checks
|
35
|
+
end
|
36
|
+
|
37
|
+
# Find the CI check the user requested from the list of upstream checks.
|
38
|
+
# This method is useful only when the user passes the `--run-name` flag to `ci-runner`. This makes
|
39
|
+
# sure the CI check actually exists.
|
40
|
+
#
|
41
|
+
# @param ci_checks [Hash] The response from the previous +fetch_ci_checks+ request.
|
42
|
+
# @param run_name [String] The name of the CI run that the user would like to retry on its machine.
|
43
|
+
#
|
44
|
+
# @return [Hash] A single check run from the list of +ci_checks+
|
45
|
+
#
|
46
|
+
# @raise [Error] If no CI checks with the given +run_name+ could be found.
|
47
|
+
# @raise [Error] If the CI check was successfull. No point to continue as there should be no tests to rerun.
|
48
|
+
def find(ci_checks, run_name)
|
49
|
+
check_run = ci_checks["check_runs"].find { |check_run| check_run["name"] == run_name }
|
50
|
+
raise(Error, no_check_message(ci_checks, run_name)) if check_run.nil?
|
51
|
+
raise(Error, check_succeed(run_name)) if check_run["conclusion"] == "success"
|
52
|
+
|
53
|
+
check_run
|
54
|
+
end
|
55
|
+
|
56
|
+
# Try to guess which runner (Minitest, RSpec) was responsible for this log output.
|
57
|
+
#
|
58
|
+
# The runner is the most important part of CI Runner. It's what determine the failures for a CI log,
|
59
|
+
# as well as how to rerun those only.
|
60
|
+
#
|
61
|
+
# @param ci_log [String] The log output from CI.
|
62
|
+
#
|
63
|
+
# @return [Runners::MinitestRunner, Runners::RSpec]
|
64
|
+
#
|
65
|
+
# @raise [Error] In case none of the runners could detect the log output.
|
66
|
+
def detect_runner(ci_log)
|
67
|
+
raise_if_not_found = lambda { raise(Error, "Couldn't detect the test runner") }
|
68
|
+
|
69
|
+
runner = [Runners::MinitestRunner, Runners::RSpec].find(raise_if_not_found) do |runner|
|
70
|
+
runner.match?(ci_log)
|
71
|
+
end
|
72
|
+
|
73
|
+
runner.new(ci_log)
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
# @param [String] run_name The name of the CI check input or chosen by the user.
|
79
|
+
#
|
80
|
+
# @return [String] A error message to display.
|
81
|
+
def check_succeed(run_name)
|
82
|
+
"The CI check '#{run_name}' was successfull. There should be no failing tests to rerun."
|
83
|
+
end
|
84
|
+
|
85
|
+
# @param [Hash] ci_checks The list of CI checks previously by the +fetch_ci_checks+ method.
|
86
|
+
# @param [String] run_name The name of the CI check input or chosen by the user.
|
87
|
+
#
|
88
|
+
# @return [String] A error message letting the user know why CI Runner couldn't continue.
|
89
|
+
def no_check_message(ci_checks, run_name)
|
90
|
+
possible_checks = ci_checks["check_runs"].map do |check_run|
|
91
|
+
if check_run["conclusion"] == "success"
|
92
|
+
"#{::CLI::UI::Glyph.lookup("v")} #{check_run["name"]}"
|
93
|
+
else
|
94
|
+
"#{::CLI::UI::Glyph.lookup("x")} #{check_run["name"]}"
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
if possible_checks.any?
|
99
|
+
<<~EOM
|
100
|
+
Couldn't find a CI check called '#{run_name}'.
|
101
|
+
CI checks on this commit are:
|
102
|
+
|
103
|
+
#{possible_checks.join("\n")}
|
104
|
+
EOM
|
105
|
+
else
|
106
|
+
<<~EOM
|
107
|
+
Couldn't find a CI check called '#{run_name}'.
|
108
|
+
|
109
|
+
There are no CI checks on this commit.
|
110
|
+
EOM
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
data/lib/ci_runner.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "cli/ui"
|
4
|
+
require_relative "ci_runner/version"
|
5
|
+
|
6
|
+
module CIRunner
|
7
|
+
Error = Class.new(StandardError)
|
8
|
+
|
9
|
+
autoload :CLI, "ci_runner/cli"
|
10
|
+
autoload :GithubClient, "ci_runner/github_client"
|
11
|
+
autoload :GitHelper, "ci_runner/git_helper"
|
12
|
+
autoload :TestRunFinder, "ci_runner/test_run_finder"
|
13
|
+
autoload :LogDownloader, "ci_runner/log_downloader"
|
14
|
+
autoload :TestFailure, "ci_runner/test_failure"
|
15
|
+
|
16
|
+
module Configuration
|
17
|
+
autoload :User, "ci_runner/configuration/user"
|
18
|
+
autoload :Project, "ci_runner/configuration/project"
|
19
|
+
end
|
20
|
+
|
21
|
+
module Runners
|
22
|
+
autoload :MinitestRunner, "ci_runner/runners/minitest_runner"
|
23
|
+
autoload :RSpec, "ci_runner/runners/rspec"
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "drb/drb"
|
4
|
+
|
5
|
+
module Minitest
|
6
|
+
extend self
|
7
|
+
|
8
|
+
def plugin_ci_runner_options(opts, options)
|
9
|
+
opts.on("--ci-runner=URI", "The UNIX socket CI Runner needs to connect to") do |value|
|
10
|
+
options[:ci_runner] = value
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def plugin_ci_runner_init(options)
|
15
|
+
return unless options[:ci_runner]
|
16
|
+
|
17
|
+
options[:args].gsub!(/\s*--ci-runner=#{options[:ci_runner]}\s*/, "")
|
18
|
+
|
19
|
+
DRb.start_service
|
20
|
+
failures = DRbObject.new_with_uri(options[:ci_runner])
|
21
|
+
|
22
|
+
filter = Struct.new(:failures) do
|
23
|
+
def ===(runnable)
|
24
|
+
failures.any? do |failure|
|
25
|
+
"#{failure.klass}##{failure.test_name}" == runnable
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
options[:filter] = filter.new(failures)
|
31
|
+
end
|
32
|
+
end
|
metadata
ADDED
@@ -0,0 +1,155 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: ci_runner
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Edouard Chin
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2022-08-02 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: cli-ui
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.5'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.5'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '13.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '13.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: thor
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.2'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.2'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '3.11'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '3.11'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rubocop-shopify
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '2.8'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '2.8'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: webmock
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '3.14'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '3.14'
|
97
|
+
description: |
|
98
|
+
Tired of copying the test suites names from a failed CI?
|
99
|
+
|
100
|
+
This gem will automate this tedious workflow. CI Runner will download the log from your CI
|
101
|
+
provider, parse it, detect failures and rerun exactly the same failing tests on your machine.
|
102
|
+
|
103
|
+
CI Runner can also detect the Ruby version used on your CI as well as which Gemfile and reuse
|
104
|
+
those when starting the run locally.
|
105
|
+
email:
|
106
|
+
- chin.edouard@gmail.com
|
107
|
+
executables:
|
108
|
+
- ci_runner
|
109
|
+
extensions: []
|
110
|
+
extra_rdoc_files: []
|
111
|
+
files:
|
112
|
+
- ci_runner.gemspec
|
113
|
+
- exe/ci_runner
|
114
|
+
- lib/ci_runner.rb
|
115
|
+
- lib/ci_runner/cli.rb
|
116
|
+
- lib/ci_runner/configuration/project.rb
|
117
|
+
- lib/ci_runner/configuration/user.rb
|
118
|
+
- lib/ci_runner/git_helper.rb
|
119
|
+
- lib/ci_runner/github_client.rb
|
120
|
+
- lib/ci_runner/log_downloader.rb
|
121
|
+
- lib/ci_runner/runners/base.rb
|
122
|
+
- lib/ci_runner/runners/minitest_runner.rb
|
123
|
+
- lib/ci_runner/runners/rspec.rb
|
124
|
+
- lib/ci_runner/test_failure.rb
|
125
|
+
- lib/ci_runner/test_run_finder.rb
|
126
|
+
- lib/ci_runner/version.rb
|
127
|
+
- lib/minitest/ci_runner_plugin.rb
|
128
|
+
homepage: https://github.com/Edouard-chin/ci_runner
|
129
|
+
licenses:
|
130
|
+
- MIT
|
131
|
+
metadata:
|
132
|
+
homepage_uri: https://github.com/Edouard-chin/ci_runner
|
133
|
+
source_code_uri: https://github.com/Edouard-chin/ci_runner
|
134
|
+
allowed_push_host: https://rubygems.org
|
135
|
+
rubygems_mfa_required: 'true'
|
136
|
+
post_install_message:
|
137
|
+
rdoc_options: []
|
138
|
+
require_paths:
|
139
|
+
- lib
|
140
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
141
|
+
requirements:
|
142
|
+
- - ">="
|
143
|
+
- !ruby/object:Gem::Version
|
144
|
+
version: 2.7.0
|
145
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
146
|
+
requirements:
|
147
|
+
- - ">="
|
148
|
+
- !ruby/object:Gem::Version
|
149
|
+
version: '0'
|
150
|
+
requirements: []
|
151
|
+
rubygems_version: 3.3.7
|
152
|
+
signing_key:
|
153
|
+
specification_version: 4
|
154
|
+
summary: Re-run failing tests from CI on your local machine without copy/pasting.
|
155
|
+
test_files: []
|