Files
brew/Library/Homebrew/dev-cmd/contributions.rb
T
Mike McQuaid 56f42e0e1d Make tests use public APIs and enforce via RuboCop
- Tests reached into private APIs with `send` and poked internal
  state via `instance_variable_get`/`instance_variable_set`, hiding
  which interfaces specs really exercise and letting visibility rot.
- Make every statically-poked method public and call it directly,
  keeping `public_send` only for dynamically-named public methods.
- Read and write state through public `attr_*` accessors instead of
  instance variable reflection.
- Add `Homebrew/NoSendInTests` and
  `Homebrew/NoInstanceVariableAccessInTests` cops scoped to
  `Library/Homebrew/test` so neither pattern creeps back in.
- Keep rare justified disables, e.g. `Module#remove_const` is
  private core Ruby and raw memoisation state has no accessor.
2026-07-24 19:55:53 +01:00

838 lines
38 KiB
Ruby

# typed: strict
# frozen_string_literal: true
require "abstract_command"
require "digest"
require "json"
require "system_command"
require "tap"
module Homebrew
module DevCmd
class Contributions < AbstractCommand
include SystemCommand::Mixin
PRIMARY_REPOS = %w[
Homebrew/brew
Homebrew/homebrew-core
Homebrew/homebrew-cask
].freeze
CONTRIBUTION_TYPES = T.let({
merged_pr_author: "merged PRs authored",
merged_pr_merger: "PRs merged",
merged_pr: "qualifying merged PRs",
approved_pr_review: "approved-review search matches",
coauthor: "co-authored commits",
}.freeze, T::Hash[Symbol, String])
MAX_PR_SEARCH = 100
# https://docs.brew.sh/Homebrew-Governance#maintainer
MAINTAINER_ACTIVITY_THRESHOLD = 50
# https://docs.brew.sh/Homebrew-Governance#lead-maintainer
LEAD_REPOSITORY_ACTIVITY_THRESHOLD = 25
MAX_CONTRIBUTIONS = T.let(MAINTAINER_ACTIVITY_THRESHOLD * 10, Integer)
QUALIFYING_CONTRIBUTION_TYPES = [:merged_pr, :approved_pr_review, :coauthor].freeze
cmd_args do
usage_banner "`contributions` [`--user=`] [`--repositories=`] [`--quarter=`] [`--from=`] [`--to=`] " \
"[`--csv`] [`--maintainer-report-csv=`]"
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."
comma_array "--repositories",
description: "Specify a comma-separated list of repositories to search. " \
"All repositories must be under the same user or organisation. " \
"Omitting this flag, or specifying `--repositories=primary`, searches only the " \
"main repositories: `Homebrew/brew`, `Homebrew/homebrew-core`, " \
"`Homebrew/homebrew-cask`."
flag "--organisation=", "--organization=", "--org=",
description: "Specify the organisation to populate sources repositories from. " \
"Omitting this flag searches the Homebrew primary repositories."
flag "--team=",
description: "Specify the team to populate users from. " \
"The first part of the team name will be used as the organisation."
flag "--quarter=",
description: "Homebrew contributions quarter to search (1-4). " \
"Omitting this flag searches the past year. " \
"If `--from` or `--to` are set, they take precedence."
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."
switch "--csv",
description: "Print a CSV of contributions across repositories over the time period."
flag "--maintainer-report-csv=",
description: "Print a CSV of Maintainer and Lead Maintainer activity criteria using fetched Git " \
"histories and GitHub's existing approved-review search for the Homebrew " \
"governance quarter, for example " \
"`--maintainer-report-csv=2026-2`. " \
"Also write it to `brew-contributions-FROM-to-TO.csv` in the current directory. " \
"Only Maintainers listed at the end of that quarter are included. " \
"The `new role` value must show a downgrade for two consecutive " \
"quarters before a downgrade is applied. " \
"Review searches return at most 100 results and other counts are capped at 500 per " \
"repository and contribution type. Repository-scoped follow-up searches ensure " \
"role activity checks remain accurate when a count is capped. Completed-period " \
"GitHub searches are cached in Homebrew's cache and removed by normal cache pruning. " \
"`YEAR-1` is December of the previous year through February, `YEAR-2` is March " \
"through May, `YEAR-3` is June through August and `YEAR-4` is September through " \
"November."
conflicts "--organisation", "--repositories"
conflicts "--organisation", "--team"
conflicts "--user", "--team"
conflicts "--maintainer-report-csv", "--user"
conflicts "--maintainer-report-csv", "--repositories"
conflicts "--maintainer-report-csv", "--organisation"
conflicts "--maintainer-report-csv", "--team"
conflicts "--maintainer-report-csv", "--quarter"
conflicts "--maintainer-report-csv", "--from"
conflicts "--maintainer-report-csv", "--to"
end
sig { override.void }
def run
maintainer_report_csv = args.maintainer_report_csv
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? || maintainer_report_csv
if maintainer_report_csv
odie "`--maintainer-report-csv` must be in YEAR-QUARTER format." unless maintainer_report_csv.match?(
/\A\d{4}-[1-4]\z/,
)
quarter_parts = maintainer_report_csv.split("-")
from, to = reporting_quarter_dates(quarter_parts.fetch(1).to_i, quarter_parts.fetch(0).to_i)
$stderr.puts "Maintainer report dates: #{from}-to-#{to}"
else
quarter = args.quarter.presence.to_i
odie "Value for `--quarter` must be between 1 and 4." if args.quarter.present? && !quarter.between?(1, 4)
quarter_dates = reporting_quarter_dates(quarter) unless quarter.zero?
from = args.from.presence || quarter_dates&.first || Date.today.prev_year.iso8601
to = args.to.presence || quarter_dates&.last || (Date.today + 1).iso8601
puts "Date range is #{time_period(from:, to:)}." if args.verbose?
end
require "utils/github"
organisation = T.let(nil, T.nilable(String))
users = if maintainer_report_csv
[]
elsif (team = args.team.presence)
team_sections = team.split("/")
organisation = team_sections.first.presence
team_name = team_sections.last.presence
if team_sections.length != 2 || organisation.nil? || team_name.nil?
odie "Team must be in the format `organisation/team`!"
end
puts "Getting members for #{organisation}/#{team_name}..." if args.verbose?
GitHub.members_by_team(organisation, team_name).keys
elsif (users = args.user.presence)
users
else
puts "Getting members for Homebrew/maintainers..." if args.verbose?
GitHub.members_by_team("Homebrew", "maintainers").keys
end
user_names = users.to_h { |user| [user, user] }
repositories = if maintainer_report_csv
organisation = "Homebrew"
PRIMARY_REPOS
elsif (org = organisation.presence) || (org = args.organisation.presence)
organisation = org
puts "Getting repositories for #{organisation}..." if args.verbose?
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
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 ||= repositories.fetch(0).split("/").fetch(0)
repository_refs = prepare_contribution_repositories(repositories, required: maintainer_report_csv.present?)
lead_maintainers = T.let({}, T::Hash[String, T::Boolean])
maintainer_since_dates = T.let({}, T::Hash[String, T.nilable(String)])
if maintainer_report_csv
user_names, lead_maintainers, maintainer_since_dates = maintainer_report_users(repository_refs, to)
end
results = scan_contributions(
organisation, repositories, repository_refs, user_names, from:, to:,
skip_reviews_if_lead_met: maintainer_report_csv.present?,
progress: maintainer_report_csv.present? || args.verbose?
)
grand_totals = results.transform_values { |user_results| total(user_results) }
if maintainer_report_csv
csv = generate_maintainer_report_csv(
results, grand_totals, user_names, lead_maintainers, maintainer_since_dates, to
)
File.write("brew-contributions-#{from}-to-#{to}.csv", csv)
puts csv
return
end
user_names.each_key do |username|
grand_total = grand_totals.fetch(username)
greater_than_total = T.let(grand_total.fetch(:merged_pr_author_hit_cap, 0).positive?, T::Boolean)
contributions = CONTRIBUTION_TYPES.keys.filter_map do |type|
type_count = grand_total[type]
next if type_count.nil? || type_count.zero?
count_prefix = ""
if ([:merged_pr_author, :merged_pr].include?(type) && grand_total.fetch(:merged_pr_author_hit_cap,
0).positive?) ||
(type == :approved_pr_review && type_count >= MAX_PR_SEARCH) || type_count >= MAX_CONTRIBUTIONS
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
qualifying_total = contribution_count(
grand_total.slice(*QUALIFYING_CONTRIBUTION_TYPES),
)
total = Utils.pluralize("time", qualifying_total, include_count: true)
total_prefix = ">=" if greater_than_total
contributions << "#{total_prefix}#{total} (total)"
contributions_string = [
"#{username} contributed",
*contributions.to_sentence,
"#{time_period(from:, to:)}.",
].join(" ")
if args.csv?
$stderr.puts contributions_string
else
puts contributions_string
end
end
return unless args.csv?
$stderr.puts
puts generate_csv(grand_totals)
end
sig {
params(repository_refs: T::Hash[String, [Pathname, String]], to: String)
.returns([T::Hash[String, String], T::Hash[String, T::Boolean], T::Hash[String, T.nilable(String)]])
}
def maintainer_report_users(repository_refs, to)
brew_path, brew_ref = repository_refs.fetch("Homebrew/brew")
require "utils/git"
quarter_end_ref = Utils.safe_popen_read(
Utils::Git.git, "-C", brew_path, "rev-list", "-1", "--before=#{to}", brew_ref, "--", "README.md"
).strip
odie "Could not find Homebrew/brew's README at the end of the reporting quarter." if quarter_end_ref.empty?
user_names = T.let({}, T::Hash[String, String])
lead_maintainers = T.let({}, T::Hash[String, T::Boolean])
Utils.safe_popen_read(Utils::Git.git, "-C", brew_path, "show", "#{quarter_end_ref}:README.md")
.dup.force_encoding(Encoding::UTF_8).each_line do |line|
lead = line.start_with?("Homebrew's [Lead Maintainers]")
next if !lead &&
!line.start_with?("Homebrew's other Maintainers") &&
!line.start_with?("Homebrew's maintainers are")
line.scan(%r{\[([^\]]+)\]\(https://github\.com/([A-Za-z\d-]+)\)}).each do |match|
next unless match.is_a?(Array)
name = match.fetch(0)
user = match.fetch(1)
user_names[user] = name
lead_maintainers[user.downcase] = true if lead
end
end
odie "Could not read the maintainers from Homebrew/brew's README." if user_names.empty?
$stderr.puts "Scanning contributions for #{user_names.length} maintainers..."
maintainer_since_dates = user_names.to_h do |user, name|
[user, maintainer_since(brew_path, quarter_end_ref, user, name)]
end
[user_names, lead_maintainers, maintainer_since_dates]
end
sig { params(repository_path: Pathname, ref: String, user: String, name: String).returns(T.nilable(String)) }
def maintainer_since(repository_path, ref, user, name)
require "utils/git"
candidates = ["https://github.com/#{user}", name].flat_map do |identity|
Utils.safe_popen_read(
Utils::Git.git, "-C", repository_path, "log", ref, "--fixed-strings",
"-S#{identity}", "--format=%H%x1f%cs", "--", "README.md"
).lines(chomp: true)
end
candidates.uniq!
candidates.sort_by! { |candidate| candidate.split("\x1f", 2).fetch(1) }
candidates.each do |candidate|
commit, date = candidate.split("\x1f", 2)
next if date.nil?
readme = Utils.safe_popen_read(Utils::Git.git, "-C", repository_path, "show", "#{commit}:README.md")
parent_readme = system_command(Utils::Git.git,
args: ["-C", repository_path, "show", "#{commit}^:README.md"],
print_stderr: false).stdout
return date if readme_mentions?(readme, user, name) && !readme_mentions?(parent_readme, user, name)
end
nil
end
sig {
params(
organisation: String,
repositories: T::Array[String],
repository_refs: T::Hash[String, [Pathname, String]],
users: T::Hash[String, String],
from: String,
to: String,
skip_reviews_if_lead_met: T::Boolean,
progress: T::Boolean,
).returns(T::Hash[String, T::Hash[String, T::Hash[Symbol, Integer]]])
}
def scan_contributions(organisation, repositories, repository_refs, users, from:, to:,
skip_reviews_if_lead_met:, progress:)
results = users.to_h do |user, _|
user_results = repositories.to_h do |repository|
[repository, CONTRIBUTION_TYPES.keys.to_h { |type| [type, 0] }]
end
[user, user_results]
end
require "utils/github"
github_users = users.keys.to_h { |user| [user, github_username_for(user, to:)] }
git_authored_pull_requests = users.keys.to_h do |user|
[user, repositories.to_h { |repository| [repository, Set.new] }]
end
git_merged_pull_requests = users.keys.to_h do |user|
[user, repositories.to_h { |repository| [repository, Set.new] }]
end
repository_refs.each do |repository, (repository_path, ref)|
require "utils/git"
output = Utils.safe_popen_read(
Utils::Git.git, "-C", repository_path, "log", ref, "--since=#{from}", "--before=#{to}",
"--format=%H%x1f%P%x1f%an%x1f%ae%x1f%B%x1e"
)
authored_pull_requests = users.keys.to_h { |user| [user, Set.new] }
merged_pull_requests = users.keys.to_h { |user| [user, Set.new] }
parse_git_log(output, users, authored_pull_requests:, merged_pull_requests:).each do |user, counts|
results.fetch(user)[repository] = counts
git_authored_pull_requests.fetch(user)[repository] = authored_pull_requests.fetch(user)
git_merged_pull_requests.fetch(user)[repository] = merged_pull_requests.fetch(user)
end
end
merged_range = "#{from}..#{Date.iso8601(to).prev_day.iso8601}"
users.each_key do |user|
github_user = github_users.fetch(user)
next if github_user.nil?
cache_key = ["merged-at", organisation, github_user, merged_range].join("\0")
merged_pull_requests = github_search_with_rate_limit(cache_key, to:) do
GitHub.search_issues("", is: "merged", user: organisation, author: github_user, merged: merged_range)
rescue GitHub::API::ValidationFailedError
opoo "Couldn't search GitHub for PRs authored by #{github_user}. Their profile might be private. " \
"Defaulting to 0."
[]
end
capped_merged_pull_requests = merged_pull_requests.length >= MAX_PR_SEARCH
if capped_merged_pull_requests
results.fetch(user).fetch(repositories.fetch(0))[:merged_pr_author_hit_cap] =
1
end
merged_pull_requests.each do |pull_request|
repository = pull_request.fetch("repository_url").delete_prefix("#{GitHub::API_URL}/repos/")
next unless repositories.include?(repository)
authored_pull_requests = git_authored_pull_requests.fetch(user).fetch(repository)
merged_pull_request_ids = git_merged_pull_requests.fetch(user).fetch(repository)
add_merged_pull_request_id(pull_request, authored_pull_requests, merged_pull_request_ids)
end
repositories.each do |repository|
counts = results.fetch(user).fetch(repository)
authored_pull_requests = git_authored_pull_requests.fetch(user).fetch(repository)
merged_pull_request_ids = git_merged_pull_requests.fetch(user).fetch(repository)
update_merged_pull_request_counts(counts, authored_pull_requests, merged_pull_request_ids)
end
next unless skip_reviews_if_lead_met
next unless capped_merged_pull_requests
next if lead_activity_met?(results.fetch(user))
repositories.each do |repository|
break if lead_activity_met?(results.fetch(user))
repository_counts = results.fetch(user).fetch(repository)
repository_total = contribution_count(repository_counts.slice(*QUALIFYING_CONTRIBUTION_TYPES))
qualifying_total = contribution_count(total(results.fetch(user)).slice(*QUALIFYING_CONTRIBUTION_TYPES))
next if repository_total >= LEAD_REPOSITORY_ACTIVITY_THRESHOLD &&
qualifying_total >= MAINTAINER_ACTIVITY_THRESHOLD
$stderr.puts "Querying merged-PR search for #{user} in #{repository}..." if progress
cache_key = ["merged-at", repository, github_user, merged_range].join("\0")
repository_pull_requests = github_search_with_rate_limit(cache_key, to:) do
GitHub.search_issues("", is: "merged", repo: repository, author: github_user, merged: merged_range)
end
authored_pull_requests = git_authored_pull_requests.fetch(user).fetch(repository)
merged_pull_request_ids = git_merged_pull_requests.fetch(user).fetch(repository)
repository_pull_requests.each do |pull_request|
add_merged_pull_request_id(pull_request, authored_pull_requests, merged_pull_request_ids)
end
update_merged_pull_request_counts(repository_counts, authored_pull_requests, merged_pull_request_ids)
end
end
review_users = github_users.filter_map { |user, github_user| [user, github_user] if github_user }
review_users.reject! { |user, _| lead_activity_met?(results.fetch(user)) } if skip_reviews_if_lead_met
review_users.each_with_index do |(user, github_user), index|
if progress
$stderr.puts "Querying approved-review search for #{user} (#{index + 1}/#{review_users.length})..."
end
cache_key = ["approved", organisation, github_user, from, to].join("\0")
approved_reviews = github_search_with_rate_limit(cache_key, to:) do
GitHub.search_approved_pull_requests_in_user_or_organisation(organisation, github_user, from:, to:)
end
capped_reviews = approved_reviews.length >= MAX_PR_SEARCH
results.fetch(user).fetch(repositories.fetch(0))[:approved_pr_review_hit_cap] = 1 if capped_reviews
approved_reviews.each do |pull_request|
repository = pull_request.fetch("repository_url").delete_prefix("#{GitHub::API_URL}/repos/")
next unless repositories.include?(repository)
increment_contribution_count(results.fetch(user).fetch(repository), :approved_pr_review)
end
next unless skip_reviews_if_lead_met
next unless capped_reviews
next if lead_activity_met?(results.fetch(user))
repositories.each do |repository|
break if lead_activity_met?(results.fetch(user))
repository_counts = results.fetch(user).fetch(repository)
repository_total = contribution_count(repository_counts.slice(*QUALIFYING_CONTRIBUTION_TYPES))
qualifying_total = contribution_count(total(results.fetch(user)).slice(*QUALIFYING_CONTRIBUTION_TYPES))
next if repository_total >= LEAD_REPOSITORY_ACTIVITY_THRESHOLD &&
qualifying_total >= MAINTAINER_ACTIVITY_THRESHOLD
$stderr.puts "Querying approved-review search for #{user} in #{repository}..." if progress
cache_key = ["approved", repository, github_user, from, to].join("\0")
repository_reviews = github_search_with_rate_limit(cache_key, to:) do
GitHub.search_issues("", is: "pr", review: "approved", repo: repository, reviewed_by: github_user,
from:, to:)
end
repository_counts[:approved_pr_review] = repository_reviews.length
end
end
results
end
sig { params(user: String, to: String).returns(T.nilable(String)) }
def github_username_for(user, to:)
return user unless user.include?("@")
if user.end_with?("@users.noreply.github.com")
return user.delete_suffix("@users.noreply.github.com").sub(/\A\d+\+/,
"")
end
cache_key = ["public-email", user].join("\0")
matches = github_search_with_rate_limit(cache_key, to:) do
GitHub.search("users", "\"#{user}\" in:email").fetch("items", [])
end
if matches.one?
login = matches.fetch(0)["login"]
return login if login.is_a?(String)
end
opoo "Could not find a unique public GitHub account for #{user}; skipping GitHub PR searches."
nil
rescue GitHub::API::ValidationFailedError
opoo "Could not search for a public GitHub account for #{user}; skipping GitHub PR searches."
nil
end
sig {
params(cache_key: String, to: String, block: T.proc.returns(T::Array[T::Hash[String, T.untyped]]))
.returns(T::Array[T::Hash[String, T.untyped]])
}
def github_search_with_rate_limit(cache_key, to:, &block)
cache_path = if Date.iso8601(to) <= Date.today
HOMEBREW_CACHE/"contributions--#{Digest::SHA256.hexdigest("1\0#{cache_key}")}.json"
end
if cache_path&.file?
begin
cached_results = JSON.parse(cache_path.read)
return cached_results if cached_results.is_a?(Array)
rescue JSON::ParserError, Errno::ENOENT
nil
end
cache_path.unlink if cache_path.exist?
end
results = yield
if cache_path
HOMEBREW_CACHE.mkpath
cache_path.atomic_write(JSON.generate(results))
end
results
rescue GitHub::API::RateLimitExceededError => e
sleep_seconds = [e.reset - Time.now.to_i, 1].max
opoo "GitHub rate limit exceeded, sleeping for #{sleep_seconds} seconds..."
sleep sleep_seconds
retry
end
sig {
params(
output: String,
users: T::Hash[String, String],
authored_pull_requests: T.nilable(T::Hash[String, T::Set[String]]),
merged_pull_requests: T.nilable(T::Hash[String, T::Set[String]]),
)
.returns(T::Hash[String, T::Hash[Symbol, Integer]])
}
def parse_git_log(output, users, authored_pull_requests: nil, merged_pull_requests: nil)
counts = users.to_h do |user, _|
[user, CONTRIBUTION_TYPES.keys.to_h { |type| [type, 0] }]
end
identity_users = T.let({}, T::Hash[String, String])
users.each do |user, name|
identity_users[user.downcase] = user
identity_users[name.downcase] = user
identity_users[user.split("@").first.to_s.sub(/\A\d+\+/, "").downcase] = user
end
records = output.split("\x1e").filter_map do |record|
fields = record.strip.split("\x1f", 5)
fields if fields.length == 5
end
record_identities = records.to_h do |fields|
[fields.fetch(0), [fields.fetch(2), fields.fetch(3)]]
end
records.each do |fields|
parents = fields.fetch(1).split
source_owner = fields.fetch(4)[%r{\AMerge pull request #\d+ from ([^/\s]+)/}, 1]
next if parents.length < 2 || source_owner.nil?
user = identity_users[source_owner.downcase]
source_identity = record_identities[parents.fetch(1)]
next if user.nil? || source_identity.nil?
name, email = source_identity
identity_users[name.strip.downcase] ||= user
identity_users[email.downcase] ||= user
identity_users[email.split("@").first.to_s.sub(/\A\d+\+/, "").downcase] ||= user
end
commit_authors = T.let(records.to_h do |fields|
sha = fields.fetch(0)
author_name = fields.fetch(2)
author_email = fields.fetch(3)
[sha, user_for_git_identity(author_name, author_email, identity_users)]
end, T::Hash[String, T.nilable(String)])
records.each do |fields|
parents_string = fields.fetch(1)
author_name = fields.fetch(2)
author_email = fields.fetch(3)
body = fields.fetch(4)
coauthors = body.scan(/^Co-authored-by:\s*(.*?)\s*<([^>]+)>/i).filter_map do |match|
next unless match.is_a?(Array)
user_for_git_identity(match.fetch(0), match.fetch(1), identity_users)
end
coauthors.uniq.each do |user|
increment_contribution_count(counts.fetch(user), :coauthor)
end
parents = parents_string.split
pull_request = body.match(%r{\AMerge pull request #(\d+) from ([^/\s]+)/})
next if parents.length < 2 || pull_request.nil?
merger = user_for_git_identity(author_name, author_email, identity_users)
pull_request_id = pull_request[1]
source_owner = pull_request[2]
next if pull_request_id.nil? || source_owner.nil?
author = identity_users[source_owner.downcase] || commit_authors[parents.fetch(1)]
if author
increment_contribution_count(counts.fetch(author), :merged_pr_author)
authored_pull_requests&.fetch(author)&.add(pull_request_id)
end
increment_contribution_count(counts.fetch(merger), :merged_pr_merger) if merger
[author, merger].compact.uniq.each do |user|
increment_contribution_count(counts.fetch(user), :merged_pr)
merged_pull_requests&.fetch(user)&.add(pull_request_id)
end
end
counts
end
private
sig {
params(
results: T::Hash[String, T::Hash[String, T::Hash[Symbol, Integer]]],
grand_totals: T::Hash[String, T::Hash[Symbol, Integer]],
user_names: T::Hash[String, String],
lead_maintainers: T::Hash[String, T::Boolean],
maintainer_since_dates: T::Hash[String, T.nilable(String)],
to: String,
).returns(String)
}
def generate_maintainer_report_csv(results, grand_totals, user_names, lead_maintainers, maintainer_since_dates,
to)
require "csv"
rows = results.sort_by do |user, _|
qualifying_total = contribution_count(grand_totals.fetch(user).slice(*QUALIFYING_CONTRIBUTION_TYPES))
[-qualifying_total, user.downcase]
end
rows.map! do |user, user_repositories|
grand_total = grand_totals.fetch(user)
repository_qualifying_totals = user_repositories.transform_values do |counts|
contribution_count(counts.slice(*QUALIFYING_CONTRIBUTION_TYPES))
end
qualifying_total = contribution_count(grand_total.slice(*QUALIFYING_CONTRIBUTION_TYPES))
maintainer_activity_met = qualifying_total >= MAINTAINER_ACTIVITY_THRESHOLD
maintainer_since = maintainer_since_dates.fetch(user)
maintainer_since_date = Date.iso8601(maintainer_since) if maintainer_since
period_end = Date.iso8601(to)
lead_maintainer = lead_maintainers.key?(user.downcase)
lead_activity_met = lead_activity_met?(user_repositories)
new_role = if lead_activity_met &&
(lead_maintainer ||
(maintainer_since_date && maintainer_since_date <= period_end.prev_year(3)))
"Lead Maintainer"
elsif maintainer_activity_met
"Maintainer"
else
"None"
end
capped = grand_total.fetch(:merged_pr_author_hit_cap, 0).positive? ||
grand_total.fetch(:approved_pr_review_hit_cap, 0).positive?
capped ||= user_repositories.any? do |_, counts|
counts.fetch(:approved_pr_review) >= MAX_PR_SEARCH ||
counts.except(:approved_pr_review).values.any? do |count|
count >= MAX_CONTRIBUTIONS
end
end
[
user,
user_names.fetch(user),
maintainer_since,
maintainer_since_date ? [(period_end - maintainer_since_date).to_i, 0].max : nil,
*PRIMARY_REPOS.flat_map do |repository|
counts = user_repositories.fetch(repository)
[*counts.values_at(*CONTRIBUTION_TYPES.keys), repository_qualifying_totals.fetch(repository)]
end,
qualifying_total,
maintainer_activity_met,
lead_activity_met,
capped,
lead_maintainer ? "Lead Maintainer" : "Maintainer",
new_role,
]
end
CSV.generate do |csv|
csv << [
"username", "name", "since", "tenure days",
*PRIMARY_REPOS.flat_map do |repository|
repository = repository.delete_prefix("Homebrew/").delete_prefix("homebrew-")
[
"#{repository} authored", "#{repository} merged", "#{repository} PRs",
"#{repository} reviews", "#{repository} coauthored", "#{repository} total"
]
end,
"total", "maintainer met", "lead met", "capped", "role", "new role"
]
rows.each { |row| csv << row }
end
end
sig { params(repositories: T::Array[String], required: T::Boolean).returns(T::Hash[String, [Pathname, String]]) }
def prepare_contribution_repositories(repositories, required:)
require "utils/git"
repository_refs = T.let({}, T::Hash[String, [Pathname, String]])
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(force: true)
end
unless repository_path&.exist?
odie "Could not find a local Git repository for #{repository}." if required
next
end
$stderr.puts "Fetching latest commits for #{repository}..."
system_command!(Utils::Git.git,
args: ["-C", repository_path, "fetch", "--quiet", "--force", "origin",
"+refs/heads/*:refs/remotes/origin/*"],
print_stderr: false)
system_command!(Utils::Git.git,
args: ["-C", repository_path, "remote", "set-head", "origin", "--auto"],
print_stderr: false)
repository_refs[repository] = [repository_path, "origin/HEAD"]
end
repository_refs
end
sig { params(readme: String, user: String, name: String).returns(T::Boolean) }
def readme_mentions?(readme, user, name)
readme = readme.dup.force_encoding(Encoding::UTF_8)
readme.include?("https://github.com/#{user}") || readme.include?(name)
end
sig {
params(
pull_request: T::Hash[String, T.untyped],
authored_pull_requests: T::Set[String],
merged_pull_requests: T::Set[String],
).void
}
def add_merged_pull_request_id(pull_request, authored_pull_requests, merged_pull_requests)
number = pull_request["number"]
return unless number.is_a?(Integer)
pull_request_id = number.to_s
authored_pull_requests << pull_request_id
merged_pull_requests << pull_request_id
end
sig {
params(
counts: T::Hash[Symbol, Integer],
authored_pull_requests: T::Set[String],
merged_pull_requests: T::Set[String],
).void
}
def update_merged_pull_request_counts(counts, authored_pull_requests, merged_pull_requests)
unless authored_pull_requests.empty?
counts[:merged_pr_author] = [authored_pull_requests.length, MAX_CONTRIBUTIONS].min
end
return if merged_pull_requests.empty?
counts[:merged_pr] = [merged_pull_requests.length, MAX_CONTRIBUTIONS].min
end
sig {
params(name: String, email: String, identity_users: T::Hash[String, String]).returns(T.nilable(String))
}
def user_for_git_identity(name, email, identity_users)
identity_users[name.strip.downcase] ||
identity_users[email.downcase] ||
identity_users[email.split("@").first.to_s.sub(/\A\d+\+/, "").downcase]
end
sig { params(counts: T::Hash[Symbol, Integer], type: Symbol).void }
def increment_contribution_count(counts, type)
count = counts.fetch(type)
counts[type] = count + 1 if count < MAX_CONTRIBUTIONS
end
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
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) }
def generate_csv(totals)
require "csv"
CSV.generate do |csv|
csv << %w[username repo authored merged PRs reviews coauthored total]
totals.sort_by { |_, counts| -contribution_count(counts.slice(*QUALIFYING_CONTRIBUTION_TYPES)) }
.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))]) }
def grand_total_row(user, grand_total)
grand_totals = grand_total.slice(*CONTRIBUTION_TYPES.keys).values
qualifying_total = contribution_count(grand_total.slice(*QUALIFYING_CONTRIBUTION_TYPES))
[user, "all", *grand_totals, qualifying_total]
end
sig { params(results: T::Hash[String, T::Hash[Symbol, Integer]]).returns(T::Hash[Symbol, Integer]) }
def total(results)
totals = {}
results.each_value do |counts|
counts.each do |kind, count|
totals[kind] ||= 0
totals[kind] += count
end
end
totals
end
sig { params(contributions: T::Hash[Symbol, Integer]).returns(Integer) }
def contribution_count(contributions)
contributions.values.sum
end
sig { params(repositories: T::Hash[String, T::Hash[Symbol, Integer]]).returns(T::Boolean) }
def lead_activity_met?(repositories)
repositories.count do |_, counts|
contribution_count(counts.slice(*QUALIFYING_CONTRIBUTION_TYPES)) >= LEAD_REPOSITORY_ACTIVITY_THRESHOLD
end >= 2
end
sig { params(quarter: Integer, current_year: Integer).returns([String, String]) }
def reporting_quarter_dates(quarter, current_year = Date.today.year)
# These aren't standard quarterly dates. We've chosen our own so that we
# can use recent maintainer activity stats as part of checking
# eligibility for expensed attendance at the AGM in February each year.
last_year = current_year - 1
dates = {
1 => [Date.new(last_year, 12, 1).iso8601, Date.new(current_year, 3, 1).iso8601],
2 => [Date.new(current_year, 3, 1).iso8601, Date.new(current_year, 6, 1).iso8601],
3 => [Date.new(current_year, 6, 1).iso8601, Date.new(current_year, 9, 1).iso8601],
4 => [Date.new(current_year, 9, 1).iso8601, Date.new(current_year, 12, 1).iso8601],
}
dates.fetch(quarter)
end
end
end
end