From 57fefff942e5b689de2665ca3a0b75f0c0893ce1 Mon Sep 17 00:00:00 2001 From: Markus Reiter Date: Fri, 15 Feb 2019 12:33:43 +0100 Subject: [PATCH 1/3] Add `brew cask automerge` command. --- Library/Homebrew/cask/cmd.rb | 1 + Library/Homebrew/cask/cmd/automerge.rb | 115 +++++++++++++++++++++++++ Library/Homebrew/utils/github.rb | 24 +++++- 3 files changed, 136 insertions(+), 4 deletions(-) create mode 100755 Library/Homebrew/cask/cmd/automerge.rb 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..66f43907fa --- /dev/null +++ b/Library/Homebrew/cask/cmd/automerge.rb @@ -0,0 +1,115 @@ +# 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 + raise "This command may only be run by Homebrew maintainers." unless ENV["HOMEBREW_DEVELOPER"] + + Homebrew.install_gem! "git_diff" + require "git_diff" + + failed = [] + + OFFICIAL_CASK_TAPS.each do |tap_name| + tap = Tap.fetch(tap_name) + + 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"])} " + + 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 + 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..345f5df343 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,18 @@ module GitHub search_issues(name, state: "open", repo: "#{tap.user}/homebrew-#{tap.repo}", in: "title") 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") From 68e0c5f904f8335d0c4e473172fecc5f09f17753 Mon Sep 17 00:00:00 2001 From: Markus Reiter Date: Fri, 15 Feb 2019 16:27:19 +0100 Subject: [PATCH 2/3] Check for write access instead of `HOMEBREW_DEVELOPER`. --- Library/Homebrew/cask/cmd/automerge.rb | 9 +++++---- Library/Homebrew/utils/github.rb | 13 +++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Library/Homebrew/cask/cmd/automerge.rb b/Library/Homebrew/cask/cmd/automerge.rb index 66f43907fa..a849d2cef6 100755 --- a/Library/Homebrew/cask/cmd/automerge.rb +++ b/Library/Homebrew/cask/cmd/automerge.rb @@ -17,16 +17,17 @@ module Cask ].freeze def run - raise "This command may only be run by Homebrew maintainers." unless ENV["HOMEBREW_DEVELOPER"] + 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 = [] - OFFICIAL_CASK_TAPS.each do |tap_name| - tap = Tap.fetch(tap_name) - + taps.each do |tap| open_pull_requests = GitHub.pull_requests(tap.full_name, state: :open, base: "master") open_pull_requests.each do |pr| diff --git a/Library/Homebrew/utils/github.rb b/Library/Homebrew/utils/github.rb index 345f5df343..e143e9163e 100644 --- a/Library/Homebrew/utils/github.rb +++ b/Library/Homebrew/utils/github.rb @@ -274,6 +274,19 @@ 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) From cd03c0f86d2c0b41c06efd4b835c9feaf779e432 Mon Sep 17 00:00:00 2001 From: Markus Reiter Date: Fri, 15 Feb 2019 18:48:37 +0100 Subject: [PATCH 3/3] Retry merging once after 5 seconds. --- Library/Homebrew/cask/cmd/automerge.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Library/Homebrew/cask/cmd/automerge.rb b/Library/Homebrew/cask/cmd/automerge.rb index a849d2cef6..b71083bac6 100755 --- a/Library/Homebrew/cask/cmd/automerge.rb +++ b/Library/Homebrew/cask/cmd/automerge.rb @@ -39,6 +39,8 @@ module Cask print "#{Formatter.url(pr["html_url"])} " + retried = false + begin GitHub.merge_pull_request( tap.full_name, @@ -48,6 +50,12 @@ module Cask ) 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