Skip to main content

github_actions_maintainer/
conventional.rs

1//! Conventional-commit classification and semantic-version bump computation.
2//!
3//! Subjects are matched against the Conventional Commits grammar
4//! (`type(scope)!: description`) on the first message line only. Breaking
5//! changes are detected from a `!` marker in the subject or a
6//! `BREAKING CHANGE` / `BREAKING-CHANGE` footer at the start of a body line.
7
8use 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/// Strength of a semantic-version bump, ordered weakest to strongest.
26#[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
48/// Classify commits by their conventional-commit subject, skipping merge
49/// commits entirely.
50pub 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
80/// Determine the strongest bump the commits require, or `None` when no commit
81/// warrants a release.
82pub 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/// Apply `level` to `current`, clearing any pre-release or build metadata.
95#[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/// True when `subject` follows the Conventional Commits grammar
108/// (`type(scope)!: description`), whatever its type.
109#[must_use]
110pub fn is_conventional_subject(subject: &str) -> bool {
111    SUBJECT_RE.is_match(subject)
112}
113
114/// Render markdown release notes grouped by commit kind, listing breaking
115/// changes first and omitting empty sections.
116///
117/// A release with no breaking change, feature, or fix (a forced or manual
118/// bump over `ci:`, `build:`, `chore:`, `docs:` and similar commits) would
119/// otherwise get an empty body, so its other conventional commits are listed
120/// under "Maintenance" instead. Releases that do have features or fixes keep
121/// listing only those, and subjects that are not conventional commits are
122/// never listed.
123#[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, &section);
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
151/// Append a `### {title}` section listing `commits` with short SHAs; nothing
152/// when `commits` is empty.
153fn 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(&current, BumpLevel::Major),
285            Version::parse("2.0.0").expect("version")
286        );
287        assert_eq!(
288            bump_version(&current, BumpLevel::Minor),
289            Version::parse("1.3.0").expect("version")
290        );
291        assert_eq!(
292            bump_version(&current, 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(&current, 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}