diff --git a/Library/Homebrew/cask/cmd.rb b/Library/Homebrew/cask/cmd.rb index 5a1c65b2e0..79b0077e25 100644 --- a/Library/Homebrew/cask/cmd.rb +++ b/Library/Homebrew/cask/cmd.rb @@ -9,6 +9,7 @@ require "cask/cmd/options" require "cask/cmd/abstract_command" require "cask/cmd/audit" +require "cask/cmd/automerge" require "cask/cmd/cat" require "cask/cmd/create" require "cask/cmd/doctor" diff --git a/Library/Homebrew/cask/cmd/automerge.rb b/Library/Homebrew/cask/cmd/automerge.rb new file mode 100755 index 0000000000..b71083bac6 --- /dev/null +++ b/Library/Homebrew/cask/cmd/automerge.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "cask/cmd/abstract_internal_command" +require "tap" +require "utils/formatter" +require "utils/github" + +module Cask + class Cmd + class Automerge < AbstractInternalCommand + OFFICIAL_CASK_TAPS = [ + "homebrew/cask", + "homebrew/cask-drivers", + "homebrew/cask-eid", + "homebrew/cask-fonts", + "homebrew/cask-versions", + ].freeze + + def run + taps = OFFICIAL_CASK_TAPS.map(&Tap.public_method(:fetch)) + + access = taps.all? { |tap| GitHub.write_access?(tap.full_name) } + raise "This command may only be run by Homebrew maintainers." unless access + + Homebrew.install_gem! "git_diff" + require "git_diff" + + failed = [] + + taps.each do |tap| + open_pull_requests = GitHub.pull_requests(tap.full_name, state: :open, base: "master") + + open_pull_requests.each do |pr| + next unless passed_ci(pr) + next unless check_diff(pr) + + number = pr["number"] + sha = pr.dig("head", "sha") + + print "#{Formatter.url(pr["html_url"])} " + + retried = false + + begin + GitHub.merge_pull_request( + tap.full_name, + number: number, sha: sha, + merge_method: :squash, + commit_message: "Squashed and auto-merged via `brew cask automerge`." + ) + puts "#{Tty.bold}#{Formatter.success("✔")}#{Tty.reset}" + rescue + unless retried + retried = true + sleep 5 + retry + end + + puts "#{Tty.bold}#{Formatter.error("✘")}#{Tty.reset}" + failed << pr["html_url"] + end + end + end + + return if failed.empty? + $stderr.puts + raise CaskError, "Failed merging the following PRs:\n#{failed.join("\n")}" + end + + def passed_ci(pr) + statuses = GitHub.open_api(pr["statuses_url"]) + + latest_pr_status = statuses.select { |status| status["context"] == "continuous-integration/travis-ci/pr" } + .max_by { |status| Time.parse(status["updated_at"]) } + + latest_pr_status&.fetch("state") == "success" + end + + def check_diff(pr) + diff_url = pr["diff_url"] + + output, _, status = curl_output("--location", diff_url) + + return false unless status.success? + + diff = GitDiff.from_string(output) + + diff_is_single_cask(diff) && diff_only_version_or_checksum_changed(diff) + end + + def diff_is_single_cask(diff) + return false unless diff.files.count == 1 + file = diff.files.first + return false unless file.a_path == file.b_path + file.a_path.match?(%r{\ACasks/[^/]+\.rb\Z}) + end + + def diff_only_version_or_checksum_changed(diff) + lines = diff.files.flat_map(&:hunks).flat_map(&:lines) + + additions = lines.select(&:addition?) + deletions = lines.select(&:deletion?) + changed_lines = deletions + additions + + return false if additions.count != deletions.count + return false if additions.count > 2 + + changed_lines.all? { |line| diff_line_is_version(line.to_s) || diff_line_is_sha256(line.to_s) } + end + + def diff_line_is_sha256(line) + line.match?(/\A[+-]\s*sha256 '[0-9a-f]{64}'\Z/) + end + + def diff_line_is_version(line) + line.match?(/\A[+-]\s*version '[^']+'\Z/) + end + + def self.help + "automatically merge “simple” Cask pull requests" + end + end + end +end diff --git a/Library/Homebrew/utils/github.rb b/Library/Homebrew/utils/github.rb index 0446569797..e143e9163e 100644 --- a/Library/Homebrew/utils/github.rb +++ b/Library/Homebrew/utils/github.rb @@ -124,9 +124,9 @@ module GitHub @api_credentials_error_message ||= begin unauthorized = (response_headers["http/1.1"] == "401 Unauthorized") scopes = response_headers["x-accepted-oauth-scopes"].to_s.split(", ") - needed_human_scopes = needed_scopes.join(", ") - needed_human_scopes = "none" if needed_human_scopes.empty? - if !unauthorized && scopes.empty? + if unauthorized && scopes.empty? + needed_human_scopes = needed_scopes.join(", ") + needed_human_scopes = "none" if needed_human_scopes.empty? credentials_scopes = response_headers["x-oauth-scopes"] case GitHub.api_credentials_type @@ -154,7 +154,7 @@ module GitHub end end - def open_api(url, data: nil, scopes: [].freeze) + def open_api(url, data: nil, request_method: nil, scopes: [].freeze) # This is a no-op if the user is opting out of using the GitHub API. return block_given? ? yield({}) : {} if ENV["HOMEBREW_NO_GITHUB_API"] @@ -184,6 +184,10 @@ module GitHub data_tmpfile.write data data_tmpfile.close args += ["--data", "@#{data_tmpfile.path}"] + + if request_method + args += ["--request", request_method.to_s] + end end args += ["--dump-header", headers_tmpfile.path] @@ -270,6 +274,31 @@ module GitHub search_issues(name, state: "open", repo: "#{tap.user}/homebrew-#{tap.repo}", in: "title") end + def user + @user ||= open_api("#{API_URL}/user") + end + + def permission(repo, user) + open_api("#{API_URL}/repos/#{repo}/collaborators/#{user}/permission") + end + + def write_access?(repo, user = nil) + user ||= self.user["login"] + ["admin", "write"].include?(permission(repo, user)["permission"]) + end + + def pull_requests(repo, base:, state: :open, **_options) + url = "#{API_URL}/repos/#{repo}/pulls?#{URI.encode_www_form(base: base, state: state)}" + open_api(url) + end + + def merge_pull_request(repo, number:, sha:, merge_method:, commit_message: nil) + url = "#{API_URL}/repos/#{repo}/pulls/#{number}/merge" + data = { sha: sha, merge_method: merge_method } + data[:commit_message] = commit_message if commit_message + open_api(url, data: data, request_method: :PUT, scopes: CREATE_ISSUE_FORK_OR_PR_SCOPES) + end + def print_pull_requests_matching(query) open_or_closed_prs = search_issues(query, type: "pr", user: "Homebrew")