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

1"""'Keep a Changelog'-Conventional Commits-Plugin.""" 

2 

3# mypy: disable-error-code=assignment 

4# ruff: noqa: RUF012 

5 

6from __future__ import annotations 

7 

8from collections import OrderedDict 

9from typing import Any, TYPE_CHECKING, TypedDict 

10 

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 

23 

24from cz_keep_a_changelog_plugin.utils import replace_github_issues 

25 

26if TYPE_CHECKING: 

27 from collections.abc import Iterable 

28 

29 from commitizen import git 

30 from commitizen.config.base_config import BaseConfig 

31 from commitizen.question import CzQuestion 

32 

33 

34class CzPluginSettings(TypedDict, total=False): 

35 """Plugin Settings.""" 

36 

37 issue_url_template: str 

38 

39 

40class CzKeepAChangelogPlugin(ConventionalCommitsCz): 

41 """'Keep a Changelog'-Conventional Commits-Plugin.""" 

42 

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 ) 

64 

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 ] 

86 

87 template_header: str = "CHANGELOG_HEADER.md.j2" 

88 template_loader: j2.BaseLoader = j2.PackageLoader( 

89 "cz_keep_a_changelog_plugin", "templates" 

90 ) 

91 

92 def __init__(self, config: BaseConfig) -> None: 

93 """Initializes the plugin. 

94 

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.""" 

103 

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. 

111 

112 Here, the header will be added if not done before. 

113 

114 Args: 

115 changelog_out: The full changelog. 

116 partial_changelog: The partial (if used `incremental`) changelog, 

117 else `None`. 

118 

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)) 

135 

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. 

143 

144 This function is executed per parsed commit. Each `GitCommit` contains 

145 the following attrs: `rev`, `title`, `body`, `author`, `author_email`. 

146 

147 Here, the issue number is replaced by a markdown link using the issue 

148 url template. 

149 

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`. 

154 

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 

166 

167 def questions(self) -> list[CzQuestion]: 

168 """Returns the questions regarding the commit message. 

169 

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 ]