diff --git a/.github/scripts/generate_release_notes_sponsors.py b/.github/scripts/generate_release_notes_sponsors.py new file mode 100755 index 00000000..216535e3 --- /dev/null +++ b/.github/scripts/generate_release_notes_sponsors.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +"""Generate a "Sponsors since " 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()) diff --git a/MAINTENANCE.md b/MAINTENANCE.md index db53f178..0dfed2b8 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -7,6 +7,14 @@ Release checklist: * Start [drafting a new release on GitHub](https://github.com/pyenv/pyenv/releases) to generate a summary of changes. Type the would-be tag name in the "Choose a tag" field and press "Generate release notes" * The summary may need editing. E.g. rephrase entries, delete/merge entries that are too minor or irrelevant to the users (e.g. typo fixes, CI) +* Add a sponsors section to the release notes by running: + ```bash + .github/scripts/generate_release_notes_sponsors.py + ``` + Paste the output at the end of the release notes. + * This lists new GitHub Sponsors and OpenCollective backers since the last release or within the last month, whichever is longer. + * The GitHub Sponsors query requires the `gh` CLI with the `read:user` scope. + * If OpenCollective is unavailable, pass `--skip-opencollective` and add those backers manually. * Update `CHANGELOG.md` with the new version number and the edited summary (only the changes section) * Push the version number in `libexec/pyenv---version` and `plugins/python-build/bin/python-build` * Minor version is pushed if there are significant functional changes (not e.g. bugfixes/formula adaptations/supporting niche use cases). @@ -14,4 +22,4 @@ Type the would-be tag name in the "Choose a tag" field and press "Generate relea * Commit the changes locally into `master` * Create a new tag locally with the same name as specified in the new release window * Push the changes including the tag -* In the still open new release window, press "Publish release". The now-existing tag will be used. \ No newline at end of file +* In the still open new release window, press "Publish release". The now-existing tag will be used.