From 11f5bc82d8d899a76539bdf31e8902ea2eb2a256 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Thu, 18 Dec 2025 16:53:36 +0100 Subject: [PATCH 1/3] refactor!: move change reference from commit body to summary Signed-off-by: squidfunk --- crates/mono-changeset/src/changeset/change.rs | 114 +++++++++++++++--- .../src/changeset/change/error.rs | 6 +- .../src/changeset/changelog/section/item.rs | 21 ++-- .../mono-changeset/src/changeset/revision.rs | 29 ----- 4 files changed, 111 insertions(+), 59 deletions(-) diff --git a/crates/mono-changeset/src/changeset/change.rs b/crates/mono-changeset/src/changeset/change.rs index c36eb6f..6dd6cd9 100644 --- a/crates/mono-changeset/src/changeset/change.rs +++ b/crates/mono-changeset/src/changeset/change.rs @@ -25,6 +25,7 @@ //! Change. +use std::collections::BTreeSet; use std::fmt::{self, Write}; use std::str::FromStr; @@ -47,6 +48,8 @@ pub struct Change { kind: Kind, /// Change summary. summary: String, + /// Change references. + references: Vec, /// Change is breaking. is_breaking: bool, } @@ -105,6 +108,12 @@ impl Change { &self.summary } + /// Returns the change references. + #[inline] + pub fn references(&self) -> &[u32] { + &self.references + } + /// Returns whether the change is breaking. #[inline] pub fn is_breaking(&self) -> bool { @@ -179,19 +188,15 @@ impl FromStr for Change { return Err(Error::Punctuation); } - // Ensure summary doesn't contain an issue or pull request reference as - // those should be moved into the commit body - if let Some(rest) = summary.split(" #").nth(1) { - if let Some(word) = rest.split_whitespace().next() { - if word.chars().all(|char| char.is_ascii_digit()) { - return Err(Error::Reference); - } - } - } - - // Return change - let summary = summary.to_string(); - Ok(Change { kind, summary, is_breaking }) + // Extract references from the summary, and ensure they are sorted, + // which is why we use a sorted set instead of a vector here + let mut references = BTreeSet::new(); + Ok(Change { + kind, + summary: extract(summary, &mut references)?, + references: Vec::from_iter(references), + is_breaking, + }) } } @@ -207,10 +212,91 @@ impl fmt::Display for Change { // Write summary f.write_str(": ")?; - self.summary.fmt(f) + self.summary.fmt(f)?; + + // Write references + if !self.references.is_empty() { + f.write_str(" (")?; + for (i, reference) in self.references.iter().enumerate() { + f.write_char('#')?; + reference.fmt(f)?; + + // Write comma if not last + if i < self.references.len() - 1 { + f.write_str(", ")?; + } + } + f.write_char(')')?; + } + + // No errors occurred + Ok(()) } } +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Extracts references in the format `(#123)` from the given string, or fails +/// if a reference was found but it's not wrapped in parenthesis. +fn extract(value: &str, references: &mut BTreeSet) -> Result { + let mut summary = Vec::new(); + + // Iterate over the given string, searching for references + let mut start = 0; + for end in 0..value.len() { + if end < start { + continue; + } + + // Check, if the current character is a '#', which might indicate a + // reference if and only if followed by numeric characters + if &value[end..=end] != "#" { + continue; + } + + // Now, try to read as many numeric characters as possible after the + // '#', and consider it a reference if we found any + let rest = &value[end + 1..]; + let Some(after) = rest.find(|char: char| !char.is_numeric()) else { + continue; + }; + + // In case we found a reference, parse it, and add it to the list if + // it is wrapped in parenthesis. Otherwise, fail with an error. + if after > 0 { + let Ok(reference) = rest[0..after].parse::() else { + continue; + }; + + // Check format if we're not at the beginning of the string + if end > 0 { + let opening = value.chars().nth(end - 1); + let closing = value.chars().nth(end + after + 1); + + // Next, check if the characters before the '#' and after the + // reference are both parenthesis, as this is required + if opening == Some('(') && closing == Some(')') { + references.insert(reference); + } else { + return Err(Error::Reference); + } + + // Extract summary part before the reference, and move the + // start position exactly after the reference + summary.push(value[start..end - 1].trim()); + start = end + after + 2; + } + } + } + + // Extract remaining part of the summary, and join parts with whitespace + // to return them as a cleaned up version of the original summary + summary.push(value[start..].trim()); + Ok(summary.join(" ")) +} + // ---------------------------------------------------------------------------- // Tests // ---------------------------------------------------------------------------- diff --git a/crates/mono-changeset/src/changeset/change/error.rs b/crates/mono-changeset/src/changeset/change/error.rs index 62ca4fe..40c77db 100644 --- a/crates/mono-changeset/src/changeset/change/error.rs +++ b/crates/mono-changeset/src/changeset/change/error.rs @@ -41,6 +41,9 @@ pub enum Error { /// Invalid kind. #[error("invalid kind")] Kind, + /// Invalid reference. + #[error("invalid reference")] + Reference, /// Summary has leading or trailing whitespace. #[error("summary has leading or trailing whitespace")] Whitespace, @@ -50,9 +53,6 @@ pub enum Error { /// Summary must not end with punctuation. #[error("summary must not end with punctuation")] Punctuation, - /// Summary must not contain issue references. - #[error("summary must not contain issue references")] - Reference, } // ---------------------------------------------------------------------------- diff --git a/crates/mono-changeset/src/changeset/changelog/section/item.rs b/crates/mono-changeset/src/changeset/changelog/section/item.rs index e1f248b..53261c9 100644 --- a/crates/mono-changeset/src/changeset/changelog/section/item.rs +++ b/crates/mono-changeset/src/changeset/changelog/section/item.rs @@ -43,8 +43,6 @@ pub struct Item<'a> { revision: &'a Revision<'a>, /// Affected scopes. scopes: Vec<&'a str>, - /// Relevant issues. - issues: Vec, } // ---------------------------------------------------------------------------- @@ -62,11 +60,7 @@ impl<'a> Section<'a> { } // Create item and add to section - self.items.push(Item { - revision, - scopes: affected, - issues: revision.issues().to_vec(), - }); + self.items.push(Item { revision, scopes: affected }); } } @@ -95,20 +89,21 @@ impl fmt::Display for Item<'_> { } } - // Retrieve change and write summary + // Write summary let change = self.revision.change(); f.write_str(" – ")?; f.write_str(change.summary())?; - // Write relevant issues - if !self.issues.is_empty() { + // Write references + let references = change.references(); + if !references.is_empty() { f.write_str(" (")?; - for (i, issue) in self.issues.iter().enumerate() { + for (i, reference) in references.iter().enumerate() { f.write_char('#')?; - issue.fmt(f)?; + reference.fmt(f)?; // Write comma if not last - if i < self.issues.len() - 1 { + if i < references.len() - 1 { f.write_str(", ")?; } } diff --git a/crates/mono-changeset/src/changeset/revision.rs b/crates/mono-changeset/src/changeset/revision.rs index 0aa8d90..cd963bc 100644 --- a/crates/mono-changeset/src/changeset/revision.rs +++ b/crates/mono-changeset/src/changeset/revision.rs @@ -48,8 +48,6 @@ pub struct Revision<'a> { change: Change, /// Affected scopes. scopes: Vec, - /// Relevant issues. - issues: Vec, } // ---------------------------------------------------------------------------- @@ -75,12 +73,6 @@ impl Revision<'_> { pub fn scopes(&self) -> &[usize] { &self.scopes } - - /// Returns a reference to the relevant issues. - #[inline] - pub fn issues(&self) -> &[u32] { - &self.issues - } } // ---------------------------------------------------------------------------- @@ -111,16 +103,11 @@ impl<'a> Changeset<'a> { cmp::max(self.increments[index], increment); } - // Next, try to find issue references in the commit body, denoted - // by a hash sign followed by a number, e.g., "#123" - let issues = commit.body().map(parse_issues).unwrap_or_default(); - // Create revision and add to changeset self.revisions.push(Revision { commit, change, scopes: scopes.into_iter().collect(), - issues: issues.into_iter().collect(), }); } @@ -153,19 +140,3 @@ impl<'a> Changeset<'a> { Ok(()) } } - -// ---------------------------------------------------------------------------- -// Functions -// ---------------------------------------------------------------------------- - -/// Parses issue references from the given commit body, e.g., `#123`. -fn parse_issues(body: &str) -> BTreeSet { - let iter = body.split_whitespace().filter_map(|word| { - word.trim_matches(|char: char| !char.is_ascii_digit() && char != '#') - .strip_prefix('#') - .and_then(|num| num.parse().ok()) - }); - - // Collect issue references into set to avoid duplicates - iter.collect() -} From d2628be90e7df455fc0b2bcf8c1c693f08f591c0 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Thu, 18 Dec 2025 16:59:20 +0100 Subject: [PATCH 2/3] refactor: commit validator must check summary for reference Signed-off-by: squidfunk --- .../mono/src/cli/command/validate/commit.rs | 58 +++++++------------ 1 file changed, 20 insertions(+), 38 deletions(-) diff --git a/crates/mono/src/cli/command/validate/commit.rs b/crates/mono/src/cli/command/validate/commit.rs index 7ea918a..9aaf37e 100644 --- a/crates/mono/src/cli/command/validate/commit.rs +++ b/crates/mono/src/cli/command/validate/commit.rs @@ -28,7 +28,6 @@ use clap::{ArgGroup, Args}; use cliclack::{confirm, input, outro}; use console::style; -use std::io::Write; use std::path::PathBuf; use std::str::FromStr; use std::{fs, process}; @@ -92,15 +91,16 @@ where } else { let path = self.file.as_ref().expect("invariant"); let message = fs::read_to_string(path)?; - if let Some(summary) = message.lines().next() { - if parse_summary(summary).is_none() { - process::exit(1); - } - } + + // Retrieve first line, and parse as summary + let summary = message.lines().next().unwrap_or_default(); + let Some(change) = parse_summary(summary) else { + process::exit(1) + }; // Prompt the user for missing information - let mut issues = parse_issues(message.as_str()); - if self.prompt && issues.next().is_none() { + let references = change.references(); + if self.prompt && references.is_empty() { if confirm("Is this commit related to an issue?") .initial_value(true) .interact()? @@ -110,35 +110,26 @@ where .placeholder(" e.g. 123") .interact()?; - // Prompt whether the issue is resolved with the commit - let is_resolved = - confirm("Does the commit resolve the issue?") - .initial_value(false) - .interact()?; - - // Create the appropriate message based on the response - let action = if is_resolved { - format!("Resolves #{num}") - } else { - format!("Concerns #{num}") - }; - - // Append message to commit message file - writeln!( - fs::OpenOptions::new().append(true).open(path)?, - "\n{action}" - )?; + // Append the reference to the first line + let mut lines: Vec<&str> = message.lines().collect(); + let first = lines.get_mut(0).expect("invariant"); + let slice = format!("{first} (#{num})"); + *first = &slice; + + // Join lines and write back to file + let new_message = lines.join("\n"); + fs::write(path, new_message)?; // Denote completion of prompt to the user outro(format!( "{} {}", - style(action), - style("added to commit body").dim() + style(format!("(#{num})")), + style("added to commit summary").dim() ))?; // Issue is not related to a commit } else { - outro(style("Nothing added to commit body").dim())?; + outro(style("Nothing added to commit summary").dim())?; } } } @@ -189,12 +180,3 @@ fn parse_summary(summary: &str) -> Option { // Return nothing None } - -/// Parses issue references from the given commit body, e.g., `#123`. -fn parse_issues(body: &str) -> impl Iterator { - body.split_whitespace().filter_map(|word| { - word.trim_matches(|char: char| !char.is_ascii_digit() && char != '#') - .strip_prefix('#') - .and_then(|num| num.parse().ok()) - }) -} From 08751cf2f3be0b106516860da8fe2235bdfd0a10 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Thu, 18 Dec 2025 17:04:30 +0100 Subject: [PATCH 3/3] fix: summary appears with trailing whitespace in changelog Signed-off-by: squidfunk --- crates/mono-changeset/src/changeset/change.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/mono-changeset/src/changeset/change.rs b/crates/mono-changeset/src/changeset/change.rs index 6dd6cd9..0e2594a 100644 --- a/crates/mono-changeset/src/changeset/change.rs +++ b/crates/mono-changeset/src/changeset/change.rs @@ -293,7 +293,9 @@ fn extract(value: &str, references: &mut BTreeSet) -> Result { // Extract remaining part of the summary, and join parts with whitespace // to return them as a cleaned up version of the original summary - summary.push(value[start..].trim()); + if start < value.len() { + summary.push(value[start..].trim()); + } Ok(summary.join(" ")) }