Coverage for cz_keep_a_changelog_plugin/cz_keep_a_changelog_plugin.py: 97.92%
44 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-01 14:01 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-01 14:01 +0000
1"""'Keep a Changelog'-Conventional Commits-Plugin."""
3# mypy: disable-error-code=assignment
4# ruff: noqa: RUF012
6from __future__ import annotations
8from collections import OrderedDict
9from typing import Any, TYPE_CHECKING, TypedDict
11from commitizen.cz.conventional_commits.conventional_commits import (
12 ConventionalCommitsCz,
13)
14from commitizen.cz.conventional_commits.conventional_commits import _parse_scope
15from commitizen.cz.conventional_commits.conventional_commits import (
16 _parse_subject,
17)
18from commitizen.cz.utils import multiple_line_breaker
19from commitizen.defaults import MAJOR
20from commitizen.defaults import MINOR
21from commitizen.defaults import PATCH
22import jinja2 as j2
24from cz_keep_a_changelog_plugin.utils import replace_github_issues
26if TYPE_CHECKING:
27 from collections.abc import Iterable
29 from commitizen import git
30 from commitizen.config.base_config import BaseConfig
31 from commitizen.question import CzQuestion
34class CzPluginSettings(TypedDict, total=False):
35 """Plugin Settings."""
37 issue_url_template: str
40class CzKeepAChangelogPlugin(ConventionalCommitsCz):
41 """'Keep a Changelog'-Conventional Commits-Plugin."""
43 bump_pattern: str | None = r"^(\w+(\(.+\))?!?):"
44 bump_map: dict[str, str] | None = OrderedDict(
45 (
46 (r"^.+!$", MAJOR),
47 (r"^feat", MINOR),
48 (r"^revert", MINOR),
49 (r"^fix", PATCH),
50 (r"^perf", PATCH),
51 (r"^refactor", PATCH),
52 )
53 )
54 bump_map_major_version_zero: dict[str, str] | None = OrderedDict(
55 (
56 (r"^.+!$", MINOR),
57 (r"^feat", MINOR),
58 (r"^revert", MINOR),
59 (r"^fix", PATCH),
60 (r"^perf", PATCH),
61 (r"^refactor", PATCH),
62 )
63 )
65 commit_parser: str | None = (
66 r"^((?P<change_type>feat|fix|perf|refactor|revert)"
67 r"(?:\((?P<scope>[^()\r\n]*)\)|\()?(?P<breaking>!)?|\w+!):"
68 r"\s(?P<message>.*)?"
69 )
70 changelog_pattern: str | None = bump_pattern
71 change_type_map: dict[str, str] | None = {
72 "feat": "Added",
73 "fix": "Fixed",
74 "perf": "Changed",
75 "refactor": "Changed",
76 "revert": "Removed",
77 }
78 change_type_order: list[str] | None = [
79 "Added",
80 "Changed",
81 "Deprecated",
82 "Removed",
83 "Fixed",
84 "Security",
85 ]
87 template_header: str = "CHANGELOG_HEADER.md.j2"
88 template_loader: j2.BaseLoader = j2.PackageLoader(
89 "cz_keep_a_changelog_plugin", "templates"
90 )
92 def __init__(self, config: BaseConfig) -> None:
93 """Initializes the plugin.
95 Args:
96 config: The base config of `commitizen`.
97 """
98 super().__init__(config)
99 self.plugin_settings: CzPluginSettings = self.config.settings.get(
100 "cz_keep_a_changelog_plugin", {}
101 )
102 """The plugin settings set in TOML."""
104 def changelog_hook(
105 self,
106 changelog_out: str,
107 partial_changelog: str | None,
108 ) -> str:
109 """Customizes the complete changelog or uses it to call further
110 functionality.
112 Here, the header will be added if not done before.
114 Args:
115 changelog_out: The full changelog.
116 partial_changelog: The partial (if used `incremental`) changelog,
117 else `None`.
119 Returns:
120 Must return the complete changelog.
121 """
122 loader = j2.ChoiceLoader(
123 [j2.FileSystemLoader("."), self.template_loader]
124 )
125 env = j2.Environment(loader=loader, trim_blocks=True)
126 jinja_template_header = env.get_template(self.template_header)
127 header = jinja_template_header.render(
128 **self.template_extras,
129 **self.config.settings["extras"],
130 )
131 header = header.strip()
132 if changelog_out.startswith(header):
133 return changelog_out
134 return "\n\n".join((header, changelog_out))
136 def changelog_message_builder_hook(
137 self,
138 message: dict[str, Any],
139 commit: git.GitCommit,
140 ) -> Iterable[dict[str, Any]] | dict[str, Any] | None:
141 """Customizes the changelog message with extra information, like adding
142 links.
144 This function is executed per parsed commit. Each `GitCommit` contains
145 the following attrs: `rev`, `title`, `body`, `author`, `author_email`.
147 Here, the issue number is replaced by a markdown link using the issue
148 url template.
150 Args:
151 message: The message to customize.
152 commit: The original `GitCommit` which contains the following attrs:
153 `rev`, `title`, `body`, `author`, `author_email`.
155 Returns:
156 A customized message or a falsy value to ignore the commit.
157 """
158 if "issue_url_template" in self.plugin_settings: 158 ↛ 165line 158 didn't jump to line 165 because the condition on line 158 was always true
159 issue_url_template = j2.Template(
160 self.plugin_settings["issue_url_template"]
161 )
162 message["message"] = replace_github_issues(
163 message["message"], issue_url_template
164 )
165 return message
167 def questions(self) -> list[CzQuestion]:
168 """Returns the questions regarding the commit message.
170 Returns:
171 The questions.
172 """
173 return [
174 {
175 "type": "list",
176 "name": "prefix",
177 "message": "Select the type of change you are committing",
178 "choices": [
179 {
180 "value": "feat",
181 "name": (
182 "feat: A new feature. Correlates with MINOR in "
183 "SemVer"
184 ),
185 "key": "f",
186 },
187 {
188 "value": "fix",
189 "name": (
190 "fix: A bug fix. Correlates with PATCH in SemVer"
191 ),
192 "key": "x",
193 },
194 {
195 "value": "docs",
196 "name": "docs: Documentation only changes",
197 "key": "d",
198 },
199 {
200 "value": "style",
201 "name": (
202 "style: Changes that do not affect the meaning of "
203 "the code (white-space, formatting, missing "
204 "semi-colons, etc)"
205 ),
206 "key": "s",
207 },
208 {
209 "value": "refactor",
210 "name": (
211 "refactor: A code change that neither fixes a bug "
212 "nor adds a feature"
213 ),
214 "key": "r",
215 },
216 {
217 "value": "perf",
218 "name": "perf: A code change that improves performance",
219 "key": "p",
220 },
221 {
222 "value": "test",
223 "name": (
224 "test: Adding missing tests or correcting existing "
225 "tests"
226 ),
227 "key": "t",
228 },
229 {
230 "value": "build",
231 "name": (
232 "build: Changes that affect the build system or "
233 "external dependencies (example scopes: pip, "
234 "docker, npm)"
235 ),
236 "key": "b",
237 },
238 {
239 "value": "ci",
240 "name": (
241 "ci: Changes to CI configuration files and scripts "
242 "(example scopes: GitLabCI)"
243 ),
244 "key": "c",
245 },
246 {
247 "value": "chore",
248 "name": (
249 "Other changes that don't modify src or test files"
250 ),
251 "key": "h",
252 },
253 {
254 "value": "revert",
255 "name": (
256 "Reverts a previous commit. Put reverted commit "
257 "SHAs into footer as'Refs: 676104e, a215868'"
258 ),
259 "key": "v",
260 },
261 ],
262 },
263 {
264 "type": "input",
265 "name": "scope",
266 "message": (
267 "What is the scope of this change? (class or file name): "
268 "(press [enter] to skip)\n"
269 ),
270 "filter": _parse_scope,
271 },
272 {
273 "type": "input",
274 "name": "subject",
275 "filter": _parse_subject,
276 "message": (
277 "Write a short and imperative summary of the code changes: "
278 "(lower case and no period)\n"
279 ),
280 },
281 {
282 "type": "input",
283 "name": "body",
284 "message": (
285 "Provide additional contextual information about the code "
286 "changes: (press [enter] to skip)\n"
287 ),
288 "filter": multiple_line_breaker,
289 },
290 {
291 "type": "confirm",
292 "name": "is_breaking_change",
293 "message": (
294 "Is this a BREAKING CHANGE? Correlates with MAJOR in SemVer"
295 ),
296 "default": False,
297 },
298 {
299 "type": "input",
300 "name": "footer",
301 "message": (
302 "Footer. Information about Breaking Changes and reference "
303 "issues that this commit closes: (press [enter] to skip)\n"
304 ),
305 },
306 ]