mirror of
https://github.com/Homebrew/brew.git
synced 2026-10-28 09:34:42 +08:00
- Keep next-period PRs out of contribution totals, including capped repository searches. - Use the adjusted boundary in cache keys to avoid stale results.
1073 lines
49 KiB
Ruby
1073 lines
49 KiB
Ruby
# typed: strict
|
|
# frozen_string_literal: true
|
|
|
|
require "cmd/shared_examples/args_parse"
|
|
require "cleanup"
|
|
require "dev-cmd/contributions"
|
|
require "utils/github"
|
|
|
|
RSpec.describe Homebrew::DevCmd::Contributions do
|
|
before do
|
|
stub_const("HOMEBREW_CACHE", mktmpdir)
|
|
allow(GitHub::API).to receive(:open_rest).and_return({})
|
|
end
|
|
|
|
it_behaves_like "parseable arguments"
|
|
it_behaves_like "a documented command", "contributions"
|
|
|
|
it "documents the governance reporting quarters" do
|
|
help_text = described_class.parser.generate_help_text.gsub(/\s+/, " ")
|
|
|
|
expect(help_text).to include(
|
|
"--maintainer-report-csv=2026-2",
|
|
"current directory",
|
|
"brew-contributions-FROM-to-TO-USER.csv",
|
|
"--from and --to",
|
|
"Only Maintainers listed at the end of that period are included",
|
|
"role recommendations use up to four full quarters of reports",
|
|
"Completed-period GitHub searches are cached in Homebrew's cache",
|
|
"Repository-scoped follow-up searches ensure role activity checks remain accurate",
|
|
"YEAR-1 is December of the previous year through February",
|
|
"YEAR-2 is March through May",
|
|
"YEAR-3 is June through August",
|
|
"YEAR-4 is September through November",
|
|
)
|
|
end
|
|
|
|
it "uses the first README mention for Maintainer tenure" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
repository_path = Pathname("/Homebrew/brew")
|
|
git_log_format = [
|
|
Homebrew::DevCmd::Contributions::GIT_LOG_COMMIT_HASH_FORMAT,
|
|
Homebrew::DevCmd::Contributions::GIT_LOG_COMMITTER_DATE_SHORT_FORMAT,
|
|
].join(Homebrew::DevCmd::Contributions::GIT_LOG_FIELD_SEPARATOR)
|
|
allow(Utils).to receive(:safe_popen_read).and_return("")
|
|
allow(Utils).to receive(:safe_popen_read)
|
|
.with(Utils::Git.git, "-C", repository_path, "log", "quarter-end-ref", "--fixed-strings",
|
|
"-SAlice", "--format=#{git_log_format}", "--", "README.md")
|
|
.and_return("first-mention#{Homebrew::DevCmd::Contributions::GIT_LOG_FIELD_SEPARATOR}2020-01-02\n")
|
|
allow(Utils).to receive(:safe_popen_read)
|
|
.with(Utils::Git.git, "-C", repository_path, "show", "first-mention:README.md")
|
|
.and_return("Homebrew was created by Alice.\n")
|
|
allow(command).to receive(:system_command)
|
|
.with(Utils::Git.git,
|
|
args: ["-C", repository_path, "show", "first-mention^:README.md"], print_stderr: false)
|
|
.and_return(instance_double(SystemCommand::Result, stdout: ""))
|
|
|
|
expect(command.maintainer_since(repository_path, "quarter-end-ref", "alice", "Alice"))
|
|
.to eq("2020-01-02")
|
|
end
|
|
|
|
it "reads historical Maintainer lists" do
|
|
command = described_class.new(["--maintainer-report-csv=2025-3"])
|
|
repository_path = Pathname("/Homebrew/brew")
|
|
repository_refs = { "Homebrew/brew" => [repository_path, "origin/HEAD"] }
|
|
allow(Utils).to receive(:safe_popen_read)
|
|
.with(Utils::Git.git, "-C", repository_path, "rev-list", "-1", "--before=2025-09-01",
|
|
"origin/HEAD", "--", "README.md")
|
|
.and_return("quarter-end-ref\n")
|
|
allow(Utils).to receive(:safe_popen_read)
|
|
.with(Utils::Git.git, "-C", repository_path, "show", "quarter-end-ref:README.md")
|
|
.and_return(<<~MARKDOWN)
|
|
Homebrew's maintainers are [Alice](https://github.com/alice) and [Bob](https://github.com/bob).
|
|
Former maintainers include [Carol](https://github.com/carol).
|
|
MARKDOWN
|
|
allow(command).to receive(:maintainer_since).and_return("2020-01-02")
|
|
|
|
expect(command.maintainer_report_users(repository_refs, "2025-09-01")).to eq([
|
|
{ "alice" => "Alice", "bob" => "Bob" }, {}, { "alice" => "2020-01-02", "bob" => "2020-01-02" }
|
|
])
|
|
end
|
|
|
|
it "filters historical Maintainers for a requested maintainer report user" do
|
|
command = described_class.new(["--maintainer-report-csv=2025-3", "--user=ALICE"])
|
|
repository_path = Pathname("/Homebrew/brew")
|
|
repository_refs = { "Homebrew/brew" => [repository_path, "origin/HEAD"] }
|
|
allow(Utils).to receive(:safe_popen_read)
|
|
.with(Utils::Git.git, "-C", repository_path, "rev-list", "-1", "--before=2025-09-01",
|
|
"origin/HEAD", "--", "README.md")
|
|
.and_return("quarter-end-ref\n")
|
|
allow(Utils).to receive(:safe_popen_read)
|
|
.with(Utils::Git.git, "-C", repository_path, "show", "quarter-end-ref:README.md")
|
|
.and_return(<<~MARKDOWN)
|
|
Homebrew's [Lead Maintainers](url) are [Alice](https://github.com/Alice).
|
|
Homebrew's other Maintainers are [Bob](https://github.com/bob).
|
|
MARKDOWN
|
|
allow(command).to receive(:maintainer_since)
|
|
.with(repository_path, "quarter-end-ref", "Alice", "Alice")
|
|
.and_return("2020-01-02")
|
|
|
|
expect do
|
|
expect(command.maintainer_report_users(repository_refs, "2025-09-01")).to eq([
|
|
{ "Alice" => "Alice" }, { "alice" => true }, { "Alice" => "2020-01-02" }
|
|
])
|
|
end.to output("Scanning contributions for 1 maintainer...\n").to_stderr
|
|
end
|
|
|
|
it "reports an unresolved maintainer report email as an identity error" do
|
|
command = described_class.new(["--maintainer-report-csv=2025-3", "--user=alice@example.com"])
|
|
repository_path = Pathname("/Homebrew/brew")
|
|
repository_refs = { "Homebrew/brew" => [repository_path, "origin/HEAD"] }
|
|
allow(Utils).to receive(:safe_popen_read)
|
|
.with(Utils::Git.git, "-C", repository_path, "rev-list", "-1", "--before=2025-09-01",
|
|
"origin/HEAD", "--", "README.md")
|
|
.and_return("quarter-end-ref\n")
|
|
allow(Utils).to receive(:safe_popen_read)
|
|
.with(Utils::Git.git, "-C", repository_path, "show", "quarter-end-ref:README.md")
|
|
.and_return("Homebrew's maintainers are [Alice](https://github.com/alice).\n")
|
|
allow(command).to receive(:github_username_for).with("alice@example.com", to: "2025-09-01")
|
|
|
|
expect { command.maintainer_report_users(repository_refs, "2025-09-01") }
|
|
.to raise_error(SystemExit)
|
|
.and output(/Could not resolve GitHub usernames for: alice@example\.com/).to_stderr
|
|
end
|
|
|
|
it "filters maintainer reports using a username resolved from an email" do
|
|
command = described_class.new([
|
|
"--maintainer-report-csv=2025-3",
|
|
"--user=39449589+alice@users.noreply.github.com",
|
|
])
|
|
repository_path = Pathname("/Homebrew/brew")
|
|
repository_refs = { "Homebrew/brew" => [repository_path, "origin/HEAD"] }
|
|
allow(Utils).to receive(:safe_popen_read)
|
|
.with(Utils::Git.git, "-C", repository_path, "rev-list", "-1", "--before=2025-09-01",
|
|
"origin/HEAD", "--", "README.md")
|
|
.and_return("quarter-end-ref\n")
|
|
allow(Utils).to receive(:safe_popen_read)
|
|
.with(Utils::Git.git, "-C", repository_path, "show", "quarter-end-ref:README.md")
|
|
.and_return("Homebrew's maintainers are [Alice](https://github.com/alice).\n")
|
|
allow(command).to receive(:maintainer_since)
|
|
.with(repository_path, "quarter-end-ref", "alice", "Alice")
|
|
.and_return("2020-01-02")
|
|
|
|
expect(command.maintainer_report_users(repository_refs, "2025-09-01")).to eq([
|
|
{ "alice" => "Alice" }, {}, { "alice" => "2020-01-02" }
|
|
])
|
|
end
|
|
|
|
it "reports only requested users not listed as quarter-end Maintainers" do
|
|
command = described_class.new([
|
|
"--maintainer-report-csv=2025-3",
|
|
"--user=bob,carol,dave",
|
|
])
|
|
repository_path = Pathname("/Homebrew/brew")
|
|
repository_refs = { "Homebrew/brew" => [repository_path, "origin/HEAD"] }
|
|
allow(Utils).to receive(:safe_popen_read)
|
|
.with(Utils::Git.git, "-C", repository_path, "rev-list", "-1", "--before=2025-09-01",
|
|
"origin/HEAD", "--", "README.md")
|
|
.and_return("quarter-end-ref\n")
|
|
allow(Utils).to receive(:safe_popen_read)
|
|
.with(Utils::Git.git, "-C", repository_path, "show", "quarter-end-ref:README.md")
|
|
.and_return("Homebrew's maintainers are [Bob](https://github.com/bob).\n")
|
|
|
|
expect { command.maintainer_report_users(repository_refs, "2025-09-01") }
|
|
.to raise_error(SystemExit)
|
|
.and output(<<~EOS).to_stderr
|
|
Error: Not listed as Maintainers at the end of the reporting period: carol and dave.
|
|
EOS
|
|
end
|
|
|
|
it "rejects empty maintainer report user values" do
|
|
command = described_class.new(["--maintainer-report-csv=2025-3", "--user=alice,,bob"])
|
|
|
|
expect(Utils::GemSetup).not_to receive(:install_bundler_gems!)
|
|
expect { command.run }
|
|
.to raise_error(SystemExit)
|
|
.and output(/`--user` must not contain empty values/).to_stderr
|
|
end
|
|
|
|
it "reports activity criteria with and without historical reports" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
quarter_end_ref = "quarter-end-ref"
|
|
repository_refs = Homebrew::DevCmd::Contributions::PRIMARY_REPOS.to_h do |repository|
|
|
[repository, [Pathname("/#{repository}"), "origin/HEAD"]]
|
|
end
|
|
no_contributions = { merged_pr_author: 0, merged_pr_merger: 0, merged_pr: 0, approved_pr_review: 0, coauthor: 0 }
|
|
alice_results = {
|
|
"Homebrew/brew" => {
|
|
merged_pr_author: 2, merged_pr_merger: 3, merged_pr: 4, approved_pr_review: 1, coauthor: 45
|
|
},
|
|
"Homebrew/homebrew-core" => no_contributions,
|
|
"Homebrew/homebrew-cask" => no_contributions,
|
|
}
|
|
bob_results = {
|
|
"Homebrew/brew" => {
|
|
merged_pr_author: 25, merged_pr_merger: 1, merged_pr: 25, approved_pr_review: 0, coauthor: 0
|
|
},
|
|
"Homebrew/homebrew-core" => {
|
|
merged_pr_author: 24, merged_pr_merger: 0, merged_pr: 24, approved_pr_review: 1, coauthor: 0
|
|
},
|
|
"Homebrew/homebrew-cask" => no_contributions.merge(coauthor: 451),
|
|
}
|
|
|
|
allow(Utils::GemSetup).to receive(:install_bundler_gems!).with(groups: ["contributions"])
|
|
allow(command).to receive(:prepare_contribution_repositories)
|
|
.with(Homebrew::DevCmd::Contributions::PRIMARY_REPOS, required: true)
|
|
.and_return(repository_refs)
|
|
allow(Utils).to receive(:safe_popen_read).and_return("")
|
|
allow(Utils).to receive(:safe_popen_read)
|
|
.with(Utils::Git.git, "-C", Pathname("/Homebrew/brew"), "rev-list", "-1", "--before=2026-03-01",
|
|
"origin/HEAD", "--", "README.md")
|
|
.and_return("#{quarter_end_ref}\n")
|
|
allow(Utils).to receive(:safe_popen_read)
|
|
.with(Utils::Git.git, "-C", Pathname("/Homebrew/brew"), "show", "#{quarter_end_ref}:README.md")
|
|
.and_return(<<~MARKDOWN)
|
|
Homebrew's [Lead Maintainers](url) are [Alice](https://github.com/alice).
|
|
Homebrew's other Maintainers are [Bob](https://github.com/bob).
|
|
MARKDOWN
|
|
allow(command).to receive(:scan_contributions)
|
|
.with("Homebrew", Homebrew::DevCmd::Contributions::PRIMARY_REPOS, repository_refs,
|
|
{ "alice" => "Alice", "bob" => "Bob" }, from: "2025-12-01", to: "2026-03-01",
|
|
skip_reviews_if_lead_met: true, progress: true)
|
|
.and_return({ "alice" => alice_results, "bob" => bob_results })
|
|
allow(command).to receive(:maintainer_since)
|
|
.with(Pathname("/Homebrew/brew"), quarter_end_ref, "alice", "Alice")
|
|
.and_return("2024-03-01")
|
|
allow(command).to receive(:maintainer_since)
|
|
.with(Pathname("/Homebrew/brew"), quarter_end_ref, "bob", "Bob")
|
|
.and_return("2022-03-01")
|
|
allow(command).to receive(:previous_maintainer_reports).with("2025-12-01").and_return([
|
|
{ "alice" => [true, true], "bob" => [true, false] },
|
|
{ "alice" => [true, true], "bob" => [true, true] },
|
|
{ "alice" => [true, true], "bob" => [true, true] },
|
|
])
|
|
csv = <<~CSV
|
|
username,name,since,tenure days,brew authored,brew merged,brew PRs,brew reviews,brew coauthored,brew total,core authored,core merged,core PRs,core reviews,core coauthored,core total,cask authored,cask merged,cask PRs,cask reviews,cask coauthored,cask total,total,maintainer met,lead met,capped,role,potential new role
|
|
bob,Bob,2022-03-01,1461,25,1,25,0,0,25,24,0,24,1,0,25,0,0,0,0,451,451,501,true,true,false,Maintainer,Maintainer
|
|
alice,Alice,2024-03-01,730,2,3,4,1,45,50,0,0,0,0,0,0,0,0,0,0,0,0,50,true,false,false,Lead Maintainer,Lead Maintainer
|
|
CSV
|
|
expect(File).to receive(:write).with("brew-contributions-2025-12-01-to-2026-03-01.csv", csv)
|
|
|
|
expect do
|
|
command.run
|
|
end.to output(csv).to_stdout.and output(<<~EOS).to_stderr
|
|
Maintainer report dates: 2025-12-01-to-2026-03-01
|
|
Scanning contributions for 2 maintainers...
|
|
EOS
|
|
|
|
allow(command).to receive(:previous_maintainer_reports).with("2025-12-01").and_return([])
|
|
csv_without_history = "#{csv.lines(chomp: true).map { |line| line.rpartition(",").first }.join("\n")}\n"
|
|
expect(File).to receive(:write).with("brew-contributions-2025-12-01-to-2026-03-01.csv", csv_without_history)
|
|
|
|
expect do
|
|
command.run
|
|
end.to output(csv_without_history).to_stdout.and output(<<~EOS).to_stderr
|
|
Maintainer report dates: 2025-12-01-to-2026-03-01
|
|
Scanning contributions for 2 maintainers...
|
|
EOS
|
|
end
|
|
|
|
it "reads contiguous full reports for the three preceding quarters" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
|
|
reports = Dir.chdir(mktmpdir) do
|
|
{
|
|
"2025-09-01-to-2025-12-01" => [25, 25],
|
|
"2025-06-01-to-2025-09-01" => [50, 0],
|
|
"2025-03-01-to-2025-06-01" => [0, 0],
|
|
}.each do |dates, totals|
|
|
Pathname("brew-contributions-#{dates}.csv")
|
|
.write("username,brew total,core total,cask total\nAlice,#{totals.join(",")},0\n")
|
|
end
|
|
|
|
command.previous_maintainer_reports("2025-12-01")
|
|
end
|
|
|
|
expect(reports).to eq([
|
|
{ "alice" => [true, true] },
|
|
{ "alice" => [true, false] },
|
|
{ "alice" => [false, false] },
|
|
])
|
|
end
|
|
|
|
it "warns when the immediately preceding report is unavailable" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
|
|
expect do
|
|
expect(Dir.chdir(mktmpdir) { command.previous_maintainer_reports("2025-12-01") }).to be_empty
|
|
end.to output(<<~EOS).to_stderr
|
|
Warning: Could not find brew-contributions-2025-09-01-to-2025-12-01.csv; omitting the potential new role column.
|
|
EOS
|
|
end
|
|
|
|
it "combines monthly counts into quarterly activity alongside legacy reports" do
|
|
command = described_class.new(["--maintainer-report-csv", "--from=2026-12-01", "--to=2027-01-01"])
|
|
reports = Dir.chdir(mktmpdir) do
|
|
[9, 10, 11].each do |month|
|
|
from = Date.new(2026, month, 1)
|
|
Pathname("brew-contributions-#{from}-to-#{from.next_month}.csv")
|
|
.write("username,brew total,core total,cask total,maintainer met,lead met\nAlice,10,10,0,false,false\n")
|
|
end
|
|
Pathname("brew-contributions-2026-06-01-to-2026-09-01.csv")
|
|
.write("username,brew total,core total,cask total\nAlice,50,0,0\n")
|
|
Pathname("brew-contributions-2026-03-01-to-2026-06-01.csv")
|
|
.write("username,brew total,core total,cask total\nAlice,0,0,0\n")
|
|
command.previous_maintainer_reports("2026-12-01")
|
|
end
|
|
|
|
expect(reports).to eq([
|
|
{ "alice" => [true, true] }, { "alice" => [true, false] }, { "alice" => [false, false] }
|
|
])
|
|
end
|
|
|
|
context "with monthly reports" do
|
|
sig { returns(Homebrew::DevCmd::Contributions) }
|
|
let(:command) do
|
|
described_class.new(["--maintainer-report-csv", "--from=2026-11-01", "--to=2026-12-01"])
|
|
end
|
|
|
|
before do
|
|
allow(Utils::GemSetup).to receive(:install_bundler_gems!)
|
|
allow(command).to receive_messages(
|
|
prepare_contribution_repositories: {},
|
|
maintainer_report_users: [
|
|
{ "alice" => "Alice" }, { "alice" => true }, { "alice" => "2020-01-01" }
|
|
],
|
|
scan_contributions: {
|
|
"alice" => Homebrew::DevCmd::Contributions::PRIMARY_REPOS.to_h do |repository|
|
|
count = (repository == "Homebrew/homebrew-cask") ? 0 : 5
|
|
[repository, { merged_pr_author: count, merged_pr_merger: 0, merged_pr: count,
|
|
approved_pr_review: 0, coauthor: 0 }]
|
|
end,
|
|
},
|
|
)
|
|
allow(command).to receive(:previous_maintainer_reports)
|
|
.with("2026-09-01").and_return([{ "alice" => [false, false] }])
|
|
end
|
|
|
|
it "writes one month of counts but assesses a complete quarter" do
|
|
report = Dir.chdir(mktmpdir) do
|
|
[9, 10].each do |month|
|
|
from = Date.new(2026, month, 1)
|
|
Pathname("brew-contributions-#{from}-to-#{from.next_month}.csv")
|
|
.write("username,brew total,core total,cask total\nAlice,10,10,0\n")
|
|
end
|
|
command.run
|
|
CSV.read("brew-contributions-2026-11-01-to-2026-12-01.csv", headers: true).first
|
|
end
|
|
|
|
expect(report&.values_at("total", "maintainer met", "lead met", "potential new role"))
|
|
.to eq(["10", "true", "true", "Lead Maintainer"])
|
|
end
|
|
|
|
it "omits recommendations when a month of the assessment quarter is missing" do
|
|
report = Dir.chdir(mktmpdir) do
|
|
command.run
|
|
CSV.read("brew-contributions-2026-11-01-to-2026-12-01.csv", headers: true)
|
|
end
|
|
|
|
expect(report.headers).not_to include("potential new role")
|
|
end
|
|
|
|
it "uses the latest completed quarter during an incomplete quarter" do
|
|
allow(command).to receive(:args).and_return(described_class.new(
|
|
["--maintainer-report-csv", "--from=2026-09-01", "--to=2026-10-01"],
|
|
).args)
|
|
allow(command).to receive(:previous_maintainer_reports)
|
|
.with("2026-06-01").and_return([{ "alice" => [false, false] }])
|
|
report = Dir.chdir(mktmpdir) do
|
|
Pathname("brew-contributions-2026-06-01-to-2026-09-01.csv")
|
|
.write("username,brew total,core total,cask total\nalice,25,25,0\n")
|
|
command.run
|
|
CSV.read("brew-contributions-2026-09-01-to-2026-10-01.csv", headers: true).first
|
|
end
|
|
|
|
expect(report&.values_at("total", "maintainer met", "lead met", "potential new role"))
|
|
.to eq(["10", "true", "true", "Lead Maintainer"])
|
|
end
|
|
|
|
it "combines weekly reports without counting overlapping monthly reports twice" do
|
|
allow(command).to receive(:args).and_return(described_class.new(
|
|
["--maintainer-report-csv", "--from=2026-11-24", "--to=2026-12-01"],
|
|
).args)
|
|
report = Dir.chdir(mktmpdir) do
|
|
from = Date.new(2026, 9, 1)
|
|
while from < Date.new(2026, 11, 24)
|
|
Pathname("brew-contributions-#{from}-to-#{from + 7}.csv")
|
|
.write("username,brew total,core total,cask total\nalice,1,1,0\n")
|
|
from += 7
|
|
end
|
|
Pathname("brew-contributions-2026-11-01-to-2026-12-01.csv")
|
|
.write("username,brew total,core total,cask total\nalice,50,50,0\n")
|
|
command.run
|
|
CSV.read("brew-contributions-2026-11-24-to-2026-12-01.csv", headers: true).first
|
|
end
|
|
|
|
expect(report&.values_at("total", "maintainer met", "lead met", "potential new role"))
|
|
.to eq(%w[10 false false None])
|
|
end
|
|
|
|
it "can assess monthly requirements independently of the reporting dates" do
|
|
stub_const("Homebrew::DevCmd::Contributions::ACTIVITY_PERIOD_MONTHS", 1)
|
|
allow(command).to receive(:previous_maintainer_reports)
|
|
.with("2026-11-01").and_return([{ "alice" => [false, false] }])
|
|
report = Dir.chdir(mktmpdir) do
|
|
command.run
|
|
CSV.read("brew-contributions-2026-11-01-to-2026-12-01.csv", headers: true).first
|
|
end
|
|
|
|
expect(report&.values_at("total", "maintainer met", "lead met", "potential new role"))
|
|
.to eq(%w[10 false false None])
|
|
end
|
|
end
|
|
|
|
it "applies multi-quarter activity requirements to potential roles" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
period_end = Date.new(2026, 3, 1)
|
|
maintainer_since = Date.new(2022, 3, 1)
|
|
previous_reports = [
|
|
{
|
|
"promoted" => [true, true], "not_promoted" => [true, true],
|
|
"demoted" => [true, false], "removed" => [false, false], "incomplete" => [true, true]
|
|
},
|
|
{ "promoted" => [true, true], "not_promoted" => [true, false], "incomplete" => [true, true] },
|
|
{ "promoted" => [true, true], "not_promoted" => [true, true] },
|
|
]
|
|
|
|
expect({
|
|
"promoted" => ["Maintainer", [true, true]],
|
|
"not_promoted" => ["Maintainer", [true, true]],
|
|
"demoted" => ["Lead Maintainer", [true, false]],
|
|
"removed" => ["Maintainer", [false, false]],
|
|
"incomplete" => ["Maintainer", [true, true]],
|
|
}.to_h do |user, (role, activity)|
|
|
[user.to_sym, command.potential_maintainer_role(
|
|
user, role, maintainer_since, period_end, activity, previous_reports
|
|
)]
|
|
end).to eq(
|
|
promoted: "Lead Maintainer",
|
|
not_promoted: "Maintainer",
|
|
demoted: "Maintainer",
|
|
removed: "None",
|
|
incomplete: nil,
|
|
)
|
|
end
|
|
|
|
it "writes filtered maintainer reports without overwriting the full report" do
|
|
command = described_class.new([
|
|
"--maintainer-report-csv=2026-1",
|
|
"--user=BOB,39449589+alice@users.noreply.github.com",
|
|
])
|
|
repository_refs = Homebrew::DevCmd::Contributions::PRIMARY_REPOS.to_h do |repository|
|
|
[repository, [Pathname("/#{repository}"), "origin/HEAD"]]
|
|
end
|
|
no_contributions = {
|
|
merged_pr_author: 0, merged_pr_merger: 0, merged_pr: 0, approved_pr_review: 0, coauthor: 0
|
|
}
|
|
results = {
|
|
"bob" => Homebrew::DevCmd::Contributions::PRIMARY_REPOS.to_h do |repository|
|
|
[repository, no_contributions]
|
|
end,
|
|
"Alice" => Homebrew::DevCmd::Contributions::PRIMARY_REPOS.to_h do |repository|
|
|
[repository, no_contributions]
|
|
end,
|
|
}
|
|
csv = "filtered maintainer report\n"
|
|
|
|
allow(Utils::GemSetup).to receive(:install_bundler_gems!).with(groups: ["contributions"])
|
|
allow(command).to receive(:prepare_contribution_repositories)
|
|
.with(Homebrew::DevCmd::Contributions::PRIMARY_REPOS, required: true)
|
|
.and_return(repository_refs)
|
|
allow(command).to receive(:maintainer_report_users)
|
|
.with(repository_refs, "2026-03-01")
|
|
.and_return([
|
|
{ "bob" => "Bob", "Alice" => "Alice" },
|
|
{},
|
|
{ "bob" => "2020-01-02", "Alice" => "2020-01-02" },
|
|
])
|
|
allow(command).to receive(:scan_contributions)
|
|
.with("Homebrew", Homebrew::DevCmd::Contributions::PRIMARY_REPOS, repository_refs,
|
|
{ "bob" => "Bob", "Alice" => "Alice" }, from: "2025-12-01", to: "2026-03-01",
|
|
skip_reviews_if_lead_met: true, progress: true)
|
|
.and_return(results)
|
|
allow(command).to receive(:generate_maintainer_report_csv).and_return(csv)
|
|
expect(File).to receive(:write)
|
|
.with("brew-contributions-2025-12-01-to-2026-03-01-alice-bob.csv", csv)
|
|
|
|
expect { command.run }
|
|
.to output(csv).to_stdout
|
|
.and output("Maintainer report dates: 2025-12-01-to-2026-03-01\n").to_stderr
|
|
end
|
|
|
|
it "uses the shared Git scanner for non-Maintainers" do
|
|
command = described_class.new([
|
|
"--user=alice", "--repositories=Homebrew/brew", "--from=2026-01-01", "--to=2026-02-01", "--csv"
|
|
])
|
|
repository_refs = { "Homebrew/brew" => [Pathname("/Homebrew/brew"), "origin/HEAD"] }
|
|
results = { "alice" => { "Homebrew/brew" => {
|
|
merged_pr_author: 1, merged_pr_merger: 0, merged_pr: 1, approved_pr_review: 2, coauthor: 3
|
|
} } }
|
|
|
|
allow(Utils::GemSetup).to receive(:install_bundler_gems!).with(groups: ["contributions"])
|
|
allow(command).to receive(:prepare_contribution_repositories)
|
|
.with(["Homebrew/brew"], required: false)
|
|
.and_return(repository_refs)
|
|
allow(command).to receive(:scan_contributions)
|
|
.with("Homebrew", ["Homebrew/brew"], repository_refs, { "alice" => "alice" },
|
|
from: "2026-01-01", to: "2026-02-01", skip_reviews_if_lead_met: false, progress: false)
|
|
.and_return(results)
|
|
|
|
expect do
|
|
command.run
|
|
end.to output(<<~CSV).to_stdout.and output(/alice contributed.*6 times \(total\)/).to_stderr
|
|
username,repo,authored,merged,PRs,reviews,coauthored,total
|
|
alice,all,1,0,1,2,3,6
|
|
CSV
|
|
end
|
|
|
|
it "marks capped merged-PR searches with no matching requested repositories as lower bounds" do
|
|
command = described_class.new([
|
|
"--user=alice", "--repositories=Homebrew/brew", "--from=2026-01-01", "--to=2026-02-01"
|
|
])
|
|
repository_refs = { "Homebrew/brew" => [Pathname("/Homebrew/brew"), "origin/HEAD"] }
|
|
results = { "alice" => { "Homebrew/brew" => {
|
|
merged_pr_author: 0, merged_pr_merger: 0, merged_pr: 0, approved_pr_review: 0, coauthor: 0,
|
|
merged_pr_author_hit_cap: 1
|
|
} } }
|
|
|
|
allow(command).to receive(:prepare_contribution_repositories)
|
|
.with(["Homebrew/brew"], required: false)
|
|
.and_return(repository_refs)
|
|
allow(command).to receive(:scan_contributions)
|
|
.with("Homebrew", ["Homebrew/brew"], repository_refs, { "alice" => "alice" },
|
|
from: "2026-01-01", to: "2026-02-01", skip_reviews_if_lead_met: false, progress: false)
|
|
.and_return(results)
|
|
|
|
expect { command.run }.to output(/alice contributed >=0 times \(total\)/).to_stdout
|
|
end
|
|
|
|
it "uses merge dates for repositories without local Git history" do
|
|
command = described_class.new(["--user=alice", "--repositories=Homebrew/untapped"])
|
|
repository = "Homebrew/untapped"
|
|
allow(GitHub).to receive(:search_approved_pull_requests_in_user_or_organisation).and_return([])
|
|
expect(GitHub).to receive(:search_issues)
|
|
.with("", is: "merged", user: "Homebrew", author: "alice", merged: "2026-01-01..2026-01-31")
|
|
.and_return([{ "number" => 123, "repository_url" => "#{GitHub::API_URL}/repos/#{repository}" }])
|
|
|
|
results = command.scan_contributions(
|
|
"Homebrew",
|
|
[repository],
|
|
{},
|
|
{ "alice" => "alice" },
|
|
from: "2026-01-01",
|
|
to: "2026-02-01",
|
|
skip_reviews_if_lead_met: false,
|
|
progress: false,
|
|
)
|
|
|
|
expect(results.fetch("alice").fetch(repository)).to eq(
|
|
merged_pr_author: 1, merged_pr_merger: 0, merged_pr: 1, approved_pr_review: 0, coauthor: 0,
|
|
)
|
|
end
|
|
|
|
it "distributes organisation-wide merged PR searches by repository" do
|
|
command = described_class.new(["--user=alice", "--repositories=Homebrew/brew,Homebrew/homebrew-core"])
|
|
repositories = %w[Homebrew/brew Homebrew/homebrew-core]
|
|
allow(GitHub).to receive(:search_approved_pull_requests_in_user_or_organisation).and_return([])
|
|
expect(GitHub).to receive(:search_issues)
|
|
.with("", is: "merged", user: "Homebrew", author: "alice", merged: "2026-01-01..2026-01-31")
|
|
.and_return([{ "number" => 123, "repository_url" => "#{GitHub::API_URL}/repos/Homebrew/homebrew-core" }])
|
|
|
|
results = command.scan_contributions(
|
|
"Homebrew",
|
|
repositories,
|
|
{},
|
|
{ "alice" => "alice" },
|
|
from: "2026-01-01",
|
|
to: "2026-02-01",
|
|
skip_reviews_if_lead_met: false,
|
|
progress: false,
|
|
)
|
|
|
|
expect(results.fetch("alice").transform_values { |counts| counts.fetch(:merged_pr_author) }).to eq(
|
|
"Homebrew/brew" => 0, "Homebrew/homebrew-core" => 1,
|
|
)
|
|
end
|
|
|
|
it "scopes capped merged PR searches by repository until Lead activity is known" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
repositories = Homebrew::DevCmd::Contributions::PRIMARY_REPOS.to_h do |repository|
|
|
[repository, [Pathname("/#{repository}"), "origin/HEAD"]]
|
|
end
|
|
no_contributions = {
|
|
merged_pr_author: 0, merged_pr_merger: 0, merged_pr: 0, approved_pr_review: 0, coauthor: 0
|
|
}
|
|
allow(Utils).to receive(:safe_popen_read).and_return("brew", "core", "cask")
|
|
allow(command).to receive(:parse_git_log) { { "alice" => no_contributions.dup } }
|
|
expect(GitHub).to receive(:search_issues)
|
|
.with("", is: "merged", user: "Homebrew", author: "alice", merged: "2025-12-01..2026-02-28")
|
|
.and_return(Array.new(100) do |index|
|
|
{ "number" => index, "repository_url" => "#{GitHub::API_URL}/repos/Homebrew/homebrew-cask" }
|
|
end)
|
|
expect(GitHub).to receive(:search_issues)
|
|
.with("", is: "merged", repo: "Homebrew/brew", author: "alice", merged: "2025-12-01..2026-02-28")
|
|
.and_return(Array.new(25) { |index| { "number" => index + 100 } })
|
|
expect(GitHub).not_to receive(:search_approved_pull_requests_in_user_or_organisation)
|
|
|
|
results = command.scan_contributions(
|
|
"Homebrew",
|
|
repositories.keys,
|
|
repositories,
|
|
{ "alice" => "Alice" },
|
|
from: "2025-12-01",
|
|
to: "2026-03-01",
|
|
skip_reviews_if_lead_met: true,
|
|
progress: true,
|
|
)
|
|
|
|
expect(results.fetch("alice").transform_values do |counts|
|
|
counts.fetch(:merged_pr_author)
|
|
end).to eq("Homebrew/brew" => 25, "Homebrew/homebrew-core" => 0, "Homebrew/homebrew-cask" => 100)
|
|
end
|
|
|
|
it "uses a GitHub username resolved from a public email address" do
|
|
command = described_class.new(["--user=alice@example.com", "--repositories=Homebrew/untapped"])
|
|
repository = "Homebrew/untapped"
|
|
allow(GitHub).to receive(:search)
|
|
.with("users", "\"alice@example.com\" in:email")
|
|
.and_return({ "items" => [{ "login" => "alice" }] })
|
|
expect(GitHub).to receive(:search_issues)
|
|
.with("", is: "merged", user: "Homebrew", author: "alice", merged: "2026-01-01..2026-01-31")
|
|
.and_return([{ "number" => 123, "repository_url" => "#{GitHub::API_URL}/repos/#{repository}" }])
|
|
expect(GitHub).to receive(:search_approved_pull_requests_in_user_or_organisation)
|
|
.with("Homebrew", "alice", from: "2026-01-01", to: "2026-01-31")
|
|
.and_return([])
|
|
|
|
results = command.scan_contributions(
|
|
"Homebrew",
|
|
[repository],
|
|
{},
|
|
{ "alice@example.com" => "alice@example.com" },
|
|
from: "2026-01-01",
|
|
to: "2026-02-01",
|
|
skip_reviews_if_lead_met: false,
|
|
progress: false,
|
|
)
|
|
|
|
expect(results.fetch("alice@example.com").fetch(repository)).to eq(
|
|
merged_pr_author: 1, merged_pr_merger: 0, merged_pr: 1, approved_pr_review: 0, coauthor: 0,
|
|
)
|
|
end
|
|
|
|
it "uses the username embedded in a GitHub no-reply email address" do
|
|
command = described_class.new(["--user=39449589+krehel@users.noreply.github.com"])
|
|
|
|
expect(GitHub).not_to receive(:search)
|
|
expect(command.github_username_for("39449589+krehel@users.noreply.github.com", to: "2026-02-01"))
|
|
.to eq("krehel")
|
|
end
|
|
|
|
it "counts authored squash-merged PRs in repositories with local Git history" do
|
|
command = described_class.new(["--user=alice", "--repositories=Homebrew/homebrew-core"])
|
|
repository = "Homebrew/homebrew-core"
|
|
repository_refs = { repository => [Pathname("/Homebrew/homebrew-core"), "origin/HEAD"] }
|
|
git_counts = { merged_pr_author: 1, merged_pr_merger: 0, merged_pr: 1, approved_pr_review: 0, coauthor: 0 }
|
|
allow(Utils).to receive(:safe_popen_read).and_return("git log")
|
|
allow(command).to receive(:parse_git_log).and_return("alice" => git_counts)
|
|
allow(GitHub).to receive(:search_approved_pull_requests_in_user_or_organisation).and_return([])
|
|
expect(GitHub).to receive(:search_issues)
|
|
.with("", is: "merged", user: "Homebrew", author: "alice", merged: "2026-01-01..2026-01-31")
|
|
.and_return([
|
|
{ "number" => 123, "repository_url" => "#{GitHub::API_URL}/repos/#{repository}" },
|
|
{ "number" => 124, "repository_url" => "#{GitHub::API_URL}/repos/#{repository}" },
|
|
])
|
|
|
|
results = command.scan_contributions(
|
|
"Homebrew",
|
|
[repository],
|
|
repository_refs,
|
|
{ "alice" => "alice" },
|
|
from: "2026-01-01",
|
|
to: "2026-02-01",
|
|
skip_reviews_if_lead_met: false,
|
|
progress: false,
|
|
)
|
|
|
|
expect(results.fetch("alice").fetch(repository)).to eq(
|
|
merged_pr_author: 2, merged_pr_merger: 0, merged_pr: 2, approved_pr_review: 0, coauthor: 0,
|
|
)
|
|
end
|
|
|
|
it "counts a self-merged PR once" do
|
|
command = described_class.new(["--user=alice", "--repositories=Homebrew/homebrew-core"])
|
|
repository = "Homebrew/homebrew-core"
|
|
repository_refs = { repository => [Pathname("/Homebrew/homebrew-core"), "origin/HEAD"] }
|
|
separator = Homebrew::DevCmd::Contributions::GIT_LOG_FIELD_SEPARATOR
|
|
record_separator = Homebrew::DevCmd::Contributions::GIT_LOG_RECORD_SEPARATOR
|
|
merge = [
|
|
"merge", "base pull-request", "Alice", "alice@example.com", "Alice", "alice@example.com",
|
|
"Merge pull request #123 from alice/topic"
|
|
].join(separator)
|
|
pull_request = [
|
|
"pull-request", "base", "Alice", "alice@example.com", "Alice", "alice@example.com", "Change something"
|
|
].join(separator)
|
|
git_log = "#{merge}#{record_separator}#{pull_request}#{record_separator}"
|
|
allow(Utils).to receive(:safe_popen_read).and_return(git_log)
|
|
allow(GitHub).to receive(:search_approved_pull_requests_in_user_or_organisation).and_return([])
|
|
expect(GitHub).to receive(:search_issues)
|
|
.with("", is: "merged", user: "Homebrew", author: "alice", merged: "2026-01-01..2026-01-31")
|
|
.and_return([{ "number" => 123, "repository_url" => "#{GitHub::API_URL}/repos/#{repository}" }])
|
|
|
|
results = command.scan_contributions(
|
|
"Homebrew",
|
|
[repository],
|
|
repository_refs,
|
|
{ "alice" => "Alice" },
|
|
from: "2026-01-01",
|
|
to: "2026-02-01",
|
|
skip_reviews_if_lead_met: false,
|
|
progress: false,
|
|
)
|
|
|
|
expect(results.fetch("alice").fetch(repository)).to eq(
|
|
merged_pr_author: 1, merged_pr_merger: 1, merged_pr: 1, approved_pr_review: 0, coauthor: 0,
|
|
)
|
|
end
|
|
|
|
it "counts a GitHub-authored PR once when Git only identifies its merger" do
|
|
command = described_class.new(["--user=alice", "--repositories=Homebrew/homebrew-core"])
|
|
repository = "Homebrew/homebrew-core"
|
|
repository_refs = { repository => [Pathname("/Homebrew/homebrew-core"), "origin/HEAD"] }
|
|
separator = Homebrew::DevCmd::Contributions::GIT_LOG_FIELD_SEPARATOR
|
|
record_separator = Homebrew::DevCmd::Contributions::GIT_LOG_RECORD_SEPARATOR
|
|
merge = [
|
|
"merge", "base pull-request", "Alice", "alice@example.com", "Alice", "alice@example.com",
|
|
"Merge pull request #123 from Homebrew/topic"
|
|
].join(separator)
|
|
pull_request = [
|
|
"pull-request", "base", "BrewTestBot", "test-bot@example.com", "BrewTestBot", "test-bot@example.com",
|
|
"Change something"
|
|
].join(separator)
|
|
git_log = "#{merge}#{record_separator}#{pull_request}#{record_separator}"
|
|
allow(Utils).to receive(:safe_popen_read).and_return(git_log)
|
|
allow(GitHub).to receive(:search_approved_pull_requests_in_user_or_organisation).and_return([])
|
|
expect(GitHub).to receive(:search_issues)
|
|
.with("", is: "merged", user: "Homebrew", author: "alice", merged: "2026-01-01..2026-01-31")
|
|
.and_return([{ "number" => 123, "repository_url" => "#{GitHub::API_URL}/repos/#{repository}" }])
|
|
|
|
results = command.scan_contributions(
|
|
"Homebrew",
|
|
[repository],
|
|
repository_refs,
|
|
{ "alice" => "Alice" },
|
|
from: "2026-01-01",
|
|
to: "2026-02-01",
|
|
skip_reviews_if_lead_met: false,
|
|
progress: false,
|
|
)
|
|
|
|
expect(results.fetch("alice").fetch(repository)).to eq(
|
|
merged_pr_author: 1, merged_pr_merger: 1, merged_pr: 1, approved_pr_review: 0, coauthor: 0,
|
|
)
|
|
end
|
|
|
|
it "attributes merged PRs once and learns non-Maintainer Git identities" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
separator = Homebrew::DevCmd::Contributions::GIT_LOG_FIELD_SEPARATOR
|
|
record_separator = Homebrew::DevCmd::Contributions::GIT_LOG_RECORD_SEPARATOR
|
|
merge = [
|
|
"merge", "base pull-request", "Alice Example", "alice@example.com", "Alice Example", "alice@example.com",
|
|
"Merge pull request #123 from Homebrew/topic"
|
|
].join(separator)
|
|
pull_request = [
|
|
"pull-request", "base", "Bob Example", "bob@example.com", "Bob Example", "bob@example.com",
|
|
"Change something\n\nCo-authored-by: Alice Example <123+alice@users.noreply.github.com>"
|
|
].join(separator)
|
|
coauthored = [
|
|
"coauthored", "base", "Someone Else", "someone@example.com", "Someone Else", "someone@example.com",
|
|
"Change another thing\n\nCo-authored-by: Bob Example <bob@example.com>"
|
|
].join(separator)
|
|
|
|
counts = command.parse_git_log(
|
|
"#{merge}#{record_separator}#{pull_request}#{record_separator}#{coauthored}#{record_separator}",
|
|
{ "alice" => "Alice Example", "bob" => "bob" },
|
|
github_identities: { "alice" => ["alice@example.com"], "bob" => ["bob", "Bob Example", "bob@example.com"] },
|
|
)
|
|
|
|
expect(counts).to eq(
|
|
"alice" => {
|
|
merged_pr_author: 0, merged_pr_merger: 1, merged_pr: 1, approved_pr_review: 0, coauthor: 1
|
|
},
|
|
"bob" => {
|
|
merged_pr_author: 1, merged_pr_merger: 0, merged_pr: 1, approved_pr_review: 0, coauthor: 1
|
|
},
|
|
)
|
|
end
|
|
|
|
it "attributes amended committers as coauthors" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
separator = Homebrew::DevCmd::Contributions::GIT_LOG_FIELD_SEPARATOR
|
|
record_separator = Homebrew::DevCmd::Contributions::GIT_LOG_RECORD_SEPARATOR
|
|
base = ["base", "", "Maintainer", "maintainer@example.com", "Maintainer", "maintainer@example.com", "Base"]
|
|
.join(separator)
|
|
amended = [
|
|
"amended", "base", "Alice Example", "alice@example.com", "John", "bob@example.com", "Change something"
|
|
].join(separator)
|
|
merge = [
|
|
"merge", "base amended", "Alice Example", "alice@example.com", "Joe", "alice@example.com",
|
|
"Merge pull request #123 from Homebrew/topic"
|
|
].join(separator)
|
|
|
|
counts = command.parse_git_log(
|
|
"#{merge}#{record_separator}#{amended}#{record_separator}#{base}#{record_separator}",
|
|
{ "alice" => "Alice Example", "bob" => "Bob Example" },
|
|
github_identities: { "alice" => ["alice@example.com"], "bob" => ["bob@example.com"] },
|
|
)
|
|
|
|
expect(counts.fetch("bob").fetch(:coauthor)).to eq(1)
|
|
end
|
|
|
|
it "matches commits under a GitHub profile's name and email for a username" do
|
|
command = described_class.new(["--user=alice", "--repositories=Homebrew/homebrew-core"])
|
|
repository = "Homebrew/homebrew-core"
|
|
commit = [
|
|
"commit", "base", "Someone Else", "someone@example.com", "Someone Else", "someone@example.com",
|
|
"Change something\n\nCo-authored-by: Alice Example <a.example@example.com>"
|
|
].join(Homebrew::DevCmd::Contributions::GIT_LOG_FIELD_SEPARATOR)
|
|
allow(Utils).to receive(:safe_popen_read)
|
|
.and_return("#{commit}#{Homebrew::DevCmd::Contributions::GIT_LOG_RECORD_SEPARATOR}")
|
|
allow(GitHub::API).to receive(:open_rest)
|
|
.with(GitHub.url_to("users", "alice"))
|
|
.and_return({ "name" => "Alice Example", "email" => "a.example@example.com" })
|
|
allow(GitHub).to receive_messages(search_issues: [], search_approved_pull_requests_in_user_or_organisation: [])
|
|
|
|
results = command.scan_contributions(
|
|
"Homebrew",
|
|
[repository],
|
|
{ repository => [Pathname("/Homebrew/homebrew-core"), "origin/HEAD"] },
|
|
{ "alice" => "alice" },
|
|
from: "2026-01-01",
|
|
to: "2026-02-01",
|
|
skip_reviews_if_lead_met: false,
|
|
progress: false,
|
|
)
|
|
|
|
expect(results.fetch("alice").fetch(repository).fetch(:coauthor)).to eq(1)
|
|
end
|
|
|
|
it "skips GitHub profile names shared by multiple requested users" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
ambiguous = [
|
|
"ambiguous", "base", "Someone Else", "someone@example.com", "Someone Else", "someone@example.com",
|
|
"Change something\n\nCo-authored-by: Alex <unknown@example.com>"
|
|
].join(Homebrew::DevCmd::Contributions::GIT_LOG_FIELD_SEPARATOR)
|
|
by_email = [
|
|
"by-email", "base", "Someone Else", "someone@example.com", "Someone Else", "someone@example.com",
|
|
"Change another thing\n\nCo-authored-by: Alex <bob@example.com>"
|
|
].join(Homebrew::DevCmd::Contributions::GIT_LOG_FIELD_SEPARATOR)
|
|
|
|
counts = command.parse_git_log(
|
|
"#{ambiguous}#{Homebrew::DevCmd::Contributions::GIT_LOG_RECORD_SEPARATOR}#{by_email}#{Homebrew::DevCmd::Contributions::GIT_LOG_RECORD_SEPARATOR}",
|
|
{ "alice" => "alice", "bob" => "bob" },
|
|
github_identities: { "alice" => %w[alice Alex alice@example.com], "bob" => %w[bob Alex bob@example.com] },
|
|
)
|
|
|
|
expect(counts.transform_values { |user_counts| user_counts.fetch(:coauthor) }).to eq("alice" => 0, "bob" => 1)
|
|
end
|
|
|
|
it "prefers an exact email match over a GitHub profile name" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
commit = [
|
|
"commit", "base", "Someone Else", "someone@example.com", "Someone Else", "someone@example.com",
|
|
"Change something\n\nCo-authored-by: Alex <bob@example.com>"
|
|
].join(Homebrew::DevCmd::Contributions::GIT_LOG_FIELD_SEPARATOR)
|
|
|
|
counts = command.parse_git_log(
|
|
"#{commit}#{Homebrew::DevCmd::Contributions::GIT_LOG_RECORD_SEPARATOR}",
|
|
{ "alice" => "alice", "bob" => "bob" },
|
|
github_identities: { "alice" => %w[alice Alex alice@example.com], "bob" => %w[bob bob@example.com] },
|
|
)
|
|
|
|
expect(counts.transform_values { |user_counts| user_counts.fetch(:coauthor) }).to eq("alice" => 0, "bob" => 1)
|
|
end
|
|
|
|
it "matches GitHub no-reply usernames but not other emails' local parts" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
unrelated = [
|
|
"unrelated", "base", "Someone Else", "someone@example.com", "Someone Else", "someone@example.com",
|
|
"Change something\n\nCo-authored-by: Other Alice <alice@unrelated.example.com>"
|
|
].join(Homebrew::DevCmd::Contributions::GIT_LOG_FIELD_SEPARATOR)
|
|
noreply = [
|
|
"noreply", "base", "Someone Else", "someone@example.com", "Someone Else", "someone@example.com",
|
|
"Change another thing\n\nCo-authored-by: A. Example <123+alice@users.noreply.github.com>"
|
|
].join(Homebrew::DevCmd::Contributions::GIT_LOG_FIELD_SEPARATOR)
|
|
|
|
counts = command.parse_git_log(
|
|
"#{unrelated}#{Homebrew::DevCmd::Contributions::GIT_LOG_RECORD_SEPARATOR}#{noreply}#{Homebrew::DevCmd::Contributions::GIT_LOG_RECORD_SEPARATOR}",
|
|
{ "alice" => "Alice Example" },
|
|
)
|
|
|
|
expect(counts.fetch("alice").fetch(:coauthor)).to eq(1)
|
|
end
|
|
|
|
it "skips approval queries after Git meets the Lead repository thresholds" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
repositories = {
|
|
"Homebrew/brew" => [Pathname("/Homebrew/brew"), "origin/HEAD"],
|
|
"Homebrew/homebrew-core" => [Pathname("/Homebrew/homebrew-core"), "origin/HEAD"],
|
|
}
|
|
counts = {
|
|
merged_pr_author: 0, merged_pr_merger: 0, merged_pr: 25, approved_pr_review: 0, coauthor: 0
|
|
}
|
|
allow(Utils).to receive(:safe_popen_read).and_return("")
|
|
allow(command).to receive(:parse_git_log).and_return("alice" => counts)
|
|
allow(GitHub).to receive(:search_issues)
|
|
.with("", is: "merged", user: "Homebrew", author: "alice", merged: "2025-12-01..2026-02-28")
|
|
.and_return([])
|
|
expect(GitHub).not_to receive(:search_approved_pull_requests_in_user_or_organisation)
|
|
|
|
expect(command.scan_contributions(
|
|
"Homebrew",
|
|
repositories.keys,
|
|
repositories,
|
|
{ "alice" => "Alice" },
|
|
from: "2025-12-01",
|
|
to: "2026-03-01",
|
|
skip_reviews_if_lead_met: true,
|
|
progress: true,
|
|
)).to eq("alice" => { "Homebrew/brew" => counts, "Homebrew/homebrew-core" => counts })
|
|
end
|
|
|
|
it "scopes capped review searches by repository until Lead activity is known" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
repositories = Homebrew::DevCmd::Contributions::PRIMARY_REPOS.to_h do |repository|
|
|
[repository, [Pathname("/#{repository}"), "origin/HEAD"]]
|
|
end
|
|
no_contributions = {
|
|
merged_pr_author: 0, merged_pr_merger: 0, merged_pr: 0, approved_pr_review: 0, coauthor: 0
|
|
}
|
|
allow(Utils).to receive(:safe_popen_read).and_return("brew", "core", "cask")
|
|
allow(command).to receive(:parse_git_log) do |output, _|
|
|
{ "alice" => (output == "cask") ? no_contributions.merge(coauthor: 500) : no_contributions.dup }
|
|
end
|
|
allow(GitHub).to receive(:search_issues)
|
|
.with("", is: "merged", user: "Homebrew", author: "alice", merged: "2025-12-01..2026-02-28")
|
|
.and_return([])
|
|
allow(GitHub).to receive(:search_approved_pull_requests_in_user_or_organisation)
|
|
.with("Homebrew", "alice", from: "2025-12-01", to: "2026-02-28")
|
|
.and_return(Array.new(100) do
|
|
{ "repository_url" => "#{GitHub::API_URL}/repos/Homebrew/homebrew-cask" }
|
|
end)
|
|
expect(GitHub).to receive(:search_issues)
|
|
.with("", is: "pr", review: "approved", repo: "Homebrew/brew", reviewed_by: "alice",
|
|
from: "2025-12-01", to: "2026-02-28")
|
|
.and_return(Array.new(25) { {} })
|
|
|
|
results = command.scan_contributions(
|
|
"Homebrew",
|
|
repositories.keys,
|
|
repositories,
|
|
{ "alice" => "Alice" },
|
|
from: "2025-12-01",
|
|
to: "2026-03-01",
|
|
skip_reviews_if_lead_met: true,
|
|
progress: true,
|
|
)
|
|
|
|
expect(results.fetch("alice").transform_values do |counts|
|
|
counts.fetch(:approved_pr_review)
|
|
end).to eq("Homebrew/brew" => 25, "Homebrew/homebrew-core" => 0, "Homebrew/homebrew-cask" => 100)
|
|
end
|
|
|
|
it "excludes the end date from approved review searches" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
repositories = Homebrew::DevCmd::Contributions::PRIMARY_REPOS.to_h do |repository|
|
|
[repository, [Pathname("/#{repository}"), "origin/HEAD"]]
|
|
end
|
|
counts = {
|
|
merged_pr_author: 0, merged_pr_merger: 0, merged_pr: 0, approved_pr_review: 0, coauthor: 0
|
|
}
|
|
allow(Utils).to receive(:safe_popen_read).and_return("")
|
|
allow(command).to receive(:parse_git_log) do
|
|
{ "alice" => counts.dup }
|
|
end
|
|
allow(GitHub).to receive(:search_issues)
|
|
.with("", is: "merged", user: "Homebrew", author: "alice", merged: "2025-12-01..2026-02-28")
|
|
.and_return([])
|
|
allow(GitHub).to receive(:search_approved_pull_requests_in_user_or_organisation)
|
|
.with("Homebrew", "alice", from: "2025-12-01", to: "2026-02-28")
|
|
.and_return([
|
|
{ "repository_url" => "#{GitHub::API_URL}/repos/Homebrew/brew" },
|
|
{ "repository_url" => "#{GitHub::API_URL}/repos/Homebrew/homebrew-core" },
|
|
])
|
|
|
|
results = command.scan_contributions(
|
|
"Homebrew",
|
|
repositories.keys,
|
|
repositories,
|
|
{ "alice" => "Alice" },
|
|
from: "2025-12-01",
|
|
to: "2026-03-01",
|
|
skip_reviews_if_lead_met: true,
|
|
progress: true,
|
|
)
|
|
|
|
expect(results.transform_values do |repository_counts|
|
|
repository_counts.transform_values { |repository_count| repository_count.fetch(:approved_pr_review) }
|
|
end).to eq("alice" => {
|
|
"Homebrew/brew" => 1, "Homebrew/homebrew-core" => 1, "Homebrew/homebrew-cask" => 0
|
|
})
|
|
end
|
|
|
|
it "caches completed GitHub searches in the prunable Homebrew cache" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
results = [{ "repository_url" => "#{GitHub::API_URL}/repos/Homebrew/brew" }]
|
|
calls = 0
|
|
|
|
2.times do
|
|
expect(command.cached_github_request(Array, "approved", "Homebrew", "alice", "2026-1", to: "2026-03-01") do
|
|
calls += 1
|
|
results
|
|
end).to eq(results)
|
|
end
|
|
|
|
expect(calls).to eq(1)
|
|
cache_file = HOMEBREW_CACHE.children.fetch(0)
|
|
expect(cache_file.basename.to_s).to match(/\Acontributions--[a-f\d]{64}\.json\z/)
|
|
|
|
expect do
|
|
Homebrew::Cleanup.new(days: 0, cache: HOMEBREW_CACHE)
|
|
.cleanup_cache([{ path: cache_file, type: nil }], cleanup_unreferenced: false)
|
|
end.to output(/Removing:/).to_stdout
|
|
expect(cache_file).not_to exist
|
|
end
|
|
|
|
it "warns about unreadable cache files before deleting them" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
command.cached_github_request(Array, "approved", to: "2026-03-01") { [] }
|
|
HOMEBREW_CACHE.children.fetch(0).write("not json")
|
|
|
|
expect do
|
|
command.cached_github_request(Array, "approved", to: "2026-03-01") { [] }
|
|
end.to output(/Deleting unreadable contributions cache/).to_stderr
|
|
end
|
|
|
|
it "warns about cached results of an unexpected type before deleting them" do
|
|
command = described_class.new(["--maintainer-report-csv=2026-1"])
|
|
command.cached_github_request(Hash, "user-profile", to: "2026-03-01") { {} }
|
|
|
|
expect do
|
|
command.cached_github_request(Array, "user-profile", to: "2026-03-01") { [] }
|
|
end.to output(/Deleting contributions cache .* containing Hash rather than Array/).to_stderr
|
|
end
|
|
|
|
it "caches GitHub profile lookups" do
|
|
command = described_class.new(["--user=alice"])
|
|
expect(GitHub::API).to receive(:open_rest)
|
|
.with(GitHub.url_to("users", "alice"))
|
|
.once
|
|
.and_return({ "name" => "Alice Example", "email" => "alice@example.com" })
|
|
|
|
identities = Array.new(2) { command.github_identities_for_username("alice", to: "2026-03-01") }
|
|
|
|
expect(identities).to eq([["alice", "Alice Example", "alice@example.com"]] * 2)
|
|
end
|
|
|
|
it "returns only the username for a missing GitHub profile" do
|
|
command = described_class.new(["--user=alice"])
|
|
allow(GitHub::API).to receive(:open_rest).and_raise(GitHub::API::HTTPNotFoundError, "Not Found")
|
|
|
|
expect(command.github_identities_for_username("alice", to: "2026-03-01")).to eq(["alice"])
|
|
end
|
|
end
|