329 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
			
		
		
	
	
			329 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
| # typed: strict
 | |
| # frozen_string_literal: true
 | |
| 
 | |
| require "abstract_command"
 | |
| require "bump_version_parser"
 | |
| require "cask"
 | |
| require "cask/download"
 | |
| require "utils/tar"
 | |
| 
 | |
| module Homebrew
 | |
|   module DevCmd
 | |
|     class BumpCaskPr < AbstractCommand
 | |
|       cmd_args do
 | |
|         description <<~EOS
 | |
|           Create a pull request to update <cask> with a new version.
 | |
| 
 | |
|           A best effort to determine the <SHA-256> will be made if the value is not
 | |
|           supplied by the user.
 | |
|         EOS
 | |
|         switch "-n", "--dry-run",
 | |
|                description: "Print what would be done rather than doing it."
 | |
|         switch "--write-only",
 | |
|                description: "Make the expected file modifications without taking any Git actions."
 | |
|         switch "--commit",
 | |
|                depends_on:  "--write-only",
 | |
|                description: "When passed with `--write-only`, generate a new commit after writing changes " \
 | |
|                             "to the cask file."
 | |
|         switch "--no-audit",
 | |
|                description: "Don't run `brew audit` before opening the PR."
 | |
|         switch "--online",
 | |
|                hidden:      true
 | |
|         switch "--no-style",
 | |
|                description: "Don't run `brew style --fix` before opening the PR."
 | |
|         switch "--no-browse",
 | |
|                description: "Print the pull request URL instead of opening in a browser."
 | |
|         switch "--no-fork",
 | |
|                description: "Don't try to fork the repository."
 | |
|         flag   "--version=",
 | |
|                description: "Specify the new <version> for the cask."
 | |
|         flag   "--version-arm=",
 | |
|                description: "Specify the new cask <version> for the ARM architecture."
 | |
|         flag   "--version-intel=",
 | |
|                description: "Specify the new cask <version> for the Intel architecture."
 | |
|         flag   "--message=",
 | |
|                description: "Prepend <message> to the default pull request message."
 | |
|         flag   "--url=",
 | |
|                description: "Specify the <URL> for the new download."
 | |
|         flag   "--sha256=",
 | |
|                description: "Specify the <SHA-256> checksum of the new download."
 | |
|         flag   "--fork-org=",
 | |
|                description: "Use the specified GitHub organization for forking."
 | |
|         switch "-f", "--force",
 | |
|                hidden: true
 | |
| 
 | |
|         conflicts "--dry-run", "--write"
 | |
|         conflicts "--no-audit", "--online"
 | |
|         conflicts "--version=", "--version-arm="
 | |
|         conflicts "--version=", "--version-intel="
 | |
| 
 | |
|         named_args :cask, number: 1, without_api: true
 | |
|       end
 | |
| 
 | |
|       sig { override.void }
 | |
|       def run
 | |
|         odeprecated "brew bump-cask-pr --online" if args.online?
 | |
|         odisabled "brew bump-cask-pr --force" if args.force?
 | |
| 
 | |
|         # This will be run by `brew audit` or `brew style` later so run it first to
 | |
|         # not start spamming during normal output.
 | |
|         gem_groups = []
 | |
|         gem_groups << "style" if !args.no_audit? || !args.no_style?
 | |
|         gem_groups << "audit" unless args.no_audit?
 | |
|         Homebrew.install_bundler_gems!(groups: gem_groups) unless gem_groups.empty?
 | |
| 
 | |
|         # As this command is simplifying user-run commands then let's just use a
 | |
|         # user path, too.
 | |
|         ENV["PATH"] = PATH.new(ORIGINAL_PATHS).to_s
 | |
| 
 | |
|         # Use the user's browser, too.
 | |
|         ENV["BROWSER"] = EnvConfig.browser
 | |
| 
 | |
|         cask = args.named.to_casks.first
 | |
| 
 | |
|         odie "This cask is not in a tap!" if cask.tap.blank?
 | |
|         odie "This cask's tap is not a Git repository!" unless cask.tap.git?
 | |
| 
 | |
|         odie <<~EOS unless cask.tap.allow_bump?(cask.token)
 | |
|           Whoops, the #{cask.token} cask has its version update
 | |
|           pull requests automatically opened by BrewTestBot!
 | |
|           We'd still love your contributions, though, so try another one
 | |
|           that's not in the autobump list:
 | |
|             #{Formatter.url("#{cask.tap.remote}/blob/master/.github/autobump.txt")}
 | |
|         EOS
 | |
| 
 | |
|         odie "You have too many PRs open: close or merge some first!" if GitHub.too_many_open_prs?(cask.tap)
 | |
| 
 | |
|         new_version = BumpVersionParser.new(
 | |
|           general: args.version,
 | |
|           intel:   args.version_intel,
 | |
|           arm:     args.version_arm,
 | |
|         )
 | |
| 
 | |
|         new_hash = unless (new_hash = args.sha256).nil?
 | |
|           raise UsageError, "`--sha256` must not be empty." if new_hash.blank?
 | |
| 
 | |
|           ["no_check", ":no_check"].include?(new_hash) ? :no_check : new_hash
 | |
|         end
 | |
| 
 | |
|         new_base_url = unless (new_base_url = args.url).nil?
 | |
|           raise UsageError, "`--url` must not be empty." if new_base_url.blank?
 | |
| 
 | |
|           begin
 | |
|             URI(new_base_url)
 | |
|           rescue URI::InvalidURIError
 | |
|             raise UsageError, "`--url` is not valid."
 | |
|           end
 | |
|         end
 | |
| 
 | |
|         if new_version.blank? && new_base_url.nil? && new_hash.nil?
 | |
|           raise UsageError, "No `--version`, `--url` or `--sha256` argument specified!"
 | |
|         end
 | |
| 
 | |
|         check_pull_requests(cask, new_version:)
 | |
| 
 | |
|         replacement_pairs ||= []
 | |
|         branch_name = "bump-#{cask.token}"
 | |
|         commit_message = nil
 | |
| 
 | |
|         old_contents = File.read(cask.sourcefile_path)
 | |
| 
 | |
|         if new_base_url
 | |
|           commit_message ||= "#{cask.token}: update URL"
 | |
| 
 | |
|           m = /^ +url "(.+?)"\n/m.match(old_contents)
 | |
|           odie "Could not find old URL in cask!" if m.nil?
 | |
| 
 | |
|           old_base_url = m.captures.fetch(0)
 | |
| 
 | |
|           replacement_pairs << [
 | |
|             /#{Regexp.escape(old_base_url)}/,
 | |
|             new_base_url.to_s,
 | |
|           ]
 | |
|         end
 | |
| 
 | |
|         if new_version.present?
 | |
|           # For simplicity, our naming defers to the arm version if we multiple architectures are specified
 | |
|           branch_version = new_version.arm || new_version.general
 | |
|           if branch_version.is_a?(Cask::DSL::Version)
 | |
|             commit_version = shortened_version(branch_version, cask:)
 | |
|             branch_name = "bump-#{cask.token}-#{branch_version.tr(",:", "-")}"
 | |
|             commit_message ||= "#{cask.token} #{commit_version}"
 | |
|           end
 | |
|           replacement_pairs = replace_version_and_checksum(cask, new_hash, new_version, replacement_pairs)
 | |
|         end
 | |
|         # Now that we have all replacement pairs, we will replace them further down
 | |
| 
 | |
|         commit_message ||= "#{cask.token}: update checksum" if new_hash
 | |
| 
 | |
|         # Remove nested arrays where elements are identical
 | |
|         replacement_pairs = replacement_pairs.reject { |pair| pair[0] == pair[1] }.uniq.compact
 | |
|         Utils::Inreplace.inreplace_pairs(cask.sourcefile_path,
 | |
|                                          replacement_pairs,
 | |
|                                          read_only_run: args.dry_run?,
 | |
|                                          silent:        args.quiet?)
 | |
| 
 | |
|         run_cask_audit(cask, old_contents)
 | |
|         run_cask_style(cask, old_contents)
 | |
| 
 | |
|         pr_info = {
 | |
|           branch_name:,
 | |
|           commit_message:,
 | |
|           old_contents:,
 | |
|           pr_message:      "Created with `brew bump-cask-pr`.",
 | |
|           sourcefile_path: cask.sourcefile_path,
 | |
|           tap:             cask.tap,
 | |
|         }
 | |
|         GitHub.create_bump_pr(pr_info, args:)
 | |
|       end
 | |
| 
 | |
|       private
 | |
| 
 | |
|       sig { params(version: Cask::DSL::Version, cask: Cask::Cask).returns(Cask::DSL::Version) }
 | |
|       def shortened_version(version, cask:)
 | |
|         if version.before_comma == cask.version.before_comma
 | |
|           version
 | |
|         else
 | |
|           version.before_comma
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       sig {
 | |
|         params(
 | |
|           cask:              Cask::Cask,
 | |
|           new_hash:          T.any(NilClass, String, Symbol),
 | |
|           new_version:       BumpVersionParser,
 | |
|           replacement_pairs: T::Array[[T.any(Regexp, String), T.any(Regexp, String)]],
 | |
|         ).returns(T::Array[[T.any(Regexp, String), T.any(Regexp, String)]])
 | |
|       }
 | |
|       def replace_version_and_checksum(cask, new_hash, new_version, replacement_pairs)
 | |
|         # When blocks are absent, arch is not relevant. For consistency, we simulate the arm architecture.
 | |
|         arch_options = cask.on_system_blocks_exist? ? OnSystem::ARCH_OPTIONS : [:arm]
 | |
|         arch_options.each do |arch|
 | |
|           SimulateSystem.with(arch:) do
 | |
|             old_cask     = Cask::CaskLoader.load(cask.sourcefile_path)
 | |
|             old_version  = old_cask.version
 | |
|             bump_version = new_version.send(arch) || new_version.general
 | |
| 
 | |
|             old_version_regex = old_version.latest? ? ":latest" : %Q(["']#{Regexp.escape(old_version.to_s)}["'])
 | |
|             replacement_pairs << [/version\s+#{old_version_regex}/m,
 | |
|                                   "version #{bump_version.latest? ? ":latest" : %Q("#{bump_version}")}"]
 | |
| 
 | |
|             # We are replacing our version here so we can get the new hash
 | |
|             tmp_contents = Utils::Inreplace.inreplace_pairs(cask.sourcefile_path,
 | |
|                                                             replacement_pairs.uniq.compact,
 | |
|                                                             read_only_run: true,
 | |
|                                                             silent:        true)
 | |
| 
 | |
|             tmp_cask = Cask::CaskLoader.load(tmp_contents)
 | |
|             old_hash = tmp_cask.sha256
 | |
|             if tmp_cask.version.latest? || new_hash == :no_check
 | |
|               opoo "Ignoring specified `--sha256=` argument." if new_hash.is_a?(String)
 | |
|               replacement_pairs << [/"#{old_hash}"/, ":no_check"] if old_hash != :no_check
 | |
|             elsif old_hash == :no_check && new_hash != :no_check
 | |
|               replacement_pairs << [":no_check", "\"#{new_hash}\""] if new_hash.is_a?(String)
 | |
|             elsif new_hash && !cask.on_system_blocks_exist? && cask.languages.empty?
 | |
|               replacement_pairs << [old_hash.to_s, new_hash.to_s]
 | |
|             elsif old_hash != :no_check
 | |
|               opoo "Multiple checksum replacements required; ignoring specified `--sha256` argument." if new_hash
 | |
|               languages = if cask.languages.empty?
 | |
|                 [nil]
 | |
|               else
 | |
|                 cask.languages
 | |
|               end
 | |
|               languages.each do |language|
 | |
|                 new_cask        = Cask::CaskLoader.load(tmp_contents)
 | |
|                 new_cask.config = if language.blank?
 | |
|                   tmp_cask.config
 | |
|                 else
 | |
|                   tmp_cask.config.merge(Cask::Config.new(explicit: { languages: [language] }))
 | |
|                 end
 | |
|                 download = Cask::Download.new(new_cask, quarantine: true).fetch(verify_download_integrity: false)
 | |
|                 Utils::Tar.validate_file(download)
 | |
| 
 | |
|                 if new_cask.sha256.to_s != download.sha256
 | |
|                   replacement_pairs << [new_cask.sha256.to_s,
 | |
|                                         download.sha256]
 | |
|                 end
 | |
|               end
 | |
|             end
 | |
|           end
 | |
|         end
 | |
|         replacement_pairs
 | |
|       end
 | |
| 
 | |
|       sig { params(cask: Cask::Cask, new_version: BumpVersionParser).void }
 | |
|       def check_pull_requests(cask, new_version:)
 | |
|         tap_remote_repo = cask.tap.full_name || cask.tap.remote_repo
 | |
| 
 | |
|         GitHub.check_for_duplicate_pull_requests(cask.token, tap_remote_repo,
 | |
|                                                  state:   "open",
 | |
|                                                  version: nil,
 | |
|                                                  file:    cask.sourcefile_path.relative_path_from(cask.tap.path).to_s,
 | |
|                                                  quiet:   args.quiet?)
 | |
| 
 | |
|         # if we haven't already found open requests, try for an exact match across closed requests
 | |
|         new_version.instance_variables.each do |version_type|
 | |
|           version = new_version.instance_variable_get(version_type)
 | |
|           next if version.blank?
 | |
| 
 | |
|           GitHub.check_for_duplicate_pull_requests(
 | |
|             cask.token,
 | |
|             tap_remote_repo,
 | |
|             state:   "closed",
 | |
|             version: shortened_version(version, cask:),
 | |
|             file:    cask.sourcefile_path.relative_path_from(cask.tap.path).to_s,
 | |
|             quiet:   args.quiet?,
 | |
|           )
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       sig { params(cask: Cask::Cask, old_contents: String).void }
 | |
|       def run_cask_audit(cask, old_contents)
 | |
|         if args.dry_run?
 | |
|           if args.no_audit?
 | |
|             ohai "Skipping `brew audit`"
 | |
|           else
 | |
|             ohai "brew audit --cask --online #{cask.full_name}"
 | |
|           end
 | |
|           return
 | |
|         end
 | |
|         failed_audit = false
 | |
|         if args.no_audit?
 | |
|           ohai "Skipping `brew audit`"
 | |
|         else
 | |
|           system HOMEBREW_BREW_FILE, "audit", "--cask", "--online", cask.full_name
 | |
|           failed_audit = !$CHILD_STATUS.success?
 | |
|         end
 | |
|         return unless failed_audit
 | |
| 
 | |
|         cask.sourcefile_path.atomic_write(old_contents)
 | |
|         odie "`brew audit` failed!"
 | |
|       end
 | |
| 
 | |
|       sig { params(cask: Cask::Cask, old_contents: String).void }
 | |
|       def run_cask_style(cask, old_contents)
 | |
|         if args.dry_run?
 | |
|           if args.no_style?
 | |
|             ohai "Skipping `brew style --fix`"
 | |
|           else
 | |
|             ohai "brew style --fix #{cask.sourcefile_path.basename}"
 | |
|           end
 | |
|           return
 | |
|         end
 | |
|         failed_style = false
 | |
|         if args.no_style?
 | |
|           ohai "Skipping `brew style --fix`"
 | |
|         else
 | |
|           system HOMEBREW_BREW_FILE, "style", "--fix", cask.sourcefile_path
 | |
|           failed_style = !$CHILD_STATUS.success?
 | |
|         end
 | |
|         return unless failed_style
 | |
| 
 | |
|         cask.sourcefile_path.atomic_write(old_contents)
 | |
|         odie "`brew style --fix` failed!"
 | |
|       end
 | |
|     end
 | |
|   end
 | |
| end
 | 
