Skip to content

Instantly share code, notes, and snippets.

@onjin
Last active October 25, 2023 09:57
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save onjin/9dfa2406c2d2becbfe3b6a048461e331 to your computer and use it in GitHub Desktop.
Save onjin/9dfa2406c2d2becbfe3b6a048461e331 to your computer and use it in GitHub Desktop.
changelog.md generate from conventional commits git log
#!/usr/bin/env python
# NOTE: use newer & fixed version as package: https://pypi.org/project/mkchangelog/
from __future__ import (absolute_import, division, print_function,
unicode_literals)
import argparse
import json
import logging
import os
import re
import subprocess
import sys
import unittest
from collections import Counter, defaultdict, namedtuple
from datetime import datetime
from distutils.util import strtobool
from io import StringIO
from itertools import groupby, tee
from operator import attrgetter
def install_package(package: str):
subprocess.check_call([sys.executable, "-m", "pip", "install", package])
try:
import consolemd
except ImportError:
install_package('consolemd')
try:
import requests
except ImportError:
install_package('requests')
try:
import semver
except ImportError:
install_package('semver')
try:
from git import Repo
except ImportError:
install_package('gitpython')
logger = logging.getLogger(__file__)
env = os.getenv
COMMANDS = {}
ALIASES = {}
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
# Regexp for commit subject
CC_REGEXP = re.compile(r"^(?P<revert>revert: |Revert \")?(?P<type>[a-z0-9]+)(?P<scope>\([ \w]+\))?: (?P<title>.*)$")
# Regex for BREAKING CHANGE section
BC_REGEXP = re.compile(
r"(?s)BREAKING CHANGE:(?P<breaking_change>.*?)(?:(?:\r*\n){2})",
re.MULTILINE,
)
# Regex for Requires trailer for manual actions requiremenets
REQ_REGEXP = re.compile(
r"(?s)Requires:(?P<requires>.*?)(?:(?:\r*\n){2})",
re.MULTILINE,
)
REFERENCE_ACTIONS = ["Closes", "Relates"]
REF_REGEXP = re.compile(
r"^\s+?(?P<action>{actions})\s+(?P<refs>.*)$".format(actions="|".join(REFERENCE_ACTIONS)),
re.MULTILINE,
)
TYPES = {
"build": "Build",
"chore": "Chore",
"ci": "CI",
"dev": "Dev",
"docs": "Docs",
"feat": "Features",
"fix": "Fixes",
"perf": "Performance",
"refactor": "Refactors",
"style": "Style",
"test": "Test",
"translations": "Translations",
}
# default priority is 10 so we need only
ORDERING = {
"feat": 10,
"fix": 20,
"refactor": 30,
}
SLACK_MARKDOWN_REGEX_REPLACE = (
(re.compile("^- ", flags=re.M), "• "),
(re.compile("^\* ", flags=re.M), "• "),
(re.compile("^ - ", flags=re.M), " ◦ "),
(re.compile("^ - ", flags=re.M), " ⬩ "), # ◆
(re.compile("^ - ", flags=re.M), " ◽ "),
(re.compile("^#+ (.+)$", flags=re.M), r"*\1*"),
(re.compile("\*\*"), "*"),
)
CommitReference = namedtuple("CommitReference", "action refs")
LogLine = namedtuple("LogLine", "subject message revert type scope title references breaking_change")
MatchedLine = namedtuple("MatchedLine", "log groups")
Version = namedtuple("Version", "name date semver")
def yes_or_no(question, default="no"):
"""Ask question and wait for yes or no decision.
Args:
question (str): question to display
default (str, optional): default answer
Returns:
bool - according to user answer
"""
if default is None:
prompt = " [y/n] "
elif default == "yes":
prompt = " [Y/n] "
elif default == "no":
prompt = " [y/N] "
else:
raise ValueError("invalid default answer: '%s'" % default)
while True:
sys.stdout.write(question + prompt)
choice = input().lower()
if default is not None and choice == "":
return strtobool(default)
try:
return strtobool(choice)
except ValueError:
sys.stdout.write("Please respond with 'yes' or 'no' " "(or 'y' or 'n').\n")
def group_commits_by_type(commits):
return groupby(
sorted(commits, key=lambda x: ORDERING.get(x.type, 50)),
attrgetter("type"),
)
def get_references_from_msg(msg):
"""Get references from commit message
Args:
msg (str): commit message
Returns:
dict[set] - dictionary with references
example:
{
"Closes": set("ISS-123", "ISS-321")
}
"""
result = REF_REGEXP.findall(msg.decode('utf-8'))
if not result:
return None
refs = defaultdict(set)
for line in result:
action, value = line
for ref in value.split(","):
refs[action].add(ref.strip())
return refs
def markdown2slack(markdown):
"""Format regular markdown to mardownish format of Slack
Args:
markdown (str): markdown to reformat
Returns:
str - reformated markdown
"""
for regex, replacement in SLACK_MARKDOWN_REGEX_REPLACE:
markdown = regex.sub(replacement, markdown)
return markdown
def add_stdout_handler(logger, verbosity):
"""Adds stdout handler with given verbosity to logger.
Args:
logger (Logger) - python logger instance
verbosity (int) - target verbosity
1 - ERROR
2 - INFO
3 - DEBUG
Usage:
add_stdout_handler(logger=logger, verbosity=3)
"""
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter(("%(asctime)s - %(name)s - %(levelname)s - %(message)s")))
V_MAP = {1: logging.ERROR, 2: logging.INFO, 3: logging.DEBUG}
level = V_MAP.get(verbosity, 1)
handler.setLevel(level)
logger.addHandler(handler)
logger.setLevel(level)
class bcolors:
HEADER = "\033[95m"
OKBLUE = "\033[94m"
OKGREEN = "\033[92m"
WARNING = "\033[93m"
FAIL = "\033[91m"
ENDC = "\033[0m"
BOLD = "\033[1m"
UNDERLINE = "\033[4m"
def bprint(text, *args, **kwargs):
"""Print helper to print colored texts.
Just add bcolors.ENDC to given text
Args:
text (str): strint to print
args (tuple): optional args
kwargs (dict): optional kwargs
"""
print(text + bcolors.ENDC, *args, **kwargs)
def v2s(prefix, version):
"""Convert version name to semantic version
Args:
prefix (str): version tags prefix
version (str): version name to convert
Returns:
semver.Semver - semantic version object
"""
return semver.parse_version_info(version[len(prefix) :])
def get_git_versions(tag_prefix):
"""Return versions lists
Args:
tag_prefix (str): versions tags prefix
Returns:
list(Version(name, date))
"""
repo = Repo(".")
versions = [
Version(
name=tag.name,
date=tag.commit.authored_datetime,
semver=v2s(tag_prefix, tag.name),
)
for tag in repo.tags
if tag.name.startswith(tag_prefix)
]
return sorted(versions, key=lambda v: v[1], reverse=True)
def get_last_version(tag_prefix):
"""Return last bumped bersion
Args:
tag_prefix (str): version tags prefix
Returns:
Version(name, date)
"""
versions = get_git_versions(tag_prefix=tag_prefix)
if not len(versions):
return None
return versions[0]
def get_next_version(tag_prefix, current_version, commits):
"""Return next version or None if not available
Args:
tag_prefix (str): version tags prefix
current_version (str): current version, f.i. v1.1.1
commits (list): list of LogLine containing commits till current version
Returns:
Version | None - next version object or None if no new version is available.
"""
next_version = v2s(tag_prefix, current_version)
current_version = str(next_version)
# count types {{{
types = Counter()
for commit in commits:
types[commit.type] += 1
if commit.breaking_change:
types["breaking_change"] += 1
# count types }}}
# bump version {{{
breaking_change = types.get("breaking_change", None)
if breaking_change and breaking_change > 0:
next_version = next_version.bump_major()
elif "feat" in types.keys():
next_version = next_version.bump_minor()
elif "fix" in types.keys():
next_version = next_version.bump_patch()
# bump version }}}
# make next version {{{
if semver.compare(current_version, str(next_version)) == 0:
return None
name = "{prefix}{version}".format(prefix=tag_prefix, version=str(next_version))
return Version(name=name, date=datetime.now(), semver=next_version)
def get_git_log(max_count=1000, rev=None, types=None):
"""Return git log parsed using Conventional Commit format.
Args:
max_count (int, optional): max lines to parse
rev (str, optional): git rev as branch name or range
"""
repo = Repo(".")
commits = repo.iter_commits(max_count=max_count, no_merges=True, rev=rev)
cc_commits = (MatchedLine(c, CC_REGEXP.match(c.summary).groupdict()) for c in commits if CC_REGEXP.match(c.summary))
messages = (
LogLine(
subject=l.log.summary.encode(l.log.encoding),
message=l.log.message.encode(l.log.encoding),
revert=True if l.groups["revert"] else False,
type=l.groups["type"],
scope=l.groups["scope"][1:-1] if l.groups["scope"] else l.groups["scope"],
title=l.groups["title"],
references=get_references_from_msg(l.log.message.encode(l.log.encoding)),
breaking_change=(BC_REGEXP.findall(l.log.message) or [None])[0],
)
for l in cc_commits
)
if types and not "all" in types:
log_types = types
else:
log_types = TYPES.keys()
messages = (line for line in messages if line.type in log_types)
return messages
def create_tag(name, message):
repo = Repo(".")
repo.create_tag(name, message=message)
def git_commit(files, message):
repo = Repo(".")
index = repo.index
index.add(files)
index.commit(message)
def get_markdown_version(from_version, to_version, commit_types, max_count, output=None):
"""Return single version changelog as markdown
Args:
from_version (Version): starting version
to_version (Version|None): ending version
commit_types (list[str]): commit types to include in changelog
max_count (int): max number of commits to parse
output (buf, optional): buffer to write result to
"""
if output is None:
output = StringIO()
if not from_version:
from_version = "HEAD"
if to_version:
rev = "{from_version}...{to_version}".format(from_version=from_version, to_version=to_version)
else:
rev = "{from_version}".format(from_version=from_version)
logger.debug("getting log for rev {rev} with types {types}".format(rev=rev, types=commit_types))
lines = list(get_git_log(max_count=max_count, rev=rev, types=commit_types))
if lines:
# separate reverts, breaking changes {{{
commits, reverts, breaking_changes = tee(lines, 3)
commits = filter(lambda l: l.revert is False, commits)
reverts = list(filter(lambda l: l.revert is True, reverts))
breaking_changes = list(filter(lambda l: l.breaking_change, breaking_changes))
# separate reverts, breaking changes }}}
# group commits by type {{{
groups = group_commits_by_type(commits)
# group commits by type }}}
for group_name, rows in groups:
output.write("### {group}\n".format(group=TYPES.get(group_name, group_name.capitalize())))
output.write("\n")
for row in sorted(rows, key=lambda i: i.scope if i.scope else ""):
if row.scope:
output.write("* **{msg.scope}:** {msg.title}".format(msg=row))
else:
output.write("* {msg.title}".format(msg=row))
if row.references and "Closes" in row.references:
output.write(" (closes {refs})".format(refs=", ".join(row.references["Closes"])))
output.write("\n")
output.write("\n")
if reverts:
output.write("### Reverts\n")
output.write("\n")
for row in reverts:
output.write("* {msg.subject}\n".format(msg=row))
output.write("\n")
if breaking_changes:
output.write("### BREAKING CHANGES\n")
output.write("\n")
for row in breaking_changes:
msg = row.breaking_change.replace("\n", " ")
if row.scope:
output.write("* **{scope}**: {breaking_change}\n".format(scope=row.scope, breaking_change=msg))
else:
output.write("* {breaking_change}\n".format(breakin=msg))
output.write("\n")
output.write("\n")
return output
def get_markdown_changelog(header, tag_prefix, commit_types, max_count, head_name=None):
"""Generate changelog in markdown format from git history
Args:
header (str): header text
tag_prefix (str): version tags prefix
commit_types (list): list of commit types to include in changelog
max_count (int): max commit lines to parse
head_name (str|optional): custom HEAD name, used to generate for next version
Returns:
str - changelog in markdown format
"""
output = StringIO()
output.write("# {header}\n\n".format(header=header))
versions = get_git_versions(tag_prefix=tag_prefix)
logger.debug("got versions: {}".format(versions))
if not versions:
return output.read()
# get unreleased changes {{{
from_version = Version(
name="HEAD",
date=datetime.now(),
semver=None,
)
to_version = versions.pop(0)
output.write(
"## {version} ({released})\n".format(
version=head_name or from_version.name,
released=from_version.date.strftime(DATE_FORMAT),
)
)
output.write("\n")
get_markdown_version(
from_version=from_version.name,
to_version=to_version.name,
commit_types=commit_types,
max_count=max_count,
output=output,
)
# get unreleased changes }}}
from_version = to_version
for to_version in versions:
output.write(
"## {version} ({released})\n".format(
version=from_version.name,
released=from_version.date.strftime(DATE_FORMAT),
)
)
output.write("\n")
get_markdown_version(
from_version=from_version.name,
to_version=to_version.name,
commit_types=commit_types,
max_count=max_count,
output=output,
)
from_version = to_version
output.write(
"## {version} ({released})\n".format(
version=from_version.name,
released=from_version.date.strftime(DATE_FORMAT),
)
)
output.write("\n")
get_markdown_version(
from_version=from_version.name,
to_version=None,
commit_types=commit_types,
max_count=max_count,
output=output,
)
output.seek(0)
return output.read()
def register_command(target_class):
"""Register command for it's name and aliases
Args:
target_class (Command): command to register
"""
COMMANDS[target_class.name] = target_class
ALIASES[target_class.name] = target_class
for alias in target_class.aliases:
ALIASES[alias] = target_class
class MetaRegistry(type):
"""Meta class for Command subclasses to auto register them."""
def __new__(meta, name, bases, class_dict):
cls = type.__new__(meta, name, bases, class_dict)
if name != "Command" and name not in COMMANDS:
register_command(cls)
return cls
class Command(metaclass=MetaRegistry):
"""Base Command class."""
name = "base"
aliases = []
@staticmethod
def add_arguments(parser):
# command might not require any sub arguments
pass
def __init__(self, options):
self.options = options
def execute(self):
raise NotImplementedError()
class TestsCommand(Command):
"""Run tests."""
name = "tests"
aliases = ["t"]
def execute(self):
argv = [sys.argv[0]]
unittest.main(module="__main__", argv=argv, verbosity=self.options.verbosity)
class BumpCommand(Command):
"""Manage version."""
name = "bump"
aliases = ["b"]
@staticmethod
def add_arguments(parser):
parser.add_argument(
"--header",
action="store",
help="changelog header, default 'Changelog'",
default="Changelog",
)
parser.add_argument(
"-m",
"--max-count",
action="store",
help="limit parsed lines, default 1000",
default=1000,
)
parser.add_argument(
"-o",
"--output",
action="store",
help="output changelog file; default CHANGELOG.md",
default="CHANGELOG.md",
)
parser.add_argument(
"-p",
"--prefix",
action="store",
help="version tag prefix; default 'v'",
default="v",
)
parser.add_argument(
"-t",
"--types",
action="store",
help="limit types",
nargs="+",
type=str,
default=["all"],
)
def execute(self):
version = get_last_version(tag_prefix=self.options.prefix)
bprint(
"Current version: {c.OKBLUE}{version.name} ({date})".format(
c=bcolors, version=version, date=version.date.strftime(DATE_FORMAT)
)
)
commits = get_git_log(
max_count=self.options.max_count,
rev="HEAD...{version}".format(version=version.name),
types=self.options.types,
)
next_version = get_next_version(
tag_prefix=self.options.prefix,
current_version=version.name,
commits=commits,
)
if next_version:
bprint(
"Next version: {c.OKGREEN}{version} ({date})".format(
c=bcolors,
version=next_version.name,
date=next_version.date.strftime(DATE_FORMAT),
)
)
else:
print("--> No next version available")
return
if yes_or_no(
"--> Show next version changelog?",
default="no",
):
output = StringIO()
get_markdown_version(
from_version="HEAD",
to_version=version.name,
commit_types=self.options.types,
max_count=self.options.max_count,
output=output,
)
output.seek(0)
renderer = consolemd.Renderer(style_name="native")
renderer.render(output.read())
if not yes_or_no(
"--> Generate {changelog} and tag version?".format(changelog=self.options.output),
default="no",
):
bprint("{c.WARNING}Exiting".format(c=bcolors))
return
# generate changelog for current version {{{
bprint("Generating: {c.BOLD}{c.OKGREEN}{output}".format(c=bcolors, output=self.options.output))
with open(self.options.output, "w") as output:
output.write(
get_markdown_changelog(
header=self.options.header,
tag_prefix=self.options.prefix,
commit_types=self.options.types,
max_count=self.options.max_count,
head_name=next_version.name,
)
)
bprint("Commiting: {c.BOLD}{c.OKGREEN}{output}".format(c=bcolors, output=self.options.output))
# commit CHANGELOG.md
git_commit(
files=[self.options.output],
message="chore(changelog): write {filename} for version {version}".format(
filename=self.options.output, version=next_version.name
),
)
# generate changelog for current version }}}
# tag version {{{
bprint("Creating tag: {c.BOLD}{c.OKGREEN}{version}".format(c=bcolors, version=next_version.name))
# create tag with "chore(version): new version v1.1.1"
create_tag(
name=next_version.name,
message="chore(version): bump version {version}".format(version=next_version.name),
)
# tag version }}}
class GenerateCommand(Command):
"""Manage version."""
name = "generate"
aliases = ["g", "gen"]
@staticmethod
def add_arguments(parser):
parser.add_argument(
"-c",
"--cli",
action="store_true",
help="mark output as CLI (colored markdown)",
default=False,
)
parser.add_argument(
"--head-name",
action="store",
help="custom unreleased version name",
default=None,
)
parser.add_argument(
"--header",
action="store",
help="changelog header, default 'Changelog'",
default="Changelog",
)
parser.add_argument(
"-m",
"--max-count",
action="store",
help="limit parsed lines, default 1000",
default=1000,
)
parser.add_argument(
"-p",
"--prefix",
action="store",
help="version tag prefix; default 'v'",
default="v",
)
parser.add_argument(
"-t",
"--types",
action="store",
help="limit types",
nargs="+",
type=str,
default=["fix", "feat"],
)
parser.add_argument(
"--slack-channel",
action="store",
help="send to slack channel",
default=None,
)
def execute(self):
changelog = get_markdown_changelog(
header=self.options.header,
tag_prefix=self.options.prefix,
commit_types=self.options.types,
max_count=self.options.max_count,
head_name=self.options.head_name,
)
if self.options.cli:
renderer = consolemd.Renderer(style_name="native")
renderer.render(changelog)
else:
print(changelog)
if self.options.slack_channel:
bprint("\n{c.OKGREEN}Sending to slack {channel}".format(c=bcolors, channel=self.options.slack_channel))
response = requests.post(
env("SLACK_WEBHOOK_URL"),
data=json.dumps(
{
"channel": self.options.slack_channel,
"text": markdown2slack(changelog),
"mrkdwn": True,
}
),
)
bprint(response.reason)
class ChangesCommand(Command):
"""Show changes between versions."""
name = "changes"
aliases = [
"c",
]
@staticmethod
def add_arguments(parser):
parser.add_argument(
"--header",
action="store",
help="display header, default 'Changes'",
default="Changes",
)
parser.add_argument(
"-c",
"--cli",
action="store_true",
help="mark output as CLI (colored markdown)",
default=False,
)
parser.add_argument(
"-m",
"--max-count",
action="store",
help="limit parsed lines, default 1000",
default=1000,
)
parser.add_argument(
"--rev-from",
action="store",
help="limit newest revision (tag/hash); default HEAD",
default="HEAD",
)
parser.add_argument(
"--rev-to",
action="store",
help="limit oldest revision (tag/hash)",
default=None,
)
parser.add_argument(
"-t",
"--types",
action="store",
help="limit types",
nargs="+",
type=str,
default=["fix", "feat"],
)
parser.add_argument(
"--slack-channel",
action="store",
help="send to slack channel",
default=None,
)
def execute(self):
output = StringIO()
output.write("# {header}\n".format(header=self.options.header))
output.write("\n")
output.write(
"## {from_version} {to_version}\n".format(
from_version=self.options.rev_from,
to_version=self.options.rev_to or "",
)
)
output.write("\n")
# header=self.options.header,
get_markdown_version(
from_version=self.options.rev_from,
to_version=self.options.rev_to,
commit_types=self.options.types,
max_count=self.options.max_count,
output=output,
)
output.seek(0)
if self.options.cli:
import consolemd
renderer = consolemd.Renderer(style_name="native")
renderer.render(output.read())
else:
print(output.read())
if self.options.slack_channel:
bprint("\n{c.OKGREEN}Sending to slack {channel}".format(c=bcolors, channel=self.options.slack_channel))
response = requests.post(
env("SLACK_WEBHOOK_URL"),
data=json.dumps(
{
"channel": self.options.slack_channel,
"text": markdown2slack(output.read()),
"mrkdwn": True,
}
),
)
bprint(response.reason)
class AliasedSubParsersAction(argparse._SubParsersAction):
"""Custom subparser action to add support for aliasing commands."""
old_init = staticmethod(argparse._ActionsContainer.__init__)
@staticmethod
def _containerInit(self, description, prefix_chars, argument_default, conflict_handler):
AliasedSubParsersAction.old_init(self, description, prefix_chars, argument_default, conflict_handler)
self.register("action", "parsers", AliasedSubParsersAction)
class _AliasedPseudoAction(argparse.Action):
def __init__(self, name, aliases, help):
dest = name
if aliases:
dest += " (%s)" % ",".join(aliases)
sup = super(AliasedSubParsersAction._AliasedPseudoAction, self)
sup.__init__(option_strings=[], dest=dest, help=help)
def add_parser(self, name, **kwargs):
aliases = kwargs.pop("aliases", [])
parser = super(AliasedSubParsersAction, self).add_parser(name, **kwargs)
# Make the aliases work.
for alias in aliases:
self._name_parser_map[alias] = parser
# Make the help text reflect them, first removing old help entry.
if "help" in kwargs:
help = kwargs.pop("help")
self._choices_actions.pop()
pseudo_action = self._AliasedPseudoAction(name, aliases, help)
self._choices_actions.append(pseudo_action)
return parser
# override argparse to register new subparser action by default
argparse._ActionsContainer.__init__ = AliasedSubParsersAction._containerInit
def get_command_class(input):
"""Return command class for given input
Args:
input (str): input command line
Returns:
Command - matching command
"""
return ALIASES.get(input)
def main():
prog = "changelog"
description = "Manage CHANGELOG and versions"
parser = argparse.ArgumentParser(prog=prog, description=description)
parser.add_argument(
"-v",
"--verbosity",
action="store",
default=1,
help="verbosity level, 1 (default), 2 or 3)",
)
subparsers = parser.add_subparsers(title="command", dest="command")
for cmd in COMMANDS.values():
subparser = subparsers.add_parser(name=cmd.name, help=cmd.__doc__, aliases=cmd.aliases)
cmd.add_arguments(subparser)
options = parser.parse_args()
add_stdout_handler(logger, int(options.verbosity))
# find command using name or alias and create instance with passed arg options
command_class = get_command_class(options.command)
if command_class:
command = command_class(options)
command.execute()
else:
parser.print_help(sys.stderr)
class Tests(unittest.TestCase):
def test_commit_subject_regexp(self):
# Given - feature with scope
summary = "feat(admin): super feature landed"
# When - matched against summary regexp
result = CC_REGEXP.match(summary)
# Then - commit is parsed properly
assert result.groups()[0] == None, result.groups() # revert status
assert result.groups()[1] == "feat", result.groups() # type
assert result.groups()[2] == "(admin)", result.groups() # scope
assert result.groups()[3] == "super feature landed", result.groups() # title
# And - named groups also works
assert result.groupdict()["revert"] == None, result.groupdict() # revert status
assert result.groupdict()["type"] == "feat", result.groupdict() # type
assert result.groupdict()["scope"] == "(admin)", result.groupdict() # scope
assert result.groupdict()["title"] == "super feature landed", result.groupdict() # title
def test_body_and_footer_references_regexp(self):
# Given - msg with body and footer
msg = b"""feat(admin): asdfasdfsdf
body someid
Closes ISS-123, ISS-432
Relates ISS-223, ISS-232
"""
# When - matched against full commit regexp
result = REF_REGEXP.findall(msg.decode('utf-8'))
# Then - we got all footer references
assert result is not None, REF_REGEXP.pattern
assert len(result) == 2
assert result[0][0] == "Closes"
assert result[0][1] == "ISS-123, ISS-432", result[0]
assert result[1][0] == "Relates"
assert result[1][1] == "ISS-223, ISS-232", result[1]
def test_only_footer_references_regexp(self):
# Given - msg with no body and footer
msg = b"""feat(admin): asdfasdfsdf
Closes ISS-123, ISS-432
Relates ISS-223, ISS-232
"""
# When - matched against full commit regexp
result = REF_REGEXP.findall(msg.decode('utf-8'))
# Then - we got all footer references
assert result is not None, REF_REGEXP.pattern
assert len(result) == 2
assert result[0][0] == "Closes"
assert result[0][1] == "ISS-123, ISS-432", result[0]
assert result[1][0] == "Relates"
assert result[1][1] == "ISS-223, ISS-232", result[1]
def test_get_references_from_msg(self):
# Given - msg with references
msg = b"""feat(admin): asdfasdfsdf
Closes ISS-123, ISS-432
Relates ISS-223, ISS-232
Closes ISS-333
Relates ISS-444
"""
# When - we get parsed references
refs = get_references_from_msg(msg)
# Then - we get dict with actions as keys and refs as values list
assert sorted(refs["Closes"]) == ["ISS-123", "ISS-333", "ISS-432"], refs
assert sorted(refs["Relates"]) == ["ISS-223", "ISS-232", "ISS-444"], refs
def test_gather_breaking_changes(self):
# Given - breaking changes in footer
msg = b"""feat(admin): asdfasdfsdf
some body
BREAKING CHANGE:
someone
broken
here
Closes ISS-123, ISS-432
Relates ISS-223, ISS-232
"""
# When - matched against regexp
result = BC_REGEXP.findall(msg.decode('utf-8'))
# Then - we got all rows
assert result is not None
assert result[0] == "\n someone\n broken\n here"
def test_next_version_for_breaking_changes(self):
# Given - current version and commits with breaking changes
current_version = "v1.2.3"
commits = [
LogLine(
subject="feat(api): some",
message="feat(api): some\n\nBREAKING CHANGES: API broken\nbadly\n\n",
revert=False,
type="feat",
scope="(api)",
title="some",
references=[],
breaking_change=["API broken"],
)
]
# When - we get next version
version = get_next_version("v", current_version, commits)
# Then - major segment is bumped
assert version.name == "v2.0.0", version
if __name__ == "__main__":
main()
# vim:filetype=python
@onjin
Copy link
Author

onjin commented Oct 25, 2023

This gist was fixed, updated and coverted into package:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment