github_actions_maintainer/
conventional.rs1use std::sync::LazyLock;
9
10use regex::Regex;
11use semver::Version;
12
13use crate::github::CommitInfo;
14
15static SUBJECT_RE: LazyLock<Regex> = LazyLock::new(|| {
16 Regex::new(
17 r"^(?P<type>[A-Za-z][A-Za-z0-9-]*)(?:\((?P<scope>[^)]+)\))?(?P<bang>!)?:\s?(?P<description>.+)$",
18 )
19 .expect("subject regex is valid")
20});
21
22static BREAKING_BODY_RE: LazyLock<Regex> =
23 LazyLock::new(|| Regex::new(r"(?m)^BREAKING[- ]CHANGE\b").expect("breaking regex is valid"));
24
25#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
27pub enum BumpLevel {
28 Patch,
29 Minor,
30 Major,
31}
32
33#[derive(Debug, Clone, Copy, Eq, PartialEq)]
34pub enum CommitKind {
35 Breaking,
36 Feature,
37 Fix,
38 Other,
39}
40
41#[derive(Debug, Clone, Eq, PartialEq)]
42pub struct ConventionalCommit {
43 pub sha: String,
44 pub kind: CommitKind,
45 pub subject: String,
46}
47
48pub fn classify_commits(commits: &[CommitInfo]) -> Vec<ConventionalCommit> {
51 commits
52 .iter()
53 .filter(|commit| !commit.is_merge)
54 .map(|commit| {
55 let subject = commit.message.lines().next().unwrap_or_default().trim().to_owned();
56 ConventionalCommit {
57 sha: commit.sha.clone(),
58 kind: classify_message(&subject, &commit.message),
59 subject,
60 }
61 })
62 .collect()
63}
64
65fn classify_message(subject: &str, message: &str) -> CommitKind {
66 let captures = SUBJECT_RE.captures(subject);
67 let breaking = captures.as_ref().is_some_and(|captures| captures.name("bang").is_some())
68 || BREAKING_BODY_RE.is_match(message);
69 if breaking {
70 return CommitKind::Breaking;
71 }
72
73 match captures.as_ref().map(|captures| &captures["type"]) {
74 Some("feat") => CommitKind::Feature,
75 Some("fix") => CommitKind::Fix,
76 _ => CommitKind::Other,
77 }
78}
79
80pub fn required_bump(commits: &[ConventionalCommit]) -> Option<BumpLevel> {
83 commits
84 .iter()
85 .filter_map(|commit| match commit.kind {
86 CommitKind::Breaking => Some(BumpLevel::Major),
87 CommitKind::Feature => Some(BumpLevel::Minor),
88 CommitKind::Fix => Some(BumpLevel::Patch),
89 CommitKind::Other => None,
90 })
91 .max()
92}
93
94#[must_use]
96pub fn bump_version(current: &Version, level: BumpLevel) -> Version {
97 let mut next = match level {
98 BumpLevel::Major => Version::new(current.major + 1, 0, 0),
99 BumpLevel::Minor => Version::new(current.major, current.minor + 1, 0),
100 BumpLevel::Patch => Version::new(current.major, current.minor, current.patch + 1),
101 };
102 next.pre = semver::Prerelease::EMPTY;
103 next.build = semver::BuildMetadata::EMPTY;
104 next
105}
106
107#[must_use]
110pub fn is_conventional_subject(subject: &str) -> bool {
111 SUBJECT_RE.is_match(subject)
112}
113
114#[must_use]
124pub fn release_notes(tag: &str, commits: &[ConventionalCommit], truncated: bool) -> String {
125 let mut notes = format!("## Release {tag}\n");
126 let sections = [
127 (CommitKind::Breaking, "Breaking Changes"),
128 (CommitKind::Feature, "Features"),
129 (CommitKind::Fix, "Bug Fixes"),
130 ];
131
132 for (kind, title) in sections {
133 let section: Vec<&ConventionalCommit> =
134 commits.iter().filter(|commit| commit.kind == kind).collect();
135 push_section(&mut notes, title, §ion);
136 }
137
138 if commits.iter().all(|commit| commit.kind == CommitKind::Other) {
139 let maintenance: Vec<&ConventionalCommit> =
140 commits.iter().filter(|commit| is_conventional_subject(&commit.subject)).collect();
141 push_section(&mut notes, "Maintenance", &maintenance);
142 }
143
144 if truncated {
145 notes.push_str("\n_Note: the commit list was truncated; some changes may be missing._\n");
146 }
147
148 notes
149}
150
151fn push_section(notes: &mut String, title: &str, commits: &[&ConventionalCommit]) {
154 use std::fmt::Write as _;
155
156 if commits.is_empty() {
157 return;
158 }
159 notes.push('\n');
160 writeln!(notes, "### {title}").expect("writing to a String cannot fail");
161 for commit in commits {
162 let short_sha = commit.sha.get(..7).unwrap_or(&commit.sha);
163 writeln!(notes, "- {} ({short_sha})", commit.subject)
164 .expect("writing to a String cannot fail");
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use semver::Version;
171
172 use super::{
173 BumpLevel, CommitKind, bump_version, classify_commits, is_conventional_subject,
174 release_notes, required_bump,
175 };
176 use crate::github::CommitInfo;
177
178 fn commit(message: &str) -> CommitInfo {
179 CommitInfo {
180 sha: String::from("0123456789abcdef"),
181 message: message.to_owned(),
182 is_merge: false,
183 }
184 }
185
186 #[test]
187 fn classify_commits_maps_conventional_types() {
188 let commits = [
189 commit("feat: add release command"),
190 commit("fix(parser): handle empty scope"),
191 commit("docs: update readme"),
192 commit("chore: bump dependencies"),
193 ];
194
195 let classified = classify_commits(&commits);
196
197 assert_eq!(classified[0].kind, CommitKind::Feature);
198 assert_eq!(classified[1].kind, CommitKind::Fix);
199 assert_eq!(classified[2].kind, CommitKind::Other);
200 assert_eq!(classified[3].kind, CommitKind::Other);
201 }
202
203 #[test]
204 fn classify_commits_does_not_treat_feature_prefix_as_feat() {
205 let classified = classify_commits(&[commit("feature: not conventional feat")]);
206
207 assert_eq!(classified[0].kind, CommitKind::Other);
208 }
209
210 #[test]
211 fn classify_commits_detects_breaking_bang_marker() {
212 let classified = classify_commits(&[
213 commit("feat!: drop legacy flags"),
214 commit("refactor(core)!: rework internals"),
215 ]);
216
217 assert_eq!(classified[0].kind, CommitKind::Breaking);
218 assert_eq!(classified[1].kind, CommitKind::Breaking);
219 }
220
221 #[test]
222 fn classify_commits_detects_breaking_change_footer() {
223 let classified = classify_commits(&[
224 commit("fix: adjust defaults\n\nBREAKING CHANGE: defaults changed"),
225 commit("chore: cleanup\n\nBREAKING-CHANGE: removed helper"),
226 ]);
227
228 assert_eq!(classified[0].kind, CommitKind::Breaking);
229 assert_eq!(classified[1].kind, CommitKind::Breaking);
230 }
231
232 #[test]
233 fn classify_commits_ignores_mid_line_breaking_mentions() {
234 let classified =
235 classify_commits(&[commit("docs: describe the BREAKING CHANGE process in text")]);
236
237 assert_eq!(classified[0].kind, CommitKind::Other);
238 }
239
240 #[test]
241 fn classify_commits_skips_merge_commits() {
242 let merge = CommitInfo {
243 sha: String::from("mergesha"),
244 message: String::from("Merge pull request #1 from acme/feat"),
245 is_merge: true,
246 };
247
248 let classified = classify_commits(&[merge, commit("fix: real change")]);
249
250 assert_eq!(classified.len(), 1);
251 assert_eq!(classified[0].kind, CommitKind::Fix);
252 }
253
254 #[test]
255 fn classify_commits_uses_first_line_as_subject() {
256 let classified = classify_commits(&[commit("feat: multi line\n\nbody detail")]);
257
258 assert_eq!(classified[0].subject, "feat: multi line");
259 }
260
261 #[test]
262 fn required_bump_prefers_the_strongest_level() {
263 let commits = classify_commits(&[
264 commit("fix: patch level"),
265 commit("feat: minor level"),
266 commit("feat!: major level"),
267 ]);
268
269 assert_eq!(required_bump(&commits), Some(BumpLevel::Major));
270 }
271
272 #[test]
273 fn required_bump_returns_none_for_chore_only_history() {
274 let commits = classify_commits(&[commit("chore: tidy"), commit("docs: notes")]);
275
276 assert_eq!(required_bump(&commits), None);
277 }
278
279 #[test]
280 fn bump_version_increments_and_resets_components() {
281 let current = Version::parse("1.2.3").expect("version");
282
283 assert_eq!(
284 bump_version(¤t, BumpLevel::Major),
285 Version::parse("2.0.0").expect("version")
286 );
287 assert_eq!(
288 bump_version(¤t, BumpLevel::Minor),
289 Version::parse("1.3.0").expect("version")
290 );
291 assert_eq!(
292 bump_version(¤t, BumpLevel::Patch),
293 Version::parse("1.2.4").expect("version")
294 );
295 }
296
297 #[test]
298 fn bump_version_clears_prerelease_metadata() {
299 let current = Version::parse("1.2.3-rc.1+build.5").expect("version");
300
301 assert_eq!(
302 bump_version(¤t, BumpLevel::Patch),
303 Version::parse("1.2.4").expect("version")
304 );
305 }
306
307 #[test]
308 fn release_notes_group_sections_and_short_shas() {
309 let commits = classify_commits(&[
310 commit("feat!: drop old flags"),
311 commit("feat: add release command"),
312 commit("fix: handle empty tags"),
313 commit("chore: noise"),
314 ]);
315
316 let notes = release_notes("v1.0.0", &commits, false);
317
318 assert_eq!(
319 notes,
320 "## Release v1.0.0\n\n### Breaking Changes\n- feat!: drop old flags (0123456)\n\n### Features\n- feat: add release command (0123456)\n\n### Bug Fixes\n- fix: handle empty tags (0123456)\n"
321 );
322 }
323
324 #[test]
325 fn release_notes_flag_truncated_commit_ranges() {
326 let notes = release_notes("v1.0.0", &[], true);
327
328 assert!(notes.contains("truncated"), "{notes}");
329 }
330
331 #[test]
332 fn release_notes_list_maintenance_commits_when_nothing_else_qualifies() {
333 let commits = classify_commits(&[
334 commit("ci(automation): audit the App weekly"),
335 commit("build(docker): move to trixie"),
336 commit("chore(deps): bump serde"),
337 commit("docs: explain trusted publishing"),
338 commit("Update README.md"),
339 ]);
340
341 let notes = release_notes("v0.7.8", &commits, false);
342
343 assert_eq!(
344 notes,
345 "## Release v0.7.8\n\n### Maintenance\n- ci(automation): audit the App weekly (0123456)\n- build(docker): move to trixie (0123456)\n- chore(deps): bump serde (0123456)\n- docs: explain trusted publishing (0123456)\n"
346 );
347 }
348
349 #[test]
350 fn release_notes_leave_maintenance_out_beside_features_or_fixes() {
351 for headline in ["fix: repair thing", "feat: add thing", "refactor!: drop thing"] {
352 let commits = classify_commits(&[commit(headline), commit("ci: tidy workflows")]);
353
354 let notes = release_notes("v1.0.0", &commits, false);
355
356 assert!(!notes.contains("Maintenance"), "{notes}");
357 assert!(!notes.contains("ci: tidy workflows"), "{notes}");
358 }
359 }
360
361 #[test]
362 fn release_notes_without_conventional_commits_stay_bare() {
363 let commits = classify_commits(&[commit("Update README.md"), commit("wip")]);
364
365 assert_eq!(release_notes("v1.0.1", &commits, false), "## Release v1.0.1\n");
366 assert_eq!(
367 release_notes("v1.0.1", &commits, true),
368 "## Release v1.0.1\n\n_Note: the commit list was truncated; some changes may be missing._\n"
369 );
370 }
371
372 #[test]
373 fn conventional_subjects_are_recognized_by_grammar_not_type() {
374 assert!(is_conventional_subject("chore(release): v1"));
375 assert!(is_conventional_subject("perf!: faster"));
376 assert!(!is_conventional_subject("Merge branch main"));
377 assert!(!is_conventional_subject("(ci): missing type"));
378 }
379}