brew/Library/Homebrew/dev-cmd/contributions.rb

261 lines
9.9 KiB
Ruby
Raw Normal View History

# typed: strict
# frozen_string_literal: true
2024-03-21 08:08:53 -07:00
require "abstract_command"
module Homebrew
2024-03-19 11:57:54 -07:00
module DevCmd
class Contributions < AbstractCommand
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)
2024-03-19 11:57:54 -07:00
cmd_args do
usage_banner "`contributions` [`--user=`] [`--repositories=`] [`--from=`] [`--to=`] [`--csv`]"
2024-03-19 11:57:54 -07:00
description <<~EOS
Summarise contributions to Homebrew repositories.
EOS
comma_array "--user=",
description: "Specify a comma-separated list of GitHub usernames or email addresses to find " \
"contributions from. Omitting this flag searches Homebrew maintainers."
2024-03-19 11:57:54 -07:00
comma_array "--repositories",
description: "Specify a comma-separated list of repositories to search. " \
"All repositories must be under the same user or organisation. " \
2024-03-19 11:57:54 -07:00
"Omitting this flag, or specifying `--repositories=primary`, searches only the " \
"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."
flag "--to=",
description: "Date (ISO 8601 format) to stop searching contributions."
2024-03-19 11:57:54 -07:00
switch "--csv",
description: "Print a CSV of contributions across repositories over the time period."
conflicts "--organisation", "--repositories"
end
2024-03-19 11:57:54 -07:00
sig { override.void }
def run
odie "Cannot get contributions as `$HOMEBREW_NO_GITHUB_API` is set!" if Homebrew::EnvConfig.no_github_api?
2025-04-22 19:23:10 +01:00
Homebrew.install_bundler_gems!(groups: ["contributions"]) if args.csv?
2024-03-19 11:57:54 -07:00
results = {}
grand_totals = {}
from = args.from.presence || Date.today.prev_year.iso8601
to = args.to.presence || (Date.today + 1).iso8601
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
2024-03-19 11:57:54 -07:00
repos
else
PRIMARY_REPOS
end
organisation ||= T.must(repositories.fetch(0).split("/").first)
2024-03-19 11:57:54 -07:00
require "utils/github"
2024-03-19 11:57:54 -07:00
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
2025-08-05 12:47:04 -04:00
# contributions as people might not always have configured their Git
# committer details to match the ones on GitHub.
2024-03-19 11:57:54 -07:00
# TODO: Switch to using the GitHub APIs instead of `git log` if
2025-08-05 12:47:04 -04:00
# they ever support trailers.
results[username] = scan_repositories(organisation, repositories, username, from:, to:)
2024-03-19 11:57:54 -07:00
grand_totals[username] = total(results[username])
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|
2024-03-19 11:57:54 -07:00
type_count = grand_totals[username][type]
next if type_count.zero?
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
2024-03-19 11:57:54 -07:00
pretty_type = CONTRIBUTION_TYPES.fetch(type)
"#{count_prefix}#{Utils.pluralize("time", type_count, include_count: true)} (#{pretty_type})"
2024-03-19 11:57:54 -07:00
end
total = Utils.pluralize("time", grand_totals[username].values.sum, include_count: true)
total_prefix = ">=" if greater_than_total
contributions << "#{total_prefix}#{total} (total)"
2024-03-19 11:57:54 -07:00
contributions_string = [
2024-03-19 11:57:54 -07:00
"#{username} contributed",
*contributions.to_sentence,
"#{time_period(from:, to: args.to)}.",
].join(" ")
if args.csv?
$stderr.puts contributions_string
else
puts contributions_string
end
2024-03-19 11:57:54 -07:00
end
return unless args.csv?
$stderr.puts
puts generate_csv(grand_totals)
2024-03-19 11:57:54 -07:00
end
2024-03-19 11:57:54 -07:00
private
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 = Tap.fetch(repository)
return [nil, nil] if tap.user == "Homebrew" && DEPRECATED_OFFICIAL_TAPS.include?(tap.repository)
[tap.path, tap]
end
2024-03-19 11:57:54 -07:00
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: T::Hash[String, T::Hash[Symbol, Integer]]).returns(String) }
2024-03-19 11:57:54 -07:00
def generate_csv(totals)
2025-04-22 19:23:10 +01:00
require "csv"
2024-03-19 11:57:54 -07:00
CSV.generate do |csv|
csv << ["user", "repository", *CONTRIBUTION_TYPES.keys, "total"]
2024-03-19 11:57:54 -07:00
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: T::Hash[Symbol, Integer]).returns(T::Array[T.any(String, T.nilable(Integer))]) }
2024-03-19 11:57:54 -07:00
def grand_total_row(user, grand_total)
grand_totals = grand_total.slice(*CONTRIBUTION_TYPES.keys).values
[user, "all", *grand_totals, grand_totals.sum]
end
sig {
params(
organisation: String,
repositories: T::Array[String],
person: String,
from: String,
to: String,
).returns(T::Hash[Symbol, T.untyped])
}
def scan_repositories(organisation, repositories, person, from:, to:)
2024-03-19 11:57:54 -07:00
data = {}
return data if repositories.blank?
2024-03-19 11:57:54 -07:00
require "utils/github"
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..."
2024-03-19 11:57:54 -07:00
tap.install
end
repository_full_name = tap&.full_name
repository_full_name ||= repository
2024-03-19 11:57:54 -07:00
repository_api_url = "#{GitHub::API_URL}/repos/#{repository_full_name}"
2024-03-19 11:57:54 -07:00
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:)
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
2024-03-19 11:57:54 -07:00
end
data
end
sig { params(results: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, Integer]) }
2024-03-19 11:57:54 -07:00
def total(results)
totals = {}
2024-03-19 11:57:54 -07:00
results.each_value do |counts|
counts.each do |kind, count|
totals[kind] ||= 0
2024-03-19 11:57:54 -07:00
totals[kind] += count
end
end
2024-03-19 11:57:54 -07:00
totals
end
end
end
end