|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# Compares GitHub Sponsors with resources/data/sponsors.json and prints what differs. |
| 3 | +# Read-only: it runs queries through `gh`, which holds the credentials, and writes nothing. |
| 4 | +import argparse |
| 5 | +import json |
| 6 | +import subprocess |
| 7 | +import sys |
| 8 | +from datetime import date, datetime, timedelta |
| 9 | +from pathlib import Path |
| 10 | + |
| 11 | +WEB_ROOT = Path(__file__).resolve().parents[4] |
| 12 | +DATA = WEB_ROOT / "resources" / "data" / "sponsors.json" |
| 13 | +FACTS = WEB_ROOT / "resources" / "data" / "facts.json" |
| 14 | + |
| 15 | +# Sponsorships started or last changed before the current tiers went live keep the placement they had. |
| 16 | +TIERS_SINCE = date(2026, 10, 29) |
| 17 | +STALE_DAYS = 30 |
| 18 | +EXPIRY_WARNING_DAYS = 30 |
| 19 | + |
| 20 | +SPONSORSHIPS = """ |
| 21 | +query($login: String!, $endCursor: String) { |
| 22 | + %s(login: $login) { |
| 23 | + sponsorshipsAsMaintainer(first: 100, after: $endCursor, activeOnly: false, includePrivate: true) { |
| 24 | + pageInfo { hasNextPage endCursor } |
| 25 | + nodes { |
| 26 | + isActive isOneTimePayment privacyLevel createdAt tierSelectedAt |
| 27 | + tier { monthlyPriceInDollars isOneTime isCustomAmount } |
| 28 | + sponsorEntity { __typename ... on User { login name } ... on Organization { login name } } |
| 29 | + } |
| 30 | + } |
| 31 | + } |
| 32 | +} |
| 33 | +""" |
| 34 | + |
| 35 | +TIERS = """ |
| 36 | +query($login: String!) { |
| 37 | + user(login: $login) { |
| 38 | + sponsorsListing { tiers(first: 50) { nodes { monthlyPriceInDollars isOneTime isCustomAmount } } } |
| 39 | + } |
| 40 | +} |
| 41 | +""" |
| 42 | + |
| 43 | + |
| 44 | +def gh(query, **variables): |
| 45 | + args = ["gh", "api", "graphql", "-f", "query=" + query] |
| 46 | + for key, value in variables.items(): |
| 47 | + if value is not None: |
| 48 | + args += ["-F", f"{key}={value}"] |
| 49 | + result = subprocess.run(args, capture_output=True, text=True) |
| 50 | + if result.returncode != 0: |
| 51 | + sys.exit("gh api graphql failed. If it names a missing scope, run `gh auth refresh -h github.com -s read:user`.\n" + result.stderr.strip()) |
| 52 | + return json.loads(result.stdout)["data"] |
| 53 | + |
| 54 | + |
| 55 | +def sponsorships(kind, login): |
| 56 | + nodes, cursor = [], None |
| 57 | + while True: |
| 58 | + page = gh(SPONSORSHIPS % kind, login=login, endCursor=cursor)[kind]["sponsorshipsAsMaintainer"] |
| 59 | + nodes += page["nodes"] |
| 60 | + if not page["pageInfo"]["hasNextPage"]: |
| 61 | + return nodes |
| 62 | + cursor = page["pageInfo"]["endCursor"] |
| 63 | + |
| 64 | + |
| 65 | +def day(stamp): |
| 66 | + return datetime.fromisoformat(stamp.replace("Z", "+00:00")).date() |
| 67 | + |
| 68 | + |
| 69 | +def rank(placement, data): |
| 70 | + order = [tier["placement"] for tier in data["tiers"]] |
| 71 | + return -1 if placement is None else order.index(placement) |
| 72 | + |
| 73 | + |
| 74 | +def earned(amount, one_time, data): |
| 75 | + multiplier = data["oneTime"]["multiplier"] if one_time else 1 |
| 76 | + placement = None |
| 77 | + for tier in data["tiers"]: |
| 78 | + if amount >= tier["monthly"] * multiplier: |
| 79 | + placement = tier["placement"] |
| 80 | + return placement |
| 81 | + |
| 82 | + |
| 83 | +def price_label(amount, one_time): |
| 84 | + return f"${amount}" + (" one time" if one_time else " a month") |
| 85 | + |
| 86 | + |
| 87 | +def main(): |
| 88 | + parser = argparse.ArgumentParser(description=__doc__) |
| 89 | + parser.add_argument("--today", type=date.fromisoformat, default=date.today()) |
| 90 | + args = parser.parse_args() |
| 91 | + today = args.today |
| 92 | + |
| 93 | + data = json.loads(DATA.read_text()) |
| 94 | + facts = json.loads(FACTS.read_text()) |
| 95 | + program = facts["links"]["sponsorsProgram"] |
| 96 | + maintainer = program.rstrip("/").rsplit("/", 1)[-1] |
| 97 | + months = data["oneTime"]["months"] |
| 98 | + |
| 99 | + nodes = sponsorships("user", maintainer) + sponsorships("organization", "TableProApp") |
| 100 | + listed = {sponsor["githubLogin"].lower(): sponsor for sponsor in data["sponsors"]} |
| 101 | + |
| 102 | + drift = 0 |
| 103 | + |
| 104 | + def report(code, text, counts=True): |
| 105 | + nonlocal drift |
| 106 | + drift += counts |
| 107 | + print(f"{code:<10} {text}") |
| 108 | + |
| 109 | + current, private = {}, 0 |
| 110 | + for node in nodes: |
| 111 | + entity = node["sponsorEntity"] |
| 112 | + if entity is None or node["tier"] is None: |
| 113 | + continue |
| 114 | + started = day(node["createdAt"]) |
| 115 | + changed = day(node["tierSelectedAt"] or node["createdAt"]) |
| 116 | + one_time = node["isOneTimePayment"] |
| 117 | + ends = started + timedelta(days=round(months * 365 / 12)) if one_time else None |
| 118 | + live = (ends is not None and ends > today) if one_time else node["isActive"] |
| 119 | + if not live: |
| 120 | + continue |
| 121 | + if node["privacyLevel"] != "PUBLIC": |
| 122 | + private += 1 |
| 123 | + if entity["login"].lower() in listed: |
| 124 | + report("PRIVATE", f"{entity['login']} sponsors privately but is listed: remove it") |
| 125 | + continue |
| 126 | + sponsor = { |
| 127 | + "login": entity["login"], |
| 128 | + "name": entity.get("name") or entity["login"], |
| 129 | + "kind": entity["__typename"], |
| 130 | + "amount": node["tier"]["monthlyPriceInDollars"], |
| 131 | + "oneTime": one_time, |
| 132 | + "ends": ends, |
| 133 | + "legacy": changed < TIERS_SINCE, |
| 134 | + "placement": earned(node["tier"]["monthlyPriceInDollars"], one_time, data), |
| 135 | + } |
| 136 | + # One login can hold a monthly and a one-time sponsorship at once: the better placement counts. |
| 137 | + known = current.get(entity["login"].lower()) |
| 138 | + if known is None or rank(sponsor["placement"], data) > rank(known["placement"], data): |
| 139 | + current[entity["login"].lower()] = sponsor |
| 140 | + |
| 141 | + for login, sponsor in sorted(current.items()): |
| 142 | + price = price_label(sponsor["amount"], sponsor["oneTime"]) |
| 143 | + entry = listed.get(login) |
| 144 | + if entry is None: |
| 145 | + if sponsor["placement"] is not None: |
| 146 | + report("MISSING", f"{sponsor['login']} ({sponsor['name']}, {sponsor['kind']}, {price}) earns placement '{sponsor['placement']}'") |
| 147 | + continue |
| 148 | + if sponsor["placement"] != entry["placement"]: |
| 149 | + earns = sponsor["placement"] or "no placement" |
| 150 | + if sponsor["legacy"]: |
| 151 | + report("LEGACY", f"{sponsor['login']} ({price}) keeps '{entry['placement']}' from before {TIERS_SINCE}", counts=False) |
| 152 | + else: |
| 153 | + report("PLACEMENT", f"{sponsor['login']} ({price}) is listed as '{entry['placement']}' but earns {earns}") |
| 154 | + if sponsor["ends"] is not None and (sponsor["ends"] - today).days <= EXPIRY_WARNING_DAYS: |
| 155 | + report("EXPIRES", f"{sponsor['login']}'s one-time placement ends on {sponsor['ends']}", counts=False) |
| 156 | + |
| 157 | + for login, entry in sorted(listed.items()): |
| 158 | + if login not in current: |
| 159 | + report("GONE", f"{entry['githubLogin']} ({entry['name']}) has no current public sponsorship: remove it and its logo") |
| 160 | + |
| 161 | + tiers = gh(TIERS, login=maintainer)["user"]["sponsorsListing"]["tiers"]["nodes"] |
| 162 | + published = {(tier["monthlyPriceInDollars"], tier["isOneTime"]) for tier in tiers if not tier["isCustomAmount"]} |
| 163 | + wanted = {(tier["monthly"], False) for tier in data["tiers"]} |
| 164 | + wanted |= {(tier["monthly"] * data["oneTime"]["multiplier"], True) for tier in data["tiers"]} |
| 165 | + for amount, one_time in sorted(wanted - published): |
| 166 | + report("TIERS", f"{price_label(amount, one_time)} is in sponsors.json but not published on GitHub") |
| 167 | + for amount, one_time in sorted(published - wanted): |
| 168 | + report("TIERS", f"{price_label(amount, one_time)} is published on GitHub but not in sponsors.json") |
| 169 | + |
| 170 | + on_homepage = sum(entry["placement"] in ("lead", "homepage") for entry in data["sponsors"]) |
| 171 | + age = (today - date.fromisoformat(data["verifiedAt"])).days |
| 172 | + if age > STALE_DAYS: |
| 173 | + report("STALE", f"verifiedAt is {age} days old") |
| 174 | + |
| 175 | + print(f"\n{len(current)} current public sponsors, {private} private (never listed), " |
| 176 | + f"{max(0, data['homepageSlots'] - on_homepage)} of {data['homepageSlots']} homepage places open.") |
| 177 | + print("No drift." if drift == 0 else f"{drift} to fix.") |
| 178 | + return 1 if drift else 0 |
| 179 | + |
| 180 | + |
| 181 | +if __name__ == "__main__": |
| 182 | + sys.exit(main()) |
0 commit comments