From 9f0db32598785e2b34d0e6bc261bcd0b0ded33d6 Mon Sep 17 00:00:00 2001 From: Colin Casey Date: Thu, 26 Oct 2023 15:41:22 -0300 Subject: [PATCH 1/3] More compliant Keep a Changelog parser --- src/changelog.rs | 343 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) diff --git a/src/changelog.rs b/src/changelog.rs index 22995d12..e2a1729f 100644 --- a/src/changelog.rs +++ b/src/changelog.rs @@ -696,3 +696,346 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Counter-examples: "What makes unicorns cry?". "#; } + +mod parser { + use chrono::{DateTime, LocalResult, TimeZone, Utc}; + use indexmap::IndexMap; + use lazy_static::lazy_static; + use markdown::mdast::Node; + use markdown::{to_mdast, ParseOptions}; + use regex::Regex; + use semver::Version; + use std::num::ParseIntError; + + const VERSION_CAPTURE: &str = r"(?P\d+\.\d+\.\d+)"; + const YEAR_CAPTURE: &str = r"(?P\d{4})"; + const MONTH_CAPTURE: &str = r"(?P\d{2})"; + const DAY_CAPTURE: &str = r"(?P\d{2})"; + + const TAG_CAPTURE: &str = r"(?P.+)"; + + lazy_static! { + static ref UNRELEASED_HEADER: Regex = + Regex::new(r"(?i)^\[?unreleased]?$").expect("Should be a valid regex"); + static ref VERSIONED_RELEASE_HEADER: Regex = Regex::new(&format!( + r"^\[?{VERSION_CAPTURE}]?\s+-\s+{YEAR_CAPTURE}[-/]{MONTH_CAPTURE}[-/]{DAY_CAPTURE}(?:\s+\[{TAG_CAPTURE}])?$" + )) + .expect("Should be a valid regex"); + } + + #[derive(Debug, Eq, PartialEq)] + pub(crate) struct Changelog { + pub(crate) unreleased: Option, + pub(crate) releases: Vec, + } + + #[derive(Debug, Eq, PartialEq)] + pub(crate) struct ReleaseEntry { + pub(crate) version: Version, + pub(crate) date: DateTime, + pub(crate) tag: Option, + pub(crate) contents: ReleaseContents, + } + + #[derive(Debug)] + pub(crate) enum ReleaseEntryType { + Unreleased, + Versioned(Version, DateTime, Option), + } + + #[derive(Debug, Eq, PartialEq)] + pub(crate) enum ReleaseTag { + Yanked, + NoChanges, + } + + #[derive(Debug, Eq, PartialEq)] + pub(crate) struct ReleaseContents { + change_groups: IndexMap>, + } + + #[derive(Debug, Eq, PartialEq, Hash)] + pub(crate) enum ChangeGroup { + Added, + Changed, + Deprecated, + Removed, + Fixed, + Security, + } + + #[derive(Debug, thiserror::Error)] + pub(crate) enum ParseChangelogError { + #[error("Could not parse changelog as markdown\nError: {0}")] + Markdown(String), + #[error("Could not parse change group type from changelog\nExpected: Added | Changed | Deprecated | Removed | Fixed | Security\nValue: {0}")] + InvalidChangeGroup(String), + #[error("Release header did not match the expected format\nExpected: [Unreleased] | [] - --
| [] - --
[]\nValue: {0}")] + NoMatchForReleaseHeading(String), + #[error("Invalid semver version in release entry - {0}\nValue: {1}\nError: {2}")] + Version(String, String, #[source] semver::Error), + #[error("Invalid year in release entry - {0}\nValue: {1}\nError: {2}")] + ReleaseEntryYear(String, String, #[source] ParseIntError), + #[error("Invalid month in release entry - {0}\nValue: {1}\nError: {2}")] + ReleaseEntryMonth(String, String, #[source] ParseIntError), + #[error("Invalid day in release entry - {0}\nValue: {1}\nError: {2}")] + ReleaseEntryDay(String, String, #[source] ParseIntError), + #[error("Invalid date in release entry - {0}\nValue: {1}-{2}-{3}")] + InvalidReleaseDate(String, i32, u32, u32), + #[error("Ambiguous date in release entry - {0}\nValue: {1}-{2}-{3}")] + AmbiguousReleaseDate(String, i32, u32, u32), + #[error( + "Could not parse release tag from changelog\nExpected: YANKED | NO CHANGES\nValue: {1}" + )] + InvalidReleaseTag(String, String), + } + + // Traverses the changelog written in markdown which has flattened entries that need to be parsed + // and converts those into a nested structure that matches the Keep a Changelog spec. For example, + // given the following markdown doc: + // + // ------------------------------------------ + // # Changelog → (Changelog) + // → - + // ## Unreleased → (ReleaseEntry::Unreleased) + // → (ReleaseContents) + // ## [x.y.z] yyyy-mm-dd → (ReleaseEntry::Versioned) + // → (ReleaseContents) + // ### Changed → (ChangeGroup) + // → (List) + // - foo → (List Item) + // - bar → (List Item) + // → - + // ### Removed → (ChangeGroup) + // → (List) + // - baz → (List Item) + // ------------------------------------------ + // This would be represented in our Changelog AST as: + // + // Changelog { + // unreleased: None, + // releases: [ + // ReleaseEntry { + // version: x.y.z, + // date: yyyy-mm-dd, + // tag: None, + // contents: ReleaseContents { + // "Changed": ["foo", "bar"], + // "Removed": ["baz"] + // } + // } + // ] + // } + pub(crate) fn parse_changelog(input: &str) -> Result { + let changelog_ast = + to_mdast(input, &ParseOptions::default()).map_err(ParseChangelogError::Markdown)?; + + let is_release_entry_heading = is_heading_of_depth(2); + let is_change_group_heading = is_heading_of_depth(3); + let is_list_node = |node: &Node| matches!(node, Node::List(_)); + + let mut unreleased = None; + let mut releases = vec![]; + + if let Node::Root(root) = changelog_ast { + // the peekable iterator here makes it easier to decide when to traverse to the next sibling + // node in the markdown AST to construct our nested structure + let mut root_iter = root.children.into_iter().peekable(); + while root_iter.peek().is_some() { + if let Some(release_heading_node) = root_iter.next_if(&is_release_entry_heading) { + let release_entry_type = + parse_release_heading(release_heading_node.to_string())?; + let mut change_groups = IndexMap::new(); + + while root_iter.peek().is_some_and(&is_change_group_heading) { + let change_group_node = root_iter.next().expect("This should be a change group heading node since we already peeked at it"); + let change_group = + parse_change_group_heading(change_group_node.to_string())?; + let changes = change_groups.entry(change_group).or_insert(vec![]); + + while root_iter.peek().is_some_and(is_list_node) { + let list_node = root_iter + .next() + .expect("This should be a list node since we already peeked at it"); + if let Some(list_items) = list_node.children() { + for list_item in list_items { + if matches!(list_item, Node::ListItem(_)) { + changes.push(list_item.to_string()); + } + } + } + } + } + + match release_entry_type { + ReleaseEntryType::Unreleased => { + unreleased = Some(ReleaseContents { change_groups }); + } + ReleaseEntryType::Versioned(version, date, tag) => { + releases.push(ReleaseEntry { + version, + date, + tag, + contents: ReleaseContents { change_groups }, + }); + } + } + } else { + root_iter.next(); + } + } + } + + Ok(Changelog { + unreleased, + releases, + }) + } + + fn is_heading_of_depth(depth: u8) -> impl Fn(&Node) -> bool { + move |node: &Node| { + if let Node::Heading(heading) = node { + return heading.depth == depth; + } + false + } + } + + fn parse_release_heading(heading: String) -> Result { + if UNRELEASED_HEADER.is_match(&heading) { + return Ok(ReleaseEntryType::Unreleased); + } + + if let Some(captures) = VERSIONED_RELEASE_HEADER.captures(&heading) { + let version = captures["version"] + .parse::() + .map_err(|e| { + ParseChangelogError::Version( + heading.clone(), + captures["version"].to_string(), + e, + ) + })?; + + let year = captures["year"].parse::().map_err(|e| { + ParseChangelogError::ReleaseEntryYear( + heading.clone(), + captures["year"].to_string(), + e, + ) + })?; + let month = captures["month"].parse::().map_err(|e| { + ParseChangelogError::ReleaseEntryMonth( + heading.clone(), + captures["month"].to_string(), + e, + ) + })?; + let day = captures["day"].parse::().map_err(|e| { + ParseChangelogError::ReleaseEntryDay( + heading.clone(), + captures["day"].to_string(), + e, + ) + })?; + + let date = match Utc.with_ymd_and_hms(year, month, day, 0, 0, 0) { + LocalResult::None => Err(ParseChangelogError::InvalidReleaseDate( + heading.clone(), + year, + month, + day, + )), + LocalResult::Single(value) => Ok(value), + LocalResult::Ambiguous(_, _) => Err(ParseChangelogError::AmbiguousReleaseDate( + heading.clone(), + year, + month, + day, + )), + }?; + + let tag = if let Some(tag_value) = captures.name("tag") { + match tag_value.as_str().to_lowercase().as_str() { + "no changes" => Ok(Some(ReleaseTag::NoChanges)), + "yanked" => Ok(Some(ReleaseTag::Yanked)), + _ => Err(ParseChangelogError::InvalidReleaseTag( + heading.clone(), + captures["tag"].to_string(), + )), + }? + } else { + None + }; + + Ok(ReleaseEntryType::Versioned(version, date, tag)) + } else { + Err(ParseChangelogError::NoMatchForReleaseHeading(heading)) + } + } + + fn parse_change_group_heading(heading: String) -> Result { + match heading.trim().to_lowercase().as_str() { + "added" => Ok(ChangeGroup::Added), + "changed" => Ok(ChangeGroup::Changed), + "deprecated" => Ok(ChangeGroup::Deprecated), + "removed" => Ok(ChangeGroup::Removed), + "fixed" => Ok(ChangeGroup::Fixed), + "security" => Ok(ChangeGroup::Security), + _ => Err(ParseChangelogError::InvalidChangeGroup(heading)), + } + } + + impl TryFrom<&str> for Changelog { + type Error = ParseChangelogError; + + fn try_from(value: &str) -> Result { + parse_changelog(value) + } + } + + #[cfg(test)] + mod test { + use super::*; + + #[test] + fn simple_test() { + let changelog = parse_changelog(" +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Node version x.y.z + +## [1.1.6] - 2023-01-25 + +### Added + +- Add basic OpenTelemetry tracing. ([#652](https://github.com/heroku/buildpacks-nodejs/pull/652)) + +## [1.1.5] - 2023-09-19 [NO CHANGES] + +## [1.1.4] - 2023-08-10 + +### Changed + +- Upgrade to Buildpack API version `0.9`. ([#552](https://github.com/heroku/buildpacks-nodejs/pull/552)) + +### Removed + +- Drop explicit support for the End-of-Life stack `heroku-18`. + +[unreleased]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.1.1...HEAD +[1.1.1]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.1.0...v1.1.1", + ).unwrap(); + println!("{changelog:?}"); + } + } +} From 7eec2a7eb412432f4d30c38bbbde1514a377d274 Mon Sep 17 00:00:00 2001 From: Colin Casey Date: Thu, 26 Oct 2023 16:06:23 -0300 Subject: [PATCH 2/3] More compliant Keep a Changelog parser --- src/changelog.rs | 93 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 84 insertions(+), 9 deletions(-) diff --git a/src/changelog.rs b/src/changelog.rs index e2a1729f..30594ead 100644 --- a/src/changelog.rs +++ b/src/changelog.rs @@ -705,6 +705,7 @@ mod parser { use markdown::{to_mdast, ParseOptions}; use regex::Regex; use semver::Version; + use std::fmt::{Display, Formatter}; use std::num::ParseIntError; const VERSION_CAPTURE: &str = r"(?P\d+\.\d+\.\d+)"; @@ -994,22 +995,94 @@ mod parser { } } + impl Display for Changelog { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + r" +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + " + .trim() + )?; + + if let Some(unreleased) = &self.unreleased { + write!(f, "\n\n## [Unreleased]\n\n{unreleased}")?; + } else { + write!(f, "\n\n## [Unreleased]")?; + } + + for entry in &self.releases { + write!( + f, + "\n\n## [{}] - {}", + entry.version, + entry.date.format("%Y-%m-%d") + )?; + if let Some(tag) = &entry.tag { + write!(f, " [{tag}]")?; + } + write!(f, "\n\n{}", entry.contents)?; + } + + writeln!(f) + } + } + + impl Display for ReleaseContents { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + for (change_group, items) in &self.change_groups { + if !items.is_empty() { + write!(f, "### {change_group}\n\n")?; + for item in items { + writeln!(f, "- {item}")?; + } + } + } + writeln!(f) + } + } + + impl Display for ReleaseTag { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ReleaseTag::Yanked => write!(f, "YANKED"), + ReleaseTag::NoChanges => write!(f, "NO CHANGES"), + } + } + } + + impl Display for ChangeGroup { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ChangeGroup::Added => write!(f, "Added"), + ChangeGroup::Changed => write!(f, "Changed"), + ChangeGroup::Deprecated => write!(f, "Deprecated"), + ChangeGroup::Removed => write!(f, "Removed"), + ChangeGroup::Fixed => write!(f, "Fixed"), + ChangeGroup::Security => write!(f, "Security"), + } + } + } + #[cfg(test)] mod test { use super::*; - #[test] - fn simple_test() { - let changelog = parse_changelog(" -# Changelog + const CHANGELOG: &str = "# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - + ## [Unreleased] - + ### Added - Node version x.y.z @@ -1033,9 +1106,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Drop explicit support for the End-of-Life stack `heroku-18`. [unreleased]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.1.1...HEAD -[1.1.1]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.1.0...v1.1.1", - ).unwrap(); - println!("{changelog:?}"); +[1.1.1]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.1.0...v1.1.1"; + + #[test] + fn simple_test() { + assert_eq!(parse_changelog(CHANGELOG).unwrap().to_string(), CHANGELOG); } } } From 5053b35e788dd7996d5e4d1b2b1ce84fac4e4edc Mon Sep 17 00:00:00 2001 From: Colin Casey Date: Fri, 24 Nov 2023 20:20:45 -0400 Subject: [PATCH 3/3] More compliant Keep a Changelog parser --- Cargo.lock | 22 +- Cargo.toml | 7 +- src/changelog.rs | 1116 -------------------- src/commands/generate_changelog/command.rs | 154 ++- src/commands/generate_changelog/errors.rs | 5 +- src/commands/prepare_release/command.rs | 668 ++++++------ src/commands/prepare_release/errors.rs | 13 +- src/main.rs | 1 - 8 files changed, 509 insertions(+), 1477 deletions(-) delete mode 100644 src/changelog.rs diff --git a/Cargo.lock b/Cargo.lock index 6f2f93e5..9ee0b48e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -344,22 +344,32 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keep_a_changelog" +version = "0.1.0" +source = "git+https://github.com/colincasey/keep_a_changelog.git#ae3de50600e5efde76287f7f42aceb6dc7d2fece" +dependencies = [ + "chrono", + "indexmap", + "lazy_static", + "markdown", + "regex", + "semver", + "thiserror", + "uriparse", +] + [[package]] name = "languages-github-actions" version = "0.0.1" dependencies = [ - "chrono", "clap", "ignore", - "indexmap", - "lazy_static", + "keep_a_changelog", "libcnb-common", "libcnb-data", "libcnb-package", - "markdown", "rand", - "regex", - "semver", "serde_json", "thiserror", "toml 0.7.6", diff --git a/Cargo.toml b/Cargo.toml index 2babcaf8..6c6cc655 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ path = "src/main.rs" strip = true [dependencies] -chrono = "0.4" clap = { version = "4", default-features = false, features = [ "derive", "error-context", @@ -24,15 +23,11 @@ clap = { version = "4", default-features = false, features = [ "usage", ] } ignore = "0.4" -indexmap = "2" -lazy_static = "1" +keep_a_changelog = { git = "https://github.com/colincasey/keep_a_changelog.git" } libcnb-common = "=0.15.0" libcnb-data = "=0.15.0" libcnb-package = "=0.15.0" -markdown = "1.0.0-alpha.14" rand = "0.8" -regex = "1" -semver = "1" serde_json = "1" thiserror = "1" toml_edit = "0.19" diff --git a/src/changelog.rs b/src/changelog.rs deleted file mode 100644 index 30594ead..00000000 --- a/src/changelog.rs +++ /dev/null @@ -1,1116 +0,0 @@ -use chrono::{DateTime, LocalResult, TimeZone, Utc}; -use indexmap::IndexMap; -use lazy_static::lazy_static; -use markdown::mdast::Node; -use markdown::{to_mdast, ParseOptions}; -use regex::Regex; -use semver::Version; -use std::cmp::Ordering; -use std::collections::HashMap; -use std::fmt::{Display, Formatter}; -use std::num::ParseIntError; - -#[derive(Debug, Eq, PartialEq)] -pub(crate) struct Changelog { - pub(crate) unreleased: Option, - pub(crate) releases: IndexMap, -} - -impl TryFrom<&str> for Changelog { - type Error = ChangelogError; - - fn try_from(value: &str) -> Result { - lazy_static! { - static ref UNRELEASED_HEADER: Regex = - Regex::new(r"(?i)^\[?unreleased]?$").expect("Should be a valid regex"); - static ref VERSION_HEADER: Regex = - Regex::new(r"^\[?(\d+\.\d+\.\d+)]?.*(\d{4})[-/](\d{2})[-/](\d{2})") - .expect("Should be a valid regex"); - } - - let changelog_ast = - to_mdast(value, &ParseOptions::default()).map_err(ChangelogError::Parse)?; - - let mut current_header: Option = None; - let mut headers: Vec = vec![]; - let mut body_nodes_by_header: HashMap> = HashMap::new(); - - if let Node::Root(root) = changelog_ast { - for child in &root.children { - if let Node::Heading(heading) = child { - match heading.depth.cmp(&2) { - Ordering::Equal => { - headers.push(child.to_string()); - current_header = Some(child.to_string()); - } - Ordering::Less => { - current_header = None; - } - Ordering::Greater => { - if let Some(header) = ¤t_header { - let body_nodes = - body_nodes_by_header.entry(header.clone()).or_default(); - body_nodes.push(child); - } - } - } - } else if let Node::Definition(_) = child { - // ignore any defined links, these will be regenerated at display time - } else if let Some(header) = ¤t_header { - let body_nodes = body_nodes_by_header.entry(header.clone()).or_default(); - body_nodes.push(child); - } - } - - let mut unreleased = None; - let mut releases = IndexMap::new(); - - for header in headers { - let empty_nodes = vec![]; - let body_nodes = body_nodes_by_header.get(&header).unwrap_or(&empty_nodes); - - let start = body_nodes - .iter() - .next() - .map(|node| node.position().map(|position| position.start.offset)) - .unwrap_or_default(); - let end = body_nodes - .iter() - .last() - .map(|node| node.position().map(|position| position.end.offset)) - .unwrap_or_default(); - - let body = if let (Some(start), Some(end)) = (start, end) { - &value[start..end] - } else { - "" - }; - - let body = body.trim().to_string(); - - if UNRELEASED_HEADER.is_match(&header) && !body.is_empty() { - unreleased = Some(body); - } else if let Some(captures) = VERSION_HEADER.captures(&header) { - let version = captures[1] - .parse::() - .map_err(ChangelogError::ParseVersion)?; - let year = captures[2] - .parse::() - .map_err(ChangelogError::ParseReleaseEntryYear)?; - let month = captures[3] - .parse::() - .map_err(ChangelogError::ParseReleaseEntryMonth)?; - let day = captures[4] - .parse::() - .map_err(ChangelogError::ParseReleaseEntryDay)?; - let date = match Utc.with_ymd_and_hms(year, month, day, 0, 0, 0) { - LocalResult::None => Err(ChangelogError::InvalidReleaseDate(format!("Could not convert year: {year}, month: {month}, day: {day} into a valid date from {header:?}"))), - LocalResult::Single(value) => Ok(value), - LocalResult::Ambiguous(_, _) => Err(ChangelogError::AmbiguousReleaseDate), - }?; - releases.insert( - version.to_string(), - ReleaseEntry { - version, - date, - body, - }, - ); - } - } - - Ok(Changelog { - unreleased, - releases, - }) - } else { - Err(ChangelogError::NoRootNode) - } - } -} - -impl Display for Changelog { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - r" -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - " - .trim() - )?; - - if let Some(unreleased) = &self.unreleased { - write!(f, "\n\n## [Unreleased]\n\n{}", unreleased.trim())?; - } else { - write!(f, "\n\n## [Unreleased]")?; - } - - for entry in self.releases.values() { - write!( - f, - "\n\n## [{}] - {}", - entry.version, - entry.date.format("%Y-%m-%d") - )?; - if !entry.body.is_empty() { - write!(f, "\n\n{}", entry.body.trim())?; - } - } - - writeln!(f) - } -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub(crate) struct ReleaseEntry { - pub(crate) version: Version, - pub(crate) date: DateTime, - pub(crate) body: String, -} - -#[derive(Debug, thiserror::Error)] -pub(crate) enum ChangelogError { - #[error("No root node in changelog markdown")] - NoRootNode, - #[error("Could not parse changelog - {0}")] - Parse(String), - #[error("Invalid semver version in release entry - {0}")] - ParseVersion(#[source] semver::Error), - #[error("Invalid year in release entry - {0}")] - ParseReleaseEntryYear(#[source] ParseIntError), - #[error("Invalid month in release entry - {0}")] - ParseReleaseEntryMonth(#[source] ParseIntError), - #[error("Invalid day in release entry - {0}")] - ParseReleaseEntryDay(#[source] ParseIntError), - #[error("Invalid date in release entry - {0}")] - InvalidReleaseDate(String), - #[error("Ambiguous date in release entry")] - AmbiguousReleaseDate, -} - -pub(crate) fn generate_release_declarations>( - changelog: &Changelog, - repository: S, - starting_with_version: &Option, -) -> String { - let repository = repository.into(); - - let mut versions = changelog.releases.values().filter_map(|release| { - if let Some(starting_version) = &starting_with_version { - if starting_version.le(&release.version) { - Some(&release.version) - } else { - None - } - } else { - Some(&release.version) - } - }); - - let mut declarations = vec![]; - let mut previous_version = versions.next(); - - declarations.push(if let Some(version) = previous_version { - format!("[unreleased]: {repository}/compare/v{version}...HEAD") - } else { - format!("[unreleased]: {repository}") - }); - - for next_version in versions { - if let Some(version) = previous_version { - declarations.push(format!( - "[{version}]: {repository}/compare/v{next_version}...v{version}" - )); - } - previous_version = Some(next_version); - } - - if let Some(version) = previous_version { - declarations.push(format!("[{version}]: {repository}/releases/tag/v{version}")); - } - - declarations.join("\n") -} - -#[cfg(test)] -mod test { - use crate::changelog::{generate_release_declarations, Changelog}; - use chrono::{TimeZone, Utc}; - use semver::{BuildMetadata, Prerelease, Version}; - - #[test] - fn test_keep_a_changelog_unreleased_entry_with_changes_parsing() { - let changelog = Changelog::try_from("## [Unreleased]\n\n- Some changes").unwrap(); - assert_eq!(changelog.unreleased, Some("- Some changes".to_string())); - } - - #[test] - fn test_blank_release_0_5_5_entry_from_jvm_repo() { - let changelog = Changelog::try_from( - "## [Unreleased] - -## [0.6.0] 2022/01/05 - -- Switch to BSD 3-Clause License -- Upgrade to libcnb version 0.4.0 -- Updated function runtime to 1.0.5 - -## [0.5.5] 2021/10/19 - -## [0.5.4] 2021/09/30 - -- Updated function runtime to 1.0.3", - ) - .unwrap(); - assert_eq!(changelog.releases.get("0.5.5").unwrap().body, ""); - assert_eq!( - changelog.to_string(), - "# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -## [0.6.0] - 2022-01-05 - -- Switch to BSD 3-Clause License -- Upgrade to libcnb version 0.4.0 -- Updated function runtime to 1.0.5 - -## [0.5.5] - 2021-10-19 - -## [0.5.4] - 2021-09-30 - -- Updated function runtime to 1.0.3 -" - ); - } - - #[test] - fn test_keep_a_changelog_unreleased_entry_with_no_changes_parsing() { - let changelog = Changelog::try_from(KEEP_A_CHANGELOG_1_0_0).unwrap(); - assert_eq!(changelog.unreleased, None); - } - - #[test] - fn test_keep_a_changelog_release_entry_parsing() { - let changelog = Changelog::try_from(KEEP_A_CHANGELOG_1_0_0).unwrap(); - let release_entry = changelog.releases.get("1.1.1").unwrap(); - assert_eq!(release_entry.version, "1.1.1".parse::().unwrap()); - assert_eq!( - release_entry.date, - Utc.with_ymd_and_hms(2023, 3, 5, 0, 0, 0).unwrap() - ); - assert_eq!( - release_entry.body, - r#"### Added - -- Arabic translation (#444). -- v1.1 French translation. -- v1.1 Dutch translation (#371). -- v1.1 Russian translation (#410). -- v1.1 Japanese translation (#363). -- v1.1 Norwegian Bokmål translation (#383). -- v1.1 "Inconsistent Changes" Turkish translation (#347). -- Default to most recent versions available for each languages -- Display count of available translations (26 to date!) -- Centralize all links into `/data/links.json` so they can be updated easily - -### Fixed - -- Improve French translation (#377). -- Improve id-ID translation (#416). -- Improve Persian translation (#457). -- Improve Russian translation (#408). -- Improve Swedish title (#419). -- Improve zh-CN translation (#359). -- Improve French translation (#357). -- Improve zh-TW translation (#360, #355). -- Improve Spanish (es-ES) transltion (#362). -- Foldout menu in Dutch translation (#371). -- Missing periods at the end of each change (#451). -- Fix missing logo in 1.1 pages -- Display notice when translation isn't for most recent version -- Various broken links, page versions, and indentations. - -### Changed - -- Upgrade dependencies: Ruby 3.2.1, Middleman, etc. - -### Removed - -- Unused normalize.css file -- Identical links assigned in each translation file -- Duplicate index file for the english version"# - ); - } - - #[test] - fn test_release_entry_parsing_with_alternate_date_format() { - let changelog = Changelog::try_from( - "## [Unreleased]\n\n## [1.0.10] 2023/05/10\n- Upgrade libcnb to 0.12.0", - ) - .unwrap(); - let release_entry = changelog.releases.get("1.0.10").unwrap(); - assert_eq!(release_entry.version, "1.0.10".parse::().unwrap()); - assert_eq!( - release_entry.date, - Utc.with_ymd_and_hms(2023, 5, 10, 0, 0, 0).unwrap() - ); - assert_eq!(release_entry.body, "- Upgrade libcnb to 0.12.0"); - } - - #[test] - fn test_keep_a_changelog_parses_all_release_entries() { - let changelog = Changelog::try_from(KEEP_A_CHANGELOG_1_0_0).unwrap(); - let releases = changelog.releases.keys().collect::>(); - assert_eq!( - releases, - vec![ - "1.1.1", "1.1.0", "1.0.0", "0.3.0", "0.2.0", "0.1.0", "0.0.8", "0.0.7", "0.0.6", - "0.0.5", "0.0.4", "0.0.3", "0.0.2", "0.0.1", - ] - ); - } - - #[test] - fn test_keep_a_changelog_to_string() { - let changelog = Changelog::try_from(KEEP_A_CHANGELOG_1_0_0).unwrap(); - assert_eq!(changelog.to_string(), KEEP_A_CHANGELOG_1_0_0); - } - - #[test] - fn test_generate_release_declarations() { - let changelog = Changelog::try_from(KEEP_A_CHANGELOG_1_0_0).unwrap(); - let declarations = generate_release_declarations( - &changelog, - "https://github.com/olivierlacan/keep-a-changelog", - &None, - ); - assert_eq!( - declarations, - r"[unreleased]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.1.1...HEAD -[1.1.1]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.1.0...v1.1.1 -[1.1.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.0.0...v1.1.0 -[1.0.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.3.0...v1.0.0 -[0.3.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.2.0...v0.3.0 -[0.2.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.1.0...v0.2.0 -[0.1.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.8...v0.1.0 -[0.0.8]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.7...v0.0.8 -[0.0.7]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.6...v0.0.7 -[0.0.6]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.5...v0.0.6 -[0.0.5]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.4...v0.0.5 -[0.0.4]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.3...v0.0.4 -[0.0.3]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.2...v0.0.3 -[0.0.2]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.1...v0.0.2 -[0.0.1]: https://github.com/olivierlacan/keep-a-changelog/releases/tag/v0.0.1" - ); - } - - #[test] - fn test_generate_release_declarations_with_no_releases() { - let changelog = Changelog::try_from("[Unreleased]").unwrap(); - let declarations = generate_release_declarations( - &changelog, - "https://github.com/olivierlacan/keep-a-changelog", - &None, - ); - assert_eq!( - declarations, - "[unreleased]: https://github.com/olivierlacan/keep-a-changelog" - ); - } - - #[test] - fn test_generate_release_declarations_with_only_one_release() { - let changelog = - Changelog::try_from("[Unreleased]\n## [0.0.1] - 2023-03-05\n\n- Some change\n") - .unwrap(); - let declarations = generate_release_declarations( - &changelog, - "https://github.com/olivierlacan/keep-a-changelog", - &None, - ); - assert_eq!( - declarations, - "[unreleased]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.1...HEAD\n[0.0.1]: https://github.com/olivierlacan/keep-a-changelog/releases/tag/v0.0.1" - ); - } - - #[test] - fn test_generate_release_declarations_starting_with_release() { - let changelog = Changelog::try_from(KEEP_A_CHANGELOG_1_0_0).unwrap(); - let declarations = generate_release_declarations( - &changelog, - "https://github.com/olivierlacan/keep-a-changelog", - &Some(Version { - major: 1, - minor: 0, - patch: 0, - pre: Prerelease::default(), - build: BuildMetadata::default(), - }), - ); - assert_eq!( - declarations, - r"[unreleased]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.1.1...HEAD -[1.1.1]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.1.0...v1.1.1 -[1.1.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.0.0...v1.1.0 -[1.0.0]: https://github.com/olivierlacan/keep-a-changelog/releases/tag/v1.0.0" - ); - } - - const KEEP_A_CHANGELOG_1_0_0: &str = r#"# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -## [1.1.1] - 2023-03-05 - -### Added - -- Arabic translation (#444). -- v1.1 French translation. -- v1.1 Dutch translation (#371). -- v1.1 Russian translation (#410). -- v1.1 Japanese translation (#363). -- v1.1 Norwegian Bokmål translation (#383). -- v1.1 "Inconsistent Changes" Turkish translation (#347). -- Default to most recent versions available for each languages -- Display count of available translations (26 to date!) -- Centralize all links into `/data/links.json` so they can be updated easily - -### Fixed - -- Improve French translation (#377). -- Improve id-ID translation (#416). -- Improve Persian translation (#457). -- Improve Russian translation (#408). -- Improve Swedish title (#419). -- Improve zh-CN translation (#359). -- Improve French translation (#357). -- Improve zh-TW translation (#360, #355). -- Improve Spanish (es-ES) transltion (#362). -- Foldout menu in Dutch translation (#371). -- Missing periods at the end of each change (#451). -- Fix missing logo in 1.1 pages -- Display notice when translation isn't for most recent version -- Various broken links, page versions, and indentations. - -### Changed - -- Upgrade dependencies: Ruby 3.2.1, Middleman, etc. - -### Removed - -- Unused normalize.css file -- Identical links assigned in each translation file -- Duplicate index file for the english version - -## [1.1.0] - 2019-02-15 - -### Added - -- Danish translation (#297). -- Georgian translation from (#337). -- Changelog inconsistency section in Bad Practices. - -### Fixed - -- Italian translation (#332). -- Indonesian translation (#336). - -## [1.0.0] - 2017-06-20 - -### Added - -- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). -- Version navigation. -- Links to latest released version in previous versions. -- "Why keep a changelog?" section. -- "Who needs a changelog?" section. -- "How do I make a changelog?" section. -- "Frequently Asked Questions" section. -- New "Guiding Principles" sub-section to "How do I make a changelog?". -- Simplified and Traditional Chinese translations from [@tianshuo](https://github.com/tianshuo). -- German translation from [@mpbzh](https://github.com/mpbzh) & [@Art4](https://github.com/Art4). -- Italian translation from [@azkidenz](https://github.com/azkidenz). -- Swedish translation from [@magol](https://github.com/magol). -- Turkish translation from [@emreerkan](https://github.com/emreerkan). -- French translation from [@zapashcanon](https://github.com/zapashcanon). -- Brazilian Portuguese translation from [@Webysther](https://github.com/Webysther). -- Polish translation from [@amielucha](https://github.com/amielucha) & [@m-aciek](https://github.com/m-aciek). -- Russian translation from [@aishek](https://github.com/aishek). -- Czech translation from [@h4vry](https://github.com/h4vry). -- Slovak translation from [@jkostolansky](https://github.com/jkostolansky). -- Korean translation from [@pierceh89](https://github.com/pierceh89). -- Croatian translation from [@porx](https://github.com/porx). -- Persian translation from [@Hameds](https://github.com/Hameds). -- Ukrainian translation from [@osadchyi-s](https://github.com/osadchyi-s). - -### Changed - -- Start using "changelog" over "change log" since it's the common usage. -- Start versioning based on the current English version at 0.3.0 to help - translation authors keep things up-to-date. -- Rewrite "What makes unicorns cry?" section. -- Rewrite "Ignoring Deprecations" sub-section to clarify the ideal - scenario. -- Improve "Commit log diffs" sub-section to further argument against - them. -- Merge "Why can’t people just use a git log diff?" with "Commit log - diffs". -- Fix typos in Simplified Chinese and Traditional Chinese translations. -- Fix typos in Brazilian Portuguese translation. -- Fix typos in Turkish translation. -- Fix typos in Czech translation. -- Fix typos in Swedish translation. -- Improve phrasing in French translation. -- Fix phrasing and spelling in German translation. - -### Removed - -- Section about "changelog" vs "CHANGELOG". - -## [0.3.0] - 2015-12-03 - -### Added - -- RU translation from [@aishek](https://github.com/aishek). -- pt-BR translation from [@tallesl](https://github.com/tallesl). -- es-ES translation from [@ZeliosAriex](https://github.com/ZeliosAriex). - -## [0.2.0] - 2015-10-06 - -### Changed - -- Remove exclusionary mentions of "open source" since this project can - benefit both "open" and "closed" source projects equally. - -## [0.1.0] - 2015-10-06 - -### Added - -- Answer "Should you ever rewrite a change log?". - -### Changed - -- Improve argument against commit logs. -- Start following [SemVer](https://semver.org) properly. - -## [0.0.8] - 2015-02-17 - -### Changed - -- Update year to match in every README example. -- Reluctantly stop making fun of Brits only, since most of the world - writes dates in a strange way. - -### Fixed - -- Fix typos in recent README changes. -- Update outdated unreleased diff link. - -## [0.0.7] - 2015-02-16 - -### Added - -- Link, and make it obvious that date format is ISO 8601. - -### Changed - -- Clarified the section on "Is there a standard change log format?". - -### Fixed - -- Fix Markdown links to tag comparison URL with footnote-style links. - -## [0.0.6] - 2014-12-12 - -### Added - -- README section on "yanked" releases. - -## [0.0.5] - 2014-08-09 - -### Added - -- Markdown links to version tags on release headings. -- Unreleased section to gather unreleased changes and encourage note - keeping prior to releases. - -## [0.0.4] - 2014-08-09 - -### Added - -- Better explanation of the difference between the file ("CHANGELOG") - and its function "the change log". - -### Changed - -- Refer to a "change log" instead of a "CHANGELOG" throughout the site - to differentiate between the file and the purpose of the file — the - logging of changes. - -### Removed - -- Remove empty sections from CHANGELOG, they occupy too much space and - create too much noise in the file. People will have to assume that the - missing sections were intentionally left out because they contained no - notable changes. - -## [0.0.3] - 2014-08-09 - -### Added - -- "Why should I care?" section mentioning The Changelog podcast. - -## [0.0.2] - 2014-07-10 - -### Added - -- Explanation of the recommended reverse chronological release ordering. - -## [0.0.1] - 2014-05-31 - -### Added - -- This CHANGELOG file to hopefully serve as an evolving example of a - standardized open source project CHANGELOG. -- CNAME file to enable GitHub Pages custom domain. -- README now contains answers to common questions about CHANGELOGs. -- Good examples and basic guidelines, including proper date formatting. -- Counter-examples: "What makes unicorns cry?". -"#; -} - -mod parser { - use chrono::{DateTime, LocalResult, TimeZone, Utc}; - use indexmap::IndexMap; - use lazy_static::lazy_static; - use markdown::mdast::Node; - use markdown::{to_mdast, ParseOptions}; - use regex::Regex; - use semver::Version; - use std::fmt::{Display, Formatter}; - use std::num::ParseIntError; - - const VERSION_CAPTURE: &str = r"(?P\d+\.\d+\.\d+)"; - const YEAR_CAPTURE: &str = r"(?P\d{4})"; - const MONTH_CAPTURE: &str = r"(?P\d{2})"; - const DAY_CAPTURE: &str = r"(?P\d{2})"; - - const TAG_CAPTURE: &str = r"(?P.+)"; - - lazy_static! { - static ref UNRELEASED_HEADER: Regex = - Regex::new(r"(?i)^\[?unreleased]?$").expect("Should be a valid regex"); - static ref VERSIONED_RELEASE_HEADER: Regex = Regex::new(&format!( - r"^\[?{VERSION_CAPTURE}]?\s+-\s+{YEAR_CAPTURE}[-/]{MONTH_CAPTURE}[-/]{DAY_CAPTURE}(?:\s+\[{TAG_CAPTURE}])?$" - )) - .expect("Should be a valid regex"); - } - - #[derive(Debug, Eq, PartialEq)] - pub(crate) struct Changelog { - pub(crate) unreleased: Option, - pub(crate) releases: Vec, - } - - #[derive(Debug, Eq, PartialEq)] - pub(crate) struct ReleaseEntry { - pub(crate) version: Version, - pub(crate) date: DateTime, - pub(crate) tag: Option, - pub(crate) contents: ReleaseContents, - } - - #[derive(Debug)] - pub(crate) enum ReleaseEntryType { - Unreleased, - Versioned(Version, DateTime, Option), - } - - #[derive(Debug, Eq, PartialEq)] - pub(crate) enum ReleaseTag { - Yanked, - NoChanges, - } - - #[derive(Debug, Eq, PartialEq)] - pub(crate) struct ReleaseContents { - change_groups: IndexMap>, - } - - #[derive(Debug, Eq, PartialEq, Hash)] - pub(crate) enum ChangeGroup { - Added, - Changed, - Deprecated, - Removed, - Fixed, - Security, - } - - #[derive(Debug, thiserror::Error)] - pub(crate) enum ParseChangelogError { - #[error("Could not parse changelog as markdown\nError: {0}")] - Markdown(String), - #[error("Could not parse change group type from changelog\nExpected: Added | Changed | Deprecated | Removed | Fixed | Security\nValue: {0}")] - InvalidChangeGroup(String), - #[error("Release header did not match the expected format\nExpected: [Unreleased] | [] - --
| [] - --
[]\nValue: {0}")] - NoMatchForReleaseHeading(String), - #[error("Invalid semver version in release entry - {0}\nValue: {1}\nError: {2}")] - Version(String, String, #[source] semver::Error), - #[error("Invalid year in release entry - {0}\nValue: {1}\nError: {2}")] - ReleaseEntryYear(String, String, #[source] ParseIntError), - #[error("Invalid month in release entry - {0}\nValue: {1}\nError: {2}")] - ReleaseEntryMonth(String, String, #[source] ParseIntError), - #[error("Invalid day in release entry - {0}\nValue: {1}\nError: {2}")] - ReleaseEntryDay(String, String, #[source] ParseIntError), - #[error("Invalid date in release entry - {0}\nValue: {1}-{2}-{3}")] - InvalidReleaseDate(String, i32, u32, u32), - #[error("Ambiguous date in release entry - {0}\nValue: {1}-{2}-{3}")] - AmbiguousReleaseDate(String, i32, u32, u32), - #[error( - "Could not parse release tag from changelog\nExpected: YANKED | NO CHANGES\nValue: {1}" - )] - InvalidReleaseTag(String, String), - } - - // Traverses the changelog written in markdown which has flattened entries that need to be parsed - // and converts those into a nested structure that matches the Keep a Changelog spec. For example, - // given the following markdown doc: - // - // ------------------------------------------ - // # Changelog → (Changelog) - // → - - // ## Unreleased → (ReleaseEntry::Unreleased) - // → (ReleaseContents) - // ## [x.y.z] yyyy-mm-dd → (ReleaseEntry::Versioned) - // → (ReleaseContents) - // ### Changed → (ChangeGroup) - // → (List) - // - foo → (List Item) - // - bar → (List Item) - // → - - // ### Removed → (ChangeGroup) - // → (List) - // - baz → (List Item) - // ------------------------------------------ - // This would be represented in our Changelog AST as: - // - // Changelog { - // unreleased: None, - // releases: [ - // ReleaseEntry { - // version: x.y.z, - // date: yyyy-mm-dd, - // tag: None, - // contents: ReleaseContents { - // "Changed": ["foo", "bar"], - // "Removed": ["baz"] - // } - // } - // ] - // } - pub(crate) fn parse_changelog(input: &str) -> Result { - let changelog_ast = - to_mdast(input, &ParseOptions::default()).map_err(ParseChangelogError::Markdown)?; - - let is_release_entry_heading = is_heading_of_depth(2); - let is_change_group_heading = is_heading_of_depth(3); - let is_list_node = |node: &Node| matches!(node, Node::List(_)); - - let mut unreleased = None; - let mut releases = vec![]; - - if let Node::Root(root) = changelog_ast { - // the peekable iterator here makes it easier to decide when to traverse to the next sibling - // node in the markdown AST to construct our nested structure - let mut root_iter = root.children.into_iter().peekable(); - while root_iter.peek().is_some() { - if let Some(release_heading_node) = root_iter.next_if(&is_release_entry_heading) { - let release_entry_type = - parse_release_heading(release_heading_node.to_string())?; - let mut change_groups = IndexMap::new(); - - while root_iter.peek().is_some_and(&is_change_group_heading) { - let change_group_node = root_iter.next().expect("This should be a change group heading node since we already peeked at it"); - let change_group = - parse_change_group_heading(change_group_node.to_string())?; - let changes = change_groups.entry(change_group).or_insert(vec![]); - - while root_iter.peek().is_some_and(is_list_node) { - let list_node = root_iter - .next() - .expect("This should be a list node since we already peeked at it"); - if let Some(list_items) = list_node.children() { - for list_item in list_items { - if matches!(list_item, Node::ListItem(_)) { - changes.push(list_item.to_string()); - } - } - } - } - } - - match release_entry_type { - ReleaseEntryType::Unreleased => { - unreleased = Some(ReleaseContents { change_groups }); - } - ReleaseEntryType::Versioned(version, date, tag) => { - releases.push(ReleaseEntry { - version, - date, - tag, - contents: ReleaseContents { change_groups }, - }); - } - } - } else { - root_iter.next(); - } - } - } - - Ok(Changelog { - unreleased, - releases, - }) - } - - fn is_heading_of_depth(depth: u8) -> impl Fn(&Node) -> bool { - move |node: &Node| { - if let Node::Heading(heading) = node { - return heading.depth == depth; - } - false - } - } - - fn parse_release_heading(heading: String) -> Result { - if UNRELEASED_HEADER.is_match(&heading) { - return Ok(ReleaseEntryType::Unreleased); - } - - if let Some(captures) = VERSIONED_RELEASE_HEADER.captures(&heading) { - let version = captures["version"] - .parse::() - .map_err(|e| { - ParseChangelogError::Version( - heading.clone(), - captures["version"].to_string(), - e, - ) - })?; - - let year = captures["year"].parse::().map_err(|e| { - ParseChangelogError::ReleaseEntryYear( - heading.clone(), - captures["year"].to_string(), - e, - ) - })?; - let month = captures["month"].parse::().map_err(|e| { - ParseChangelogError::ReleaseEntryMonth( - heading.clone(), - captures["month"].to_string(), - e, - ) - })?; - let day = captures["day"].parse::().map_err(|e| { - ParseChangelogError::ReleaseEntryDay( - heading.clone(), - captures["day"].to_string(), - e, - ) - })?; - - let date = match Utc.with_ymd_and_hms(year, month, day, 0, 0, 0) { - LocalResult::None => Err(ParseChangelogError::InvalidReleaseDate( - heading.clone(), - year, - month, - day, - )), - LocalResult::Single(value) => Ok(value), - LocalResult::Ambiguous(_, _) => Err(ParseChangelogError::AmbiguousReleaseDate( - heading.clone(), - year, - month, - day, - )), - }?; - - let tag = if let Some(tag_value) = captures.name("tag") { - match tag_value.as_str().to_lowercase().as_str() { - "no changes" => Ok(Some(ReleaseTag::NoChanges)), - "yanked" => Ok(Some(ReleaseTag::Yanked)), - _ => Err(ParseChangelogError::InvalidReleaseTag( - heading.clone(), - captures["tag"].to_string(), - )), - }? - } else { - None - }; - - Ok(ReleaseEntryType::Versioned(version, date, tag)) - } else { - Err(ParseChangelogError::NoMatchForReleaseHeading(heading)) - } - } - - fn parse_change_group_heading(heading: String) -> Result { - match heading.trim().to_lowercase().as_str() { - "added" => Ok(ChangeGroup::Added), - "changed" => Ok(ChangeGroup::Changed), - "deprecated" => Ok(ChangeGroup::Deprecated), - "removed" => Ok(ChangeGroup::Removed), - "fixed" => Ok(ChangeGroup::Fixed), - "security" => Ok(ChangeGroup::Security), - _ => Err(ParseChangelogError::InvalidChangeGroup(heading)), - } - } - - impl TryFrom<&str> for Changelog { - type Error = ParseChangelogError; - - fn try_from(value: &str) -> Result { - parse_changelog(value) - } - } - - impl Display for Changelog { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - r" -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - " - .trim() - )?; - - if let Some(unreleased) = &self.unreleased { - write!(f, "\n\n## [Unreleased]\n\n{unreleased}")?; - } else { - write!(f, "\n\n## [Unreleased]")?; - } - - for entry in &self.releases { - write!( - f, - "\n\n## [{}] - {}", - entry.version, - entry.date.format("%Y-%m-%d") - )?; - if let Some(tag) = &entry.tag { - write!(f, " [{tag}]")?; - } - write!(f, "\n\n{}", entry.contents)?; - } - - writeln!(f) - } - } - - impl Display for ReleaseContents { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - for (change_group, items) in &self.change_groups { - if !items.is_empty() { - write!(f, "### {change_group}\n\n")?; - for item in items { - writeln!(f, "- {item}")?; - } - } - } - writeln!(f) - } - } - - impl Display for ReleaseTag { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - ReleaseTag::Yanked => write!(f, "YANKED"), - ReleaseTag::NoChanges => write!(f, "NO CHANGES"), - } - } - } - - impl Display for ChangeGroup { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - ChangeGroup::Added => write!(f, "Added"), - ChangeGroup::Changed => write!(f, "Changed"), - ChangeGroup::Deprecated => write!(f, "Deprecated"), - ChangeGroup::Removed => write!(f, "Removed"), - ChangeGroup::Fixed => write!(f, "Fixed"), - ChangeGroup::Security => write!(f, "Security"), - } - } - } - - #[cfg(test)] - mod test { - use super::*; - - const CHANGELOG: &str = "# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added - -- Node version x.y.z - -## [1.1.6] - 2023-01-25 - -### Added - -- Add basic OpenTelemetry tracing. ([#652](https://github.com/heroku/buildpacks-nodejs/pull/652)) - -## [1.1.5] - 2023-09-19 [NO CHANGES] - -## [1.1.4] - 2023-08-10 - -### Changed - -- Upgrade to Buildpack API version `0.9`. ([#552](https://github.com/heroku/buildpacks-nodejs/pull/552)) - -### Removed - -- Drop explicit support for the End-of-Life stack `heroku-18`. - -[unreleased]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.1.1...HEAD -[1.1.1]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.1.0...v1.1.1"; - - #[test] - fn simple_test() { - assert_eq!(parse_changelog(CHANGELOG).unwrap().to_string(), CHANGELOG); - } - } -} diff --git a/src/commands/generate_changelog/command.rs b/src/commands/generate_changelog/command.rs index f13b66d5..af624feb 100644 --- a/src/commands/generate_changelog/command.rs +++ b/src/commands/generate_changelog/command.rs @@ -1,8 +1,8 @@ use crate::buildpacks::{find_releasable_buildpacks, read_buildpack_descriptor}; -use crate::changelog::Changelog; use crate::commands::generate_changelog::errors::Error; use crate::github::actions; use clap::Parser; +use keep_a_changelog::{Changelog, Changes}; use libcnb_data::buildpack::BuildpackId; use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; @@ -20,13 +20,7 @@ pub(crate) struct GenerateChangelogArgs { enum ChangelogEntryType { Unreleased, - Version(String), -} - -enum ChangelogEntry { - VersionNotPresent, - Empty, - Changes(String), + Version(keep_a_changelog::Version), } pub(crate) fn execute(args: GenerateChangelogArgs) -> Result<()> { @@ -35,7 +29,9 @@ pub(crate) fn execute(args: GenerateChangelogArgs) -> Result<()> { find_releasable_buildpacks(¤t_dir).map_err(Error::FindReleasableBuildpacks)?; let changelog_entry_type = match args.version { - Some(version) => ChangelogEntryType::Version(version), + Some(version) => { + ChangelogEntryType::Version(version.parse().map_err(Error::InvalidVersion)?) + } None => ChangelogEntryType::Unreleased, }; @@ -62,83 +58,149 @@ pub(crate) fn execute(args: GenerateChangelogArgs) -> Result<()> { fn read_changelog_entry( path: &PathBuf, changelog_entry_type: &ChangelogEntryType, -) -> Result { +) -> Result> { let contents = std::fs::read_to_string(path).map_err(|e| Error::ReadingChangelog(path.clone(), e))?; - let changelog = Changelog::try_from(contents.as_str()) + let changelog: Changelog = contents + .parse() .map_err(|e| Error::ParsingChangelog(path.clone(), e))?; Ok(match changelog_entry_type { - ChangelogEntryType::Unreleased => changelog - .unreleased - .map_or(ChangelogEntry::Empty, ChangelogEntry::Changes), - - ChangelogEntryType::Version(version) => { - changelog - .releases - .get(version) - .map_or(ChangelogEntry::VersionNotPresent, |entry| { - if entry.body.is_empty() { - ChangelogEntry::Empty - } else { - ChangelogEntry::Changes(entry.body.clone()) - } - }) - } + ChangelogEntryType::Unreleased => Some(changelog.unreleased.changes), + ChangelogEntryType::Version(version) => changelog + .releases + .get_version(version) + .map(|release| release.changes.clone()), }) } -fn generate_changelog(changes_by_buildpack: &HashMap) -> String { - let changelog = changes_by_buildpack - .iter() - .map(|(buildpack_id, changes)| (buildpack_id.to_string(), changes)) - .collect::>() +fn generate_changelog(changes_by_buildpack: &HashMap>) -> String { + let (buildpacks_with_no_changes, buildpacks_with_changes): (Vec<_>, Vec<_>) = + changes_by_buildpack + .iter() + .filter_map(|(buildpack_id, changes)| { + changes + .as_ref() + .map(|value| (buildpack_id.to_string(), value)) + }) + .collect::>() + .into_iter() + .partition(|(_, changes)| changes.is_empty()); + + let notable_changes = buildpacks_with_changes .into_iter() - .filter_map(|(buildpack_id, changes)| match changes { - ChangelogEntry::Empty => Some(format!("## {buildpack_id}\n\n- No changes.")), - ChangelogEntry::Changes(value) => Some(format!("## {buildpack_id}\n\n{value}")), - ChangelogEntry::VersionNotPresent => None, + .map(|(buildpack_id, changes)| { + let mut section = String::new(); + section.push_str(&format!("## {buildpack_id}\n\n")); + for (change_group, items) in changes { + section.push_str(&format!("### {change_group}\n")); + for item in items { + section.push_str(&format!("\n- {item}")); + } + } + section }) .collect::>() .join("\n\n"); - format!("{}\n\n", changelog.trim()) + + let extra_details = buildpacks_with_no_changes + .iter() + .map(|(buildpack_id, _)| buildpack_id.to_string()) + .collect::>(); + + if extra_details.is_empty() { + format!("{}\n", notable_changes.trim()) + } else { + format!("{}\n\n> The following buildpacks had their versions bumped but contained no changes: {}\n", notable_changes.trim(), extra_details.join(", ")) + } } #[cfg(test)] mod test { - use crate::commands::generate_changelog::command::{generate_changelog, ChangelogEntry}; + use crate::commands::generate_changelog::command::generate_changelog; + use keep_a_changelog::{ChangeGroup, Changes}; use libcnb_data::buildpack_id; use std::collections::HashMap; + fn changes(items: Vec) -> Changes { + let mut unreleased = keep_a_changelog::Unreleased::default(); + for item in items { + unreleased.add(ChangeGroup::Changed, item); + } + unreleased.changes + } + #[test] - fn test_generating_changelog() { + fn test_generating_changelog_with_buildpacks_containing_no_changes() { let values = HashMap::from([ ( buildpack_id!("c"), - ChangelogEntry::Changes("- change c.1".to_string()), + Some(changes(vec!["change c.1".to_string()])), ), ( buildpack_id!("a"), - ChangelogEntry::Changes("- change a.1\n- change a.2".to_string()), + Some(changes(vec![ + "change a.1".to_string(), + "change a.2".to_string(), + ])), ), - (buildpack_id!("b"), ChangelogEntry::VersionNotPresent), - (buildpack_id!("d"), ChangelogEntry::Empty), + (buildpack_id!("b"), None), + (buildpack_id!("d"), Some(changes(vec![]))), + (buildpack_id!("e"), Some(changes(vec![]))), ]); assert_eq!( generate_changelog(&values), - r"## a + "\ +## a + +### Changed - change a.1 - change a.2 ## c +### Changed + - change c.1 -## d +> The following buildpacks had their versions bumped but contained no changes: d, e +" + ); + } + + #[test] + fn test_generating_changelog_with_buildpacks_that_all_have_changes() { + let values = HashMap::from([ + ( + buildpack_id!("c"), + Some(changes(vec!["change c.1".to_string()])), + ), + ( + buildpack_id!("a"), + Some(changes(vec![ + "change a.1".to_string(), + "change a.2".to_string(), + ])), + ), + (buildpack_id!("b"), None), + ]); -- No changes. + assert_eq!( + generate_changelog(&values), + "\ +## a + +### Changed + +- change a.1 +- change a.2 +## c + +### Changed + +- change c.1 " ); } diff --git a/src/commands/generate_changelog/errors.rs b/src/commands/generate_changelog/errors.rs index 877dd010..030ed29c 100644 --- a/src/commands/generate_changelog/errors.rs +++ b/src/commands/generate_changelog/errors.rs @@ -1,5 +1,4 @@ use crate::buildpacks::{FindReleasableBuildpacksError, ReadBuildpackDescriptorError}; -use crate::changelog::ChangelogError; use crate::github::actions::SetActionOutputError; use std::path::PathBuf; @@ -10,11 +9,13 @@ pub(crate) enum Error { #[error(transparent)] FindReleasableBuildpacks(FindReleasableBuildpacksError), #[error(transparent)] + InvalidVersion(keep_a_changelog::ParseVersionError), + #[error(transparent)] ReadBuildpackDescriptor(ReadBuildpackDescriptorError), #[error("Could not read changelog\nPath: {}\nError: {1}", .0.display())] ReadingChangelog(PathBuf, #[source] std::io::Error), #[error("Could not parse changelog\nPath: {}\nError: {1}", .0.display())] - ParsingChangelog(PathBuf, #[source] ChangelogError), + ParsingChangelog(PathBuf, #[source] keep_a_changelog::ParseChangelogError), #[error(transparent)] SetActionOutput(SetActionOutputError), } diff --git a/src/commands/prepare_release/command.rs b/src/commands/prepare_release/command.rs index c9e36dd8..735bc824 100644 --- a/src/commands/prepare_release/command.rs +++ b/src/commands/prepare_release/command.rs @@ -1,18 +1,14 @@ use crate::buildpacks::find_releasable_buildpacks; -use crate::changelog::{generate_release_declarations, Changelog, ReleaseEntry}; use crate::commands::prepare_release::errors::Error; use crate::github::actions; -use chrono::{DateTime, Utc}; use clap::{Parser, ValueEnum}; -use indexmap::IndexMap; +use keep_a_changelog::{ChangeGroup, Changelog, PromoteOptions, ReleaseLink, ReleaseTag}; use libcnb_data::buildpack::{BuildpackId, BuildpackVersion}; -use semver::{BuildMetadata, Prerelease, Version}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::fs::write; use std::path::{Path, PathBuf}; use std::str::FromStr; use toml_edit::{value, ArrayOfTables, Document, Table}; -use uriparse::URI; type Result = std::result::Result; @@ -23,8 +19,6 @@ pub(crate) struct PrepareReleaseArgs { pub(crate) bump: BumpCoordinate, #[arg(long)] pub(crate) repository_url: String, - #[arg(long)] - pub(crate) declarations_starting_version: Option, } #[derive(ValueEnum, Debug, Clone)] @@ -47,18 +41,7 @@ struct ChangelogFile { pub(crate) fn execute(args: PrepareReleaseArgs) -> Result<()> { let current_dir = std::env::current_dir().map_err(Error::GetCurrentDir)?; - let repository_url = URI::try_from(args.repository_url.as_str()) - .map(URI::into_owned) - .map_err(|e| Error::InvalidRepositoryUrl(args.repository_url.clone(), e))?; - - let declarations_starting_version = args - .declarations_starting_version - .map(|value| { - value - .parse::() - .map_err(|e| Error::InvalidDeclarationsStartingVersion(value, e)) - }) - .transpose()?; + let repository_url = args.repository_url; let buildpack_dirs = find_releasable_buildpacks(¤t_dir).map_err(Error::FindReleasableBuildpacks)?; @@ -86,7 +69,8 @@ pub(crate) fn execute(args: PrepareReleaseArgs) -> Result<()> { let next_version = get_next_version(¤t_version, &args.bump); - for (mut buildpack_file, changelog_file) in buildpack_files.into_iter().zip(changelog_files) { + for (mut buildpack_file, mut changelog_file) in buildpack_files.into_iter().zip(changelog_files) + { let updated_dependencies = get_buildpack_dependency_ids(&buildpack_file)? .into_iter() .filter(|buildpack_id| updated_buildpack_ids.contains(buildpack_id)) @@ -106,22 +90,14 @@ pub(crate) fn execute(args: PrepareReleaseArgs) -> Result<()> { buildpack_file.path.display(), ); - let new_changelog = promote_changelog_unreleased_to_version( - &changelog_file.changelog, + promote_changelog_unreleased_to_version( + &mut changelog_file.changelog, &next_version, - &Utc::now(), + &repository_url, &updated_dependencies, - ); - - let release_declarations = generate_release_declarations( - &new_changelog, - repository_url.to_string(), - &declarations_starting_version, - ); - - let changelog_contents = format!("{new_changelog}\n{release_declarations}\n"); + )?; - write(&changelog_file.path, changelog_contents) + write(&changelog_file.path, changelog_file.changelog.to_string()) .map_err(|e| Error::WritingChangelog(changelog_file.path.clone(), e))?; eprintln!( @@ -148,7 +124,8 @@ fn read_buildpack_file(path: PathBuf) -> Result { fn read_changelog_file(path: PathBuf) -> Result { let contents = std::fs::read_to_string(&path).map_err(|e| Error::ReadingChangelog(path.clone(), e))?; - let changelog = Changelog::try_from(contents.as_str()) + let changelog = contents + .parse() .map_err(|e| Error::ParsingChangelog(path.clone(), e))?; Ok(ChangelogFile { path, changelog }) } @@ -309,94 +286,72 @@ fn update_buildpack_contents_with_new_version( } fn promote_changelog_unreleased_to_version( - changelog: &Changelog, - version: &BuildpackVersion, - date: &DateTime, + changelog: &mut Changelog, + next_version: &BuildpackVersion, + repository_url: &String, updated_dependencies: &HashSet, -) -> Changelog { - let updated_dependencies_text = if updated_dependencies.is_empty() { - None - } else { - let mut updated_dependencies_bullet_points = updated_dependencies - .iter() - .map(|id| format!("- Updated `{id}` to `{version}`.")) - .collect::>(); - updated_dependencies_bullet_points.sort(); - Some(updated_dependencies_bullet_points.join("\n")) - }; - - let changes_with_dependencies = (&changelog.unreleased, &updated_dependencies_text); +) -> Result<()> { + // record dependency updates in the changelog + let sorted_updated_dependencies = updated_dependencies + .iter() + .map(ToString::to_string) + .collect::>(); + for updated_dependency in sorted_updated_dependencies { + changelog.unreleased.add( + ChangeGroup::Changed, + format!("Updated `{updated_dependency}` to `{next_version}`."), + ); + } - let body = if let (Some(changes), Some(dependencies)) = changes_with_dependencies { - merge_existing_changelog_entries_with_dependency_changes(changes, dependencies) - } else if let (Some(changes), None) = changes_with_dependencies { - changes.clone() - } else if let (None, Some(dependencies)) = changes_with_dependencies { - format!("### Changed\n\n{dependencies}") - } else { - "- No changes.".to_string() - }; + // create a new release entry from unreleased + let release_version: keep_a_changelog::Version = next_version + .to_string() + .parse() + .map_err(Error::ParseChangelogReleaseVersion)?; - let new_release_entry = ReleaseEntry { - version: Version { - major: version.major, - minor: version.minor, - patch: version.patch, - pre: Prerelease::default(), - build: BuildMetadata::default(), - }, - date: *date, - body, - }; + let previous_version = changelog + .releases + .into_iter() + .next() + .map(|release| release.version.clone()); - let mut releases = IndexMap::from([(version.to_string(), new_release_entry)]); - for (id, entry) in &changelog.releases { - releases.insert(id.clone(), entry.clone()); - } - Changelog { - unreleased: None, - releases, + let new_release_link: ReleaseLink = if let Some(value) = previous_version { + format!("{repository_url}/compare/v{value}...v{release_version}") + } else { + format!("{repository_url}/releases/tag/v{release_version}") } -} + .parse() + .map_err(Error::ParseReleaseLink)?; -fn merge_existing_changelog_entries_with_dependency_changes( - changelog_entries: &str, - updated_dependencies: &str, -) -> String { - if changelog_entries.contains("### Changed") { - changelog_entries - .split("### ") - .map(|entry| { - if entry.starts_with("Changed") { - format!("{}\n{}\n\n", entry.trim_end(), updated_dependencies) - } else { - entry.to_string() - } - }) - .collect::>() - .join("### ") - } else { - format!( - "{}\n\n### Changed\n\n{}", - changelog_entries.trim_end(), - updated_dependencies - ) + let mut promote_options = + PromoteOptions::new(release_version.clone()).with_link(new_release_link); + if changelog.unreleased.changes.is_empty() { + promote_options = promote_options.with_tag(ReleaseTag::NoChanges); } + + changelog + .promote_unreleased(&promote_options) + .map_err(Error::PromoteUnreleased)?; + + changelog.unreleased.link = Some( + format!("{repository_url}/compare/v{release_version}...HEAD") + .parse() + .map_err(Error::ParseReleaseLink)?, + ); + + Ok(()) } #[cfg(test)] mod test { - use crate::changelog::{Changelog, ReleaseEntry}; use crate::commands::prepare_release::command::{ get_fixed_version, promote_changelog_unreleased_to_version, update_buildpack_contents_with_new_version, BuildpackFile, }; use crate::commands::prepare_release::errors::Error; - use chrono::{TimeZone, Utc}; - use indexmap::IndexMap; + use keep_a_changelog::{Changelog, ReleaseDate}; use libcnb_data::buildpack::BuildpackVersion; use libcnb_data::buildpack_id; - use semver::Version; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::str::FromStr; @@ -558,287 +513,412 @@ optional = true #[test] fn test_promote_changelog_unreleased_to_version_with_existing_entries() { - let release_entry_0_8_16 = ReleaseEntry { - version: "0.8.16".parse::().unwrap(), - date: Utc.with_ymd_and_hms(2023, 2, 27, 0, 0, 0).unwrap(), - body: "- Added node version 19.7.0, 19.6.1, 14.21.3, 16.19.1, 18.14.1, 18.14.2.\n- Added node version 18.14.0, 19.6.0.".to_string() - }; + let mut changelog: Changelog = "\ +# Changelog - let release_entry_0_8_15 = ReleaseEntry { - version: "0.8.15".parse::().unwrap(), - date: Utc.with_ymd_and_hms(2023, 2, 27, 0, 0, 0).unwrap(), - body: "- `name` is no longer a required field in package.json. ([#447](https://github.com/heroku/buildpacks-nodejs/pull/447))\n- Added node version 19.5.0.".to_string() - }; +All notable changes to this project will be documented in this file. - let changelog = Changelog { - unreleased: Some( - "- Added node version 18.15.0.\n- Added yarn version 4.0.0-rc.2".to_string(), - ), - releases: IndexMap::from([ - ("0.8.16".to_string(), release_entry_0_8_16.clone()), - ("0.8.15".to_string(), release_entry_0_8_15.clone()), - ]), - }; +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - assert_eq!( - changelog.unreleased, - Some("- Added node version 18.15.0.\n- Added yarn version 4.0.0-rc.2".to_string()) - ); - assert_eq!(changelog.releases.get("0.8.17"), None); - assert_eq!( - changelog.releases.get("0.8.16"), - Some(&release_entry_0_8_16) - ); - assert_eq!( - changelog.releases.get("0.8.15"), - Some(&release_entry_0_8_15) - ); +## [Unreleased] + +### Added + +- Added node version 18.15.0. +- Added yarn version 4.0.0-rc.2 + +## [0.8.16] - 2023-02-27 + +### Added + +- Added node version 19.7.0, 19.6.1, 14.21.3, 16.19.1, 18.14.1, 18.14.2. +- Added node version 18.14.0, 19.6.0. + +## [0.8.15] - 2023-02-26 + +### Added + +- `name` is no longer a required field in package.json. ([#447](https://github.com/heroku/buildpacks-nodejs/pull/447)) +- Added node version 19.5.0. + +[unreleased]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.16...HEAD +[0.8.16]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.15...v0.8.16 +[0.8.15]: https://github.com/heroku/buildpacks-nodejs/releases/tag/v/v0.8.15\n".parse().unwrap(); let next_version = BuildpackVersion { major: 0, minor: 8, patch: 17, }; - let date = Utc.with_ymd_and_hms(2023, 6, 16, 0, 0, 0).unwrap(); let updated_dependencies = HashSet::new(); - let changelog = promote_changelog_unreleased_to_version( - &changelog, + let repository_url = "https://github.com/heroku/buildpacks-nodejs".to_string(); + let today = ReleaseDate::today(); + promote_changelog_unreleased_to_version( + &mut changelog, &next_version, - &date, + &repository_url, &updated_dependencies, - ); + ) + .unwrap(); - assert_eq!(changelog.unreleased, None); - assert_eq!( - changelog.releases.get("0.8.17"), - Some(&ReleaseEntry { - version: "0.8.17".parse::().unwrap(), - date, - body: "- Added node version 18.15.0.\n- Added yarn version 4.0.0-rc.2".to_string() - }) - ); - assert_eq!( - changelog.releases.get("0.8.16"), - Some(&release_entry_0_8_16) - ); - assert_eq!( - changelog.releases.get("0.8.15"), - Some(&release_entry_0_8_15) - ); + assert_eq!(changelog.to_string(), format!("\ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.8.17] - {today} + +### Added + +- Added node version 18.15.0. +- Added yarn version 4.0.0-rc.2 + +## [0.8.16] - 2023-02-27 + +### Added + +- Added node version 19.7.0, 19.6.1, 14.21.3, 16.19.1, 18.14.1, 18.14.2. +- Added node version 18.14.0, 19.6.0. + +## [0.8.15] - 2023-02-26 + +### Added + +- `name` is no longer a required field in package.json. ([#447](https://github.com/heroku/buildpacks-nodejs/pull/447)) +- Added node version 19.5.0. + +[unreleased]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.17...HEAD +[0.8.17]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.16...v0.8.17 +[0.8.16]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.15...v0.8.16 +[0.8.15]: https://github.com/heroku/buildpacks-nodejs/releases/tag/v/v0.8.15\n" + )); } #[test] fn test_promote_changelog_unreleased_to_version_with_no_entries() { - let changelog = Changelog { - unreleased: None, - releases: IndexMap::new(), - }; + let mut changelog: Changelog = "\ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - assert_eq!(changelog.unreleased, None); - assert_eq!(changelog.releases.get("0.8.17"), None); +## Unreleased + +[unreleased]: https://github.com/heroku/buildpacks-nodejs\n" + .parse() + .unwrap(); let next_version = BuildpackVersion { major: 0, minor: 8, patch: 17, }; - let date = Utc.with_ymd_and_hms(2023, 6, 16, 0, 0, 0).unwrap(); let updated_dependencies = HashSet::new(); - let changelog = promote_changelog_unreleased_to_version( - &changelog, + let repository_url = "https://github.com/heroku/buildpacks-nodejs".to_string(); + let today = ReleaseDate::today(); + promote_changelog_unreleased_to_version( + &mut changelog, &next_version, - &date, + &repository_url, &updated_dependencies, - ); + ) + .unwrap(); - assert_eq!(changelog.unreleased, None); assert_eq!( - changelog.releases.get("0.8.17"), - Some(&ReleaseEntry { - version: "0.8.17".parse::().unwrap(), - date, - body: "- No changes.".to_string() - }) + changelog.to_string(), + format!( + "\ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.8.17] - {today} [NO CHANGES] + +[unreleased]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.17...HEAD +[0.8.17]: https://github.com/heroku/buildpacks-nodejs/releases/tag/v0.8.17\n" + ) ); } #[test] fn test_promote_changelog_unreleased_to_version_with_existing_entries_and_updated_dependencies() { - let release_entry_0_8_16 = ReleaseEntry { - version: "0.8.16".parse::().unwrap(), - date: Utc.with_ymd_and_hms(2023, 2, 27, 0, 0, 0).unwrap(), - body: "### Added\n\n- Added node version 19.7.0, 19.6.1, 14.21.3, 16.19.1, 18.14.1, 18.14.2.\n- Added node version 18.14.0, 19.6.0.".to_string() - }; + let mut changelog: Changelog = "\ +# Changelog - let release_entry_0_8_15 = ReleaseEntry { - version: "0.8.15".parse::().unwrap(), - date: Utc.with_ymd_and_hms(2023, 2, 27, 0, 0, 0).unwrap(), - body: "### Changed\n\n- `name` is no longer a required field in package.json. ([#447](https://github.com/heroku/buildpacks-nodejs/pull/447))\n\n### Added\n\n- Added node version 19.5.0.".to_string() - }; +All notable changes to this project will be documented in this file. - let changelog = Changelog { - unreleased: Some( - "### Added\n\n- Added node version 18.15.0.\n- Added yarn version 4.0.0-rc.2" - .to_string(), - ), - releases: IndexMap::from([ - ("0.8.16".to_string(), release_entry_0_8_16.clone()), - ("0.8.15".to_string(), release_entry_0_8_15.clone()), - ]), - }; +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - assert_eq!( - changelog.unreleased, - Some( - "### Added\n\n- Added node version 18.15.0.\n- Added yarn version 4.0.0-rc.2" - .to_string() - ) - ); - assert_eq!(changelog.releases.get("0.8.17"), None); - assert_eq!( - changelog.releases.get("0.8.16"), - Some(&release_entry_0_8_16) - ); - assert_eq!( - changelog.releases.get("0.8.15"), - Some(&release_entry_0_8_15) - ); +## [Unreleased] + +### Added + +- Added node version 18.15.0. +- Added yarn version 4.0.0-rc.2 + +## [0.8.16] - 2023-02-27 + +### Added + +- Added node version 19.7.0, 19.6.1, 14.21.3, 16.19.1, 18.14.1, 18.14.2. +- Added node version 18.14.0, 19.6.0. + +## [0.8.15] - 2023-02-26 + +### Added + +- `name` is no longer a required field in package.json. ([#447](https://github.com/heroku/buildpacks-nodejs/pull/447)) +- Added node version 19.5.0. + +[unreleased]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.16...HEAD +[0.8.16]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.15...v0.8.16 +[0.8.15]: https://github.com/heroku/buildpacks-nodejs/releases/tag/v/v0.8.15\n".parse().unwrap(); let next_version = BuildpackVersion { major: 0, minor: 8, patch: 17, }; - let date = Utc.with_ymd_and_hms(2023, 6, 16, 0, 0, 0).unwrap(); let updated_dependencies = HashSet::from([buildpack_id!("b"), buildpack_id!("a")]); - let changelog = promote_changelog_unreleased_to_version( - &changelog, + let repository_url = "https://github.com/heroku/buildpacks-nodejs".to_string(); + let today = ReleaseDate::today(); + promote_changelog_unreleased_to_version( + &mut changelog, &next_version, - &date, + &repository_url, &updated_dependencies, - ); + ) + .unwrap(); - assert_eq!(changelog.unreleased, None); - assert_eq!( - changelog.releases.get("0.8.17"), - Some(&ReleaseEntry { - version: "0.8.17".parse::().unwrap(), - date, - body: "### Added\n\n- Added node version 18.15.0.\n- Added yarn version 4.0.0-rc.2\n\n### Changed\n\n- Updated `a` to `0.8.17`.\n- Updated `b` to `0.8.17`.".to_string() - }) - ); - assert_eq!( - changelog.releases.get("0.8.16"), - Some(&release_entry_0_8_16) - ); - assert_eq!( - changelog.releases.get("0.8.15"), - Some(&release_entry_0_8_15) - ); + assert_eq!(changelog.to_string(), format!("\ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.8.17] - {today} + +### Added + +- Added node version 18.15.0. +- Added yarn version 4.0.0-rc.2 + +### Changed + +- Updated `a` to `0.8.17`. +- Updated `b` to `0.8.17`. + +## [0.8.16] - 2023-02-27 + +### Added + +- Added node version 19.7.0, 19.6.1, 14.21.3, 16.19.1, 18.14.1, 18.14.2. +- Added node version 18.14.0, 19.6.0. + +## [0.8.15] - 2023-02-26 + +### Added + +- `name` is no longer a required field in package.json. ([#447](https://github.com/heroku/buildpacks-nodejs/pull/447)) +- Added node version 19.5.0. + +[unreleased]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.17...HEAD +[0.8.17]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.16...v0.8.17 +[0.8.16]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.15...v0.8.16 +[0.8.15]: https://github.com/heroku/buildpacks-nodejs/releases/tag/v/v0.8.15\n" + )); } #[test] fn test_promote_changelog_unreleased_to_version_with_no_entries_and_updated_dependencies() { - let changelog = Changelog { - unreleased: None, - releases: IndexMap::new(), - }; + let mut changelog: Changelog = "\ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.8.16] - 2023-02-27 + +### Added + +- Added node version 19.7.0, 19.6.1, 14.21.3, 16.19.1, 18.14.1, 18.14.2. +- Added node version 18.14.0, 19.6.0. + +## [0.8.15] - 2023-02-26 + +### Added - assert_eq!(changelog.unreleased, None); - assert_eq!(changelog.releases.get("0.8.17"), None); +- `name` is no longer a required field in package.json. ([#447](https://github.com/heroku/buildpacks-nodejs/pull/447)) +- Added node version 19.5.0. + +[unreleased]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.16...HEAD +[0.8.16]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.15...v0.8.16 +[0.8.15]: https://github.com/heroku/buildpacks-nodejs/releases/tag/v/v0.8.15\n".parse().unwrap(); let next_version = BuildpackVersion { major: 0, minor: 8, patch: 17, }; - let date = Utc.with_ymd_and_hms(2023, 6, 16, 0, 0, 0).unwrap(); - let updated_dependencies = HashSet::from([buildpack_id!("a"), buildpack_id!("b")]); - let changelog = promote_changelog_unreleased_to_version( - &changelog, + let updated_dependencies = HashSet::from([buildpack_id!("b"), buildpack_id!("a")]); + let repository_url = "https://github.com/heroku/buildpacks-nodejs".to_string(); + let today = ReleaseDate::today(); + promote_changelog_unreleased_to_version( + &mut changelog, &next_version, - &date, + &repository_url, &updated_dependencies, - ); + ) + .unwrap(); - assert_eq!(changelog.unreleased, None); - assert_eq!( - changelog.releases.get("0.8.17"), - Some(&ReleaseEntry { - version: "0.8.17".parse::().unwrap(), - date, - body: "### Changed\n\n- Updated `a` to `0.8.17`.\n- Updated `b` to `0.8.17`." - .to_string() - }) - ); - } + assert_eq!(changelog.to_string(), format!("\ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.8.17] - {today} + +### Changed + +- Updated `a` to `0.8.17`. +- Updated `b` to `0.8.17`. + +## [0.8.16] - 2023-02-27 + +### Added + +- Added node version 19.7.0, 19.6.1, 14.21.3, 16.19.1, 18.14.1, 18.14.2. +- Added node version 18.14.0, 19.6.0. + +## [0.8.15] - 2023-02-26 + +### Added + +- `name` is no longer a required field in package.json. ([#447](https://github.com/heroku/buildpacks-nodejs/pull/447)) +- Added node version 19.5.0. + +[unreleased]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.17...HEAD +[0.8.17]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.16...v0.8.17 +[0.8.16]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.15...v0.8.16 +[0.8.15]: https://github.com/heroku/buildpacks-nodejs/releases/tag/v/v0.8.15\n" + )); + } #[test] fn test_promote_changelog_unreleased_to_version_with_changed_entries_is_merged_with_updated_dependencies( ) { - let changelog = Changelog { - unreleased: Some( - r" -- Entry not under a header + let mut changelog: Changelog = "\ +# Changelog -### Added +All notable changes to this project will be documented in this file. -- Added node version 18.15.0. -- Added yarn version 4.0.0-rc.2 +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] ### Changed -- Lowed limits +- Added feature X -### Removed +## [0.8.16] - 2023-02-27 -- Dropped all deprecated methods - " - .trim() - .to_string(), - ), - releases: IndexMap::new(), - }; +### Added + +- Added node version 19.7.0, 19.6.1, 14.21.3, 16.19.1, 18.14.1, 18.14.2. +- Added node version 18.14.0, 19.6.0. + +## [0.8.15] - 2023-02-26 + +### Added + +- `name` is no longer a required field in package.json. ([#447](https://github.com/heroku/buildpacks-nodejs/pull/447)) +- Added node version 19.5.0. + +[unreleased]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.16...HEAD +[0.8.16]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.15...v0.8.16 +[0.8.15]: https://github.com/heroku/buildpacks-nodejs/releases/tag/v/v0.8.15\n".parse().unwrap(); let next_version = BuildpackVersion { major: 0, minor: 8, patch: 17, }; - let date = Utc.with_ymd_and_hms(2023, 6, 16, 0, 0, 0).unwrap(); let updated_dependencies = HashSet::from([buildpack_id!("b"), buildpack_id!("a")]); - let changelog = promote_changelog_unreleased_to_version( - &changelog, + let repository_url = "https://github.com/heroku/buildpacks-nodejs".to_string(); + let today = ReleaseDate::today(); + promote_changelog_unreleased_to_version( + &mut changelog, &next_version, - &date, + &repository_url, &updated_dependencies, - ); + ) + .unwrap(); - assert_eq!(changelog.unreleased, None); - assert_eq!( - changelog.releases.get("0.8.17").unwrap().body, - r" -- Entry not under a header + assert_eq!(changelog.to_string(), format!("\ +# Changelog -### Added +All notable changes to this project will be documented in this file. -- Added node version 18.15.0. -- Added yarn version 4.0.0-rc.2 +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.8.17] - {today} ### Changed -- Lowed limits +- Added feature X - Updated `a` to `0.8.17`. - Updated `b` to `0.8.17`. -### Removed +## [0.8.16] - 2023-02-27 -- Dropped all deprecated methods - " - .trim() - .to_string() - ); +### Added + +- Added node version 19.7.0, 19.6.1, 14.21.3, 16.19.1, 18.14.1, 18.14.2. +- Added node version 18.14.0, 19.6.0. + +## [0.8.15] - 2023-02-26 + +### Added + +- `name` is no longer a required field in package.json. ([#447](https://github.com/heroku/buildpacks-nodejs/pull/447)) +- Added node version 19.5.0. + +[unreleased]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.17...HEAD +[0.8.17]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.16...v0.8.17 +[0.8.16]: https://github.com/heroku/buildpacks-nodejs/compare/v0.8.15...v0.8.16 +[0.8.15]: https://github.com/heroku/buildpacks-nodejs/releases/tag/v/v0.8.15\n" + )); } fn create_buildpack_file(contents: &str) -> BuildpackFile { diff --git a/src/commands/prepare_release/errors.rs b/src/commands/prepare_release/errors.rs index 0df3af6e..63df0843 100644 --- a/src/commands/prepare_release/errors.rs +++ b/src/commands/prepare_release/errors.rs @@ -1,5 +1,4 @@ use crate::buildpacks::FindReleasableBuildpacksError; -use crate::changelog::ChangelogError; use crate::github::actions::SetActionOutputError; use libcnb_data::buildpack::BuildpackVersion; use std::collections::HashMap; @@ -14,10 +13,6 @@ pub(crate) enum Error { FindReleasableBuildpacks(FindReleasableBuildpacksError), #[error(transparent)] SetActionOutput(SetActionOutputError), - #[error("Invalid URL `{0}` for argument --repository-url\nError: {1}")] - InvalidRepositoryUrl(String, #[source] uriparse::URIError), - #[error("Invalid Version `{0}` for argument --declarations-starting-version\nError: {1}")] - InvalidDeclarationsStartingVersion(String, #[source] semver::Error), #[error("No buildpacks found under {}", .0.display())] NoBuildpacksFound(PathBuf), #[error("Not all versions match:\n{}", list_versions_with_path(.0))] @@ -27,7 +22,13 @@ pub(crate) enum Error { #[error("Could not read changelog\nPath: {}\nError: {1}", .0.display())] ReadingChangelog(PathBuf, #[source] io::Error), #[error("Could not parse changelog\nPath: {}\nError: {1}", .0.display())] - ParsingChangelog(PathBuf, #[source] ChangelogError), + ParsingChangelog(PathBuf, #[source] keep_a_changelog::ParseChangelogError), + #[error(transparent)] + ParseChangelogReleaseVersion(keep_a_changelog::ParseVersionError), + #[error(transparent)] + ParseReleaseLink(keep_a_changelog::ParseReleaseLinkError), + #[error(transparent)] + PromoteUnreleased(keep_a_changelog::PromoteUnreleasedError), #[error("Could not write changelog\nPath: {}\nError: {1}", .0.display())] WritingChangelog(PathBuf, #[source] io::Error), #[error("Missing required field `{1}` in buildpack.toml\nPath: {}", .0.display())] diff --git a/src/main.rs b/src/main.rs index 0adb3888..06a02d36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,6 @@ use crate::commands::{ use clap::Parser; mod buildpacks; -mod changelog; mod commands; mod github;