Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 102 additions & 14 deletions crates/mono-changeset/src/changeset/change.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

//! Change.

use std::collections::BTreeSet;
use std::fmt::{self, Write};
use std::str::FromStr;

Expand All @@ -47,6 +48,8 @@ pub struct Change {
kind: Kind,
/// Change summary.
summary: String,
/// Change references.
references: Vec<u32>,
/// Change is breaking.
is_breaking: bool,
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
})
}
}

Expand All @@ -207,8 +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<u32>) -> Result<String> {
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::<u32>() 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
if start < value.len() {
summary.push(value[start..].trim());
}
Ok(summary.join(" "))
}

// ----------------------------------------------------------------------------
Expand Down
6 changes: 3 additions & 3 deletions crates/mono-changeset/src/changeset/change/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
}

// ----------------------------------------------------------------------------
Expand Down
21 changes: 8 additions & 13 deletions crates/mono-changeset/src/changeset/changelog/section/item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,6 @@ pub struct Item<'a> {
revision: &'a Revision<'a>,
/// Affected scopes.
scopes: Vec<&'a str>,
/// Relevant issues.
issues: Vec<u32>,
}

// ----------------------------------------------------------------------------
Expand All @@ -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 });
}
}

Expand Down Expand Up @@ -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(", ")?;
}
}
Expand Down
29 changes: 0 additions & 29 deletions crates/mono-changeset/src/changeset/revision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,6 @@ pub struct Revision<'a> {
change: Change,
/// Affected scopes.
scopes: Vec<usize>,
/// Relevant issues.
issues: Vec<u32>,
}

// ----------------------------------------------------------------------------
Expand All @@ -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
}
}

// ----------------------------------------------------------------------------
Expand Down Expand Up @@ -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(),
});
}

Expand Down Expand Up @@ -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<u32> {
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()
}
58 changes: 20 additions & 38 deletions crates/mono/src/cli/command/validate/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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()?
Expand All @@ -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())?;
}
}
}
Expand Down Expand Up @@ -189,12 +180,3 @@ fn parse_summary(summary: &str) -> Option<Change> {
// Return nothing
None
}

/// Parses issue references from the given commit body, e.g., `#123`.
fn parse_issues(body: &str) -> impl Iterator<Item = u32> {
body.split_whitespace().filter_map(|word| {
word.trim_matches(|char: char| !char.is_ascii_digit() && char != '#')
.strip_prefix('#')
.and_then(|num| num.parse().ok())
})
}
Loading