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/// Render markdown release notes grouped by commit kind, listing breaking
108/// changes first and omitting empty sections.
109#[must_use]
110pub fn release_notes(tag: &str, commits: &[ConventionalCommit], truncated: bool) -> String {
111    use std::fmt::Write as _;
112
113    let mut notes = format!("## Release {tag}\n");
114    let sections = [
115        (CommitKind::Breaking, "Breaking Changes"),
116        (CommitKind::Feature, "Features"),
117        (CommitKind::Fix, "Bug Fixes"),
118    ];
119
120    for (kind, title) in sections {
121        let mut header_written = false;
122        for commit in commits.iter().filter(|commit| commit.kind == kind) {
123            if !header_written {
124                notes.push('\n');
125                writeln!(notes, "### {title}").expect("writing to a String cannot fail");
126                header_written = true;
127            }
128            let short_sha = commit.sha.get(..7).unwrap_or(&commit.sha);
129            writeln!(notes, "- {} ({short_sha})", commit.subject)
130                .expect("writing to a String cannot fail");
131        }
132    }
133
134    if truncated {
135        notes.push('\n');
136        writeln!(notes, "_Note: the commit list was truncated; some changes may be missing._")
137            .expect("writing to a String cannot fail");
138    }
139
140    notes
141}
142
143#[cfg(test)]
144mod tests {
145    use semver::Version;
146
147    use super::{
148        BumpLevel, CommitKind, bump_version, classify_commits, release_notes, required_bump,
149    };
150    use crate::github::CommitInfo;
151
152    fn commit(message: &str) -> CommitInfo {
153        CommitInfo {
154            sha: String::from("0123456789abcdef"),
155            message: message.to_owned(),
156            is_merge: false,
157        }
158    }
159
160    #[test]
161    fn classify_commits_maps_conventional_types() {
162        let commits = [
163            commit("feat: add release command"),
164            commit("fix(parser): handle empty scope"),
165            commit("docs: update readme"),
166            commit("chore: bump dependencies"),
167        ];
168
169        let classified = classify_commits(&commits);
170
171        assert_eq!(classified[0].kind, CommitKind::Feature);
172        assert_eq!(classified[1].kind, CommitKind::Fix);
173        assert_eq!(classified[2].kind, CommitKind::Other);
174        assert_eq!(classified[3].kind, CommitKind::Other);
175    }
176
177    #[test]
178    fn classify_commits_does_not_treat_feature_prefix_as_feat() {
179        let classified = classify_commits(&[commit("feature: not conventional feat")]);
180
181        assert_eq!(classified[0].kind, CommitKind::Other);
182    }
183
184    #[test]
185    fn classify_commits_detects_breaking_bang_marker() {
186        let classified = classify_commits(&[
187            commit("feat!: drop legacy flags"),
188            commit("refactor(core)!: rework internals"),
189        ]);
190
191        assert_eq!(classified[0].kind, CommitKind::Breaking);
192        assert_eq!(classified[1].kind, CommitKind::Breaking);
193    }
194
195    #[test]
196    fn classify_commits_detects_breaking_change_footer() {
197        let classified = classify_commits(&[
198            commit("fix: adjust defaults\n\nBREAKING CHANGE: defaults changed"),
199            commit("chore: cleanup\n\nBREAKING-CHANGE: removed helper"),
200        ]);
201
202        assert_eq!(classified[0].kind, CommitKind::Breaking);
203        assert_eq!(classified[1].kind, CommitKind::Breaking);
204    }
205
206    #[test]
207    fn classify_commits_ignores_mid_line_breaking_mentions() {
208        let classified =
209            classify_commits(&[commit("docs: describe the BREAKING CHANGE process in text")]);
210
211        assert_eq!(classified[0].kind, CommitKind::Other);
212    }
213
214    #[test]
215    fn classify_commits_skips_merge_commits() {
216        let merge = CommitInfo {
217            sha: String::from("mergesha"),
218            message: String::from("Merge pull request #1 from acme/feat"),
219            is_merge: true,
220        };
221
222        let classified = classify_commits(&[merge, commit("fix: real change")]);
223
224        assert_eq!(classified.len(), 1);
225        assert_eq!(classified[0].kind, CommitKind::Fix);
226    }
227
228    #[test]
229    fn classify_commits_uses_first_line_as_subject() {
230        let classified = classify_commits(&[commit("feat: multi line\n\nbody detail")]);
231
232        assert_eq!(classified[0].subject, "feat: multi line");
233    }
234
235    #[test]
236    fn required_bump_prefers_the_strongest_level() {
237        let commits = classify_commits(&[
238            commit("fix: patch level"),
239            commit("feat: minor level"),
240            commit("feat!: major level"),
241        ]);
242
243        assert_eq!(required_bump(&commits), Some(BumpLevel::Major));
244    }
245
246    #[test]
247    fn required_bump_returns_none_for_chore_only_history() {
248        let commits = classify_commits(&[commit("chore: tidy"), commit("docs: notes")]);
249
250        assert_eq!(required_bump(&commits), None);
251    }
252
253    #[test]
254    fn bump_version_increments_and_resets_components() {
255        let current = Version::parse("1.2.3").expect("version");
256
257        assert_eq!(
258            bump_version(&current, BumpLevel::Major),
259            Version::parse("2.0.0").expect("version")
260        );
261        assert_eq!(
262            bump_version(&current, BumpLevel::Minor),
263            Version::parse("1.3.0").expect("version")
264        );
265        assert_eq!(
266            bump_version(&current, BumpLevel::Patch),
267            Version::parse("1.2.4").expect("version")
268        );
269    }
270
271    #[test]
272    fn bump_version_clears_prerelease_metadata() {
273        let current = Version::parse("1.2.3-rc.1+build.5").expect("version");
274
275        assert_eq!(
276            bump_version(&current, BumpLevel::Patch),
277            Version::parse("1.2.4").expect("version")
278        );
279    }
280
281    #[test]
282    fn release_notes_group_sections_and_short_shas() {
283        let commits = classify_commits(&[
284            commit("feat!: drop old flags"),
285            commit("feat: add release command"),
286            commit("fix: handle empty tags"),
287            commit("chore: noise"),
288        ]);
289
290        let notes = release_notes("v1.0.0", &commits, false);
291
292        assert_eq!(
293            notes,
294            "## 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"
295        );
296    }
297
298    #[test]
299    fn release_notes_flag_truncated_commit_ranges() {
300        let notes = release_notes("v1.0.0", &[], true);
301
302        assert!(notes.contains("truncated"), "{notes}");
303    }
304}