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