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