You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

364 lines
11 KiB

#!/usr/bin/env python3
"""Generate a "Sponsors since <date>" section for release notes.
Queries GitHub Sponsors and OpenCollective for new sponsors since the latest
pyenv release or one month ago, whichever is longer. The output is Markdown
suitable for GitHub Releases.
Requirements:
* Python 3.8+
* The ``gh`` CLI authenticated with the ``read:user`` scope.
* Network access to https://opencollective.com.
"""
import argparse
import calendar
import datetime
import json
import pathlib
import subprocess
import sys
import typing
import urllib.error
import urllib.request
GITHUB_ORG = "pyenv"
OPENCOLLECTIVE_MEMBERS_URL = "https://opencollective.com/pyenv/members.json"
class SponsorDataError(RuntimeError):
"""Raised when a sponsors data source cannot be queried or parsed."""
def parse_date(value: str) -> datetime.date:
return datetime.datetime.fromisoformat(value).date()
def one_month_ago(today: typing.Optional[datetime.date] = None) -> datetime.date:
today = today or datetime.date.today()
year = today.year
month = today.month - 1
if month == 0:
year -= 1
month = 12
try:
return datetime.date(year, month, today.day)
except ValueError:
last_day = calendar.monthrange(year, month)[1]
return datetime.date(year, month, last_day)
def latest_release_date() -> datetime.date:
"""Return the publish date of the latest GitHub release."""
latest_release_error = None
try:
result = subprocess.run(
["gh", "api", f"repos/{GITHUB_ORG}/{GITHUB_ORG}/releases/latest"],
capture_output=True,
text=True,
check=True,
)
data = json.loads(result.stdout)
published = data["published_at"].replace("Z", "+00:00")
return datetime.datetime.fromisoformat(published).date()
except (
subprocess.CalledProcessError,
OSError,
json.JSONDecodeError,
KeyError,
) as exc:
latest_release_error = exc
try:
tag = subprocess.run(
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
date_str = subprocess.run(
["git", "log", "-1", "--format=%cI", tag],
capture_output=True,
text=True,
check=True,
).stdout.strip()
return datetime.datetime.fromisoformat(date_str).date()
except (subprocess.CalledProcessError, OSError) as exc:
detail = ""
if latest_release_error is not None:
detail = f" GitHub release lookup failed first: {latest_release_error}."
raise SponsorDataError(
"Could not determine the latest release date. "
"Pass --since explicitly or run from a clone with release tags."
f"{detail}"
) from exc
def compute_since_date(explicit_since: typing.Optional[datetime.date]) -> datetime.date:
if explicit_since is not None:
return explicit_since
return min(latest_release_date(), one_month_ago())
def github_sponsors(since: datetime.date) -> typing.List[typing.Dict]:
"""Return GitHub Sponsors created on or after *since*."""
query = """
query($org: String!, $after: String) {
organization(login: $org) {
sponsorshipsAsMaintainer(
first: 100,
after: $after,
activeOnly: false,
orderBy: {field: CREATED_AT, direction: DESC}
) {
pageInfo {
hasNextPage
endCursor
}
nodes {
createdAt
sponsorEntity {
... on User { login, name }
... on Organization { login, name }
}
}
}
}
}
"""
since_dt = datetime.datetime.combine(
since, datetime.time.min, tzinfo=datetime.timezone.utc
)
sponsors = []
cursor = None
while True:
command = [
"gh",
"api",
"graphql",
"-F",
f"org={GITHUB_ORG}",
"-f",
f"query={query}",
]
if cursor is not None:
command.extend(["-F", f"after={cursor}"])
result = subprocess.run(
command,
capture_output=True,
text=True,
)
if result.returncode != 0:
detail = result.stderr.strip() or result.stdout.strip() or "no output"
raise SponsorDataError(
"GitHub Sponsors query failed. "
"Check that `gh auth status` shows access to the pyenv org "
"and that the token has the scopes required to read sponsorships. "
f"`gh api graphql` exited {result.returncode}: {detail}"
)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise SponsorDataError(
"GitHub Sponsors query returned invalid JSON."
) from exc
if "errors" in data:
raise SponsorDataError(
f"GitHub Sponsors query returned errors: {data['errors']}"
)
try:
sponsorships = data["data"]["organization"]["sponsorshipsAsMaintainer"]
except (TypeError, KeyError) as exc:
raise SponsorDataError(
"GitHub Sponsors query returned an unexpected response shape."
) from exc
try:
nodes = sponsorships["nodes"]
page_info = sponsorships["pageInfo"]
except (TypeError, KeyError) as exc:
raise SponsorDataError(
"GitHub Sponsors query returned incomplete pagination data."
) from exc
for node in nodes:
try:
created = datetime.datetime.fromisoformat(
node["createdAt"].replace("Z", "+00:00")
)
entity = node["sponsorEntity"]
login = entity["login"]
except (AttributeError, KeyError, TypeError, ValueError) as exc:
raise SponsorDataError(
"GitHub Sponsors query returned an unexpected sponsor record."
) from exc
if created < since_dt:
return sponsors
sponsors.append({
"login": login,
"name": entity.get("name") or login,
})
try:
has_next_page = page_info["hasNextPage"]
cursor = page_info["endCursor"]
except (TypeError, KeyError) as exc:
raise SponsorDataError(
"GitHub Sponsors query returned incomplete pagination data."
) from exc
if not has_next_page:
return sponsors
def opencollective_sponsors(since: datetime.date, data: typing.Union[str, None]) -> typing.List[typing.Dict]:
"""Return OpenCollective backers active on or after *since*."""
if data is None:
req = urllib.request.Request(
f"{OPENCOLLECTIVE_MEMBERS_URL}?limit=1000",
headers={"User-Agent": f"{GITHUB_ORG}/release-notes-sponsors"},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = resp.read()
except urllib.error.HTTPError as exc:
raise SponsorDataError(
f"OpenCollective sponsors query failed for {OPENCOLLECTIVE_MEMBERS_URL}: "
f"HTTP {exc.code} {exc.reason}"
) from exc
except urllib.error.URLError as exc:
raise SponsorDataError(
f"OpenCollective sponsors query failed for {OPENCOLLECTIVE_MEMBERS_URL}: "
f"{exc.reason}"
) from exc
try:
members = json.loads(data)
except json.JSONDecodeError as exc:
raise SponsorDataError(
"OpenCollective sponsors query returned invalid JSON."
) from exc
since_dt = datetime.datetime.combine(since, datetime.time.min)
sponsors = []
for member in members:
if member.get("role") != "BACKER":
continue
try:
last_transaction_at = datetime.datetime.strptime(member["lastTransactionAt"], "%Y-%m-%d %H:%M")
profile = member["profile"].rstrip("/")
except (KeyError, TypeError, ValueError) as exc:
raise SponsorDataError(
"OpenCollective sponsors query returned an unexpected member record."
) from exc
if last_transaction_at < since_dt:
continue
slug = profile.split("/")[-1]
if slug == "github-sponsors":
# Listed separately under GitHub Sponsors.
continue
sponsors.append({
"name": member.get("name") or slug,
"profile": profile,
})
return sponsors
def render(
since: datetime.date,
gh_sponsors: typing.List[typing.Dict],
oc_sponsors: typing.List[typing.Dict],
) -> str:
lines = [f"## Sponsors since {since.isoformat()}", ""]
if gh_sponsors:
lines.append("### GitHub Sponsors")
for sponsor in gh_sponsors:
lines.append(
markdown_link(
sponsor["name"],
f"https://github.com/{sponsor['login']}",
)
)
lines.append("")
if oc_sponsors:
lines.append("### Open Collective")
for sponsor in oc_sponsors:
lines.append(markdown_link(sponsor["name"], sponsor["profile"]))
lines.append("")
if not gh_sponsors and not oc_sponsors:
lines.append("*No new sponsors in this period.*")
lines.append("")
return "\n".join(lines)
def markdown_link(text: str, url: str) -> str:
escaped_text = escape_markdown_text(text)
escaped_url = url.replace(")", "%29")
return f"- [{escaped_text}]({escaped_url})"
def escape_markdown_text(text: str) -> str:
escaped = text.replace("\\", "\\\\")
for char in r"`*_{}[]()#+-.!|>":
escaped = escaped.replace(char, f"\\{char}")
return escaped
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate a sponsors section for GitHub release notes."
)
parser.add_argument(
"--since",
type=parse_date,
metavar="YYYY-MM-DD",
help="Include sponsors created on or after this date.",
)
parser.add_argument(
"--output",
metavar="FILE",
help="Write the section to FILE instead of stdout.",
)
parser.add_argument(
"--skip-opencollective",
action="store_true",
help="Skip OpenCollective backers if the members endpoint is unavailable.",
)
parser.add_argument(
"--opencollective-data",
metavar="FILE",
help="Take OpenCollective API reply from FILE.",
)
args = parser.parse_args()
try:
since = compute_since_date(args.since)
oc_sponsors = []
if not args.skip_opencollective:
manual_data = (
pathlib.Path(args.opencollective_data).read_text()) \
if args.opencollective_data \
else None
oc_sponsors = opencollective_sponsors(since, manual_data)
section = render(since, github_sponsors(since), oc_sponsors)
except SponsorDataError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(section)
else:
print(section, end="")
return 0
if __name__ == "__main__":
sys.exit(main())