From 7345607ca04f01363ffa90d053c57f922b68dfb3 Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Mon, 16 Jun 2025 17:33:24 +0100 Subject: [PATCH] a*.rb: move to strict Sorbet sigil. Co-authored-by: Rylan Polster --- Library/Homebrew/api.rb | 26 ++- Library/Homebrew/api_hashable.rb | 15 +- Library/Homebrew/bundle/commands/add.rb | 3 +- .../dev-cmd/generate-cask-ci-matrix.rb | 4 +- .../Homebrew/requirements/arch_requirement.rb | 8 +- Library/Homebrew/system_config.rb | 3 +- Library/Homebrew/utils/github/api.rb | 165 ++++++++++++------ Library/Homebrew/utils/github/artifacts.rb | 4 +- 8 files changed, 154 insertions(+), 74 deletions(-) diff --git a/Library/Homebrew/api.rb b/Library/Homebrew/api.rb index 3948bcde3d..a1420cab7e 100644 --- a/Library/Homebrew/api.rb +++ b/Library/Homebrew/api.rb @@ -1,4 +1,4 @@ -# typed: true # rubocop:todo Sorbet/StrictSigil +# typed: strict # frozen_string_literal: true require "api/analytics" @@ -11,10 +11,10 @@ module Homebrew module API extend Cachable - HOMEBREW_CACHE_API = (HOMEBREW_CACHE/"api").freeze - HOMEBREW_CACHE_API_SOURCE = (HOMEBREW_CACHE/"api-source").freeze + HOMEBREW_CACHE_API = T.let((HOMEBREW_CACHE/"api").freeze, Pathname) + HOMEBREW_CACHE_API_SOURCE = T.let((HOMEBREW_CACHE/"api-source").freeze, Pathname) - sig { params(endpoint: String).returns(Hash) } + sig { params(endpoint: String).returns(T::Hash[String, T.untyped]) } def self.fetch(endpoint) return cache[endpoint] if cache.present? && cache.key?(endpoint) @@ -33,7 +33,8 @@ module Homebrew end sig { - params(endpoint: String, target: Pathname, stale_seconds: Integer).returns([T.any(Array, Hash), T::Boolean]) + params(endpoint: String, target: Pathname, + stale_seconds: Integer).returns([T.any(T::Array[T.untyped], T::Hash[String, T.untyped]), T::Boolean]) } def self.fetch_json_api_file(endpoint, target: HOMEBREW_CACHE_API/endpoint, stale_seconds: Homebrew::EnvConfig.api_auto_update_secs.to_i) @@ -96,7 +97,8 @@ module Homebrew mtime = insecure_download ? Time.new(1970, 1, 1) : Time.now FileUtils.touch(target, mtime:) unless skip_download - JSON.parse(target.read(encoding: Encoding::UTF_8), freeze: true) + # Can use `target.read` again when/if https://github.com/sorbet/sorbet/pull/8999 is merged/released. + JSON.parse(File.read(target, encoding: Encoding::UTF_8), freeze: true) rescue JSON::ParserError target.unlink retry_count += 1 @@ -122,8 +124,11 @@ module Homebrew end end - sig { params(json: Hash, bottle_tag: T.nilable(::Utils::Bottles::Tag)).returns(Hash) } - def self.merge_variations(json, bottle_tag: nil) + sig { + params(json: T::Hash[String, T.untyped], + bottle_tag: ::Utils::Bottles::Tag).returns(T::Hash[String, T.untyped]) + } + def self.merge_variations(json, bottle_tag: T.unsafe(nil)) return json unless json.key?("variations") bottle_tag ||= Homebrew::SimulateSystem.current_tag @@ -147,7 +152,10 @@ module Homebrew false end - sig { params(json_data: Hash).returns([T::Boolean, T.any(String, Array, Hash)]) } + sig { + params(json_data: T::Hash[String, T.untyped]) + .returns([T::Boolean, T.any(String, T::Array[T.untyped], T::Hash[String, T.untyped])]) + } private_class_method def self.verify_and_parse_jws(json_data) signatures = json_data["signatures"] homebrew_signature = signatures&.find { |sig| sig.dig("header", "kid") == "homebrew-1" } diff --git a/Library/Homebrew/api_hashable.rb b/Library/Homebrew/api_hashable.rb index c520d6ff65..7c64f378d9 100644 --- a/Library/Homebrew/api_hashable.rb +++ b/Library/Homebrew/api_hashable.rb @@ -1,24 +1,26 @@ -# typed: true # rubocop:todo Sorbet/StrictSigil +# typed: strict # frozen_string_literal: true # Used to substitute common paths with generic placeholders when generating JSON for the API. module APIHashable + sig { void } def generating_hash! return if generating_hash? # Apply monkeypatches for API generation - @old_homebrew_prefix = HOMEBREW_PREFIX - @old_homebrew_cellar = HOMEBREW_CELLAR - @old_home = Dir.home - @old_git_config_global = ENV.fetch("GIT_CONFIG_GLOBAL", nil) + @old_homebrew_prefix = T.let(HOMEBREW_PREFIX, T.nilable(Pathname)) + @old_homebrew_cellar = T.let(HOMEBREW_CELLAR, T.nilable(Pathname)) + @old_home = T.let(Dir.home, T.nilable(String)) + @old_git_config_global = T.let(ENV.fetch("GIT_CONFIG_GLOBAL", nil), T.nilable(String)) Object.send(:remove_const, :HOMEBREW_PREFIX) Object.const_set(:HOMEBREW_PREFIX, Pathname.new(HOMEBREW_PREFIX_PLACEHOLDER)) ENV["HOME"] = HOMEBREW_HOME_PLACEHOLDER ENV["GIT_CONFIG_GLOBAL"] = File.join(@old_home, ".gitconfig") - @generating_hash = true + @generating_hash = T.let(true, T.nilable(T::Boolean)) end + sig { void } def generated_hash! return unless generating_hash? @@ -31,6 +33,7 @@ module APIHashable @generating_hash = false end + sig { returns(T::Boolean) } def generating_hash? @generating_hash ||= false @generating_hash == true diff --git a/Library/Homebrew/bundle/commands/add.rb b/Library/Homebrew/bundle/commands/add.rb index fe26cd8a28..60718601fb 100644 --- a/Library/Homebrew/bundle/commands/add.rb +++ b/Library/Homebrew/bundle/commands/add.rb @@ -1,4 +1,4 @@ -# typed: true # rubocop:todo Sorbet/StrictSigil +# typed: strict # frozen_string_literal: true require "bundle/adder" @@ -7,6 +7,7 @@ module Homebrew module Bundle module Commands module Add + sig { params(args: String, type: Symbol, global: T::Boolean, file: T.nilable(String)).void } def self.run(*args, type:, global:, file:) Homebrew::Bundle::Adder.add(*args, type:, global:, file:) end diff --git a/Library/Homebrew/dev-cmd/generate-cask-ci-matrix.rb b/Library/Homebrew/dev-cmd/generate-cask-ci-matrix.rb index dfaa9a050e..215cb7360e 100644 --- a/Library/Homebrew/dev-cmd/generate-cask-ci-matrix.rb +++ b/Library/Homebrew/dev-cmd/generate-cask-ci-matrix.rb @@ -67,8 +67,8 @@ module Homebrew end raise UsageError, "Only one url can be specified" if pr_url&.count&.> 1 - labels = if pr_url - pr = GitHub::API.open_rest(pr_url.first) + labels = if pr_url && (first_pr_url = pr_url.first) + pr = GitHub::API.open_rest(first_pr_url) pr.fetch("labels").map { |l| l.fetch("name") } else [] diff --git a/Library/Homebrew/requirements/arch_requirement.rb b/Library/Homebrew/requirements/arch_requirement.rb index 65940a9946..4847d97774 100644 --- a/Library/Homebrew/requirements/arch_requirement.rb +++ b/Library/Homebrew/requirements/arch_requirement.rb @@ -1,4 +1,4 @@ -# typed: true # rubocop:todo Sorbet/StrictSigil +# typed: strict # frozen_string_literal: true require "requirement" @@ -7,10 +7,14 @@ require "requirement" class ArchRequirement < Requirement fatal true + @arch = T.let(nil, T.nilable(Symbol)) + + sig { returns(T.nilable(Symbol)) } attr_reader :arch + sig { params(tags: T::Array[Symbol]).void } def initialize(tags) - @arch = tags.shift + @arch = T.let(tags.shift, T.nilable(Symbol)) super end diff --git a/Library/Homebrew/system_config.rb b/Library/Homebrew/system_config.rb index 80183666fa..3a1a53cb44 100644 --- a/Library/Homebrew/system_config.rb +++ b/Library/Homebrew/system_config.rb @@ -132,7 +132,8 @@ module SystemConfig out.puts "#{tap_name} branch: #{tap.git_branch || "(none)"}" if default_branches.exclude?(tap.git_branch) end - if (json_file = Homebrew::API::HOMEBREW_CACHE_API/json_file_name) && json_file.exist? + json_file = Homebrew::API::HOMEBREW_CACHE_API/json_file_name + if json_file.exist? out.puts "#{tap_name} JSON: #{json_file.mtime.utc.strftime("%d %b %H:%M UTC")}" elsif !tap.installed? out.puts "#{tap_name}: N/A" diff --git a/Library/Homebrew/utils/github/api.rb b/Library/Homebrew/utils/github/api.rb index 6891ebf740..7caed0a38a 100644 --- a/Library/Homebrew/utils/github/api.rb +++ b/Library/Homebrew/utils/github/api.rb @@ -1,9 +1,10 @@ -# typed: true # rubocop:todo Sorbet/StrictSigil +# typed: strict # frozen_string_literal: true require "system_command" module GitHub + sig { params(scopes: T::Array[String]).returns(String) } def self.pat_blurb(scopes = ALL_SCOPES) require "utils/formatter" require "utils/shell" @@ -16,20 +17,21 @@ module GitHub EOS end - API_URL = "https://api.github.com" - API_MAX_PAGES = 50 + API_URL = T.let("https://api.github.com", String) + API_MAX_PAGES = T.let(50, Integer) private_constant :API_MAX_PAGES - API_MAX_ITEMS = 5000 + API_MAX_ITEMS = T.let(5000, Integer) private_constant :API_MAX_ITEMS - PAGINATE_RETRY_COUNT = 3 + PAGINATE_RETRY_COUNT = T.let(3, Integer) private_constant :PAGINATE_RETRY_COUNT - CREATE_GIST_SCOPES = ["gist"].freeze - CREATE_ISSUE_FORK_OR_PR_SCOPES = ["repo"].freeze - CREATE_WORKFLOW_SCOPES = ["workflow"].freeze - ALL_SCOPES = (CREATE_GIST_SCOPES + CREATE_ISSUE_FORK_OR_PR_SCOPES + CREATE_WORKFLOW_SCOPES).freeze + CREATE_GIST_SCOPES = T.let(["gist"].freeze, T::Array[String]) + CREATE_ISSUE_FORK_OR_PR_SCOPES = T.let(["repo"].freeze, T::Array[String]) + CREATE_WORKFLOW_SCOPES = T.let(["workflow"].freeze, T::Array[String]) + ALL_SCOPES = T.let((CREATE_GIST_SCOPES + CREATE_ISSUE_FORK_OR_PR_SCOPES + CREATE_WORKFLOW_SCOPES).freeze, + T::Array[String]) private_constant :ALL_SCOPES - GITHUB_PERSONAL_ACCESS_TOKEN_REGEX = /^(?:[a-f0-9]{40}|(?:gh[pousr]|github_pat)_\w{36,251})$/ + GITHUB_PERSONAL_ACCESS_TOKEN_REGEX = T.let(/^(?:[a-f0-9]{40}|(?:gh[pousr]|github_pat)_\w{36,251})$/, Regexp) private_constant :GITHUB_PERSONAL_ACCESS_TOKEN_REGEX # Helper functions for accessing the GitHub API. @@ -40,46 +42,60 @@ module GitHub # Generic API error. class Error < RuntimeError + sig { returns(T.nilable(String)) } attr_reader :github_message + + sig { params(message: T.nilable(String), github_message: String).void } + def initialize(message = nil, github_message = T.unsafe(nil)) + @github_message = T.let(github_message, T.nilable(String)) + super(message) + end end # Error when the requested URL is not found. class HTTPNotFoundError < Error + sig { params(github_message: String).void } def initialize(github_message) - @github_message = github_message - super + super(nil, github_message) end end # Error when the API rate limit is exceeded. class RateLimitExceededError < Error + sig { params(reset: Integer, github_message: String).void } def initialize(reset, github_message) - @github_message = github_message new_pat_message = ", or:\n#{GitHub.pat_blurb}" if API.credentials.blank? - super <<~EOS + message = <<~EOS GitHub API Error: #{github_message} Try again in #{pretty_ratelimit_reset(reset)}#{new_pat_message} EOS + super(message, github_message) end + sig { params(reset: Integer).returns(String) } def pretty_ratelimit_reset(reset) pretty_duration(Time.at(reset) - Time.now) end end - GITHUB_IP_ALLOWLIST_ERROR = Regexp.new("Although you appear to have the correct authorization credentials, " \ - "the `(.+)` organization has an IP allow list enabled, " \ - "and your IP address is not permitted to access this resource").freeze + GITHUB_IP_ALLOWLIST_ERROR = T.let( + Regexp.new( + "Although you appear to have the correct authorization credentials, " \ + "the `(.+)` organization has an IP allow list enabled, " \ + "and your IP address is not permitted to access this resource", + ).freeze, + Regexp, + ) - NO_CREDENTIALS_MESSAGE = <<~MESSAGE.freeze + NO_CREDENTIALS_MESSAGE = T.let <<~MESSAGE.freeze, String No GitHub credentials found in macOS Keychain, GitHub CLI or the environment. #{GitHub.pat_blurb} MESSAGE # Error when authentication fails. class AuthenticationFailedError < Error + sig { params(credentials_type: Symbol, github_message: String).void } def initialize(credentials_type, github_message) - @github_message = github_message message = "GitHub API Error: #{github_message}\n" message << case credentials_type when :github_cli_token @@ -103,12 +119,13 @@ module GitHub when :none NO_CREDENTIALS_MESSAGE end - super message.freeze + super message.freeze, github_message end end # Error when the user has no GitHub API credentials set at all (macOS keychain, GitHub CLI or envvar). class MissingAuthenticationError < Error + sig { void } def initialize super NO_CREDENTIALS_MESSAGE end @@ -116,24 +133,21 @@ module GitHub # Error when the API returns a validation error. class ValidationFailedError < Error + sig { params(github_message: String, errors: T::Array[String]).void } def initialize(github_message, errors) - @github_message = if errors.empty? - github_message - else - "#{github_message}: #{errors}" - end + github_message = "#{github_message}: #{errors}" unless errors.empty? - super(@github_message) + super(github_message, github_message) end end - ERRORS = [ + ERRORS = T.let([ AuthenticationFailedError, HTTPNotFoundError, RateLimitExceededError, Error, JSON::ParserError, - ].freeze + ].freeze, T::Array[T.any(T.class_of(Error), T.class_of(JSON::ParserError))]) # Gets the token from the GitHub CLI for github.com. sig { returns(T.nilable(String)) } @@ -151,7 +165,7 @@ module GitHub print_stderr: false return unless result.success? - gh_out.chomp + gh_out.chomp.presence end end @@ -178,14 +192,16 @@ module GitHub # https://github.com/Homebrew/brew/issues/6862#issuecomment-572610344 return unless GITHUB_PERSONAL_ACCESS_TOKEN_REGEX.match?(github_password) - github_password + github_password.presence end end + sig { returns(T.nilable(String)) } def self.credentials + @credentials ||= T.let(nil, T.nilable(String)) @credentials ||= Homebrew::EnvConfig.github_api_token.presence - @credentials ||= github_cli_token.presence - @credentials ||= keychain_username_password.presence + @credentials ||= github_cli_token + @credentials ||= keychain_username_password end sig { returns(Symbol) } @@ -201,18 +217,19 @@ module GitHub end end - CREDENTIAL_NAMES = { + CREDENTIAL_NAMES = T.let({ env_token: "HOMEBREW_GITHUB_API_TOKEN", github_cli_token: "GitHub CLI login", keychain_username_password: "macOS Keychain GitHub", - }.freeze + }.freeze, T::Hash[Symbol, String]) # Given an API response from GitHub, warn the user if their credentials # have insufficient permissions. + sig { params(response_headers: T::Hash[String, String], needed_scopes: T::Array[String]).void } def self.credentials_error_message(response_headers, needed_scopes) return if response_headers.empty? - scopes = response_headers["x-accepted-oauth-scopes"].to_s.split(", ") + scopes = response_headers["x-accepted-oauth-scopes"].to_s.split(", ").presence needed_scopes = Set.new(scopes || needed_scopes) credentials_scopes = response_headers["x-oauth-scopes"] return if needed_scopes.subset?(Set.new(credentials_scopes.to_s.split(", "))) @@ -222,17 +239,35 @@ module GitHub credentials_scopes = "none" if credentials_scopes.blank? what = CREDENTIAL_NAMES.fetch(credentials_type) - @credentials_error_message ||= onoe <<~EOS - Your #{what} credentials do not have sufficient scope! - Scopes required: #{needed_scopes} - Scopes present: #{credentials_scopes} - #{github_permission_link} - EOS + @credentials_error_message ||= T.let(begin + error_message = <<~EOS + Your #{what} credentials do not have sufficient scope! + Scopes required: #{needed_scopes} + Scopes present: #{credentials_scopes} + #{github_permission_link} + EOS + onoe error_message + error_message + end, T.nilable(String)) end - def self.open_rest( - url, data: nil, data_binary_path: nil, request_method: nil, scopes: [].freeze, parse_json: true - ) + sig { + params( + url: T.any(String, URI::Generic), + data: T::Hash[Symbol, T.untyped], + data_binary_path: String, + request_method: Symbol, + scopes: T::Array[String], + parse_json: T::Boolean, + _block: T.nilable( + T.proc + .params(data: T::Hash[String, T.untyped]) + .returns(T.untyped), + ), + ).returns(T.untyped) + } + def self.open_rest(url, data: T.unsafe(nil), data_binary_path: T.unsafe(nil), request_method: T.unsafe(nil), + scopes: [].freeze, parse_json: true, &_block) # This is a no-op if the user is opting out of using the GitHub API. return block_given? ? yield({}) : {} if Homebrew::EnvConfig.no_github_api? @@ -289,7 +324,7 @@ module GitHub begin if !http_code.start_with?("2") || !result.status.success? - raise_error(output, result.stderr, http_code, headers, scopes) + raise_error(output, result.stderr, http_code, headers || "", scopes) end return if http_code == "204" # No Content @@ -305,7 +340,18 @@ module GitHub end end - def self.paginate_rest(url, additional_query_params: nil, per_page: 100, scopes: [].freeze) + sig { + params( + url: T.any(String, URI::Generic), + additional_query_params: String, + per_page: Integer, + scopes: T::Array[String], + _block: T.proc + .params(result: T.untyped, page: Integer) + .returns(T.untyped), + ).void + } + def self.paginate_rest(url, additional_query_params: T.unsafe(nil), per_page: 100, scopes: [].freeze, &_block) (1..API_MAX_PAGES).each do |page| retry_count = 1 result = begin @@ -324,9 +370,17 @@ module GitHub end end - def self.open_graphql(query, variables: nil, scopes: [].freeze, raise_errors: true) + sig { + params( + query: String, + variables: T::Hash[Symbol, T.untyped], + scopes: T::Array[String], + raise_errors: T::Boolean, + ).returns(T.untyped) + } + def self.open_graphql(query, variables: T.unsafe(nil), scopes: [].freeze, raise_errors: true) data = { query:, variables: } - result = open_rest("#{API_URL}/graphql", scopes:, data:, request_method: "POST") + result = open_rest("#{API_URL}/graphql", scopes:, data:, request_method: :POST) if raise_errors raise Error, result["errors"].map { |e| e["message"] }.join("\n") if result["errors"].present? @@ -340,13 +394,13 @@ module GitHub sig { params( query: String, - variables: T.nilable(T::Hash[Symbol, T.untyped]), + variables: T::Hash[Symbol, T.untyped], scopes: T::Array[String], raise_errors: T::Boolean, - _block: T.proc.params(data: T::Hash[String, T.untyped]).returns(T::Hash[String, T.untyped]), + _block: T.proc.params(data: T::Hash[String, T.untyped]).returns(T.untyped), ).void } - def self.paginate_graphql(query, variables: nil, scopes: [].freeze, raise_errors: true, &_block) + def self.paginate_graphql(query, variables: T.unsafe(nil), scopes: [].freeze, raise_errors: true, &_block) result = API.open_graphql(query, variables:, scopes:, raise_errors:) has_next_page = T.let(true, T::Boolean) @@ -361,6 +415,15 @@ module GitHub end end + sig { + params( + output: String, + errors: String, + http_code: String, + headers: String, + scopes: T::Array[String], + ).void + } def self.raise_error(output, errors, http_code, headers, scopes) json = begin JSON.parse(output) diff --git a/Library/Homebrew/utils/github/artifacts.rb b/Library/Homebrew/utils/github/artifacts.rb index 1bfc967d4f..e0463e9fd0 100644 --- a/Library/Homebrew/utils/github/artifacts.rb +++ b/Library/Homebrew/utils/github/artifacts.rb @@ -11,11 +11,11 @@ module GitHub # @param artifact_id [String] a value that uniquely identifies the downloaded artifact sig { params(url: String, artifact_id: String).void } def self.download_artifact(url, artifact_id) - raise API::MissingAuthenticationError if API.credentials == :none + token = API.credentials + raise API::MissingAuthenticationError if token.blank? # We use a download strategy here to leverage the Homebrew cache # to avoid repeated downloads of (possibly large) bottles. - token = API.credentials downloader = GitHubArtifactDownloadStrategy.new(url, artifact_id, token:) downloader.fetch downloader.stage