ci_runner 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift("#{__dir__}/../lib")
5
+ require "ci_runner"
6
+
7
+ CIRunner::CLI.start(ARGV)
@@ -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