mirror of
https://github.com/zama-ai/concrete.git
synced 2026-02-09 03:55:04 -05:00
252 lines
9.7 KiB
Python
252 lines
9.7 KiB
Python
"""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}
|
|
version_infos = {
|
|
VersionInfo.parse(tag_name): tag_name
|
|
for tag_name in tags_by_name
|
|
if VersionInfo.isvalid(tag_name)
|
|
}
|
|
all_release_version_infos = {
|
|
version_info: tags_by_name[tag_name]
|
|
for version_info, tag_name in version_infos.items()
|
|
if 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)
|