diff --git a/Library/Homebrew/dev-cmd/sponsors.rb b/Library/Homebrew/dev-cmd/sponsors.rb new file mode 100644 index 0000000000..568daa9452 --- /dev/null +++ b/Library/Homebrew/dev-cmd/sponsors.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "cli/parser" +require "utils/github" + +module Homebrew + module_function + + def sponsors_args + Homebrew::CLI::Parser.new do + usage_banner <<~EOS + `sponsors` + + Print a Markdown summary of Homebrew's GitHub Sponsors, suitable for pasting into a README. + EOS + end + end + + def sponsors + sponsors_args.parse + + sponsors = { + "named" => [], + "users" => 0, + "orgs" => 0, + } + + GitHub.sponsors_by_tier("Homebrew").each do |tier| + sponsors["named"] += tier["sponsors"] if tier["tier"] >= 100 + sponsors["users"] += tier["count"] + sponsors["orgs"] += tier["sponsors"].count { |s| s["type"] == "organization" } + end + + items = [] + items += sponsors["named"].map { |s| "[#{s["name"]}](https://github.com/#{s["login"]})" } + + anon_users = sponsors["users"] - sponsors["named"].length - sponsors["orgs"] + + items << if items.length > 1 + "#{anon_users} other users" + else + "#{anon_users} users" + end + + if sponsors["orgs"] == 1 + items << "#{sponsors["orgs"]} organization" + elsif sponsors["orgs"] > 1 + items << "#{sponsors["orgs"]} organizations" + end + + sponsor_text = if items.length > 2 + items[0..-2].join(", ") + " and #{items.last}" + else + items.join(" and ") + end + + puts "Homebrew is generously supported by #{sponsor_text} via [GitHub Sponsors](https://github.com/sponsors/Homebrew)." + end +end diff --git a/Library/Homebrew/test/dev-cmd/sponsors_spec.rb b/Library/Homebrew/test/dev-cmd/sponsors_spec.rb new file mode 100644 index 0000000000..667dff55b6 --- /dev/null +++ b/Library/Homebrew/test/dev-cmd/sponsors_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "cmd/shared_examples/args_parse" + +describe "Homebrew.sponsors_args" do + it_behaves_like "parseable arguments" +end diff --git a/Library/Homebrew/test/utils/github_spec.rb b/Library/Homebrew/test/utils/github_spec.rb index 354c2585c4..37f2fd0483 100644 --- a/Library/Homebrew/test/utils/github_spec.rb +++ b/Library/Homebrew/test/utils/github_spec.rb @@ -49,6 +49,14 @@ describe GitHub do end end + describe "::sponsors_by_tier", :needs_network do + it "errors on an unauthenticated token" do + expect { + subject.sponsors_by_tier("Homebrew") + }.to raise_error(/INSUFFICIENT_SCOPES|FORBIDDEN/) + end + end + describe "::get_artifact_url", :needs_network do it "fails to find a nonexistant workflow" do expect { diff --git a/Library/Homebrew/utils/github.rb b/Library/Homebrew/utils/github.rb index cc7fa75cd7..4ac886a188 100644 --- a/Library/Homebrew/utils/github.rb +++ b/Library/Homebrew/utils/github.rb @@ -231,6 +231,15 @@ module GitHub end end + def open_graphql(query, scopes: [].freeze) + data = { query: query } + result = open_api("https://api.github.com/graphql", scopes: scopes, data: data, request_method: "POST") + + raise Error, result["errors"].map { |e| "#{e["type"]}: #{e["message"]}" }.join("\n") if result["errors"].present? + + result["data"] + end + def raise_api_error(output, errors, http_code, headers, scopes) json = begin JSON.parse(output) @@ -393,30 +402,26 @@ module GitHub end def approved_reviews(user, repo, pr, commit: nil) - url = "https://api.github.com/graphql" - data = { - query: <<~EOS, - { repository(name: "#{repo}", owner: "#{user}") { - pullRequest(number: #{pr}) { - reviews(states: APPROVED, first: 100) { - nodes { - author { - ... on User { email login name databaseId } - ... on Organization { email login name databaseId } - } - authorAssociation - commit { oid } + query = <<~EOS + { repository(name: "#{repo}", owner: "#{user}") { + pullRequest(number: #{pr}) { + reviews(states: APPROVED, first: 100) { + nodes { + author { + ... on User { email login name databaseId } + ... on Organization { email login name databaseId } } + authorAssociation + commit { oid } } } } } - EOS - } - result = open_api(url, scopes: ["user:email"], data: data, request_method: "POST") - raise Error, result["errors"] if result["errors"].present? + } + EOS - reviews = result["data"]["repository"]["pullRequest"]["reviews"]["nodes"] + result = open_graphql(query, scopes: ["user:email"]) + reviews = result["repository"]["pullRequest"]["reviews"]["nodes"] reviews.map do |r| next if commit.present? && commit != r["commit"]["oid"] @@ -493,20 +498,21 @@ module GitHub end def sponsors_by_tier(user) - url = "https://api.github.com/graphql" - data = { - query: <<~EOS, - { - organization(login: "#{user}") { - sponsorsListing { - tiers(first: 100) { - nodes { - monthlyPriceInDollars - adminInfo { - sponsorships(first: 100) { - totalCount - nodes { - sponsor { login } + query = <<~EOS + { organization(login: "#{user}") { + sponsorsListing { + tiers(first: 10, orderBy: {field: MONTHLY_PRICE_IN_CENTS, direction: DESC}) { + nodes { + monthlyPriceInDollars + adminInfo { + sponsorships(first: 100, includePrivate: true) { + totalCount + nodes { + privacyLevel + sponsorEntity { + __typename + ... on Organization { login name } + ... on User { login name } } } } @@ -515,9 +521,35 @@ module GitHub } } } - EOS - } - open_api(url, scopes: ["admin:org", "user"], data: data, request_method: "POST") + } + EOS + result = open_graphql(query, scopes: ["admin:org", "user"]) + + tiers = result["organization"]["sponsorsListing"]["tiers"]["nodes"] + + tiers.map do |t| + tier = t["monthlyPriceInDollars"] + raise Error, "Your token needs the 'admin:org' scope to access this API" if t["adminInfo"].nil? + + sponsorships = t["adminInfo"]["sponsorships"] + count = sponsorships["totalCount"] + sponsors = sponsorships["nodes"].map do |sponsor| + next unless sponsor["privacyLevel"] == "PUBLIC" + + se = sponsor["sponsorEntity"] + { + "name" => se["name"].presence || sponsor["login"], + "login" => se["login"], + "type" => se["__typename"].downcase, + } + end.compact + + { + "tier" => tier, + "count" => count, + "sponsors" => sponsors, + } + end.compact end def get_repo_license(user, repo) diff --git a/README.md b/README.md index 5270a44b8d..8ff7588354 100644 --- a/README.md +++ b/README.md @@ -88,3 +88,5 @@ Secure password storage and syncing is provided by [1Password for Teams](https:/ Homebrew is a member of the [Software Freedom Conservancy](https://sfconservancy.org). [![Software Freedom Conservancy](https://sfconservancy.org/img/conservancy_64x64.png)](https://sfconservancy.org) + +Homebrew is generously supported by [Zeno R.R. Davatz](https://github.com/zdavatz), [Randy Reddig](https://github.com/ydnar) and 262 other users via [GitHub Sponsors](https://github.com/sponsors/Homebrew). diff --git a/completions/internal_commands_list.txt b/completions/internal_commands_list.txt index 69f6c41c2a..3beacf0b09 100644 --- a/completions/internal_commands_list.txt +++ b/completions/internal_commands_list.txt @@ -71,6 +71,7 @@ ruby search sh shellenv +sponsors style switch tap diff --git a/docs/Manpage.md b/docs/Manpage.md index 2709a17839..aaabdb46e6 100644 --- a/docs/Manpage.md +++ b/docs/Manpage.md @@ -967,6 +967,11 @@ build systems would not find otherwise. * `--env`: Use the standard `PATH` instead of superenv's when `std` is passed. +### `sponsors` + +Print a Markdown summary of Homebrew's GitHub Sponsors, suitable for pasting +into a README. + ### `style` [*`options`*] [*`file`*|*`tap`*|*`formula`*] Check formulae or files for conformance to Homebrew style guidelines. diff --git a/manpages/brew.1 b/manpages/brew.1 index a653c4df12..4972791289 100644 --- a/manpages/brew.1 +++ b/manpages/brew.1 @@ -1254,6 +1254,9 @@ Start a Homebrew build environment shell\. Uses our years\-battle\-hardened Home \fB\-\-env\fR Use the standard \fBPATH\fR instead of superenv\'s when \fBstd\fR is passed\. . +.SS "\fBsponsors\fR" +Print a Markdown summary of Homebrew\'s GitHub Sponsors, suitable for pasting into a README\. +. .SS "\fBstyle\fR [\fIoptions\fR] [\fIfile\fR|\fItap\fR|\fIformula\fR]" Check formulae or files for conformance to Homebrew style guidelines\. .