diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f57912..37cab2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,4 +63,4 @@ jobs: python3 -m pip install setuptools wheel python3 -m pip install -r ./requirements-dev.txt - name: "Update release notes" - run: python3 ./script/generate_releasenotes.py --token ${{ secrets.GITHUB_TOKEN }} --repo ${{ github.repository }} --update-release `git describe --abbrev=0` + run: python3 ./bin/gen_releasenotes --token ${{ secrets.GITHUB_TOKEN }} --repo ${{ github.repository }} --update-release `git describe --abbrev=0` diff --git a/script/bootstrap b/bin/bootstrap similarity index 100% rename from script/bootstrap rename to bin/bootstrap diff --git a/script/dev-deploy b/bin/dev-deploy similarity index 100% rename from script/dev-deploy rename to bin/dev-deploy diff --git a/bin/gen_releasenotes b/bin/gen_releasenotes new file mode 100755 index 0000000..311b050 --- /dev/null +++ b/bin/gen_releasenotes @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Helper script to generate release notes.""" +import argparse +import logging +import os +import re +import subprocess +from datetime import datetime +from typing import List + +from git import Tag +from github import Github, Repository +from packaging.version import Version + +# http://docs.python.org/2/howto/logging.html#library-config +# Avoids spurious error messages if no logger is configured by the user +logging.getLogger(__name__).addHandler(logging.NullHandler()) + +logging.basicConfig(level=logging.CRITICAL) + +_LOGGER = logging.getLogger(__name__) + +VERSION = "1.0.0" + +ROOT = os.path.dirname(os.path.abspath(f"{__file__}/..")) + +BODY = """ +[![Downloads for this release](https://img.shields.io/github/downloads/{repo}/{version}/total.svg)](https://github.com/{repo}/releases/{version}) + +{changes} + +## Links + +- [If you like what I (@limych) do please consider sponsoring me on Patreon](https://www.patreon.com/join/limych?) +""" + +CHANGE = "- [{line}]({link}) @{author}\n" +NOCHANGE = "_No changes in this release._" + + +def get_commits(repo: Repository, since: datetime, until: datetime): + """Get commits in repo.""" + commits = repo.get_commits(since=since, until=until) + if len(list(commits)) == 1: + return [] + return reversed(list(commits)[:-1]) + + +def get_release_tags(repo: Repository) -> List[Tag]: + """Get list of all release tags from repository.""" + tags = list(repo.get_tags()) + reg = re.compile( + r"(v|^)?[0-9]+\.[0-9]+\.[0-9]+" + r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+)?$" + ) + return list(filter(lambda tag: re.match(reg, tag.name), tags)) + + +def get_period(repo: Repository, release: str = None) -> List[datetime]: + """Return time period for release notes.""" + data = [datetime.now()] + dateformat = "%a, %d %b %Y %H:%M:%S GMT" + found = release is None + for tag in get_release_tags(repo): + commit = repo.get_commit(tag.commit.sha) + timestamp = datetime.strptime(commit.last_modified, dateformat) + _LOGGER.debug("Process tag %s => timestamp %s", tag.name, timestamp) + data.append(timestamp) + if found: + break + if release is not None and release == tag.name: + found = True + return list(reversed(data[-2:])) + + +def gen_changes(repo: Repository, tag: str = None) -> str: + """Generate list of commits.""" + changes = "" + period = get_period(repo, tag) + _LOGGER.debug("Period: %s", period) + + commits = get_commits(repo, period[0], period[1]) + for commit in commits: + msg = repo.get_git_commit(commit.sha).message + if "\n" in msg: + msg = msg.split("\n")[0] + if ( + "Bump version " in msg + or "Merge branch " in msg + or "Merge tag " in msg + or "Merge pull request " in msg + ): + continue + changes += CHANGE.format( + line=msg, link=commit.html_url, author=commit.author.login + ) + + return changes if changes != "" else NOCHANGE + + +def _bump_release(release, bump_type): + """Bump a release tuple consisting of 3 numbers.""" + major, minor, patch = release + + if bump_type == "patch": + patch += 1 + elif bump_type == "minor": + minor += 1 + patch = 0 + + return major, minor, patch + + +def bump_version(version: Version) -> Version: + """Return a new version given a current version and action.""" + to_change = {} + + # Convert 0.67.3 to 0.67.4 + # Convert 0.67.3.b5 to 0.67.3 + # Convert 0.67.3.dev0 to 0.67.3 + to_change["dev"] = None + to_change["pre"] = None + + if not version.is_prerelease: + to_change["release"] = _bump_release(version.release, "patch") + + temp = Version("0") + temp._version = version._version._replace( # pylint: disable=protected-access + **to_change + ) + return Version(str(temp)) + + +def main(): + """Execute script.""" + parser = argparse.ArgumentParser( + description=f"Release notes generator. Version {VERSION}" + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable debugging output.", + ) + parser.add_argument( + "-n", + "--dry-run", + "--dryrun", + action="store_true", + help="Preview release notes generation without running it.", + ) + parser.add_argument( + "--token", + help="Github token to access to repository.", + # required=True, + ) + parser.add_argument( + "--repo", + help="Github repository (default: %(default)s).", + default=subprocess.run( + ["git", "config", "--get", "remote.origin.url"], + stdout=subprocess.PIPE, + check=True, + ) + .stdout.decode("UTF-8") + .replace("https://github.com/", "") + .replace(".git", "") + .strip(), + ) + parser.add_argument( + "--release", + help="Github release tag to update release notes.", + ) + arguments = parser.parse_args() + + if arguments.verbose: + _LOGGER.setLevel(logging.DEBUG) + + if arguments.dry_run: + _LOGGER.debug("Dry run mode ENABLED") + print("!!! Dry Run !!!") + + github = Github(arguments.token) + _LOGGER.debug("Repo: %s", arguments.repo) + repo = github.get_repo(arguments.repo) + if arguments.release is None: + changes = gen_changes(repo) + _LOGGER.debug(changes) + if changes != NOCHANGE: + version = Version(get_release_tags(repo)[0].name.lstrip("v")) + _LOGGER.debug(version) + new_version = bump_version(version) + _LOGGER.debug(new_version) + msg = BODY.format( + repo=arguments.repo, + version=version, + changes=changes, + ) + print("Generated release notes for v%s:\n%s", new_version, msg) + else: + print("Not enough changes for a release.") + else: + version = arguments.release.replace("refs/tags/", "") + _LOGGER.debug("Release tag: %s", version) + msg = BODY.format( + repo=arguments.repo, + version=version, + changes=gen_changes(repo, version), + ) + if arguments.dry_run: + print("Generated release notes:\n" + msg) + else: + release = repo.get_release(version) + release.update_release( + name=version, + prerelease=release.prerelease, + draft=release.draft, + message=msg, + ) + + +if __name__ == "__main__": + main() diff --git a/script/run-in-env.sh b/bin/run-in-env similarity index 100% rename from script/run-in-env.sh rename to bin/run-in-env diff --git a/script/setup b/bin/setup similarity index 100% rename from script/setup rename to bin/setup diff --git a/script/update b/bin/update similarity index 100% rename from script/update rename to bin/update diff --git a/script/update_requirements.py b/bin/update_requirements similarity index 100% rename from script/update_requirements.py rename to bin/update_requirements diff --git a/requirements-dev.txt b/requirements-dev.txt index 58008ff..97029cf 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,6 +2,7 @@ black flake8 mypy +packaging~=20.4 pre-commit PyGithub==1.53 pylint diff --git a/script/__init__.py b/script/__init__.py deleted file mode 100755 index da52cab..0000000 --- a/script/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Development support scripts.""" diff --git a/script/generate_releasenotes.py b/script/generate_releasenotes.py deleted file mode 100755 index fc9d353..0000000 --- a/script/generate_releasenotes.py +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env python3 -"""Helper script to generate release notes.""" -import argparse -import logging -import os -import re -import subprocess -from datetime import datetime -from typing import Dict - -from github import Github, Repository - -# http://docs.python.org/2/howto/logging.html#library-config -# Avoids spurious error messages if no logger is configured by the user -logging.getLogger(__name__).addHandler(logging.NullHandler()) - -logging.basicConfig(level=logging.CRITICAL) - -_LOGGER = logging.getLogger(__name__) - -VERSION = "0.1.0" - -ROOT = os.path.dirname(os.path.abspath(f"{__file__}/..")) - -BODY = """ -[![Downloads for this release](https://img.shields.io/github/downloads/{repo}/{version}/total.svg)](https://github.com/{repo}/releases/{version}) - -{changes} - -## Links - -- [If you like what I (@limych) do please consider sponsoring me on Patreon](https://www.patreon.com/join/limych?) -""" - -CHANGE = "- [{line}]({link}) @{author}\n" -NOCHANGE = "_No changes in this release._" - - -def new_commits(repo: Repository, sha: str): - """Get new commits in repo.""" - dateformat = "%a, %d %b %Y %H:%M:%S GMT" - release_commit = repo.get_commit(sha) - since = datetime.strptime(release_commit.last_modified, dateformat) - commits = repo.get_commits(since=since) - if len(list(commits)) == 1: - return [] - return reversed(list(commits)[:-1]) - - -def last_release(github: Github, repo: Repository, skip=True) -> Dict[str, str]: - """Return last release.""" - tag_sha = None - data = {} - tags = list(repo.get_tags()) - reg = "(v|^)?(\\d+\\.)?(\\d+\\.)?(\\*|\\d+)$" - _LOGGER.debug("Found tags: %s", tags) - if tags: - for tag in tags: - tag_name = tag.name - if re.match(reg, tag_name): - tag_sha = tag.commit.sha - if skip: - skip = False - continue - break - data["tag_name"] = tag_name - data["tag_sha"] = tag_sha - return data - - -def get_commits(github: Github, repo: Repository) -> str: - """Generate list of commits.""" - changes = "" - commits = new_commits(repo, last_release(github, repo)["tag_sha"]) - - for commit in commits: - msg = repo.get_git_commit(commit.sha).message - if "Bump version " in msg: - continue - if "Merge branch " in msg: - continue - if "Merge tag " in msg: - continue - if "Merge pull request " in msg: - continue - if "\n" in msg: - msg = msg.split("\n")[0] - changes += CHANGE.format( - line=msg, link=commit.html_url, author=commit.author.login - ) - - if changes == "": - changes = NOCHANGE - - return changes - - -def main(): - """Execute script.""" - parser = argparse.ArgumentParser( - description=f"Release notes generator. Version {VERSION}" - ) - parser.add_argument( - "-v", "--verbose", action="store_true", help="Enable debugging output.", - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Preview version bumping without running it.", - ) - parser.add_argument( - "--token", - help="Github token to access to repository.", - # required=True, - ) - parser.add_argument( - "--repo", - help="Github repository (default: %(default)s).", - default=subprocess.run( - ["git", "config", "--get", "remote.origin.url"], - stdout=subprocess.PIPE, - check=True, - ) - .stdout.decode("UTF-8") - .replace("https://github.com/", "") - .replace(".git", "") - .strip(), - ) - parser.add_argument( - "--update-release", help="Github release tag to update.", required=True, - ) - arguments = parser.parse_args() - - if arguments.verbose: - _LOGGER.setLevel(logging.DEBUG) - - if arguments.dry_run: - print("!!! Dry Run !!!") - - github = Github(arguments.token) - _LOGGER.debug("Repo: %s", arguments.repo) - repo = github.get_repo(arguments.repo) - version = arguments.update_release.replace("refs/tags/", "") - _LOGGER.debug("Tag: %s", version) - release = repo.get_release(version) - release.update_release( - name=version, - prerelease=release.prerelease, - draft=release.draft, - message=BODY.format( - repo=arguments.repo, version=version, changes=get_commits(github, repo), - ), - ) - - -if __name__ == "__main__": - main()