Skip to content

Commit 0095844

Browse files
committed
Script for generating changelog file
1 parent 0db50a7 commit 0095844

File tree

9 files changed

+220
-76
lines changed

9 files changed

+220
-76
lines changed

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ pytest-mock==3.14.1
3333
wrapt==1.17.2
3434
botocore==1.38.33
3535
boto3==1.38.33
36+
python-frontmatter==1.1.0
3637

3738
# from kubeobject
3839
freezegun==1.5.2

scripts/release/calculate_next_version.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,48 @@
33

44
from git import Repo
55

6+
from scripts.release.changelog import (
7+
DEFAULT_CHANGELOG_PATH,
8+
DEFAULT_INITIAL_GIT_TAG_VERSION,
9+
)
610
from scripts.release.release_notes import calculate_next_version_with_changelog
711

812
if __name__ == "__main__":
913
parser = argparse.ArgumentParser()
1014
parser.add_argument(
15+
"-p",
1116
"--path",
12-
action="store",
1317
default=".",
18+
metavar="",
19+
action="store",
1420
type=pathlib.Path,
1521
help="Path to the Git repository. Default is the current directory '.'",
1622
)
1723
parser.add_argument(
18-
"--changelog_path",
19-
default="changelog/",
24+
"-c",
25+
"--changelog-path",
26+
default=DEFAULT_CHANGELOG_PATH,
27+
metavar="",
2028
action="store",
2129
type=str,
22-
help="Path to the changelog directory relative to the repository root. Default is 'changelog/'",
30+
help=f"Path to the changelog directory relative to the repository root. Default is '{DEFAULT_CHANGELOG_PATH}'",
2331
)
2432
parser.add_argument(
25-
"--initial_commit_sha",
33+
"-s",
34+
"--initial-commit-sha",
35+
metavar="",
2636
action="store",
2737
type=str,
2838
help="SHA of the initial commit to start from if no previous version tag is found.",
2939
)
3040
parser.add_argument(
31-
"--initial_version",
32-
default="1.0.0",
41+
"-v",
42+
"--initial-version",
43+
default=DEFAULT_INITIAL_GIT_TAG_VERSION,
44+
metavar="",
3345
action="store",
3446
type=str,
35-
help="Version to use if no previous version tag is found. Default is '1.0.0'",
47+
help=f"Version to use if no previous version tag is found. Default is '{DEFAULT_INITIAL_GIT_TAG_VERSION}'",
3648
)
3749
parser.add_argument("--output", "-o", type=pathlib.Path)
3850
args = parser.parse_args()

scripts/release/changelog.py

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,18 @@
66
import frontmatter
77
from git import Commit, Repo
88

9-
CHANGELOG_PATH = "changelog/"
9+
DEFAULT_CHANGELOG_PATH = "changelog/"
10+
DEFAULT_INITIAL_GIT_TAG_VERSION = "1.0.0"
11+
FILENAME_DATE_FORMAT = "%Y%m%d"
12+
FRONTMATTER_DATE_FORMAT = "%Y-%m-%d"
1013

1114
PRELUDE_ENTRIES = ["prelude"]
1215
BREAKING_CHANGE_ENTRIES = ["breaking", "major"]
1316
FEATURE_ENTRIES = ["feat", "feature"]
1417
BUGFIX_ENTRIES = ["fix", "bugfix", "hotfix", "patch"]
1518

1619

17-
class ChangeType(StrEnum):
20+
class ChangeKind(StrEnum):
1821
PRELUDE = "prelude"
1922
BREAKING = "breaking"
2023
FEATURE = "feature"
@@ -23,7 +26,7 @@ class ChangeType(StrEnum):
2326

2427

2528
class ChangeMeta:
26-
def __init__(self, date: datetime, kind: ChangeType, title: str):
29+
def __init__(self, date: datetime, kind: ChangeKind, title: str):
2730
self.date = date
2831
self.kind = kind
2932
self.title = title
@@ -33,7 +36,7 @@ def get_changelog_entries(
3336
previous_version_commit: Commit,
3437
repo: Repo,
3538
changelog_sub_path: str,
36-
) -> list[tuple[ChangeType, str]]:
39+
) -> list[tuple[ChangeKind, str]]:
3740
changelog = []
3841

3942
# Compare previous version commit with current working tree
@@ -77,40 +80,51 @@ def extract_changelog_data(working_dir: str, file_path: str) -> (ChangeMeta, str
7780
return change_meta, contents
7881

7982

80-
def extract_date_and_kind_from_file_name(file_name: str) -> (datetime, ChangeType):
83+
def extract_date_and_kind_from_file_name(file_name: str) -> (datetime, ChangeKind):
8184
match = re.match(r"(\d{8})_([a-zA-Z]+)_(.+)\.md", file_name)
8285
if not match:
8386
raise Exception(f"{file_name} - doesn't match expected pattern")
8487

8588
date_str, kind_str, _ = match.groups()
8689
try:
87-
date = datetime.datetime.strptime(date_str, "%Y%m%d").date()
88-
except Exception:
89-
raise Exception(f"{file_name} - date part {date_str} is not in the expected format YYYYMMDD")
90+
date = parse_change_date(date_str, FILENAME_DATE_FORMAT)
91+
except Exception as e:
92+
raise Exception(f"{file_name} - {e}")
9093

91-
kind = get_change_type(kind_str)
94+
kind = get_change_kind(kind_str)
9295

9396
return date, kind
9497

9598

96-
def get_change_type(kind_str: str) -> ChangeType:
99+
def parse_change_date(date_str: str, date_format: str) -> datetime:
100+
try:
101+
date = datetime.datetime.strptime(date_str, date_format).date()
102+
except Exception:
103+
raise Exception(f"date {date_str} is not in the expected format {date_format}")
104+
105+
return date
106+
107+
108+
def get_change_kind(kind_str: str) -> ChangeKind:
97109
if kind_str.lower() in PRELUDE_ENTRIES:
98-
return ChangeType.PRELUDE
110+
return ChangeKind.PRELUDE
99111
if kind_str.lower() in BREAKING_CHANGE_ENTRIES:
100-
return ChangeType.BREAKING
112+
return ChangeKind.BREAKING
101113
elif kind_str.lower() in FEATURE_ENTRIES:
102-
return ChangeType.FEATURE
114+
return ChangeKind.FEATURE
103115
elif kind_str.lower() in BUGFIX_ENTRIES:
104-
return ChangeType.FIX
116+
return ChangeKind.FIX
105117
else:
106-
return ChangeType.OTHER
118+
return ChangeKind.OTHER
107119

108120

109121
def strip_changelog_entry_frontmatter(file_contents: str) -> (ChangeMeta, str):
110122
"""Strip the front matter from a changelog entry."""
111123
data = frontmatter.loads(file_contents)
112124

113-
meta = ChangeMeta(date=data["date"], title=str(data["title"]), kind=get_change_type(str(data["kind"])))
125+
kind = get_change_kind(str(data["kind"]))
126+
date = parse_change_date(str(data["date"]), FRONTMATTER_DATE_FORMAT)
127+
meta = ChangeMeta(date=date, title=str(data["title"]), kind=kind)
114128

115129
## Add newline to contents so the Markdown file also contains a newline at the end
116130
contents = data.content + "\n"

scripts/release/changelog_test.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import datetime
22

33
import pytest
4-
54
from changelog import (
6-
ChangeType,
5+
ChangeKind,
76
extract_date_and_kind_from_file_name,
87
strip_changelog_entry_frontmatter,
98
)
@@ -13,65 +12,65 @@ def test_extract_changelog_data_from_file_name():
1312
# Test prelude
1413
assert extract_date_and_kind_from_file_name("20250502_prelude_release_notes.md") == (
1514
datetime.date(2025, 5, 2),
16-
ChangeType.PRELUDE,
15+
ChangeKind.PRELUDE,
1716
)
1817

1918
# Test breaking changes
2019
assert extract_date_and_kind_from_file_name("20250101_breaking_api_update.md") == (
2120
datetime.date(2025, 1, 1),
22-
ChangeType.BREAKING,
21+
ChangeKind.BREAKING,
2322
)
2423
assert extract_date_and_kind_from_file_name("20250508_breaking_remove_deprecated.md") == (
2524
datetime.date(2025, 5, 8),
26-
ChangeType.BREAKING,
25+
ChangeKind.BREAKING,
2726
)
2827
assert extract_date_and_kind_from_file_name("20250509_major_schema_change.md") == (
2928
datetime.date(2025, 5, 9),
30-
ChangeType.BREAKING,
29+
ChangeKind.BREAKING,
3130
)
3231

3332
# Test features
3433
assert extract_date_and_kind_from_file_name("20250509_feature_new_dashboard.md") == (
3534
datetime.date(2025, 5, 9),
36-
ChangeType.FEATURE,
35+
ChangeKind.FEATURE,
3736
)
3837
assert extract_date_and_kind_from_file_name("20250511_feat_add_metrics.md") == (
3938
datetime.date(2025, 5, 11),
40-
ChangeType.FEATURE,
39+
ChangeKind.FEATURE,
4140
)
4241

4342
# Test fixes
4443
assert extract_date_and_kind_from_file_name("20251210_fix_olm_missing_images.md") == (
4544
datetime.date(2025, 12, 10),
46-
ChangeType.FIX,
45+
ChangeKind.FIX,
4746
)
4847
assert extract_date_and_kind_from_file_name("20251010_bugfix_memory_leak.md") == (
4948
datetime.date(2025, 10, 10),
50-
ChangeType.FIX,
49+
ChangeKind.FIX,
5150
)
5251
assert extract_date_and_kind_from_file_name("20250302_hotfix_security_issue.md") == (
5352
datetime.date(2025, 3, 2),
54-
ChangeType.FIX,
53+
ChangeKind.FIX,
5554
)
5655
assert extract_date_and_kind_from_file_name("20250301_patch_typo_correction.md") == (
5756
datetime.date(2025, 3, 1),
58-
ChangeType.FIX,
57+
ChangeKind.FIX,
5958
)
6059

6160
# Test other
6261
assert extract_date_and_kind_from_file_name("20250520_docs_update_readme.md") == (
6362
datetime.date(2025, 5, 20),
64-
ChangeType.OTHER,
63+
ChangeKind.OTHER,
6564
)
6665
assert extract_date_and_kind_from_file_name("20250610_refactor_codebase.md") == (
6766
datetime.date(2025, 6, 10),
68-
ChangeType.OTHER,
67+
ChangeKind.OTHER,
6968
)
7069

7170
# Invalid date part (day 40 does not exist)
7271
with pytest.raises(Exception) as e:
7372
extract_date_and_kind_from_file_name("20250640_refactor_codebase.md")
74-
assert str(e.value) == "20250640_refactor_codebase.md - date part 20250640 is not in the expected format YYYYMMDD"
73+
assert str(e.value) == "20250640_refactor_codebase.md - date 20250640 is not in the expected format YYYYMMDD"
7574

7675
# Wrong file name format (date part)
7776
with pytest.raises(Exception) as e:
@@ -99,7 +98,7 @@ def test_strip_changelog_entry_frontmatter():
9998
change_meta, contents = strip_changelog_entry_frontmatter(file_contents)
10099

101100
assert change_meta.title == "This is my change"
102-
assert change_meta.kind == ChangeType.FEATURE
101+
assert change_meta.kind == ChangeKind.FEATURE
103102
assert change_meta.date == datetime.date(2025, 7, 10)
104103

105104
assert (

scripts/release/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
from _pytest.fixtures import fixture
66
from git import Repo
77

8-
from scripts.release.changelog import CHANGELOG_PATH
8+
from scripts.release.changelog import DEFAULT_CHANGELOG_PATH
99

1010

1111
@fixture(scope="session")
12-
def git_repo(change_log_path: str = CHANGELOG_PATH) -> Repo:
12+
def git_repo(change_log_path: str = DEFAULT_CHANGELOG_PATH) -> Repo:
1313
"""
1414
Create a temporary git repository for testing.
1515
Visual representation of the repository structure is in test_git_repo.mmd (mermaid/gitgraph https://mermaid.js.org/syntax/gitgraph.html).
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import argparse
2+
import datetime
3+
import os
4+
import re
5+
6+
from scripts.release.changelog import (
7+
BREAKING_CHANGE_ENTRIES,
8+
BUGFIX_ENTRIES,
9+
DEFAULT_CHANGELOG_PATH,
10+
FEATURE_ENTRIES,
11+
FILENAME_DATE_FORMAT,
12+
FRONTMATTER_DATE_FORMAT,
13+
PRELUDE_ENTRIES,
14+
parse_change_date,
15+
)
16+
17+
MAX_TITLE_LENGTH = 50
18+
19+
20+
def sanitize_title(title: str) -> str:
21+
# Remove non-alphabetic and space characters
22+
regex = re.compile("[^a-zA-Z ]+")
23+
title = regex.sub("", title)
24+
25+
# Lowercase and split by space
26+
words = [word.lower() for word in title.split(" ")]
27+
28+
result = words[0]
29+
30+
for word in words[1:]:
31+
if len(result) + len("_") + len(word) <= MAX_TITLE_LENGTH:
32+
result = result + "_" + word
33+
else:
34+
break
35+
36+
return result
37+
38+
39+
if __name__ == "__main__":
40+
parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
41+
parser.add_argument(
42+
"-c",
43+
"--changelog-path",
44+
default=DEFAULT_CHANGELOG_PATH,
45+
metavar="",
46+
action="store",
47+
type=str,
48+
help=f"Path to the changelog directory relative to the repository root. Default is {DEFAULT_CHANGELOG_PATH}",
49+
)
50+
parser.add_argument(
51+
"-d",
52+
"--date",
53+
default=datetime.datetime.now().strftime(FRONTMATTER_DATE_FORMAT),
54+
metavar="",
55+
action="store",
56+
type=str,
57+
help=f"Date in 'YYYY-MM-DD' format to use for the changelog entry. Default is today's date",
58+
)
59+
parser.add_argument(
60+
"-e",
61+
"--editor",
62+
action="store_true",
63+
help="Open the created changelog entry in the default editor (if set, otherwise uses 'vi'). Default is True",
64+
)
65+
parser.add_argument(
66+
"-k",
67+
"--kind",
68+
action="store",
69+
metavar="",
70+
required=True,
71+
type=str,
72+
help=f"""Kind of the changelog entry:
73+
- '{".".join(PRELUDE_ENTRIES)}' for prelude entries
74+
- '{".".join(BREAKING_CHANGE_ENTRIES)}' for breaking change entries
75+
- '{".".join(FEATURE_ENTRIES)}' for feature entries
76+
- '{".".join(BUGFIX_ENTRIES)}' for bugfix entries
77+
- everything else will be treated as other entries""",
78+
)
79+
parser.add_argument("title", type=str, help="Title for the changelog entry")
80+
args = parser.parse_args()
81+
82+
date = parse_change_date(args.date, FRONTMATTER_DATE_FORMAT)
83+
sanitized_title = sanitize_title(args.title)
84+
filename = f"{datetime.datetime.strftime(date, FILENAME_DATE_FORMAT)}_{args.kind}_{sanitized_title}.md"
85+
86+
working_dir = os.getcwd()
87+
changelog_path = os.path.join(working_dir, args.changelog_path, filename)
88+
89+
# Create directory if it doesn't exist
90+
os.makedirs(os.path.dirname(changelog_path), exist_ok=True)
91+
92+
# Create the file
93+
with open(changelog_path, "w") as f:
94+
# Add frontmatter based on args
95+
f.write("---\n")
96+
f.write(f"title: {args.title}\n")
97+
f.write(f"kind: {args.kind}\n")
98+
f.write(f"date: {date}\n")
99+
f.write("---\n\n")
100+
101+
if args.editor:
102+
editor = os.environ.get("EDITOR", "vi") # Fallback to vim if EDITOR is not set
103+
os.system(f'{editor} "{changelog_path}"')
104+
105+
print(f"Created changelog entry at: {changelog_path}")

0 commit comments

Comments
 (0)