ci_runner 0.1.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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