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
@@ -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: []
|