ci_runner 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CIRunner
4
+ VERSION = "0.1.0"
5
+ 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: []