ci_runner 0.1.0 → 0.3.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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require "stringio"
5
+
6
+ module CIRunner
7
+ module Client
8
+ # Client used to retrieve private Resources on buildkite.
9
+ #
10
+ # For public resources, the API can be used but only a limited number of users will be
11
+ # be able to access it as it requires a token scoped for the organization (most users
12
+ # working on opensource project aren't member of the organization they contribute to).
13
+ #
14
+ # @see https://forum.buildkite.community/t/api-access-to-public-builds/1425/2
15
+ # @see Client::Buildkite
16
+ #
17
+ class AuthenticatedBuildkite < Base
18
+ API_ENDPOINT = "api.buildkite.com"
19
+
20
+ # Retrieve URLs to download job logs for all steps.
21
+ #
22
+ # @param org [String] The organizatio name.
23
+ # @param pipeline [String] The pipeline name.
24
+ # @param number [Integer] The build number.
25
+ #
26
+ # @return [Array<String>] An array of URLs
27
+ #
28
+ # @see https://buildkite.com/docs/apis/rest-api/builds#get-a-build
29
+ def job_logs(org, pipeline, number)
30
+ build = get("/v2/organizations/#{org}/pipelines/#{pipeline}/builds/#{number}")
31
+
32
+ build["jobs"].map do |job|
33
+ job["raw_log_url"]
34
+ end
35
+ end
36
+
37
+ # @param url [String] A URL pointing to a log output resource.
38
+ #
39
+ # @return [StringIO]
40
+ #
41
+ # @see https://buildkite.com/docs/apis/rest-api/jobs#get-a-jobs-log-output
42
+ def download_log(url)
43
+ StringIO.new(get(url))
44
+ end
45
+
46
+ # Get information about an access token. Used to check if the token has the correct scopes.
47
+ #
48
+ # @see https://buildkite.com/docs/apis/rest-api/access-token
49
+ #
50
+ # @return [Hash] See Buildkite doc
51
+ def access_token
52
+ get("/v2/access-token")
53
+ end
54
+
55
+ private
56
+
57
+ # Add authentication before making the request.
58
+ #
59
+ # @param request [Net::HTTPRequest] A subclass of Net::HTTPRequest.
60
+ #
61
+ # @return [void]
62
+ def authentication(request)
63
+ request["Authorization"] = "Bearer #{@access_token}"
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+
7
+ module CIRunner
8
+ module Client
9
+ class Base
10
+ # @return [Net::HTTP] An instance of Net:HTTP configured to make requests to the GitHub API endpoint.
11
+ def self.default_client
12
+ Net::HTTP.new(self::API_ENDPOINT, 443).tap do |http|
13
+ http.use_ssl = true
14
+ http.read_timeout = 3
15
+ http.write_timeout = 3
16
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
17
+ end
18
+ end
19
+
20
+ # @param access_token [String] The access token with "repo" scope.
21
+ # @param client [Net::HTTP]
22
+ def initialize(access_token = nil, client = self.class.default_client)
23
+ @access_token = access_token
24
+ @client = client
25
+ end
26
+
27
+ # Set a new Client object.
28
+ # NET::HTTP is not threadsafe so each time we need to make requests concurrently we need to use a new client.
29
+ #
30
+ # @return [void]
31
+ def reset!
32
+ @client = self.class.default_client
33
+ end
34
+
35
+ private
36
+
37
+ # Add authentication before making the request.
38
+ #
39
+ # @param request [Net::HTTPRequest] A subclass of Net::HTTPRequest.
40
+ #
41
+ # @return [void]
42
+ def authentication(request)
43
+ end
44
+
45
+ # Perform an authenticated GET request.
46
+ #
47
+ # @param path [String] The resource to access.
48
+ #
49
+ # @return (See #request)
50
+ def get(path)
51
+ request(Net::HTTP::Get, path)
52
+ end
53
+
54
+ # Perform an authenticated request.
55
+ #
56
+ # @param verb_class [Net::HTTPRequest] A subclass of Net::HTTPRequest.
57
+ # @param path [String] The resource to access.
58
+ #
59
+ # @return [Hash, String] A decoded JSON response or a String pointing to the Location redirection.
60
+ def request(verb_class, path)
61
+ req = verb_class.new(path)
62
+ req["Accept"] = "application/json"
63
+ authentication(req)
64
+
65
+ response = @client.request(req)
66
+
67
+ case response.code.to_i
68
+ when 200..204
69
+ response.content_type == "application/json" ? JSON.parse(response.body) : response.body
70
+ when 302
71
+ response["Location"]
72
+ else
73
+ raise(Error.new(response.code, response.body, self.class.name.split("::").last))
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require "open-uri"
5
+
6
+ module CIRunner
7
+ module Client
8
+ # Client used for public Buildkite resources.
9
+ # Allow any users to download log output for builds that are in organizations they
10
+ # are not a member of.
11
+ #
12
+ # This client doesn't use the official buildkite API. The data returned are not exactly the same.
13
+ class Buildkite < Base
14
+ API_ENDPOINT = "buildkite.com"
15
+
16
+ # Check if the build is public and can be accessed without authentication.
17
+ #
18
+ # @param org [String] The organizatio name.
19
+ # @param pipeline [String] The pipeline name.
20
+ # @param number [Integer] The build number.
21
+ #
22
+ # @return [Boolean]
23
+ def public_build?(org, pipeline, build_number)
24
+ job_logs(org, pipeline, build_number)
25
+
26
+ true
27
+ rescue Error => e
28
+ return false if e.error_code == 403
29
+
30
+ raise(e)
31
+ end
32
+
33
+ # Retrieve URL paths to download job logs for all steps.
34
+ #
35
+ # @param org [String] The organizatio name.
36
+ # @param pipeline [String] The pipeline name.
37
+ # @param number [Integer] The build number.
38
+ #
39
+ # @return [Array<String>] An array of URL paths
40
+ def job_logs(org, pipeline, build_number)
41
+ @build ||= get("/#{org}/#{pipeline}/builds/#{build_number}")
42
+
43
+ @build["jobs"].map do |job|
44
+ job["base_path"] + "/raw_log"
45
+ end
46
+ end
47
+
48
+ # Download raw log output for a job.
49
+ #
50
+ # @param path [String] A URL path
51
+ #
52
+ # @return [Tempfile, IO] Depending on the size of the response. Quirk of URI.open.
53
+ def download_log(path)
54
+ redirection_url = get(path)
55
+
56
+ URI.open(redirection_url)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module CIRunner
6
+ module Client
7
+ class CircleCI < Base
8
+ API_ENDPOINT = "circleci.com"
9
+
10
+ # Make an API request to get the authenticated user. Used to verify if the access token
11
+ # the user has stored in its config is valid.
12
+ #
13
+ # @return [Hash] See Circle CI documentation.
14
+ #
15
+ # @see https://circleci.com/docs/api/v1/index.html#user
16
+ def me
17
+ get("/api/v1.1/me")
18
+ end
19
+
20
+ # @param repository [String] The full repository name including the owner (rails/rails).
21
+ # @param build_number [Integer] The CircleCI build number.
22
+ #
23
+ # @see https://circleci.com/docs/api/v1/index.html#single-job
24
+ def job(repository, build_number)
25
+ get("/api/v1.1/project/github/#{repository}/#{build_number}")
26
+ rescue Error => e
27
+ reraise_with_reason(e)
28
+ end
29
+
30
+ private
31
+
32
+ # Add authentication before making the request.
33
+ #
34
+ # @param request [Net::HTTPRequest] A subclass of Net::HTTPRequest.
35
+ #
36
+ # @return [void]
37
+ def authentication(request)
38
+ request.basic_auth(@access_token, "") if @access_token
39
+ end
40
+
41
+ # @param error [Client::Error]
42
+ #
43
+ # @raise [Client::Error] A better error message in case of a 404.
44
+ def reraise_with_reason(error)
45
+ if @access_token.nil? && error.error_code == 404
46
+ raise(error, <<~EOM.rstrip)
47
+ 404 while trying to fetch the CircleCI build.
48
+
49
+ {{warning:Please save a CircleCI token in your configuration.}}
50
+ {{command:ci_runner help circle_ci_token}}
51
+ EOM
52
+ else
53
+ raise(error)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CIRunner
4
+ module Client
5
+ class Error < StandardError
6
+ attr_reader :error_code
7
+
8
+ # @param error_code [String] The HTTP status code.
9
+ # @param error_body [String] The response from the provider.
10
+ # @param provider [String] The name of the CI provider.
11
+ # @param message [String, nil]
12
+ def initialize(error_code, error_body, provider, message = nil)
13
+ @error_code = error_code.to_i
14
+
15
+ if message
16
+ super(message)
17
+ else
18
+ super(<<~EOM.rstrip)
19
+ Error while making a request to #{provider}. Code: #{error_code}
20
+
21
+ The response was: #{error_body}
22
+ EOM
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require "open-uri"
5
+
6
+ module CIRunner
7
+ module Client
8
+ # A simple client to interact the GitHub API.
9
+ #
10
+ # @example Using the client
11
+ # Github.new("access_token").me
12
+ class Github < Base
13
+ API_ENDPOINT = "api.github.com"
14
+
15
+ # Make an API request to get the authenticated user. Used to verify if the access token
16
+ # the user has stored in its config is valid.
17
+ #
18
+ # @return [Hash] See GitHub documentation.
19
+ #
20
+ # @see https://docs.github.com/en/rest/users/users#get-the-authenticated-user
21
+ def me
22
+ get("/user")
23
+ end
24
+
25
+ # Get the latest release of a repository.
26
+ #
27
+ # @param repository [String] The full repository name, including the owner (rails/rails)
28
+ #
29
+ # @return [Hash] See GitHub documentation.
30
+ #
31
+ # https://docs.github.com/en/rest/releases/releases#get-the-latest-release
32
+ def latest_release(repository)
33
+ get("/repos/#{repository}/releases/latest")
34
+ end
35
+
36
+ # Makes an API request to get the CI checks for the +commit+.
37
+ #
38
+ # @param repository [String] The full repository name, including the owner (rails/rails)
39
+ # @param commit [String] The Git commit that has been pushed to GitHub.
40
+ #
41
+ # @return [Hash] See GitHub documentation.
42
+ #
43
+ # @see https://docs.github.com/en/rest/checks/runs#list-check-runs-for-a-git-reference
44
+ def check_runs(repository, commit)
45
+ get("/repos/#{repository}/commits/#{commit}/check-runs")
46
+ end
47
+
48
+ # Makes an API request to get the Commit statuses for the +commit+.
49
+ #
50
+ # @param repository [String] The full repository name, including the owner (rails/rails)
51
+ # @param commit [String] The Git commit that has been pushed to GitHub.
52
+ #
53
+ # @return [Hash] See GitHub documentation.
54
+ #
55
+ # @see https://docs.github.com/en/rest/commits/statuses#list-commit-statuses-for-a-reference
56
+ def commit_statuses(repository, commit)
57
+ get("/repos/#{repository}/commits/#{commit}/statuses")
58
+ end
59
+
60
+ # Makes two requests to get the CI log for a check run.
61
+ # The first request returns a 302 containing a Location header poiting to a short lived url to download the log.
62
+ # The second request is to actually download the log.
63
+ #
64
+ # @param repository [String] The full repository name, including the owner (rails/rails)
65
+ # @param check_run_id [Integer] The GitHub ID of the check run.
66
+ #
67
+ # @return [Tempfile, IO] Depending on the size of the response. Quirk of URI.open.
68
+ #
69
+ # @see https://docs.github.com/en/rest/actions/workflow-jobs#download-job-logs-for-a-workflow-run
70
+ def download_log(repository, check_run_id)
71
+ download_url = get("/repos/#{repository}/actions/jobs/#{check_run_id}/logs")
72
+
73
+ URI.open(download_url)
74
+ end
75
+
76
+ private
77
+
78
+ # Add authentication before making the request.
79
+ #
80
+ # @param request [Net::HTTPRequest] A subclass of Net::HTTPRequest.
81
+ #
82
+ # @return [void]
83
+ def authentication(request)
84
+ request.basic_auth("user", @access_token)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -39,6 +39,13 @@ module CIRunner
39
39
  @yaml_config.dig("github", "token")
40
40
  end
41
41
 
42
+ # Retrieve the stored CircleCI access token of the user.
43
+ #
44
+ # @return [String, nil] Depending if the user ran the `ci_runner circle_ci_token TOKEN` command.
45
+ def circle_ci_token
46
+ @yaml_config.dig("circle_ci", "token")
47
+ end
48
+
42
49
  # Write the GitHub token to the user configuration file
43
50
  #
44
51
  # @param token [String] A valid GitHub access token.
@@ -50,6 +57,47 @@ module CIRunner
50
57
  save!(@yaml_config)
51
58
  end
52
59
 
60
+ # Write the Circle CI token to the user configuration file
61
+ #
62
+ # @param token [String] A valid Circle CI access token.
63
+ #
64
+ # @return [void]
65
+ def save_circle_ci_token(token)
66
+ @yaml_config["circle_ci"] = { "token" => token }
67
+
68
+ save!(@yaml_config)
69
+ end
70
+
71
+ # Retrieve the stored Buildkite access token of the user that has access to the +organization+.
72
+ #
73
+ # @return [String, nil] Depending if the user ran the `ci_runner buildkite TOKEN ORGANIZATION` command.
74
+ def buildkite_token(organization)
75
+ @yaml_config.dig("buildkite", "tokens", organization.downcase)
76
+ end
77
+
78
+ # Write the Buildkite token to the user configuration file.
79
+ #
80
+ # @param token [String] A valid Buildkite access token.
81
+ # @param organization [String] The name of the organization the token has access to.
82
+ #
83
+ # @return [void]
84
+ def save_buildkite_token(token, organization)
85
+ existing_tokens = @yaml_config.dig("buildkite", "tokens") || {}
86
+ existing_tokens[organization.downcase] = token
87
+
88
+ @yaml_config["buildkite"] = { "tokens" => existing_tokens }
89
+
90
+ save!(@yaml_config)
91
+ end
92
+
93
+ # @return [Pathname] The path of the CI Runner directory configuration.
94
+ #
95
+ # @example
96
+ # puts config_directory # ~/.ci_runner
97
+ def config_directory
98
+ config_file.dirname
99
+ end
100
+
53
101
  # @return [Pathname] The path of the configuration file.
54
102
  #
55
103
  # @example
@@ -8,44 +8,44 @@ module CIRunner
8
8
  # A PORO to help download and cache a GitHub CI log.
9
9
  #
10
10
  # @example Using the service
11
- # log_dl = LogDownloader.new("commit_sha", "catanacorp/catana", { "id" => 1, "name" => "Ruby Test 3.1.2" })
11
+ # log_dl = LogDownloader.new(
12
+ # CICheck::GitHub.new(
13
+ # "catanacorp/catana",
14
+ # "commit_sha",
15
+ # "Tests Ruby 2.7",
16
+ # "failed",
17
+ # 12345,
18
+ # )
19
+ # )
12
20
  # log_file = log_dl.fetch
13
21
  # puts log_file # => File
14
- #
15
- # @see https://docs.github.com/en/rest/actions/workflow-jobs#download-job-logs-for-a-workflow-run
16
22
  class LogDownloader
17
- # @param commit [String] A Git commit. Used to compute the file name we are going to cache.
18
- # @param repository [String] The repository full name, including the owner (i.e. rails/rails).
19
- # @param check_run [Hash] A GitHub CI check for which we want to download the log.
20
- def initialize(commit, repository, check_run)
21
- @commit = commit
22
- @repository = repository
23
+ # @param check_run [Check::Base] A Base::Check subclass for which we want to download the log.
24
+ def initialize(check_run)
23
25
  @check_run = check_run
24
26
  end
25
27
 
26
- # Download the CI logs from GitHub or retrieve it from disk in case we previously downloaded it.
28
+ # Ask the +@check_run+ to download the log from its CI or retrieve it from disk in case we previously downloaded it.
27
29
  #
28
30
  # @param block [Proc, Lambda] A proc that gets called if fetching the logs from GitHub fails. Allows the CLI to
29
31
  # prematurely exit while cleaning up the CLI::UI frame.
30
32
  #
31
- # @return [File] A file ready to be read.
33
+ # @return [Pathname] The path to the log file.
32
34
  def fetch(&block)
33
35
  return cached_log if cached_log
34
36
 
35
- github_client = GithubClient.new(Configuration::User.instance.github_token)
36
37
  error = nil
37
38
 
38
- ::CLI::UI.spinner("Downloading CI logs from GitHub", auto_debrief: false) do
39
- logfile = github_client.download_log(@repository, @check_run["id"])
40
-
41
- cache_log(logfile)
42
- rescue GithubClient::Error => e
39
+ ::CLI::UI.spinner("Downloading CI logs from #{@check_run.provider}", auto_debrief: false) do
40
+ cache_log(@check_run.download_log)
41
+ rescue Client::Error, Error => e
43
42
  error = e
44
43
 
45
44
  ::CLI::UI::Spinner::TASK_FAILED
46
45
  end
47
46
 
48
47
  block.call(error) if error
48
+
49
49
  cached_log
50
50
  end
51
51
 
@@ -74,14 +74,14 @@ module CIRunner
74
74
  # @example Given a repository "rails/rails". A CI check called "Ruby 3.0". A commit "abcdef".
75
75
  # puts computed_filed_path # ==> /var/tmpdir/T/.../rails/rails/log-abcdef-Ruby 3.0
76
76
  def computed_file_path
77
- normalized_run_name = @check_run["name"].tr("/", "_")
77
+ normalized_run_name = @check_run.name.tr("/", "_")
78
78
 
79
- log_folder.join("log-#{@commit[0..12]}-#{normalized_run_name}.log")
79
+ log_folder.join("log-#{@check_run.commit[0..12]}-#{normalized_run_name}.log")
80
80
  end
81
81
 
82
82
  # @return [Pathname]
83
83
  def log_folder
84
- Pathname(Dir.tmpdir).join(@repository)
84
+ Pathname(Dir.tmpdir).join(@check_run.repository)
85
85
  end
86
86
 
87
87
  # @return [Pathname, false] Depending if the log has been downloaded before.
@@ -43,13 +43,15 @@ module CIRunner
43
43
  # @return [void]
44
44
  def parse!
45
45
  @ci_log.each_line do |line|
46
- case line
46
+ line_no_ansi_color = line.gsub(/\e\[\d+m/, "")
47
+
48
+ case line_no_ansi_color
47
49
  when seed_regex
48
50
  @seed = first_matching_group(Regexp.last_match)
49
51
  when ruby_detection_regex
50
52
  @ruby_version = first_matching_group(Regexp.last_match)
51
53
 
52
- @buffer << line if buffering?
54
+ @buffer << line_no_ansi_color if buffering?
53
55
  when gemfile_detection_regex
54
56
  @gemfile = first_matching_group(Regexp.last_match)
55
57
  when buffer_detection_regex
@@ -58,9 +60,9 @@ module CIRunner
58
60
  @buffer.clear
59
61
  end
60
62
 
61
- @buffer << line
63
+ @buffer << line_no_ansi_color
62
64
  else
63
- @buffer << line if buffering?
65
+ @buffer << line_no_ansi_color if buffering?
64
66
  end
65
67
  end
66
68
 
@@ -40,7 +40,7 @@ module CIRunner
40
40
  def self.match?(ci_log)
41
41
  default_reporter = %r{(Finished in) \d+\.\d{6}s, \d+\.\d{4} runs/s, \d+\.\d{4} assertions/s\.}
42
42
 
43
- Regexp.union(default_reporter, SEED_REGEX, "minitest").match?(ci_log)
43
+ Regexp.union(default_reporter, SEED_REGEX).match?(ci_log)
44
44
  end
45
45
 
46
46
  # @return [String] See Runners::Base#report
@@ -15,7 +15,7 @@ module CIRunner
15
15
  command = /bundle exec rspec/
16
16
  summary = /Failed examples:/
17
17
 
18
- Regexp.union(command, summary, /rspec/i).match?(log)
18
+ Regexp.union(command, summary).match?(log)
19
19
  end
20
20
 
21
21
  # @return [String] See Runners::Base#report
@@ -56,6 +56,9 @@ module CIRunner
56
56
 
57
57
  regex = %r{.*/?(test/.*?)\Z}
58
58
  unless path.to_s.match?(regex)
59
+ # TODO(on: '2022-09-17', to: "edouard-chin") Revisit this as it's too brittle.
60
+ # If a test file doesn't live the in the `test/` root folder, this will raise an error.
61
+ # I should instead warn the user and move on.
59
62
  raise "Can't create a relative path."
60
63
  end
61
64