brew/Library/Homebrew/dev-cmd/contributions.rb
Mike McQuaid a408da65fc
Hide default gems warnings for Ruby 3.4.
These omit warnings with Ruby 3.3 and are required for Ruby 3.4. We'll
fix them when we're upgrading to 3.4 instead.
2024-05-17 14:42:44 +09:00

217 lines
7.4 KiB
Ruby

# typed: true
# frozen_string_literal: true
require "abstract_command"
require "warnings"
Warnings.ignore :default_gems do
require "csv"
end
module Homebrew
module DevCmd
class Contributions < AbstractCommand
PRIMARY_REPOS = %w[brew core cask].freeze
SUPPORTED_REPOS = [
PRIMARY_REPOS,
OFFICIAL_CMD_TAPS.keys.map { |t| t.delete_prefix("homebrew/") },
OFFICIAL_CASK_TAPS.reject { |t| t == "cask" },
].flatten.freeze
MAX_REPO_COMMITS = 1000
cmd_args do
usage_banner "`contributions` [--user=<email|username>] [<--repositories>`=`] [<--csv>]"
description <<~EOS
Summarise contributions to Homebrew repositories.
EOS
comma_array "--repositories",
description: "Specify a comma-separated list of repositories to search. " \
"Supported repositories: #{SUPPORTED_REPOS.map { |t| "`#{t}`" }.to_sentence}. " \
"Omitting this flag, or specifying `--repositories=primary`, searches only the " \
"main repositories: brew,core,cask. " \
"Specifying `--repositories=all`, searches all repositories. "
flag "--from=",
description: "Date (ISO-8601 format) to start searching contributions. " \
"Omitting this flag searches the last year."
flag "--to=",
description: "Date (ISO-8601 format) to stop searching contributions."
comma_array "--user=",
description: "Specify a comma-separated list of GitHub usernames or email addresses to find " \
"contributions from. Omitting this flag searches maintainers."
switch "--csv",
description: "Print a CSV of contributions across repositories over the time period."
end
sig { override.void }
def run
results = {}
grand_totals = {}
repos = if args.repositories.blank? || T.must(args.repositories).include?("primary")
PRIMARY_REPOS
elsif T.must(args.repositories).include?("all")
SUPPORTED_REPOS
else
args.repositories
end
from = args.from.presence || Date.today.prev_year.iso8601
contribution_types = [:author, :committer, :coauthorship, :review]
users = args.user.presence || GitHub.members_by_team("Homebrew", "maintainers").keys
users.each do |username|
# TODO: Using the GitHub username to scan the `git log` undercounts some
# contributions as people might not always have configured their Git
# 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:)
grand_totals[username] = total(results[username])
contributions = contribution_types.filter_map do |type|
type_count = grand_totals[username][type]
next if type_count.to_i.zero?
"#{Utils.pluralize("time", type_count, include_count: true)} (#{type})"
end
contributions <<
"#{Utils.pluralize("time", grand_totals[username].values.sum, include_count: true)} (total)"
puts [
"#{username} contributed",
*contributions.to_sentence,
"#{time_period(from:, to: args.to)}.",
].join(" ")
end
return unless args.csv?
puts
puts generate_csv(grand_totals)
end
private
sig { params(repo: String).returns(Pathname) }
def find_repo_path_for_repo(repo)
return HOMEBREW_REPOSITORY if repo == "brew"
Tap.fetch("homebrew", repo).path
end
sig { params(from: T.nilable(String), to: T.nilable(String)).returns(String) }
def time_period(from:, to:)
if from && to
"between #{from} and #{to}"
elsif from
"after #{from}"
elsif to
"before #{to}"
else
"in all time"
end
end
sig { params(totals: Hash).returns(String) }
def generate_csv(totals)
CSV.generate do |csv|
csv << %w[user repo author committer coauthorship review total]
totals.sort_by { |_, v| -v.values.sum }.each do |user, total|
csv << grand_total_row(user, total)
end
end
end
sig { params(user: String, grand_total: Hash).returns(Array) }
def grand_total_row(user, grand_total)
[
user,
"all",
grand_total[:author],
grand_total[:committer],
grand_total[:coauthorship],
grand_total[:review],
grand_total.values.sum,
]
end
def scan_repositories(repos, person, from:)
data = {}
repos.each do |repo|
if SUPPORTED_REPOS.exclude?(repo)
return ofail "Unsupported repository: #{repo}. Try one of #{SUPPORTED_REPOS.join(", ")}."
end
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..."
tap.install
end
repo_full_name = if repo == "brew"
"homebrew/brew"
else
tap.full_name
end
puts "Determining contributions for #{person} on #{repo_full_name}..." if args.verbose?
author_commits, committer_commits = GitHub.count_repo_commits(repo_full_name, person, args,
max: MAX_REPO_COMMITS)
data[repo] = {
author: author_commits,
committer: committer_commits,
coauthorship: git_log_trailers_cmd(T.must(repo_path), person, "Co-authored-by", from:, to: args.to),
review: count_reviews(repo_full_name, person),
}
end
data
end
sig { params(results: Hash).returns(Hash) }
def total(results)
totals = { author: 0, committer: 0, coauthorship: 0, review: 0 }
results.each_value do |counts|
counts.each do |kind, count|
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).returns(Integer) }
def count_reviews(repo_full_name, person)
GitHub.count_issues("", is: "pr", repo: repo_full_name, reviewed_by: person, review: "approved", args:)
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