Skip to content

Instantly share code, notes, and snippets.

@medecau
Created January 6, 2023 21:47
Show Gist options
  • Save medecau/da827533390abcc32c91f11c4d5bb34d to your computer and use it in GitHub Desktop.
Save medecau/da827533390abcc32c91f11c4d5bb34d to your computer and use it in GitHub Desktop.
A CHANGELOG bootstrap script
import argparse
import re
import subprocess
import shlex
import itertools
# this will be used for SUBJECT_CATEGORIES
def insensitive(pattern):
"""Return a case-insensitive regex pattern."""
return re.compile(pattern, re.IGNORECASE | re.VERBOSE)
# map categories to regex patterns
# the order of the categories is the order in which they will be printed
# the "Other" category is a catch-all and should always be last
SUBJECT_CATEGORIES = {
# these are verbose regex patterns, they are easier to read and write
# the keyword is used to determine the category of the commit
"Security": insensitive(
r"""
.* # match anything before the keyword
(?: # match any of the following keywords
vuln
|vulnerabl(e|ity|ities)
|security
)
.* # match anything after the keyword
"""
),
"Removed": insensitive(
r"""
.*
(?: # match any of the following keywords
remove(?:|d|s)
|delete(?:|d|s)
|drop(?:|ped|s)
|revert(?:|ed|s)
|undo(?:|es)
|disable(?:|d|s)
)
.*
"""
),
"Added": insensitive(
r"""
.*
(?: # match any of the following keywords
add(?:|ed|s)
|new
|implement(?:|ed|s)
|create(?:|d|s)
|feat(?:|ure|ures)
)
.*
"""
),
"Changed": insensitive(
r"""
.*
(?: # match any of the following keywords
refactor(?:|ed|es|ing)
|update(?:|d|s)
|change(?:|d|s)
|modif(?:y|ied|ies)
)
.*
"""
),
"Deprecated": insensitive(
r"""
.*
(?: # match any of the following keywords
deprecate(?:|d|s)
)
.*
"""
),
"Fixed": insensitive(
r"""
.*
(?: # match any of the following keywords
fix(?:|ed|es)
)
.*
"""
),
"Tests": insensitive(
r"""
.*
(?: # match any of the following keywords
test(?:|ed|ing|s)
)
.*
"""
),
"Documentation": insensitive(
r"""
.*
(?: # match any of the following keywords
doc(?:|ument|s)
|readme
)
.*
"""
),
"Chore": insensitive(
r"""
.*
(?: # match any of the following keywords
chore(?:|s)
|bump(?:|s)
)
.*
"""
),
# catch-all category
"Other": re.compile(r".*"),
}
def run_command(command):
"""Run a command and return the output."""
return subprocess.check_output(shlex.split(command), text=True).strip()
def get_commit_subject_lines(rev_interval):
"""Get the subject lines for a given revision interval."""
return run_command(f"git log --format='%s' {rev_interval}").splitlines()
def get_revision_date(tag):
"""Get the date of a tag."""
return run_command(f"git log -1 --format=%cd --date=short {tag}")
def sort_commits(commits):
"""Sort commits by category priority."""
# get the categories in priority order
categories_by_priority = list(SUBJECT_CATEGORIES.keys())
# sort the commits by category priority
return sorted(
commits,
key=lambda cat: categories_by_priority.index(classify_commit_subject(cat)),
)
def classify_commit_subject(subject):
"""Classify a commit subject into a category."""
for category, regex in SUBJECT_CATEGORIES.items():
if regex.match(subject):
return category
return "other"
def print_rev_changelog(rev, rev_interval):
"""Print the changelog for a given revision interval."""
commits = sort_commits(get_commit_subject_lines(rev_interval))
grouped_commits = itertools.groupby(commits, classify_commit_subject)
print(f"\n# {get_revision_date(rev)}")
for category, subjects in grouped_commits:
print(f"\n## {category}\n")
for subject in subjects:
print(f"- {subject}")
def get_tags():
"""Get the tags in reverse chronological order."""
return run_command("git tag --sort=creatordate").splitlines()[::-1]
def main(mode):
"""Get the changelog for the current repository."""
tags = get_tags()
# get the changelog for a revision interval
if mode == "latest":
print_rev_changelog("HEAD", f"{tags[0]}..HEAD")
return
# mode is 'bootstrap'
# we won't do this because you can do it with scriv now
# # get the changelog for HEAD
# print_rev_changelog("HEAD", f"{tags[0]}..HEAD")
# get the changelog for the rest of the tags
for idx in range(len(tags) - 1):
t1, t2 = tags[idx], tags[idx + 1]
print_rev_changelog(t1, f"{t2}..{t1}")
# get the changelog for the oldest tag
print_rev_changelog(tags[-1], tags[-1])
if __name__ == "__main__":
tags = get_tags()
# setup argparse parser
parser = argparse.ArgumentParser(
description="Get the changelog for the current repository."
)
# add mode argument
parser.add_argument(
"mode",
nargs="?",
default="latest",
choices=["latest", "bootstrap"],
help="The mode to run the script in.",
)
# parse the arguments
args = parser.parse_args()
main(args.mode)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment