diff --git a/Library/Homebrew/dev-cmd/contributions.rb b/Library/Homebrew/dev-cmd/contributions.rb index 8700197174..f8e142a047 100644 --- a/Library/Homebrew/dev-cmd/contributions.rb +++ b/Library/Homebrew/dev-cmd/contributions.rb @@ -6,13 +6,23 @@ require "abstract_command" module Homebrew module DevCmd class Contributions < AbstractCommand - PRIMARY_REPOS = T.let(%w[brew core cask].freeze, T::Array[String]) - SUPPORTED_REPOS = T.let([ - PRIMARY_REPOS, - OFFICIAL_CMD_TAPS.keys.map { |t| t.delete_prefix("homebrew/") }, - OFFICIAL_CASK_TAPS.reject { |t| t == "cask" }, - ].flatten.freeze, T::Array[String]) - MAX_REPO_COMMITS = 1000 + PRIMARY_REPOS = T.let(%w[ + Homebrew/brew + Homebrew/homebrew-core + Homebrew/homebrew-cask + ].freeze, T::Array[String]) + ALL_REPOS = T.let([ + *PRIMARY_REPOS, + *OFFICIAL_CMD_TAPS.keys, + ].freeze, T::Array[String]) + CONTRIBUTION_TYPES = T.let({ + merged_pr_author: "merged PR author", + approved_pr_review: "approved PR reviewer", + committer: "commit author or committer", + coauthor: "commit coauthor", + }.freeze, T::Hash[Symbol, String]) + MAX_COMMITS = T.let(1000, Integer) + MAX_PR_SEARCH = T.let(100, Integer) cmd_args do usage_banner "`contributions` [`--user=`] [`--repositories=`] [`--from=`] [`--to=`] [`--csv`]" @@ -24,10 +34,14 @@ module Homebrew "contributions from. Omitting this flag searches Homebrew maintainers." comma_array "--repositories", description: "Specify a comma-separated list of repositories to search. " \ - "Supported repositories: #{SUPPORTED_REPOS.map { |t| "`#{t}`" }.to_sentence}. " \ + "All repositories must be under the same user or organisation. " \ "Omitting this flag, or specifying `--repositories=primary`, searches only the " \ - "main repositories: `brew`, `core`, `cask`. " \ - "Specifying `--repositories=all` searches all repositories. " + "main repositories: `Homebrew/brew`, `Homebrew/homebrew-core`, " \ + "`Homebrew/homebrew-cask`. Specifying `--repositories=all` searches all " \ + "non-deprecated Homebrew repositories. " + flag "--organisation=", "--organization=", "--org=", + description: "Specify the organisation to populate sources repositories from. " \ + "Omitting this flag searches the Homebrew primary repositories." flag "--from=", description: "Date (ISO 8601 format) to start searching contributions. " \ "Omitting this flag searches the past year." @@ -35,34 +49,43 @@ module Homebrew description: "Date (ISO 8601 format) to stop searching contributions." switch "--csv", description: "Print a CSV of contributions across repositories over the time period." + conflicts "--organisation", "--repositories" end sig { override.void } def run + odie "Cannot get contributions as `$HOMEBREW_NO_GITHUB_API` is set!" if Homebrew::EnvConfig.no_github_api? + Homebrew.install_bundler_gems!(groups: ["contributions"]) if args.csv? results = {} grand_totals = {} - repos = T.must( - if args.repositories.blank? || args.repositories&.include?("primary") - PRIMARY_REPOS - elsif args.repositories&.include?("all") - SUPPORTED_REPOS - else - args.repositories - end, - ) - - repos.each do |repo| - if SUPPORTED_REPOS.exclude?(repo) - odie "Unsupported repository: #{repo}. Try one of #{SUPPORTED_REPOS.join(", ")}." - end - end - from = args.from.presence || Date.today.prev_year.iso8601 + to = args.to.presence || (Date.today + 1).iso8601 - contribution_types = [:author, :committer, :coauthor, :review] + organisation = nil + repositories = if (org = args.organisation.presence) + organisation = org + GitHub.organisation_repositories(organisation, from, to, args.verbose?) + elsif (repos = args.repositories.presence) && repos.length == 1 && (first_repository = repos.first) + case first_repository + when "primary" + PRIMARY_REPOS + when "all" + ALL_REPOS + else + Array(first_repository) + end + elsif (repos = args.repositories.presence) + organisations = repos.map { |repository| repository.split("/").first }.uniq + odie "All repositories must be under the same user or organisation!" if organisations.length > 1 + + repos + else + PRIMARY_REPOS + end + organisation ||= T.must(repositories.fetch(0).split("/").first) require "utils/github" users = args.user.presence || GitHub.members_by_team("Homebrew", "maintainers").keys @@ -72,17 +95,28 @@ module Homebrew # committer details to match the ones on GitHub. # TODO: Switch to using the GitHub APIs instead of `git log` if # they ever support trailers. - results[username] = scan_repositories(repos, username, from:) + results[username] = scan_repositories(organisation, repositories, username, from:, to:) grand_totals[username] = total(results[username]) - contributions = contribution_types.filter_map do |type| + search_types = [:merged_pr_author, :approved_pr_review].freeze + greater_than_total = T.let(false, T::Boolean) + contributions = CONTRIBUTION_TYPES.keys.filter_map do |type| type_count = grand_totals[username][type] - next if type_count.to_i.zero? + next if type_count.zero? - "#{Utils.pluralize("time", type_count, include_count: true)} (#{type})" + count_prefix = "" + if (search_types.include?(type) && type_count == MAX_PR_SEARCH) || + (type == :committer && type_count == MAX_COMMITS) + greater_than_total ||= true + count_prefix = ">=" + end + + pretty_type = CONTRIBUTION_TYPES.fetch(type) + "#{count_prefix}#{Utils.pluralize("time", type_count, include_count: true)} (#{pretty_type})" end - contributions << - "#{Utils.pluralize("time", grand_totals[username].values.sum, include_count: true)} (total)" + total = Utils.pluralize("time", grand_totals[username].values.sum, include_count: true) + total_prefix = ">=" if greater_than_total + contributions << "#{total_prefix}#{total} (total)" contributions_string = [ "#{username} contributed", @@ -104,12 +138,16 @@ module Homebrew private - sig { params(repo: String).returns(Pathname) } - def find_repo_path_for_repo(repo) - return HOMEBREW_REPOSITORY if repo == "brew" + sig { params(repository: String).returns([T.nilable(Pathname), T.nilable(Tap)]) } + def repository_path_and_tap(repository) + return [HOMEBREW_REPOSITORY, nil] if repository == "Homebrew/brew" + return [nil, nil] if repository.exclude?("/homebrew-") require "tap" - Tap.fetch("homebrew", repo).path + tap = Tap.fetch(repository) + return [nil, nil] if tap.user == "Homebrew" && DEPRECATED_OFFICIAL_TAPS.include?(tap.repository) + + [tap.path, tap] end sig { params(from: T.nilable(String), to: T.nilable(String)).returns(String) } @@ -130,7 +168,7 @@ module Homebrew require "csv" CSV.generate do |csv| - csv << %w[user repo author committer coauthor review total] + csv << ["user", "repository", *CONTRIBUTION_TYPES.keys, "total"] totals.sort_by { |_, v| -v.values.sum }.each do |user, total| csv << grand_total_row(user, total) @@ -138,63 +176,67 @@ module Homebrew end end - sig { - params( - user: String, - grand_total: T::Hash[Symbol, Integer], - ).returns( - [String, String, T.nilable(Integer), T.nilable(Integer), T.nilable(Integer), T.nilable(Integer), Integer], - ) - } + sig { params(user: String, grand_total: T::Hash[Symbol, Integer]).returns(T::Array[T.any(String, T.nilable(Integer))]) } def grand_total_row(user, grand_total) - [ - user, - "all", - grand_total[:author], - grand_total[:committer], - grand_total[:coauthor], - grand_total[:review], - grand_total.values.sum, - ] + grand_totals = grand_total.slice(*CONTRIBUTION_TYPES.keys).values + [user, "all", *grand_totals, grand_totals.sum] end sig { params( - repos: T::Array[String], - person: String, - from: String, + organisation: String, + repositories: T::Array[String], + person: String, + from: String, + to: String, ).returns(T::Hash[Symbol, T.untyped]) } - def scan_repositories(repos, person, from:) + def scan_repositories(organisation, repositories, person, from:, to:) data = {} - return data if repos.blank? + return data if repositories.blank? - require "tap" require "utils/github" - repos.each do |repo| - repo_path = find_repo_path_for_repo(repo) - tap = Tap.fetch("homebrew", repo) - unless repo_path.exist? - opoo "Repository #{repo} not yet tapped! Tapping it now..." + + max = MAX_COMMITS + verbose = args.verbose? + + puts "Querying pull requests for #{person} in #{organisation}..." if args.verbose? + organisation_merged_prs = \ + GitHub.search_merged_pull_requests_in_user_or_organisation(organisation, person, from:, to:) + organisation_approved_reviews = \ + GitHub.search_approved_pull_requests_in_user_or_organisation(organisation, person, from:, to:) + + require "utils/git" + + repositories.each do |repository| + repository_path, tap = repository_path_and_tap(repository) + if repository_path && tap && !repository_path.exist? + opoo "Repository #{repository} not yet tapped! Tapping it now..." tap.install end - repo_full_name = if repo == "brew" - "homebrew/brew" - else - tap.full_name + repository_full_name = tap&.full_name + repository_full_name ||= repository + + repository_api_url = "#{GitHub::API_URL}/repos/#{repository_full_name}" + + puts "Determining contributions for #{person} on #{repository_full_name}..." if args.verbose? + + merged_pr_author = organisation_merged_prs.count do |pr| + pr.fetch("repository_url") == repository_api_url end + approved_pr_review = organisation_approved_reviews.count do |pr| + pr.fetch("repository_url") == repository_api_url + end + committer = GitHub.count_repository_commits(repository_full_name, person, max:, verbose:, from:, to:) + coauthor = Utils::Git.count_coauthors(repository_path, person, from:, to:) - puts "Determining contributions for #{person} on #{repo_full_name}..." if args.verbose? - - author_commits, committer_commits = GitHub.count_repo_commits(repo_full_name, person, - from:, to: args.to, max: MAX_REPO_COMMITS) - data[repo] = { - author: author_commits, - committer: committer_commits, - coauthor: git_log_trailers_cmd(repo_path, person, "Co-authored-by", from:, to: args.to), - review: count_reviews(repo_full_name, person, from:, to: args.to), - } + data[repository] = { merged_pr_author:, approved_pr_review:, committer:, coauthor: } + rescue GitHub::API::RateLimitExceededError => e + sleep_seconds = e.reset - Time.now.to_i + opoo "GitHub rate limit exceeded, sleeping for #{sleep_seconds} seconds..." + sleep sleep_seconds + retry end data @@ -202,43 +244,17 @@ module Homebrew sig { params(results: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, Integer]) } def total(results) - totals = { author: 0, committer: 0, coauthor: 0, review: 0 } + totals = {} results.each_value do |counts| counts.each do |kind, count| + totals[kind] ||= 0 totals[kind] += count end end totals end - - sig { - params(repo_path: Pathname, person: String, trailer: String, from: T.nilable(String), - to: T.nilable(String)).returns(Integer) - } - def git_log_trailers_cmd(repo_path, person, trailer, from:, to:) - cmd = ["git", "-C", repo_path, "log", "--oneline"] - cmd << "--format='%(trailers:key=#{trailer}:)'" - cmd << "--before=#{to}" if to - cmd << "--after=#{from}" if from - - Utils.safe_popen_read(*cmd).lines.count { |l| l.include?(person) } - end - - sig { - params(repo_full_name: String, person: String, from: T.nilable(String), - to: T.nilable(String)).returns(Integer) - } - def count_reviews(repo_full_name, person, from:, to:) - require "utils/github" - GitHub.count_issues("", is: "pr", repo: repo_full_name, reviewed_by: person, review: "approved", from:, to:) - rescue GitHub::API::ValidationFailedError - if args.verbose? - onoe "Couldn't search GitHub for PRs by #{person}. Their profile might be private. Defaulting to 0." - end - 0 # Users who have made their contributions private are not searchable to determine counts. - end end end end diff --git a/Library/Homebrew/dev-cmd/pr-pull.rb b/Library/Homebrew/dev-cmd/pr-pull.rb index 68769d6dda..a22e801d66 100644 --- a/Library/Homebrew/dev-cmd/pr-pull.rb +++ b/Library/Homebrew/dev-cmd/pr-pull.rb @@ -208,8 +208,7 @@ module Homebrew if pull_request # This is a tap pull request and approving reviewers should also sign-off. tap = T.must(Tap.from_path(git_repo.pathname)) - review_trailers = GitHub.approved_reviews(tap.user, tap.full_name.split("/").last, - pull_request).map do |r| + review_trailers = GitHub.repository_approved_reviews(tap.user, tap.full_repository, pull_request).map do |r| "Signed-off-by: #{r["name"]} <#{r["email"]}>" end trailers = trailers.lines.concat(review_trailers).map(&:strip).uniq.join("\n") diff --git a/Library/Homebrew/official_taps.rb b/Library/Homebrew/official_taps.rb index a918fb5639..1bfc8cb16a 100644 --- a/Library/Homebrew/official_taps.rb +++ b/Library/Homebrew/official_taps.rb @@ -23,6 +23,7 @@ DEPRECATED_OFFICIAL_TAPS = %w[ devel-only dupes emacs + formula-analytics fuse games gui diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/contributions.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/contributions.rbi index 8d6f0b0b80..d82b7dd95b 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/contributions.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/contributions.rbi @@ -17,6 +17,12 @@ class Homebrew::DevCmd::Contributions::Args < Homebrew::CLI::Args sig { returns(T.nilable(String)) } def from; end + sig { returns(T.nilable(String)) } + def organisation; end + + sig { returns(T.nilable(String)) } + def organization; end + sig { returns(T.nilable(T::Array[String])) } def repositories; end diff --git a/Library/Homebrew/tap.rb b/Library/Homebrew/tap.rb index ffab263397..0c14f6ce6d 100644 --- a/Library/Homebrew/tap.rb +++ b/Library/Homebrew/tap.rb @@ -169,6 +169,12 @@ class Tap sig { returns(String) } attr_reader :repository + # The repository name of this {Tap} including the leading `homebrew-`. + # + # @api public + sig { returns(String) } + attr_reader :full_repository + # The name of this {Tap}. It combines {#user} and {#repository} with a slash. # {#name} is always in lowercase. # e.g. `user/repository` @@ -210,7 +216,8 @@ class Tap @user = user @repository = repository @name = T.let("#{@user}/#{@repository}".downcase, String) - @full_name = T.let("#{@user}/homebrew-#{@repository}", String) + @full_repository = T.let("homebrew-#{@repository}", String) + @full_name = T.let("#{@user}/#{@full_repository}", String) @path = T.let(HOMEBREW_TAP_DIRECTORY/@full_name.downcase, Pathname) @git_repository = T.let(GitRepository.new(@path), GitRepository) end diff --git a/Library/Homebrew/test/utils/git_spec.rb b/Library/Homebrew/test/utils/git_spec.rb index 32d2749f52..57ecc3b0b8 100644 --- a/Library/Homebrew/test/utils/git_spec.rb +++ b/Library/Homebrew/test/utils/git_spec.rb @@ -164,8 +164,8 @@ RSpec.describe Utils::Git do end describe "::ensure_installed!" do - it "returns nil if git already available" do - expect(described_class.ensure_installed!).to be_nil + it "doesn't fail if git already available" do + expect { described_class.ensure_installed! }.not_to raise_error end context "when git is not already available" do diff --git a/Library/Homebrew/test/utils/github_spec.rb b/Library/Homebrew/test/utils/github_spec.rb index d4b6ca2ac6..b964d73c61 100644 --- a/Library/Homebrew/test/utils/github_spec.rb +++ b/Library/Homebrew/test/utils/github_spec.rb @@ -32,9 +32,9 @@ RSpec.describe GitHub do end end - describe "::approved_reviews", :needs_network do + describe "::repository_approved_reviews", :needs_network do it "can get reviews for a pull request" do - reviews = described_class.approved_reviews("Homebrew", "homebrew-core", 1, commit: "deadbeef") + reviews = described_class.repository_approved_reviews("Homebrew", "homebrew-core", 1, commit: "deadbeef") expect(reviews).to eq([]) end end @@ -88,51 +88,60 @@ RSpec.describe GitHub do describe "::count_repo_commits" do let(:five_shas) { %w[abcdef ghjkl mnop qrst uvwxyz] } let(:ten_shas) { %w[abcdef ghjkl mnop qrst uvwxyz fedcba lkjhg ponm tsrq zyxwvu] } + let(:max) { 1000 } + let(:verbose) { false } + let(:from) { nil } + let(:to) { nil } it "counts commits authored by a user" do allow(described_class).to receive(:repo_commits_for_user) - .with("homebrew/cask", "user1", "author", nil, nil, nil).and_return(five_shas) + .with("homebrew/cask", "user1", "author", nil, nil, max, verbose).and_return(five_shas) allow(described_class).to receive(:repo_commits_for_user) - .with("homebrew/cask", "user1", "committer", nil, nil, nil).and_return([]) + .with("homebrew/cask", "user1", "committer", nil, nil, max, verbose).and_return([]) - expect(described_class.count_repo_commits("homebrew/cask", "user1")).to eq([5, 0]) + expect(described_class.count_repository_commits("homebrew/cask", "user1", max:, verbose:, from:, +to:)).to eq(5) end it "counts commits committed by a user" do allow(described_class).to receive(:repo_commits_for_user) - .with("homebrew/core", "user1", "author", nil, nil, nil).and_return([]) + .with("homebrew/core", "user1", "author", nil, nil, max, verbose).and_return([]) allow(described_class).to receive(:repo_commits_for_user) - .with("homebrew/core", "user1", "committer", nil, nil, nil).and_return(five_shas) + .with("homebrew/core", "user1", "committer", nil, nil, max, verbose).and_return(five_shas) - expect(described_class.count_repo_commits("homebrew/core", "user1")).to eq([0, 5]) + expect(described_class.count_repository_commits("homebrew/core", "user1", max:, verbose:, from:, +to:)).to eq(5) end it "calculates correctly when authored > committed with different shas" do allow(described_class).to receive(:repo_commits_for_user) - .with("homebrew/cask", "user1", "author", nil, nil, nil).and_return(ten_shas) + .with("homebrew/cask", "user1", "author", nil, nil, max, verbose).and_return(ten_shas) allow(described_class).to receive(:repo_commits_for_user) - .with("homebrew/cask", "user1", "committer", nil, nil, nil).and_return(%w[1 2 3 4 5]) + .with("homebrew/cask", "user1", "committer", nil, nil, max, verbose).and_return(%w[1 2 3 4 5]) - expect(described_class.count_repo_commits("homebrew/cask", "user1")).to eq([10, 5]) + expect(described_class.count_repository_commits("homebrew/cask", "user1", max:, verbose:, from:, +to:)).to eq(15) end it "calculates correctly when committed > authored" do allow(described_class).to receive(:repo_commits_for_user) - .with("homebrew/cask", "user1", "author", nil, nil, nil).and_return(five_shas) + .with("homebrew/cask", "user1", "author", nil, nil, max, verbose).and_return(five_shas) allow(described_class).to receive(:repo_commits_for_user) - .with("homebrew/cask", "user1", "committer", nil, nil, nil).and_return(ten_shas) + .with("homebrew/cask", "user1", "committer", nil, nil, max, verbose).and_return(ten_shas) - expect(described_class.count_repo_commits("homebrew/cask", "user1")).to eq([5, 5]) + expect(described_class.count_repository_commits("homebrew/cask", "user1", max:, verbose:, from:, +to:)).to eq(10) end it "deduplicates commits authored and committed by the same user" do allow(described_class).to receive(:repo_commits_for_user) - .with("homebrew/core", "user1", "author", nil, nil, nil).and_return(five_shas) + .with("homebrew/core", "user1", "author", nil, nil, max, verbose).and_return(five_shas) allow(described_class).to receive(:repo_commits_for_user) - .with("homebrew/core", "user1", "committer", nil, nil, nil).and_return(five_shas) + .with("homebrew/core", "user1", "committer", nil, nil, max, verbose).and_return(five_shas) # Because user1 authored and committed the same 5 commits. - expect(described_class.count_repo_commits("homebrew/core", "user1")).to eq([5, 0]) + expect(described_class.count_repository_commits("homebrew/core", "user1", max:, verbose:, from:, +to:)).to eq(5) end end end diff --git a/Library/Homebrew/utils/git.rb b/Library/Homebrew/utils/git.rb index 8669acf504..edd129a5b3 100644 --- a/Library/Homebrew/utils/git.rb +++ b/Library/Homebrew/utils/git.rb @@ -10,6 +10,7 @@ module Utils module Git extend SystemCommand::Mixin + sig { returns(T::Boolean) } def self.available? version.present? end @@ -21,6 +22,7 @@ module Utils @version = status.success? ? stdout.chomp[/git version (\d+(?:\.\d+)*)/, 1] : nil end + sig { returns(T.nilable(String)) } def self.path return unless available? return @path if defined?(@path) @@ -28,24 +30,31 @@ module Utils @path = Utils.popen_read(git, "--homebrew=print-path").chomp.presence end + sig { returns(Pathname) } def self.git return @git if defined?(@git) @git = HOMEBREW_SHIMS_PATH/"shared/git" end + sig { params(url: String).returns(T::Boolean) } def self.remote_exists?(url) return true unless available? quiet_system "git", "ls-remote", url end + sig { void } def self.clear_available_cache remove_instance_variable(:@version) if defined?(@version) remove_instance_variable(:@path) if defined?(@path) remove_instance_variable(:@git) if defined?(@git) end + sig { + params(repo: T.any(Pathname, String), file: T.any(Pathname, String), + before_commit: T.nilable(String)).returns(String) + } def self.last_revision_commit_of_file(repo, file, before_commit: nil) args = if before_commit.nil? ["--skip=1"] @@ -87,12 +96,14 @@ module Utils file_at_commit(repo, file, commit_hash) end + sig { params(repo: T.any(Pathname, String), file: T.any(Pathname, String), commit: String).returns(String) } def self.file_at_commit(repo, file, commit) relative_file = Pathname(file) relative_file = relative_file.relative_path_from(repo) if relative_file.absolute? Utils.popen_read(git, "-C", repo, "show", "#{commit}:#{relative_file}") end + sig { void } def self.ensure_installed! return if available? @@ -135,6 +146,7 @@ module Utils ENV["GIT_COMMITTER_EMAIL"] = Homebrew::EnvConfig.git_committer_email end + sig { void } def self.setup_gpg! gnupg_bin = HOMEBREW_PREFIX/"opt/gnupg/bin" return unless gnupg_bin.directory? @@ -145,21 +157,37 @@ module Utils # Special case of `git cherry-pick` that permits non-verbose output and # optional resolution on merge conflict. def self.cherry_pick!(repo, *args, resolve: false, verbose: false) - cmd = [git, "-C", repo, "cherry-pick"] + args + cmd = [git.to_s, "-C", repo, "cherry-pick"] + args output = Utils.popen_read(*cmd, err: :out) if $CHILD_STATUS.success? puts output if verbose output else - system git, "-C", repo, "cherry-pick", "--abort" unless resolve + system git.to_s, "-C", repo, "cherry-pick", "--abort" unless resolve raise ErrorDuringExecution.new(cmd, status: $CHILD_STATUS, output: [[:stdout, output]]) end end + sig { returns(T::Boolean) } def self.supports_partial_clone_sparse_checkout? # There is some support for partial clones prior to 2.20, but we avoid using it # due to performance issues Version.new(version) >= Version.new("2.20.0") end + + sig { + params(repository_path: T.nilable(Pathname), person: String, from: T.nilable(String), + to: T.nilable(String)).returns(Integer) + } + def self.count_coauthors(repository_path, person, from:, to:) + return 0 if repository_path.blank? + + cmd = [git.to_s, "-C", repository_path.to_s, "log", "--oneline"] + cmd << "--format='%(trailers:key=Co-authored-by:)''" + cmd << "--before=#{to}" if to + cmd << "--after=#{from}" if from + + Utils.safe_popen_read(*cmd).lines.count { |l| l.include?(person) } + end end end diff --git a/Library/Homebrew/utils/github.rb b/Library/Homebrew/utils/github.rb index 9779723924..1071ea9e7f 100644 --- a/Library/Homebrew/utils/github.rb +++ b/Library/Homebrew/utils/github.rb @@ -13,21 +13,9 @@ require "system_command" # @api internal module GitHub extend SystemCommand::Mixin - extend Utils::Output::Mixin - def self.check_runs(repo: nil, commit: nil, pull_request: nil) - if pull_request - repo = pull_request.fetch("base").fetch("repo").fetch("full_name") - commit = pull_request.fetch("head").fetch("sha") - end - - API.open_rest(url_to("repos", repo, "commits", commit, "check-runs")) - end - - def self.create_check_run(repo:, data:) - API.open_rest(url_to("repos", repo, "check-runs"), data:) - end + MAX_PER_PAGE = T.let(100, Integer) def self.issues(repo:, **filters) uri = url_to("repos", repo, "issues") @@ -36,13 +24,11 @@ module GitHub end def self.search_issues(query, **qualifiers) - search_results_items("issues", query, **qualifiers) - end - - def self.count_issues(query, **qualifiers) - search_results_count("issues", query, **qualifiers) + json = search("issues", query, **qualifiers) + json.fetch("items", []) end + sig { params(files: T::Hash[String, T.untyped], description: String, private: T::Boolean).returns(String) } def self.create_gist(files, description, private:) url = "#{API_URL}/gists" data = { "public" => !private, "files" => files, "description" => description } @@ -65,38 +51,22 @@ module GitHub search_issues(name, repo: tap_remote_repo, state:, type:, in: "title") end + sig { returns(T::Hash[String, T.untyped]) } def self.user @user ||= API.open_rest("#{API_URL}/user") end + sig { params(repo: String, user: String).returns(T::Hash[String, T.untyped]) } def self.permission(repo, user) API.open_rest("#{API_URL}/repos/#{repo}/collaborators/#{user}/permission") end + sig { params(repo: String, user: T.nilable(String)).returns(T::Boolean) } def self.write_access?(repo, user = nil) user ||= self.user["login"] ["admin", "write"].include?(permission(repo, user)["permission"]) end - def self.branch_exists?(user, repo, branch) - API.open_rest("#{API_URL}/repos/#{user}/#{repo}/branches/#{branch}") - true - rescue API::HTTPNotFoundError - false - end - - def self.pull_requests(repo, **options) - url = "#{API_URL}/repos/#{repo}/pulls?#{URI.encode_www_form(options)}" - API.open_rest(url) - end - - def self.merge_pull_request(repo, number:, sha:, merge_method:, commit_message: nil) - url = "#{API_URL}/repos/#{repo}/pulls/#{number}/merge" - data = { sha:, merge_method: } - data[:commit_message] = commit_message if commit_message - API.open_rest(url, data:, request_method: :PUT, scopes: CREATE_ISSUE_FORK_OR_PR_SCOPES) - end - def self.print_pull_requests_matching(query, only = nil) open_or_closed_prs = search_issues(query, is: only, type: "pr", user: "Homebrew") @@ -120,6 +90,7 @@ module GitHub puts "No pull requests found for #{query.inspect}" if open_prs.blank? && closed_prs.blank? end + sig { params(repo: String, org: T.nilable(String)).returns(T::Hash[String, T.untyped]) } def self.create_fork(repo, org: nil) url = "#{API_URL}/repos/#{repo}/forks" data = {} @@ -128,6 +99,7 @@ module GitHub API.open_rest(url, data:, scopes:) end + sig { params(repo: String, org: T.nilable(String)).returns(T::Boolean) } def self.fork_exists?(repo, org: nil) _, reponame = repo.split("/") @@ -139,6 +111,7 @@ module GitHub true end + sig { params(repo: String, title: String, head: String, base: String, body: String).returns(T::Hash[String, T.untyped]) } def self.create_pull_request(repo, title, head, base, body) url = "#{API_URL}/repos/#{repo}/pulls" data = { title:, head:, base:, body:, maintainer_can_modify: true } @@ -146,6 +119,7 @@ module GitHub API.open_rest(url, data:, scopes:) end + sig { params(full_name: String).returns(T::Boolean) } def self.private_repo?(full_name) uri = url_to "repos", full_name API.open_rest(uri) { |json| json["private"] } @@ -169,7 +143,7 @@ module GitHub Array(value).map { |v| "#{key.to_s.tr("_", "-")}:#{v}" } end - "q=#{URI.encode_www_form_component(params.compact.join(" "))}&per_page=100" + "q=#{URI.encode_www_form_component(params.compact.join(" "))}&per_page=#{MAX_PER_PAGE}" end def self.url_to(*subroutes) @@ -182,17 +156,7 @@ module GitHub API.open_rest(uri) end - def self.search_results_items(entity, *queries, **qualifiers) - json = search(entity, *queries, **qualifiers) - json.fetch("items", []) - end - - def self.search_results_count(entity, *queries, **qualifiers) - json = search(entity, *queries, **qualifiers) - json.fetch("total_count", 0) - end - - def self.approved_reviews(user, repo, pull_request, commit: nil) + def self.repository_approved_reviews(user, repo, pull_request, commit: nil) query = <<~EOS { repository(name: "#{repo}", owner: "#{user}") { pullRequest(number: #{pull_request}) { @@ -233,13 +197,6 @@ module GitHub end end - def self.dispatch_event(user, repo, event, **payload) - url = "#{API_URL}/repos/#{user}/#{repo}/dispatches" - API.open_rest(url, data: { event_type: event, client_payload: payload }, - request_method: :POST, - scopes: CREATE_ISSUE_FORK_OR_PR_SCOPES) - end - def self.workflow_dispatch_event(user, repo, workflow, ref, **inputs) url = "#{API_URL}/repos/#{user}/#{repo}/actions/workflows/#{workflow}/dispatches" API.open_rest(url, data: { ref:, inputs: }, @@ -247,11 +204,13 @@ module GitHub scopes: CREATE_ISSUE_FORK_OR_PR_SCOPES) end + sig { params(user: String, repo: String, tag: String).returns(T::Hash[String, T.untyped]) } def self.get_release(user, repo, tag) url = "#{API_URL}/repos/#{user}/#{repo}/releases/tags/#{tag}" API.open_rest(url, request_method: :GET) end + sig { params(user: String, repo: String).returns(T::Hash[String, T.untyped]) } def self.get_latest_release(user, repo) url = "#{API_URL}/repos/#{user}/#{repo}/releases/latest" API.open_rest(url, request_method: :GET) @@ -264,6 +223,10 @@ module GitHub API.open_rest(url, data:, request_method: :POST, scopes: CREATE_ISSUE_FORK_OR_PR_SCOPES) end + sig { + params(user: String, repo: String, tag: String, id: T.nilable(String), name: T.nilable(String), + body: T.nilable(String), draft: T::Boolean).returns(T::Hash[String, T.untyped]) + } def self.create_or_update_release(user, repo, tag, id: nil, name: nil, body: nil, draft: false) url = "#{API_URL}/repos/#{user}/#{repo}/releases" method = if id @@ -340,6 +303,7 @@ module GitHub [check_suite, user, repo, pull_request, workflow_id, scopes, artifact_pattern] end + sig { params(workflow_array: T::Array[T.untyped]).returns(T::Array[T.untyped]) } def self.get_artifact_urls(workflow_array) check_suite, user, repo, pr, workflow_id, scopes, artifact_pattern = *workflow_array if check_suite.empty? @@ -384,7 +348,7 @@ module GitHub matching_artifacts.map { |art| art["archive_download_url"] } end - def self.public_member_usernames(org, per_page: 100) + def self.public_member_usernames(org, per_page: MAX_PER_PAGE) url = "#{API_URL}/orgs/#{org}/public_members" members = [] @@ -396,6 +360,7 @@ module GitHub end end + sig { params(org: String, team: String).returns(T::Hash[String, T.untyped]) } def self.members_by_team(org, team) query = <<~EOS { organization(login: "#{org}") { @@ -501,6 +466,7 @@ module GitHub end end + sig { params(user: String, repo: String, ref: T.nilable(String)).returns(T.nilable(String)) } def self.get_repo_license(user, repo, ref: nil) url = "#{API_URL}/repos/#{user}/#{repo}/license" url += "?ref=#{ref}" if ref.present? @@ -583,6 +549,7 @@ module GitHub pull_requests || [] end + sig { params(name: String, tap_remote_repo: String, version: T.nilable(String)).returns(T::Array[T::Hash[String, T.untyped]]) } def self.fetch_open_pull_requests(name, tap_remote_repo, version: nil) return [] if tap_remote_repo.blank? @@ -686,6 +653,7 @@ module GitHub end end + sig { params(tap_remote_repo: String, pull_request: String).returns(T::Array[T.untyped]) } def self.get_pull_request_changed_files(tap_remote_repo, pull_request) files = [] API.paginate_rest(url_to("repos", tap_remote_repo, "pulls", pull_request, "files")) do |result| @@ -694,13 +662,15 @@ module GitHub files end - private_class_method def self.add_auth_token_to_url!(url) + sig { params(url: String).returns(String) } + def self.add_auth_token_to_url!(url) if API.credentials_type == :env_token url.sub!(%r{^https://github\.com/}, "https://x-access-token:#{API.credentials}@github.com/") end url end + sig { params(tap_remote_repo: String, org: T.nilable(String)).returns(T::Array[String]) } def self.forked_repo_info!(tap_remote_repo, org: nil) response = create_fork(tap_remote_repo, org:) # GitHub API responds immediately but fork takes a few seconds to be ready. @@ -822,7 +792,7 @@ module GitHub end end - def self.pull_request_commits(user, repo, pull_request, per_page: 100) + def self.pull_request_commits(user, repo, pull_request, per_page: MAX_PER_PAGE) pr_data = API.open_rest(url_to("repos", user, repo, "pulls", pull_request)) commits_api = pr_data["commits_url"] commit_count = pr_data["commits"] @@ -883,36 +853,47 @@ module GitHub output[/^Status: (200)/, 1] != "200" end - def self.repo_commits_for_user(nwo, user, filter, from, to, max) - return if Homebrew::EnvConfig.no_github_api? + sig { + params(repository_name_with_owner: String, user: String, filter: String, from: T.nilable(String), + to: T.nilable(String), max: Integer, verbose: T::Boolean).returns(T::Array[String]) + } + def self.repo_commits_for_user(repository_name_with_owner, user, filter, from, to, max, verbose) + return [] if Homebrew::EnvConfig.no_github_api? params = ["#{filter}=#{user}"] params << "since=#{DateTime.parse(from).iso8601}" if from.present? params << "until=#{DateTime.parse(to).iso8601}" if to.present? commits = [] - API.paginate_rest("#{API_URL}/repos/#{nwo}/commits", additional_query_params: params.join("&")) do |result| + API.paginate_rest("#{API_URL}/repos/#{repository_name_with_owner}/commits", + additional_query_params: params.join("&")) do |result| commits.concat(result.map { |c| c["sha"] }) - if max.present? && commits.length >= max - opoo "#{user} exceeded #{max} #{nwo} commits as #{filter}, stopped counting!" + if commits.length >= max + if verbose + opoo "#{user} exceeded #{max} #{repository_name_with_owner} commits as #{filter}, stopped counting!" + end break end end commits end - def self.count_repo_commits(nwo, user, from: nil, to: nil, max: nil) + sig { + params(repository_name_with_owner: String, user: String, max: Integer, verbose: T::Boolean, + from: T.nilable(String), to: T.nilable(String)).returns(Integer) + } + def self.count_repository_commits(repository_name_with_owner, user, max:, verbose:, from: nil, to: nil) odie "Cannot count commits as `$HOMEBREW_NO_GITHUB_API` is set!" if Homebrew::EnvConfig.no_github_api? - author_shas = repo_commits_for_user(nwo, user, "author", from, to, max) - committer_shas = repo_commits_for_user(nwo, user, "committer", from, to, max) - return [0, 0] if author_shas.blank? && committer_shas.blank? + author_shas = repo_commits_for_user(repository_name_with_owner, user, "author", from, to, max, verbose) + committer_shas = repo_commits_for_user(repository_name_with_owner, user, "committer", from, to, max, verbose) + return 0 if author_shas.blank? && committer_shas.blank? author_count = author_shas.count # Only count commits where the author and committer are different. committer_count = committer_shas.difference(author_shas).count - [author_count, committer_count] + author_count + committer_count end MAXIMUM_OPEN_PRS = 15 @@ -981,4 +962,55 @@ module GitHub false end + + sig { params(organisation: String, from: String, to: String, verbose: T::Boolean).returns(T::Array[String]) } + def self.organisation_repositories(organisation, from, to, verbose) + from_date = Date.parse(from) + to_date = Date.parse(to) + + rest_api_url = "#{GitHub::API_URL}/orgs/#{organisation}/repos?type=sources&per_page=#{MAX_PER_PAGE}" + repositories = GitHub::API.open_rest(rest_api_url) + repositories.filter_map do |repository| + pushed_at = Date.parse(repository.fetch("pushed_at")) + created_at = Date.parse(repository.fetch("created_at")) + archived_at = Date.parse(repository.fetch("archived_at", from)) + full_name = repository.fetch("full_name") + + not_pushed = pushed_at < from_date + not_created = created_at > to_date + archived = archived_at < from_date + + if not_pushed || not_created || archived + if verbose + reasons = [] + reasons << "not pushed" if not_pushed + reasons << "not created" if not_created + reasons << "archived" if archived + opoo "Repository #{full_name} #{reasons.join(", ")} from #{from_date} to #{to_date}. Skipping." + end + + next + end + + full_name + end + end + + sig { params(user: String, author: String, from: T.nilable(String), to: T.nilable(String)).returns(T::Array[T.untyped]) } + def self.search_merged_pull_requests_in_user_or_organisation(user, author, from:, to:) + search_issues("", is: "merged", user:, author:, from:, to:) + rescue GitHub::API::ValidationFailedError + opoo "Couldn't search GitHub for PRs authored by #{author}. Their profile might be private. Defaulting to 0." + 0 + end + + sig { + params(user: String, reviewed_by: String, from: T.nilable(String), to: T.nilable(String)).returns(T::Array[T.untyped]) + } + def self.search_approved_pull_requests_in_user_or_organisation(user, reviewed_by, from:, to:) + search_issues("", is: "pr", review: "approved", user:, reviewed_by:, from:, to:) + rescue GitHub::API::ValidationFailedError + opoo "Couldn't search GitHub for PRs reviewed by #{reviewed_by}. Their profile might be private. Defaulting to 0." + 0 + end end diff --git a/Library/Homebrew/utils/github/api.rb b/Library/Homebrew/utils/github/api.rb index 09bfd58323..35bc8d0a18 100644 --- a/Library/Homebrew/utils/github/api.rb +++ b/Library/Homebrew/utils/github/api.rb @@ -68,17 +68,21 @@ module GitHub class RateLimitExceededError < Error sig { params(reset: Integer, github_message: String).void } def initialize(reset, github_message) + @reset = T.let(reset, Integer) new_pat_message = ", or:\n#{GitHub.pat_blurb}" if API.credentials.blank? message = <<~EOS GitHub API Error: #{github_message} - Try again in #{pretty_ratelimit_reset(reset)}#{new_pat_message} + Try again in #{pretty_ratelimit_reset}#{new_pat_message} EOS super(message, github_message) end - sig { params(reset: Integer).returns(String) } - def pretty_ratelimit_reset(reset) - pretty_duration(Time.at(reset) - Time.now) + sig { returns(Integer) } + attr_reader :reset + + sig { returns(String) } + def pretty_ratelimit_reset + pretty_duration(Time.at(@reset) - Time.now) end end