diff --git a/Library/Homebrew/dev-cmd/bump.rb b/Library/Homebrew/dev-cmd/bump.rb new file mode 100644 index 0000000000000000000000000000000000000000..fe04da322566a07d34526e3aaeee4fe7714982f7 --- /dev/null +++ b/Library/Homebrew/dev-cmd/bump.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "cli/parser" + +module Homebrew + module_function + + def bump_args + Homebrew::CLI::Parser.new do + usage_banner <<~EOS + `bump` [<options>] [<formula>] + + Display out-of-date brew formulae and the latest version available. + Also displays whether a pull request has been opened with the URL. + EOS + flag "--limit=", + description: "Limit number of package results returned." + switch :verbose + switch :debug + end + end + + def bump + args = bump_args.parse + + requested_formulae = args.formulae.map(&:name) if args.formulae.present? + + requested_limit = args.limit.to_i if args.limit.present? + + repology_data = if requested_formulae + response = {} + requested_formulae.each do |formula| + raise FormulaUnavailableError, formula unless validate_formula(formula) + + package_data = Repology.single_package_query(formula) + response[package_data.keys.first] = package_data.values.first if package_data + end + + response + else + Repology.parse_api_response(requested_limit) + end + + validated_formulae = {} + + validated_formulae = Repology.validate_and_format_packages(repology_data, requested_limit) if repology_data + + if requested_formulae + repology_excluded_formulae = requested_formulae.reject do |formula| + repology_data[formula] + end + + formulae = {} + repology_excluded_formulae.each do |formula| + formulae[formula] = Repology.format_package(formula, nil) + end + + formulae.each { |formula, data| validated_formulae[formula] = data } + end + + display(validated_formulae) + end + + def validate_formula(formula_name) + Formula[formula_name] + rescue + nil + end + + def up_to_date?(package) + package && + package[:current_formula_version] == package[:repology_latest_version] && + package[:current_formula_version] == package[:livecheck_latest_version] + end + + def display(formulae) + formulae.each do |formula, package_details| + title = (up_to_date?(package_details) ? "#{formula} is up to date!" : formula).to_s + ohai title + puts "Current formula version: #{package_details[:current_formula_version]}" + puts "Latest Repology version: #{package_details[:repology_latest_version]}" + puts "Latest livecheck version: #{package_details[:livecheck_latest_version]}" + puts "Open pull requests: #{package_details[:open_pull_requests]}" + end + end +end diff --git a/Library/Homebrew/test/dev-cmd/bump_spec.rb b/Library/Homebrew/test/dev-cmd/bump_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..81b22e319baa97dc315c09d2d14c414ed3c56eed --- /dev/null +++ b/Library/Homebrew/test/dev-cmd/bump_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "cmd/shared_examples/args_parse" + +describe "brew bump" do + describe "Homebrew.bump_args" do + it_behaves_like "parseable arguments" + end + + describe "formula", :integration_test do + it "returns data for single valid specified formula" do + install_test_formula "testball" + + expect { brew "bump", "testball" } + .to output.to_stdout + .and not_to_output.to_stderr + .and be_a_success + end + + it "returns data for multiple valid specified formula" do + install_test_formula "testball" + install_test_formula "testball2" + + expect { brew "bump", "testball", "testball2" } + .to output.to_stdout + .and not_to_output.to_stderr + .and be_a_success + end + end +end diff --git a/Library/Homebrew/test/utils/livecheck_formula_spec.rb b/Library/Homebrew/test/utils/livecheck_formula_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..41ffe71132d3e5d275e33b86237b87c282884051 --- /dev/null +++ b/Library/Homebrew/test/utils/livecheck_formula_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "utils/livecheck_formula" +require "formula_installer" + +describe LivecheckFormula do + describe "init" do + let(:f) { formula { url "foo-1.0" } } + let(:options) { FormulaInstaller.new(f).display_options(f) } + let(:action) { "#{f.full_name} #{options}".strip } + + it "runs livecheck command for Formula" do + formatted_response = described_class.init(action) + + expect(formatted_response).not_to be_nil + expect(formatted_response).to be_a(Hash) + expect(formatted_response.size).not_to eq(0) + end + end + + describe "parse_livecheck_response" do + it "returns a hash of Formula version data" do + example_raw_command_response = "aacgain : 7834 ==> 1.8" + formatted_response = described_class.parse_livecheck_response(example_raw_command_response) + + expect(formatted_response).not_to be_nil + expect(formatted_response).to be_a(Hash) + + expect(formatted_response).to include(:name) + expect(formatted_response).to include(:formula_version) + expect(formatted_response).to include(:livecheck_version) + + expect(formatted_response[:name]).to eq("aacgain") + expect(formatted_response[:formula_version]).to eq("7834") + expect(formatted_response[:livecheck_version]).to eq("1.8") + end + end +end diff --git a/Library/Homebrew/test/utils/repology_spec.rb b/Library/Homebrew/test/utils/repology_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ff3bbde90912bfcaa85a6320e39df1c201c5e1d5 --- /dev/null +++ b/Library/Homebrew/test/utils/repology_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "utils/repology" + +describe Repology do + describe "formula_data" do + it "returns nil for invalid Homebrew Formula" do + expect(described_class.formula_data("invalidName")).to be_nil + end + end + + describe "single_package_query" do + it "returns nil for non-existent package" do + response = described_class.single_package_query("invalidName") + + expect(response).to be_nil + end + + it "returns a hash for existing package" do + response = described_class.single_package_query("openclonk") + + expect(response).not_to be_nil + expect(response).to be_a(Hash) + end + end + + describe "parse_api_response" do + limit = 1 + response = described_class.parse_api_response(limit) + + it "returns a hash of data" do + expect(response).not_to be_nil + expect(response).to be_a(Hash) + end + end +end diff --git a/Library/Homebrew/utils.rb b/Library/Homebrew/utils.rb index 0c4068c89e3c9f1b2a1c7d916f6fe3f26b243e80..7533e66b6e71dba1b44be4689d32f9ad2801b220 100644 --- a/Library/Homebrew/utils.rb +++ b/Library/Homebrew/utils.rb @@ -9,7 +9,9 @@ require "utils/git" require "utils/github" require "utils/inreplace" require "utils/link" +require "utils/livecheck_formula" require "utils/popen" +require "utils/repology" require "utils/svn" require "utils/tty" require "tap_constants" diff --git a/Library/Homebrew/utils/livecheck_formula.rb b/Library/Homebrew/utils/livecheck_formula.rb new file mode 100644 index 0000000000000000000000000000000000000000..9a52fa1d9e8d9859e9d03f240a4f8c69708e853b --- /dev/null +++ b/Library/Homebrew/utils/livecheck_formula.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module LivecheckFormula + module_function + + def init(formula) + ohai "Checking livecheck formula: #{formula}" if Homebrew.args.verbose? + + response = Utils.popen_read(HOMEBREW_BREW_FILE, "livecheck", formula, "--quiet").chomp + + parse_livecheck_response(response) + end + + def parse_livecheck_response(response) + # e.g response => aacgain : 7834 ==> 1.8 + output = response.delete(" ").split(/:|==>/) + + # e.g. ["openclonk", "7.0", "8.1"] + package_name, brew_version, latest_version = output + + { + name: package_name, + formula_version: brew_version, + livecheck_version: latest_version, + } + end +end diff --git a/Library/Homebrew/utils/repology.rb b/Library/Homebrew/utils/repology.rb new file mode 100644 index 0000000000000000000000000000000000000000..0d367be39657c91ca02140c384fddb71c74b6813 --- /dev/null +++ b/Library/Homebrew/utils/repology.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "utils/curl" + +module Repology + module_function + + MAX_PAGINATION = 15 + + def query_api(last_package_in_response = "") + last_package_in_response += "/" if last_package_in_response.present? + url = "https://repology.org/api/v1/projects/#{last_package_in_response}?inrepo=homebrew&outdated=1" + + output, _errors, _status = curl_output(url.to_s) + JSON.parse(output) + end + + def single_package_query(name) + url = "https://repology.org/api/v1/project/#{name}" + + output, _errors, _status = curl_output(url.to_s) + data = JSON.parse(output) + + homebrew = data.select do |repo| + repo["repo"] == "homebrew" + end + + homebrew.empty? ? nil : { name => data } + end + + def parse_api_response(limit = nil) + ohai "Querying outdated packages from Repology" + + page_no = 1 + outdated_packages = {} + last_package_index = "" + + while page_no <= MAX_PAGINATION + odebug "Paginating Repology API page: #{page_no}" + + response = query_api(last_package_index) + response_size = response.size + outdated_packages.merge!(response) + last_package_index = outdated_packages.size - 1 + + page_no += 1 + break if limit && outdated_packages.size >= limit || response_size <= 1 + end + + puts "#{outdated_packages.size} outdated #{"package".pluralize(outdated_packages.size)} found" + puts + + outdated_packages + end + + def validate_and_format_packages(outdated_repology_packages, limit) + if outdated_repology_packages.size > 10 && (limit.blank? || limit > 10) + ohai "Verifying outdated repology packages" + end + + packages = {} + + outdated_repology_packages.each do |_name, repositories| + repology_homebrew_repo = repositories.find do |repo| + repo["repo"] == "homebrew" + end + + next if repology_homebrew_repo.blank? + + latest_version = repositories.find { |repo| repo["status"] == "newest" } + + next if latest_version.blank? + + latest_version = latest_version["version"] + srcname = repology_homebrew_repo["srcname"] + package_details = format_package(srcname, latest_version) + packages[srcname] = package_details unless package_details.nil? + + break if limit && packages.size >= limit + end + + packages + end + + def format_package(package_name, latest_version) + formula = formula_data(package_name) + + return if formula.blank? + + formula_name = formula.to_s + tap_full_name = formula.tap&.full_name + current_version = formula.version.to_s + livecheck_response = LivecheckFormula.init(package_name) + pull_requests = GitHub.fetch_pull_requests(formula_name, tap_full_name, state: "open") + + if pull_requests.try(:any?) + pull_requests = pull_requests.map { |pr| "#{pr["title"]} (#{Formatter.url(pr["html_url"])})" }.join(", ") + end + + pull_requests = "none" if pull_requests.blank? + + { + repology_latest_version: latest_version || "not found", + current_formula_version: current_version.to_s, + livecheck_latest_version: livecheck_response[:livecheck_version] || "not found", + open_pull_requests: pull_requests, + } + end + + def formula_data(package_name) + Formula[package_name] + rescue + nil + end +end diff --git a/completions/internal_commands_list.txt b/completions/internal_commands_list.txt index f768c56229504467694db79abf6b0402e931d195..474f832272d4dfbe48a098504dd2cd8c6b33e239 100644 --- a/completions/internal_commands_list.txt +++ b/completions/internal_commands_list.txt @@ -13,6 +13,7 @@ abv analytics audit bottle +bump bump-formula-pr bump-revision cask diff --git a/docs/Manpage.md b/docs/Manpage.md index d35cd591fff3646aa0ba1a919fa63aeec1236a71..3c0ebe39db17619cf2ee21afa2a144714488b068 100644 --- a/docs/Manpage.md +++ b/docs/Manpage.md @@ -817,6 +817,14 @@ value, while `--no-rebuild` will remove it. * `--root-url`: Use the specified *`URL`* as the root of the bottle's URL instead of Homebrew's default. +### `bump` [*`options`*] [*`formula`*] + +Display out-of-date brew formulae and the latest version available. +Also displays whether a pull request has been opened with the URL. + +* `--limit`: + Limit number of package results returned. + ### `bump-formula-pr` [*`options`*] [*`formula`*] Create a pull request to update *`formula`* with a new URL or a new tag. diff --git a/manpages/brew.1 b/manpages/brew.1 index 120e68cf37497fbab7b44c901b012815b931bc8d..1fda57a44403bf4c71cf63cf6494cea92e08410a 100644 --- a/manpages/brew.1 +++ b/manpages/brew.1 @@ -1139,6 +1139,13 @@ When passed with \fB\-\-write\fR, a new commit will not generated after writing \fB\-\-root\-url\fR Use the specified \fIURL\fR as the root of the bottle\'s URL instead of Homebrew\'s default\. . +.SS "\fBbump\fR [\fIoptions\fR] [\fIformula\fR]" +Display out\-of\-date brew formulae and the latest version available\. Also displays whether a pull request has been opened with the URL\. +. +.TP +\fB\-\-limit\fR +Limit number of package results returned\. +. .SS "\fBbump\-formula\-pr\fR [\fIoptions\fR] [\fIformula\fR]" Create a pull request to update \fIformula\fR with a new URL or a new tag\. .