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 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