diff --git a/Makefile b/Makefile index fa5b4b85c..12b7b600b 100644 --- a/Makefile +++ b/Makefile @@ -228,3 +228,9 @@ set_version: check_version_coherence: poetry run python ./script/make_utils/version_utils.py check-version .PHONY: check_version_coherence + +changelog: check_version_coherence + PROJECT_VER=($$(poetry version));\ + PROJECT_VER="$${PROJECT_VER[1]}";\ + poetry run python ./script/make_utils/changelog_helper.py > "CHANGELOG_$${PROJECT_VER}.md" +.PHONY: changelog diff --git a/poetry.lock b/poetry.lock index 8af1c6336..a7a3574a3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -595,7 +595,7 @@ qtconsole = "*" [[package]] name = "jupyter-client" -version = "7.0.5" +version = "7.0.6" description = "Jupyter protocol implementation and client libraries" category = "dev" optional = false @@ -1901,7 +1901,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = ">=3.8,<3.9" -content-hash = "783e41a9b79babbea7b986de2d4b901e2472e202613952e9881ecf340ff69d18" +content-hash = "b044859111d093c4bc499eed965c77062ebddab73f46f8b704edc801868696f5" [metadata.files] alabaster = [ @@ -2220,8 +2220,8 @@ jupyter = [ {file = "jupyter-1.0.0.zip", hash = "sha256:3e1f86076bbb7c8c207829390305a2b1fe836d471ed54be66a3b8c41e7f46cc7"}, ] jupyter-client = [ - {file = "jupyter_client-7.0.5-py3-none-any.whl", hash = "sha256:124a6e6979c38999d9153b1c4d1808c4c820a45066d5ed1857a5b59c04ffccb3"}, - {file = "jupyter_client-7.0.5.tar.gz", hash = "sha256:382aca66dcaf96d7eaaa6c546d57cdf8b3b1cf5bc1f2704c58a1d8d244f1163d"}, + {file = "jupyter_client-7.0.6-py3-none-any.whl", hash = "sha256:074bdeb1ffaef4a3095468ee16313938cfdc48fc65ca95cc18980b956c2e5d79"}, + {file = "jupyter_client-7.0.6.tar.gz", hash = "sha256:8b6e06000eb9399775e0a55c52df6c1be4766666209c22f90c2691ded0e338dc"}, ] jupyter-console = [ {file = "jupyter_console-6.4.0-py3-none-any.whl", hash = "sha256:7799c4ea951e0e96ba8260575423cb323ea5a03fcf5503560fa3e15748869e27"}, diff --git a/pyproject.toml b/pyproject.toml index 3d721fa38..d880b1809 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,9 +38,10 @@ py-cpuinfo = "^8.0.0" python-dotenv = "^0.19.0" sphinx-copybutton = "^0.4.0" nbmake = "^0.9" -python-semantic-release = "^7.19.2" +python-semantic-release = "7.19.2" semver = "^2.13.0" tomlkit = "^0.7.0" +GitPython = "^3.1.24" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/script/make_utils/changelog_helper.py b/script/make_utils/changelog_helper.py new file mode 100644 index 000000000..8adfa616b --- /dev/null +++ b/script/make_utils/changelog_helper.py @@ -0,0 +1,248 @@ +"""Tool to bypass the insane logic of semantic-release and generate changelogs we want""" + +import argparse +import subprocess +import sys +from collections import deque + +from git.repo import Repo +from semantic_release.changelog import markdown_changelog +from semantic_release.errors import UnknownCommitMessageStyleError +from semantic_release.settings import config, current_commit_parser +from semantic_release.vcs_helpers import get_repository_owner_and_name +from semver import VersionInfo + + +def log_msg(*args, file=sys.stderr, **kwargs): + """Shortcut to print to sys.stderr.""" + print(*args, file=file, **kwargs) + + +def strip_leading_v(version_str: str): + """Strip leading v of a version which is not SemVer compatible.""" + return version_str[1:] if version_str.startswith("v") else version_str + + +def get_poetry_project_version() -> VersionInfo: + """Run poetry version and get the project version""" + command = ["poetry", "version"] + poetry_version_output = subprocess.check_output(command, text=True) + return version_string_to_version_info(poetry_version_output.split(" ")[1]) + + +def raise_exception_or_print_warning(is_error: bool, message_body: str): + """Raise an exception if is_error is true else print a warning to stderr""" + msg_start = "Error" if is_error else "Warning" + msg = f"{msg_start}: {message_body}" + if is_error: + raise RuntimeError(msg) + log_msg(msg) + + +def version_string_to_version_info(version_string: str) -> VersionInfo: + """Convert git tag to VersionInfo.""" + return VersionInfo.parse(strip_leading_v(version_string)) + + +def generate_changelog(repo: Repo, from_commit_excluded: str, to_commit_included: str) -> dict: + """Recreate the functionality from semantic release with the from and to commits. + + Args: + repo (Repo): the gitpython Repo object representing your git repository + from_commit_excluded (str): the commit after which we want to collect commit messages for + the changelog + to_commit_included (str): the last commit included in the collected commit messages for the + changelog. + + Returns: + dict: the same formatted dict as the generate_changelog from semantic-release + """ + # Additional sections will be added as new types are encountered + changes: dict = {"breaking": []} + + rev = f"{from_commit_excluded}...{to_commit_included}" + + for commit in repo.iter_commits(rev): + hash_ = commit.hexsha + commit_message = ( + commit.message.replace("\r\n", "\n") + if isinstance(commit.message, str) + else commit.message.replace(b"\r\n", b"\n") + ) + try: + message = current_commit_parser()(commit_message) + if message.type not in changes: + log_msg(f"Creating new changelog section for {message.type} ") + changes[message.type] = [] + + # Capitalize the first letter of the message, leaving others as they were + # (using str.capitalize() would make the other letters lowercase) + formatted_message = message.descriptions[0][0].upper() + message.descriptions[0][1:] + if config.get("changelog_capitalize") is False: + formatted_message = message.descriptions[0] + + # By default, feat(x): description shows up in changelog with the + # scope bolded, like: + # + # * **x**: description + if config.get("changelog_scope") and message.scope: + formatted_message = f"**{message.scope}:** {formatted_message}" + + changes[message.type].append((hash_, formatted_message)) + + if message.breaking_descriptions: + # Copy breaking change descriptions into changelog + for paragraph in message.breaking_descriptions: + changes["breaking"].append((hash_, paragraph)) + elif message.bump == 3: + # Major, but no breaking descriptions, use commit subject instead + changes["breaking"].append((hash_, message.descriptions[0])) + + except UnknownCommitMessageStyleError as err: + log_msg(f"Ignoring UnknownCommitMessageStyleError: {err}") + + return changes + + +def main(args): + """Entry point""" + + repo = Repo(args.repo_root) + + sha1_to_tags = {tag.commit.hexsha: tag for tag in repo.tags} + + to_commit = repo.commit(args.to_ref) + log_msg(f"To commit: {to_commit}") + + to_tag = sha1_to_tags.get(to_commit.hexsha, None) + if to_tag is None: + raise_exception_or_print_warning( + is_error=args.to_ref_must_have_tag, + message_body=f"to-ref {args.to_ref} has no tag associated to it", + ) + + to_version = ( + get_poetry_project_version() + if to_tag is None + else version_string_to_version_info(to_tag.name) + ) + log_msg(f"Project version {to_version} taken from tag: {to_tag is not None}") + + from_commit = None + if args.from_ref is None: + tags_by_name = {strip_leading_v(tag.name): tag for tag in repo.tags} + all_release_version_infos = { + version_info: tags_by_name[tag_name] + for tag_name in tags_by_name + if VersionInfo.isvalid(tag_name) + and (version_info := VersionInfo.parse(tag_name)) + and version_info.prerelease is None + } + log_msg(f"All release versions {all_release_version_infos}") + + versions_before_project_version = [ + version_info for version_info in all_release_version_infos if version_info < to_version + ] + if len(versions_before_project_version) > 0: + highest_version_before_current_version = max(versions_before_project_version) + highest_version_tag = all_release_version_infos[highest_version_before_current_version] + from_commit = highest_version_tag.commit + else: + # No versions before, get the initial commit reachable from to_commit + # from https://stackoverflow.com/a/48232574 + last_element_extractor = deque(repo.iter_commits(to_commit), 1) + from_commit = last_element_extractor.pop() + else: + from_commit = repo.commit(args.from_ref) + + log_msg(f"From commit: {from_commit}") + ancestor_commit = repo.merge_base(to_commit, from_commit) + assert len(ancestor_commit) == 1 + ancestor_commit = ancestor_commit[0] + log_msg(f"Common ancestor: {ancestor_commit}") + + if ancestor_commit != from_commit: + do_not_change_from_ref = args.do_not_change_from_ref and args.from_ref is not None + raise_exception_or_print_warning( + is_error=do_not_change_from_ref, + message_body=( + f"the ancestor {ancestor_commit} for {from_commit} and {to_commit} " + f"is not the same commit as the commit for '--from-ref' {from_commit}." + ), + ) + + ancestor_tag = sha1_to_tags.get(ancestor_commit.hexsha, None) + if ancestor_tag is None: + raise_exception_or_print_warning( + is_error=args.ancestor_must_have_tag, + message_body=( + f"the ancestor {ancestor_commit} for " f"{from_commit} and {to_commit} has no tag" + ), + ) + + ancestor_version_str = ( + None if ancestor_tag is None else str(version_string_to_version_info(ancestor_tag.name)) + ) + + log_msg( + f"Collecting commits from \n{ancestor_commit} " + f"(tag: {ancestor_tag} - parsed version " + f"{str(ancestor_version_str)}) to \n{to_commit} " + f"(tag: {to_tag} - parsed version {str(to_version)})" + ) + + log_dict = generate_changelog(repo, ancestor_commit.hexsha, to_commit.hexsha) + + owner, name = get_repository_owner_and_name() + md_changelog = markdown_changelog( + owner, + name, + str(to_version), + log_dict, + header=True, + previous_version=ancestor_version_str, + ) + + print(md_changelog) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser("Changelog helper", allow_abbrev=False) + + parser.add_argument("--repo-root", type=str, default=".", help="Path to the repo root") + parser.add_argument( + "--to-ref", + type=str, + help="Specify the git ref-like string (sha1, tag, HEAD~, etc.) that will mark the LAST " + "included commit of the changelog. If this is not specified, the current project version " + "will be used to create a changelog with the current commit as last commit.", + ) + parser.add_argument( + "--from-ref", + type=str, + help="Specify the git ref-like string (sha1, tag, HEAD~, etc.) that will mark the commit " + "BEFORE the first included commit of the changelog. If this is not specified, the most " + "recent actual release tag (no pre-releases) before the '--to-ref' argument will be used. " + "If the tagged commit is not an ancestor of '--to-ref' then the most recent common ancestor" + "(git merge-base) will be used unless '--do-not-change-from-ref' is specified.", + ) + parser.add_argument( + "--ancestor-must-have-tag", + action="store_true", + help="Set if the used ancestor must have a tag associated to it.", + ) + parser.add_argument( + "--to-ref-must-have-tag", + action="store_true", + help="Set if '--to-ref' must have a tag associated to it.", + ) + parser.add_argument( + "--do-not-change-from-ref", + action="store_true", + help="Specify to prevent selecting a different '--from-ref' than the one specified in cli. " + "Will raise an exception if '--from-ref' is not a suitable ancestor for '--to-ref' and " + "would otherwise use the most recent common ancestor (git merge-base) as '--from-ref'.", + ) + + cli_args = parser.parse_args() + main(cli_args) diff --git a/script/make_utils/version_utils.py b/script/make_utils/version_utils.py index 05e0ff3d2..58150f36f 100644 --- a/script/make_utils/version_utils.py +++ b/script/make_utils/version_utils.py @@ -13,9 +13,7 @@ from semver import VersionInfo def strip_leading_v(version_str: str): """Strip leading v of a version which is not SemVer compatible.""" - if version_str and version_str[0] == "v": - return version_str[1:] - return version_str + return version_str[1:] if version_str.startswith("v") else version_str def islatest(args):