Merge pull request #20121 from Homebrew/sorbet_strict_a

a*.rb: move to strict Sorbet sigil.
This commit is contained in:
Mike McQuaid 2025-06-17 10:05:27 +00:00 committed by GitHub
commit d7d8c61f00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 154 additions and 74 deletions

View File

@ -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" }

View File

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

View File

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

View File

@ -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
[]

View File

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

View File

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

View File

@ -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, " \
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
"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
@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)

View File

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