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 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(¤t, BumpLevel::Major),
259 Version::parse("2.0.0").expect("version")
260 );
261 assert_eq!(
262 bump_version(¤t, BumpLevel::Minor),
263 Version::parse("1.3.0").expect("version")
264 );
265 assert_eq!(
266 bump_version(¤t, 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(¤t, 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}