Skip to content

Commit 3936eff

Browse files
authored
Added support for renovate changelogs (#253)
1 parent c41997e commit 3936eff

File tree

7 files changed

+209
-93
lines changed

7 files changed

+209
-93
lines changed

renovate-config.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,12 @@
22
"allowedPostUpgradeCommands": [
33
"^uv sync",
44
"^uv .+"
5-
]
5+
],
6+
"postUpgradeTasks": {
7+
"commands": [
8+
"uv run scripts/changelog.py create-renovate -m 'Updated `{{{depName}}}` from {{{currentVersion}}} -> {{{newVersion}}}'",
9+
"uv sync"
10+
],
11+
"executionMode": "update"
12+
}
613
}

scripts/apps.py

Lines changed: 55 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,58 @@
55
from pathlib import Path
66

77
import toml
8+
from git import Commit
89

10+
from scripts.changes import Changes
911
from scripts.contextlibs import chdir
12+
from scripts.project import Project
13+
14+
15+
class App:
16+
module_name: str | None = None
17+
project: Project | None = None
18+
19+
@classmethod
20+
def create(cls, project: Project, module_name: str):
21+
app = App()
22+
app.project = project
23+
app.module_name = module_name
24+
return app
25+
26+
@property
27+
def pyproject(self):
28+
with open(self.absolute_path / "pyproject.toml") as f: # noqa: PTH123
29+
return toml.loads(f.read())
30+
31+
@property
32+
def name(self) -> str:
33+
return self.pyproject["project"]["name"]
34+
35+
@property
36+
def version(self) -> str:
37+
return self.pyproject["project"]["version"]
38+
39+
@property
40+
def version_git_tag(self):
41+
return f"{self.name}/v{self.version}"
42+
43+
@cached_property
44+
def absolute_path(self) -> Path:
45+
return get_app_dir(self.module_name).absolute()
46+
47+
@cached_property
48+
def relative_path(self):
49+
return self.absolute_path.relative_to(self.project.path)
50+
51+
@contextmanager
52+
def with_app_dir(self):
53+
with chdir(self.absolute_path):
54+
yield
55+
56+
def get_changes(self, base_commit: Commit, target_commit: Commit) -> Changes:
57+
return Changes.from_app_commits(
58+
app=self, base_commit=base_commit, target_commit=target_commit
59+
)
1060

1161

1262
def get_source_dir() -> Path:
@@ -19,7 +69,7 @@ def get_app_dir(path: str) -> Path:
1969
return get_source_dir() / path
2070

2171

22-
def list_apps() -> list[Path]:
72+
def list_app_paths() -> list[Path]:
2373
"""List the apps in the repo"""
2474
return sorted(
2575
dir_path
@@ -32,34 +82,9 @@ def list_apps() -> list[Path]:
3282

3383
def list_app_names() -> list[str]:
3484
"""List the app names"""
35-
return [name.stem for name in list_apps()]
85+
return [name.stem for name in list_app_paths()]
3686

3787

38-
class App:
39-
module_name: str
40-
41-
@property
42-
def pyproject(self):
43-
with open(self.app_dir / "pyproject.toml") as f: # noqa: PTH123
44-
return toml.loads(f.read())
45-
46-
@property
47-
def name(self):
48-
return self.pyproject["project"]["name"]
49-
50-
@property
51-
def version(self):
52-
return self.pyproject["project"]["version"]
53-
54-
@property
55-
def version_git_tag(self):
56-
return f"{self.name}/v{self.version}"
57-
58-
@cached_property
59-
def app_dir(self) -> Path:
60-
return get_app_dir(self.module_name).absolute()
61-
62-
@contextmanager
63-
def with_app_dir(self):
64-
with chdir(self.app_dir):
65-
yield
88+
def list_apps(project: Project) -> list[App]:
89+
"""Get a list of App instances"""
90+
return [App.create(project, name) for name in list_app_names()]

scripts/changelog.py

Lines changed: 49 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
from fnmatch import fnmatch
21
from os import makedirs
3-
from textwrap import indent
2+
from pathlib import Path
3+
from textwrap import dedent, indent
44

55
from click import echo
66
from click_log import simple_verbosity_option
@@ -33,7 +33,7 @@ def changelog(ctx):
3333
@app_option
3434
@pass_app
3535
@pass_context
36-
def list_all(ctx: Context, app: App): # noqa: ARG001
36+
def list_all(_ctx: Context, app: App):
3737
"""Print out the current set of changes"""
3838
scriv = Scriv()
3939
fragments = scriv.fragments_to_combine()
@@ -48,24 +48,15 @@ def list_all(ctx: Context, app: App): # noqa: ARG001
4848

4949
def _echo_change(change: Diff):
5050
"""Echo the change to stdout"""
51+
5152
line = [change.change_type, change.a_path]
5253

5354
if change.renamed_file:
54-
line.extend(["->", changelog.b_path])
55+
line.extend(["->", change.b_path])
5556

5657
echo(indent(" ".join(line), "\t"))
5758

5859

59-
def _is_source_excluded(path) -> bool:
60-
excluded_paths = ["*/changelog.d/*", "*/CHANGELOG.md"]
61-
return any([fnmatch(path, exclude) for exclude in excluded_paths]) # noqa: C419
62-
63-
64-
def _is_changelog_excluded(path) -> bool:
65-
excluded_paths = ["*/scriv.ini"]
66-
return any([fnmatch(path, exclude) for exclude in excluded_paths]) # noqa: C419
67-
68-
6960
@changelog.command()
7061
@option(
7162
"-b",
@@ -89,58 +80,30 @@ def check(ctx: Context, project: Project, base: str, target: str):
8980

9081
is_error = False
9182

92-
for app_abs_path in list_apps():
93-
app_rel_path = app_abs_path.relative_to(project.path)
94-
95-
# we count these towards a changelog being present
96-
# but not against the absence of one
97-
top_level_dependency_changes = [
98-
change
99-
for change in base_commit.diff(target_commit, paths=["uv.lock"])
100-
if not _is_source_excluded(change.a_path)
101-
and not _is_source_excluded(change.b_path)
102-
]
103-
has_top_level_dependency_changes = len(top_level_dependency_changes) > 0
104-
105-
source_changes = [
106-
change
107-
for change in base_commit.diff(target_commit, paths=[app_rel_path])
108-
if not _is_source_excluded(change.a_path)
109-
and not _is_source_excluded(change.b_path)
110-
]
111-
has_source_changes = len(source_changes) > 0
112-
113-
changelogd_changes = [
114-
change
115-
for change in base_commit.diff(
116-
target_commit, paths=[app_rel_path / "changelog.d"]
117-
)
118-
if not _is_changelog_excluded(change.a_path)
119-
and not _is_changelog_excluded(change.b_path)
120-
]
121-
has_changelogd_changes = len(changelogd_changes) > 0
122-
123-
if has_source_changes and not has_changelogd_changes:
124-
echo(f"Changelog(s) are missing in {app_rel_path} for these changes:")
125-
for change in source_changes:
83+
for app in list_apps(project):
84+
changes = app.get_changes(base_commit=base_commit, target_commit=target_commit)
85+
86+
if changes.has_source_changes and not changes.has_changelogd_changes:
87+
echo(f"Changelog(s) are missing in {app.relative_path} for these changes:")
88+
for change in changes.source_changes:
12689
_echo_change(change)
12790
is_error = True
12891
echo("")
12992
elif (
130-
not has_source_changes
131-
and not has_top_level_dependency_changes
132-
and has_changelogd_changes
93+
not changes.has_source_changes
94+
and not changes.has_top_level_dependency_changes
95+
and changes.has_changelogd_changes
13396
):
13497
echo(
135-
f"Changelog(s) are present in {app_rel_path} but there are no source changes:" # noqa: E501
98+
f"Changelog(s) are present in {app.relative_path} but there are no source changes:" # noqa: E501
13699
)
137-
for change in changelogd_changes:
100+
for change in changes.changelogd_changes:
138101
_echo_change(change)
139102
is_error = True
140103
echo("")
141104

142105
# verify the fragments aren't empty
143-
with chdir(app_abs_path):
106+
with chdir(app.absolute_path):
144107
scriv = Scriv()
145108
fragments = scriv.fragments_to_combine()
146109
for fragment in fragments:
@@ -149,7 +112,9 @@ def check(ctx: Context, project: Project, base: str, target: str):
149112
empty_fragments = list(filter(lambda frag: not frag.content.strip(), fragments))
150113

151114
if empty_fragments:
152-
echo(f"Changelog(s) are present in {app_rel_path} but have no content:")
115+
echo(
116+
f"Changelog(s) are present in {app.relative_path} but have no content:"
117+
)
153118
for fragment in empty_fragments:
154119
echo(f"\t{fragment.path}")
155120
is_error = True
@@ -159,5 +124,34 @@ def check(ctx: Context, project: Project, base: str, target: str):
159124
ctx.exit(1)
160125

161126

127+
@changelog.command("create-renovate")
128+
@option(
129+
"-m",
130+
"--message",
131+
help="The message for the changelog line",
132+
required=True,
133+
)
134+
@pass_project
135+
def create_renovate(project: Project, message: str):
136+
"""Create a changelog for renovate"""
137+
for changed in project.repo.head.commit.diff(None):
138+
if not changed.a_path.endswith("pyproject.toml"):
139+
continue
140+
141+
echo(f"Adding changelog for: {changed.a_path}")
142+
143+
with chdir(Path(changed.a_path).parent):
144+
scriv = Scriv()
145+
frag = scriv.new_fragment()
146+
147+
frag.content = dedent(
148+
f"""
149+
### Changed
150+
151+
- {message}"""
152+
)
153+
frag.write()
154+
155+
162156
if __name__ == "__main__":
163157
changelog()

scripts/changes.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from ast import TypeAlias
2+
from dataclasses import dataclass
3+
from fnmatch import fnmatch
4+
from functools import cached_property
5+
from typing import TYPE_CHECKING
6+
7+
from git import Commit, Diff
8+
9+
if TYPE_CHECKING:
10+
from scripts.apps import App
11+
else:
12+
App: TypeAlias = None
13+
14+
15+
@dataclass(frozen=True)
16+
class Changes:
17+
"""
18+
Representation for various categories of changes in git.
19+
"""
20+
21+
all_changes: list[Diff]
22+
top_level_dependency_changes: list[Diff]
23+
source_changes: list[Diff]
24+
changelogd_changes: list[Diff]
25+
26+
@cached_property
27+
def has_top_level_dependency_changes(self) -> bool:
28+
return len(self.top_level_dependency_changes) > 0
29+
30+
def has_source_changes(self) -> bool:
31+
return len(self.source_changes) > 0
32+
33+
def has_changelogd_changes(self) -> bool:
34+
return len(self.changelogd_changes) > 0
35+
36+
@classmethod
37+
def from_app_commits(cls, *, app: App, base_commit: Commit, target_commit: Commit):
38+
"""Create the Changes object from an app and commit range"""
39+
all_changes = base_commit.diff(target_commit)
40+
# we count these towards a changelog being present
41+
# but not against the absence of one
42+
top_level_dependency_changes = base_commit.diff(
43+
target_commit, paths=["uv.lock"]
44+
)
45+
46+
source_changes = [
47+
change
48+
for change in base_commit.diff(target_commit, paths=[app.relative_path])
49+
if not _is_source_excluded(change.a_path)
50+
and not _is_source_excluded(change.b_path)
51+
]
52+
53+
changelogd_changes = [
54+
change
55+
for change in base_commit.diff(
56+
target_commit, paths=[app.relative_path / "changelog.d"]
57+
)
58+
if not _is_changelog_excluded(change.a_path)
59+
and not _is_changelog_excluded(change.b_path)
60+
]
61+
62+
return cls(
63+
all_changes,
64+
top_level_dependency_changes,
65+
source_changes,
66+
changelogd_changes,
67+
)
68+
69+
70+
def _is_source_excluded(path: str | None) -> bool:
71+
"""Return True if the source path is excluded"""
72+
if path is None:
73+
return False
74+
75+
excluded_paths = ["*/changelog.d/*", "*/CHANGELOG.md"]
76+
77+
return any(fnmatch(path, exclude) for exclude in excluded_paths)
78+
79+
80+
def _is_changelog_excluded(path: str | None) -> bool:
81+
"""Return True if the changelog path is excluded"""
82+
if path is None:
83+
return False
84+
85+
excluded_paths = ["*/scriv.ini"]
86+
87+
return any(fnmatch(path, exclude) for exclude in excluded_paths)

scripts/contextlibs.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from contextlib import contextmanager
2+
from os import PathLike
23
from os import chdir as _chdir
34
from pathlib import Path
45

56

67
@contextmanager
7-
def chdir(path):
8+
def chdir(path: str | bytes | PathLike[str]):
89
original = Path.cwd()
910

1011
try:

0 commit comments

Comments
 (0)