Sam Ford a89457fcb9
bump: skip PR checking when up to date
`brew bump` will check for PRs related to a package even if the
package version and livecheck version are the same. We're presumably
only interested in related PRs when the livecheck version differs, so
we can reduce GitHub API requests by skipping the check(s) when the
versions are equal. We use `bump` in `autobump` workflows, so this
should help with recent rate limiting issues (e.g., 3203 out of 3426
autobumped formulae were up to date in a recent run).

This also reworks the output for duplicate PRs, making it clear when
`bump` skipped checking PRs (as printing "none" would suggest that
PRs were checked) and only printing the "Maybe duplicate" information
when checked. This makes it a little easier to understand what `bump`
has done internally from the output.
2024-11-24 20:50:37 -05:00

543 lines
21 KiB
Ruby

# typed: strict
# frozen_string_literal: true
require "abstract_command"
require "bump_version_parser"
require "livecheck/livecheck"
require "utils/repology"
module Homebrew
module DevCmd
class Bump < AbstractCommand
class VersionBumpInfo < T::Struct
const :type, Symbol
const :multiple_versions, T::Boolean
const :version_name, String
const :current_version, BumpVersionParser
const :repology_latest, T.any(String, Version)
const :new_version, BumpVersionParser
const :duplicate_pull_requests, T.nilable(T.any(T::Array[String], String))
const :maybe_duplicate_pull_requests, T.nilable(T.any(T::Array[String], String))
end
cmd_args do
description <<~EOS
Displays out-of-date packages and the latest version available. If the
returned current and livecheck versions differ or when querying specific
packages, also displays whether a pull request has been opened with the URL.
EOS
switch "--full-name",
description: "Print formulae/casks with fully-qualified names."
switch "--no-pull-requests",
description: "Do not retrieve pull requests from GitHub."
switch "--auto",
description: "Read the list of formulae/casks from the tap autobump list.",
hidden: true
switch "--formula", "--formulae",
description: "Check only formulae."
switch "--cask", "--casks",
description: "Check only casks."
switch "--eval-all",
description: "Evaluate all formulae and casks."
switch "--repology",
description: "Use Repology to check for outdated packages."
flag "--tap=",
description: "Check formulae and casks within the given tap, specified as <user>`/`<repo>."
switch "--installed",
description: "Check formulae and casks that are currently installed."
switch "--no-fork",
description: "Don't try to fork the repository."
switch "--open-pr",
description: "Open a pull request for the new version if none have been opened yet."
flag "--start-with=",
description: "Letter or word that the list of package results should alphabetically follow."
conflicts "--cask", "--formula"
conflicts "--tap=", "--installed"
conflicts "--eval-all", "--installed"
conflicts "--installed", "--auto"
conflicts "--no-pull-requests", "--open-pr"
named_args [:formula, :cask], without_api: true
end
sig { override.void }
def run
Homebrew.install_bundler_gems!(groups: ["livecheck"])
Homebrew.with_no_api_env do
eval_all = args.eval_all? || Homebrew::EnvConfig.eval_all?
formulae_and_casks = if args.auto?
raise UsageError, "`--formula` or `--cask` must be passed with `--auto`." if !args.formula? && !args.cask?
tap_arg = args.tap
raise UsageError, "`--tap=` must be passed with `--auto`." if tap_arg.blank?
tap = Tap.fetch(tap_arg)
autobump_list = tap.autobump
what = args.cask? ? "casks" : "formulae"
raise UsageError, "No autobumped #{what} found." if autobump_list.blank?
autobump_list.map do |name|
qualified_name = "#{tap.name}/#{name}"
next Cask::CaskLoader.load(qualified_name) if args.cask?
Formulary.factory(qualified_name)
end
elsif args.tap
tap = Tap.fetch(T.must(args.tap))
raise UsageError, "`--tap` requires `--auto` for official taps." if tap.official?
formulae = args.cask? ? [] : tap.formula_files.map { |path| Formulary.factory(path) }
casks = args.formula? ? [] : tap.cask_files.map { |path| Cask::CaskLoader.load(path) }
formulae + casks
elsif args.installed?
formulae = args.cask? ? [] : Formula.installed
casks = args.formula? ? [] : Cask::Caskroom.casks
formulae + casks
elsif args.named.present?
args.named.to_formulae_and_casks_with_taps
elsif eval_all
formulae = args.cask? ? [] : Formula.all(eval_all:)
casks = args.formula? ? [] : Cask::Cask.all(eval_all:)
formulae + casks
else
raise UsageError,
"`brew bump` without named arguments needs `--installed` or `--eval-all` passed or " \
"`HOMEBREW_EVAL_ALL` set!"
end
if args.start_with
formulae_and_casks.select! do |formula_or_cask|
name = formula_or_cask.respond_to?(:token) ? formula_or_cask.token : formula_or_cask.name
name.start_with?(args.start_with)
end
end
formulae_and_casks = formulae_and_casks&.sort_by do |formula_or_cask|
formula_or_cask.respond_to?(:token) ? formula_or_cask.token : formula_or_cask.name
end
if args.repology? && !Utils::Curl.curl_supports_tls13?
begin
ensure_formula_installed!("curl", reason: "Repology queries") unless HOMEBREW_BREWED_CURL_PATH.exist?
rescue FormulaUnavailableError
opoo "A newer `curl` is required for Repology queries."
end
end
handle_formulae_and_casks(formulae_and_casks)
end
end
private
sig { params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(T::Boolean) }
def skip_repology?(formula_or_cask)
return true unless args.repology?
(ENV["CI"].present? && args.open_pr? && formula_or_cask.livecheckable?) ||
(formula_or_cask.is_a?(Formula) && formula_or_cask.versioned_formula?)
end
sig { params(formulae_and_casks: T::Array[T.any(Formula, Cask::Cask)]).void }
def handle_formulae_and_casks(formulae_and_casks)
Livecheck.load_other_tap_strategies(formulae_and_casks)
ambiguous_casks = []
if !args.formula? && !args.cask?
ambiguous_casks = formulae_and_casks
.group_by { |item| Livecheck.package_or_resource_name(item, full_name: true) }
.values
.select { |items| items.length > 1 }
.flatten
.select { |item| item.is_a?(Cask::Cask) }
end
ambiguous_names = []
unless args.full_name?
ambiguous_names = (formulae_and_casks - ambiguous_casks)
.group_by { |item| Livecheck.package_or_resource_name(item) }
.values
.select { |items| items.length > 1 }
.flatten
end
formulae_and_casks.each_with_index do |formula_or_cask, i|
puts if i.positive?
next if skip_ineligible_formulae(formula_or_cask)
use_full_name = args.full_name? || ambiguous_names.include?(formula_or_cask)
name = Livecheck.package_or_resource_name(formula_or_cask, full_name: use_full_name)
repository = if formula_or_cask.is_a?(Formula)
Repology::HOMEBREW_CORE
else
Repology::HOMEBREW_CASK
end
package_data = Repology.single_package_query(name, repository:) unless skip_repology?(formula_or_cask)
retrieve_and_display_info_and_open_pr(
formula_or_cask,
name,
package_data&.values&.first || [],
ambiguous_cask: ambiguous_casks.include?(formula_or_cask),
)
end
end
sig {
params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(T::Boolean)
}
def skip_ineligible_formulae(formula_or_cask)
if formula_or_cask.is_a?(Formula)
skip = formula_or_cask.disabled? || formula_or_cask.head_only?
name = formula_or_cask.name
text = "Formula is #{formula_or_cask.disabled? ? "disabled" : "HEAD-only"} so not accepting updates.\n"
else
skip = formula_or_cask.disabled?
name = formula_or_cask.token
text = "Cask is disabled so not accepting updates.\n"
end
if (tap = formula_or_cask.tap) && !tap.allow_bump?(name)
skip = true
text = "#{text.split.first} is autobumped so will have bump PRs opened by BrewTestBot every ~3 hours.\n"
end
return false unless skip
ohai name
puts text
true
end
sig {
params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(T.any(Version, String))
}
def livecheck_result(formula_or_cask)
name = Livecheck.package_or_resource_name(formula_or_cask)
referenced_formula_or_cask, = Livecheck.resolve_livecheck_reference(
formula_or_cask,
full_name: false,
debug: false,
)
# Check skip conditions for a referenced formula/cask
if referenced_formula_or_cask
skip_info = Livecheck::SkipConditions.referenced_skip_information(
referenced_formula_or_cask,
name,
full_name: false,
verbose: false,
)
end
skip_info ||= Livecheck::SkipConditions.skip_information(
formula_or_cask,
full_name: false,
verbose: false,
)
if skip_info.present?
return "#{skip_info[:status]}" \
"#{" - #{skip_info[:messages].join(", ")}" if skip_info[:messages].present?}"
end
version_info = Livecheck.latest_version(
formula_or_cask,
referenced_formula_or_cask:,
json: true, full_name: false, verbose: true, debug: false
)
return "unable to get versions" if version_info.blank?
if !version_info.key?(:latest_throttled)
Version.new(version_info[:latest])
elsif version_info[:latest_throttled].nil?
"unable to get throttled versions"
else
Version.new(version_info[:latest_throttled])
end
rescue => e
"error: #{e}"
end
sig {
params(
formula_or_cask: T.any(Formula, Cask::Cask),
name: String,
version: T.nilable(String),
).returns T.nilable(T.any(T::Array[String], String))
}
def retrieve_pull_requests(formula_or_cask, name, version: nil)
tap_remote_repo = formula_or_cask.tap&.remote_repository || formula_or_cask.tap&.full_name
pull_requests = begin
GitHub.fetch_pull_requests(name, tap_remote_repo, version:)
rescue GitHub::API::ValidationFailedError => e
odebug "Error fetching pull requests for #{formula_or_cask} #{name}: #{e}"
nil
end
return if pull_requests.blank?
pull_requests.map { |pr| "#{pr["title"]} (#{Formatter.url(pr["html_url"])})" }.join(", ")
end
sig {
params(
formula_or_cask: T.any(Formula, Cask::Cask),
repositories: T::Array[String],
name: String,
).returns(VersionBumpInfo)
}
def retrieve_versions_by_arch(formula_or_cask:, repositories:, name:)
is_cask_with_blocks = formula_or_cask.is_a?(Cask::Cask) && formula_or_cask.on_system_blocks_exist?
type, version_name = if formula_or_cask.is_a?(Formula)
[:formula, "formula version:"]
else
[:cask, "cask version: "]
end
old_versions = {}
new_versions = {}
repology_latest = repositories.present? ? Repology.latest_version(repositories) : "not found"
# When blocks are absent, arch is not relevant. For consistency, we simulate the arm architecture.
arch_options = is_cask_with_blocks ? OnSystem::ARCH_OPTIONS : [:arm]
arch_options.each do |arch|
SimulateSystem.with(arch:) do
version_key = is_cask_with_blocks ? arch : :general
# We reload the formula/cask here to ensure we're getting the correct version for the current arch
if formula_or_cask.is_a?(Formula)
loaded_formula_or_cask = formula_or_cask
current_version_value = T.must(loaded_formula_or_cask.stable).version
else
loaded_formula_or_cask = Cask::CaskLoader.load(formula_or_cask.sourcefile_path)
current_version_value = Version.new(loaded_formula_or_cask.version)
end
livecheck_latest = livecheck_result(loaded_formula_or_cask)
new_version_value = if (livecheck_latest.is_a?(Version) &&
Livecheck::LivecheckVersion.create(formula_or_cask, livecheck_latest) >=
Livecheck::LivecheckVersion.create(formula_or_cask, current_version_value)) ||
current_version_value == "latest"
livecheck_latest
elsif livecheck_latest.is_a?(String) && livecheck_latest.start_with?("skipped")
"skipped"
elsif repology_latest.is_a?(Version) &&
repology_latest > current_version_value &&
!loaded_formula_or_cask.livecheckable? &&
current_version_value != "latest"
repology_latest
end.presence
# Store old and new versions
old_versions[version_key] = current_version_value
new_versions[version_key] = new_version_value
end
end
# If arm and intel versions are identical, as it happens with casks where only the checksums differ,
# we consolidate them into a single version.
if old_versions[:arm].present? && old_versions[:arm] == old_versions[:intel]
old_versions = { general: old_versions[:arm] }
end
if new_versions[:arm].present? && new_versions[:arm] == new_versions[:intel]
new_versions = { general: new_versions[:arm] }
end
multiple_versions = old_versions.values_at(:arm, :intel).all?(&:present?) ||
new_versions.values_at(:arm, :intel).all?(&:present?)
current_version = BumpVersionParser.new(general: old_versions[:general],
arm: old_versions[:arm],
intel: old_versions[:intel])
begin
new_version = BumpVersionParser.new(general: new_versions[:general],
arm: new_versions[:arm],
intel: new_versions[:intel])
rescue
# When livecheck fails, we fail gracefully. Otherwise VersionParser will
# raise a usage error
new_version = BumpVersionParser.new(general: "unable to get versions")
end
# We use the arm version for the pull request version. This is consistent
# with the behavior of bump-cask-pr.
pull_request_version = if multiple_versions && new_version.general != "unable to get versions"
new_version.arm.to_s
else
new_version.general.to_s
end
if !args.no_pull_requests? && (new_version != current_version)
duplicate_pull_requests = retrieve_pull_requests(
formula_or_cask,
name,
version: pull_request_version,
)
maybe_duplicate_pull_requests = if duplicate_pull_requests.nil?
retrieve_pull_requests(formula_or_cask, name)
end
end
VersionBumpInfo.new(
type:,
multiple_versions:,
version_name:,
current_version:,
repology_latest:,
new_version:,
duplicate_pull_requests:,
maybe_duplicate_pull_requests:,
)
end
sig {
params(
formula_or_cask: T.any(Formula, Cask::Cask),
name: String,
repositories: T::Array[String],
ambiguous_cask: T::Boolean,
).void
}
def retrieve_and_display_info_and_open_pr(formula_or_cask, name, repositories, ambiguous_cask: false)
version_info = retrieve_versions_by_arch(formula_or_cask:,
repositories:,
name:)
current_version = version_info.current_version
new_version = version_info.new_version
repology_latest = version_info.repology_latest
# Check if all versions are equal
versions_equal = (new_version == current_version)
title_name = ambiguous_cask ? "#{name} (cask)" : name
title = if (repology_latest == current_version.general || !repology_latest.is_a?(Version)) && versions_equal
"#{title_name} #{Tty.green}is up to date!#{Tty.reset}"
else
title_name
end
# Conditionally format output based on type of formula_or_cask
current_versions = if version_info.multiple_versions
"arm: #{current_version.arm}
intel: #{current_version.intel}"
else
current_version.general.to_s
end
current_versions << " (deprecated)" if formula_or_cask.deprecated?
new_versions = if version_info.multiple_versions && new_version.arm && new_version.intel
"arm: #{new_version.arm}
intel: #{new_version.intel}"
else
new_version.general
end
version_label = version_info.version_name
duplicate_pull_requests = version_info.duplicate_pull_requests
maybe_duplicate_pull_requests = version_info.maybe_duplicate_pull_requests
ohai title
puts <<~EOS
Current #{version_label} #{current_versions}
Latest livecheck version: #{new_versions}#{" (throttled)" if formula_or_cask.livecheck.throttle}
EOS
puts <<~EOS unless skip_repology?(formula_or_cask)
Latest Repology version: #{repology_latest}
EOS
if formula_or_cask.is_a?(Formula) && formula_or_cask.synced_with_other_formulae?
outdated_synced_formulae = synced_with(formula_or_cask, new_version.general)
puts <<~EOS if outdated_synced_formulae.present?
Version syncing: #{title_name} version should be kept in sync with
#{outdated_synced_formulae.join(", ")}.
EOS
end
if !args.no_pull_requests? && !versions_equal
if duplicate_pull_requests
duplicate_pull_requests_text = duplicate_pull_requests
elsif maybe_duplicate_pull_requests
duplicate_pull_requests_text = "none"
maybe_duplicate_pull_requests_text = maybe_duplicate_pull_requests
else
duplicate_pull_requests_text = "none"
maybe_duplicate_pull_requests_text = "none"
end
puts "Duplicate pull requests: #{duplicate_pull_requests_text}"
if maybe_duplicate_pull_requests_text
puts "Maybe duplicate pull requests: #{maybe_duplicate_pull_requests_text}"
end
end
return unless args.open_pr?
if GitHub.too_many_open_prs?(formula_or_cask.tap)
odie "You have too many PRs open: close or merge some first!"
end
if repology_latest.is_a?(Version) &&
repology_latest > current_version.general &&
repology_latest > new_version.general &&
formula_or_cask.livecheckable?
puts "#{title_name} was not bumped to the Repology version because it's livecheckable."
end
if new_version.blank? || versions_equal ||
(!new_version.general.is_a?(Version) && !version_info.multiple_versions)
return
end
return if duplicate_pull_requests.present?
version_args = if version_info.multiple_versions
%W[--version-arm=#{new_version.arm} --version-intel=#{new_version.intel}]
else
"--version=#{new_version.general}"
end
bump_cask_pr_args = [
"bump-#{version_info.type}-pr",
name,
*version_args,
"--no-browse",
"--message=Created by `brew bump`",
]
bump_cask_pr_args << "--no-fork" if args.no_fork?
system HOMEBREW_BREW_FILE, *bump_cask_pr_args
end
sig {
params(
formula: Formula,
new_version: T.nilable(T.any(Version, Cask::DSL::Version)),
).returns(T::Array[String])
}
def synced_with(formula, new_version)
synced_with = []
formula.tap&.synced_versions_formulae&.each do |synced_formulae|
next unless synced_formulae.include?(formula.name)
synced_formulae.each do |synced_formula|
synced_formula = Formulary.factory(synced_formula)
next if synced_formula == formula.name
synced_with << synced_formula.name if synced_formula.version != new_version
end
end
synced_with
end
end
end
end