mirror of
https://github.com/pyenv/pyenv.git
synced 2026-08-03 19:10:34 +09:00
Add script to generate sponsors section for release notes (#3478)
* OpenCollectrive API is blocked by a Cloudflare challenge page in some locations so allow to specify the OpenCollective API data manually --------- Co-authored-by: Ivan Pozdeev <vano@mail.mipt.ru>
This commit is contained in:
parent
3974647f0f
commit
91aacc5dbf
364
.github/scripts/generate_release_notes_sponsors.py
vendored
Executable file
364
.github/scripts/generate_release_notes_sponsors.py
vendored
Executable file
@ -0,0 +1,364 @@
|
|||||||
|
#!/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())
|
||||||
@ -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.
|
* 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"
|
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)
|
* 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)
|
* 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`
|
* 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).
|
* 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`
|
* Commit the changes locally into `master`
|
||||||
* Create a new tag locally with the same name as specified in the new release window
|
* Create a new tag locally with the same name as specified in the new release window
|
||||||
* Push the changes including the tag
|
* Push the changes including the tag
|
||||||
* In the still open new release window, press "Publish release". The now-existing tag will be used.
|
* In the still open new release window, press "Publish release". The now-existing tag will be used.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user