Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MNT] changelog generator script and release workflow improvement #1659

Merged
merged 5 commits into from
Sep 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/pypi_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
pytest-nosoftdeps:
name: no-softdeps
runs-on: ${{ matrix.os }}
needs: [build_wheels]
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -80,6 +81,5 @@ jobs:
- name: Publish package to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: ${{ secrets.PYPI_USER }}
password: ${{ secrets.PYPI_PASSWORD }}
password: ${{ secrets.PYPI_API_TOKEN }}
packages-dir: wheelhouse/
191 changes: 191 additions & 0 deletions build_tools/changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"""RestructuredText changelog generator."""

from collections import defaultdict
import os

HEADERS = {
"Accept": "application/vnd.github.v3+json",
}

if os.getenv("GITHUB_TOKEN") is not None:
HEADERS["Authorization"] = f"token {os.getenv('GITHUB_TOKEN')}"

OWNER = "jdb78"
REPO = "pytorch-forecasting"
GITHUB_REPOS = "https://api.github.com/repos"


def fetch_merged_pull_requests(page: int = 1) -> list[dict]:
"""Fetch a page of merged pull requests.

Parameters
----------
page : int, optional
Page number to fetch, by default 1.
Returns all merged pull request from the ``page``-th page of closed PRs,
where pages are in descending order of last update.

Returns
-------
list
List of merged pull requests from the ``page``-th page of closed PRs.
Elements of list are dictionaries with PR details, as obtained
from the GitHub API via ``httpx.get``, from the ``pulls`` endpoint.
"""
import httpx

params = {
"base": "main",
"state": "closed",
"page": page,
"per_page": 50,
"sort": "updated",
"direction": "desc",
}
r = httpx.get(
f"{GITHUB_REPOS}/{OWNER}/{REPO}/pulls",
headers=HEADERS,
params=params,
)
return [pr for pr in r.json() if pr["merged_at"]]


def fetch_latest_release(): # noqa: D103
"""Fetch the latest release from the GitHub API.

Returns
-------
dict
Dictionary with details of the latest release.
Dictionary is as obtained from the GitHub API via ``httpx.get``,
for ``releases/latest`` endpoint.
"""
import httpx

response = httpx.get(f"{GITHUB_REPOS}/{OWNER}/{REPO}/releases/latest", headers=HEADERS)

if response.status_code == 200:
return response.json()
else:
raise ValueError(response.text, response.status_code)


def fetch_pull_requests_since_last_release() -> list[dict]:
"""Fetch all pull requests merged since last release.

Returns
-------
list
List of pull requests merged since the latest release.
Elements of list are dictionaries with PR details, as obtained
from the GitHub API via ``httpx.get``, through ``fetch_merged_pull_requests``.
"""
from dateutil import parser

release = fetch_latest_release()
published_at = parser.parse(release["published_at"])
print(f"Latest release {release['tag_name']} was published at {published_at}")

is_exhausted = False
page = 1
all_pulls = []
while not is_exhausted:
pulls = fetch_merged_pull_requests(page=page)
all_pulls.extend([p for p in pulls if parser.parse(p["merged_at"]) > published_at])
is_exhausted = any(parser.parse(p["updated_at"]) < published_at for p in pulls)
page += 1
return all_pulls


def github_compare_tags(tag_left: str, tag_right: str = "HEAD"):
"""Compare commit between two tags."""
import httpx

response = httpx.get(f"{GITHUB_REPOS}/{OWNER}/{REPO}/compare/{tag_left}...{tag_right}")
if response.status_code == 200:
return response.json()
else:
raise ValueError(response.text, response.status_code)


def render_contributors(prs: list, fmt: str = "rst"):
"""Find unique authors and print a list in given format."""
authors = sorted({pr["user"]["login"] for pr in prs}, key=lambda x: x.lower())

header = "Contributors"
if fmt == "github":
print(f"### {header}")
print(", ".join(f"@{user}" for user in authors))
elif fmt == "rst":
print(header)
print("~" * len(header), end="\n\n")
print(",\n".join(f":user:`{user}`" for user in authors))


def assign_prs(prs, categs: list[dict[str, list[str]]]):
"""Assign PR to categories based on labels."""
assigned = defaultdict(list)

for i, pr in enumerate(prs):
for cat in categs:
pr_labels = [label["name"] for label in pr["labels"]]
if not set(cat["labels"]).isdisjoint(set(pr_labels)):
assigned[cat["title"]].append(i)

# if any(l.startswith("module") for l in pr_labels):
# print(i, pr_labels)

assigned["Other"] = list(set(range(len(prs))) - {i for _, j in assigned.items() for i in j})

return assigned


def render_row(pr):
"""Render a single row with PR in restructuredText format."""
print(
"*",
pr["title"].replace("`", "``"),
f"(:pr:`{pr['number']}`)",
f":user:`{pr['user']['login']}`",
)


def render_changelog(prs, assigned):
# sourcery skip: use-named-expression
"""Render changelog."""
from dateutil import parser

for title, _ in assigned.items():
pr_group = [prs[i] for i in assigned[title]]
if pr_group:
print(f"\n{title}")
print("~" * len(title), end="\n\n")

for pr in sorted(pr_group, key=lambda x: parser.parse(x["merged_at"])):
render_row(pr)


if __name__ == "__main__":
categories = [
{"title": "Enhancements", "labels": ["feature", "enhancement"]},
{"title": "Fixes", "labels": ["bug", "fix", "bugfix"]},
{"title": "Maintenance", "labels": ["maintenance", "chore"]},
{"title": "Refactored", "labels": ["refactor"]},
{"title": "Documentation", "labels": ["documentation"]},
]

pulls = fetch_pull_requests_since_last_release()
print(f"Found {len(pulls)} merged PRs since last release")
assigned = assign_prs(pulls, categories)
render_changelog(pulls, assigned)
print()
render_contributors(pulls)

release = fetch_latest_release()
diff = github_compare_tags(release["tag_name"])
if diff["total_commits"] != len(pulls):
raise ValueError(
"Something went wrong and not all PR were fetched. "
f'There are {len(pulls)} PRs but {diff["total_commits"]} in the diff. '
"Please verify that all PRs are included in the changelog."
)
Loading