diff --git a/README.md b/README.md index 541a39d6a0..d6d22c230e 100644 --- a/README.md +++ b/README.md @@ -330,7 +330,7 @@ This minimal example creates artifacts that can be used on another job. - CHANGELOG.md ``` -Please note that the stage is `doc` and has to be changed accordingly to your need. +Please note that the stage is `doc` and has to be changed accordingly to your need. ## Configuration File @@ -406,6 +406,10 @@ skip_tags = "v0.1.0-beta.1" ignore_tags = "" topo_order = false sort_commits = "oldest" +link_parsers = [ + { pattern = "#(\\d+)", href = "https://github.com/orhun/git-cliff/issues/$1"}, + { pattern = "RFC(\\d+)", replace = "https://datatracker.ietf.org/doc/html/rfc$1"}, +] ``` #### conventional_commits @@ -513,6 +517,17 @@ Possible values: This can also be achieved by specifying the `--sort` command line argument. +#### link_parsers + +An array of link parsers for extracting external references, and turning them into URLs, using regex. + +Examples: + +- `{ pattern = "#(\\d+)", href = "https://github.com/orhun/git-cliff/issues/$1"}` + - Extract all gitlab issues and PRs and generate URLs linking to them. +- `{ pattern = "RFC(\\d+)", replace = "https://datatracker.ietf.org/doc/html/rfc$1"}` + - Extract mentiones of IETF RFCs and generate URLs linking to them. + ## Templating A template is a text where variables and expressions get replaced with values when it is rendered. @@ -550,7 +565,8 @@ following context is generated to use for templating: "footers": ["[footer]", "[footer]"], "breaking_description": "", "breaking": false, - "conventional": true + "conventional": true, + "links": [{"text": "[text]", "href": "[href]"}] } ], "commit_id": "a440c6eb26404be4877b7e3ad592bfaa5d4eb210 (release commit)", @@ -595,6 +611,7 @@ If [conventional_commits](#conventional_commits) is set to `false`, then some of "scope": "(overrided by commit_parsers)", "message": "(full commit message including description, footers, etc.)", "conventional": false, + "links": [{"text": "[text]", "href": "[href]"}] } ], "commit_id": "a440c6eb26404be4877b7e3ad592bfaa5d4eb210 (release commit)", diff --git a/git-cliff-core/src/commit.rs b/git-cliff-core/src/commit.rs index 6c559ccbf2..cb0845a1d6 100644 --- a/git-cliff-core/src/commit.rs +++ b/git-cliff-core/src/commit.rs @@ -1,6 +1,7 @@ use crate::config::{ CommitParser, GitConfig, + LinkParser, }; use crate::error::{ Error as AppError, @@ -29,6 +30,20 @@ pub struct Commit<'a> { pub group: Option, /// Commit scope based on conventional type or a commit parser. pub scope: Option, + /// A list of links found in the commit + pub links: Vec, +} + +/// Object representing a link +#[derive( + Debug, Clone, PartialEq, serde_derive::Deserialize, serde_derive::Serialize, +)] +#[serde(rename_all = "camelCase")] +pub struct Link { + /// Text of the link. + pub text: String, + /// URL of the link + pub href: String, } impl<'a> From<&GitCommit<'a>> for Commit<'a> { @@ -49,6 +64,7 @@ impl Commit<'_> { conv: None, group: None, scope: None, + links: vec![], } } @@ -56,6 +72,7 @@ impl Commit<'_> { /// /// * converts commit to a conventional commit /// * sets the group for the commit + /// * extacts links and generates URLs pub fn process(&self, config: &GitConfig) -> Result { let mut commit = self.clone(); if config.conventional_commits { @@ -69,6 +86,9 @@ impl Commit<'_> { commit = commit.parse(parsers, config.filter_commits.unwrap_or(false))?; } + if let Some(parsers) = &config.link_parsers { + commit = commit.parse_links(parsers)?; + } Ok(commit) } @@ -118,6 +138,27 @@ impl Commit<'_> { ))) } } + + /// Parses the commit using [`LinkParser`]s. + /// + /// Sets the [`links`] of the commit. + /// + /// [`links`]: Commit::links + pub fn parse_links(mut self, parsers: &[LinkParser]) -> Result { + for parser in parsers { + let regex = &parser.pattern; + let replace = &parser.href; + for mat in regex.find_iter(&self.message) { + let text = mat.as_str(); + let href = regex.replace(text, replace); + self.links.push(Link { + text: text.to_string(), + href: href.to_string(), + }); + } + } + Ok(self) + } } impl Serialize for Commit<'_> { @@ -163,6 +204,7 @@ impl Serialize for Commit<'_> { commit.serialize_field("scope", &self.scope)?; } } + commit.serialize_field("links", &self.links)?; commit.serialize_field("conventional", &self.conv.is_some())?; commit.end() } @@ -207,4 +249,58 @@ mod test { assert_eq!(Some(String::from("test_group")), commit.group); assert_eq!(Some(String::from("test_scope")), commit.scope); } + + #[test] + fn parse_link() { + let test_cases = vec![ + ( + Commit::new( + String::from("123123"), + String::from("test(commit): add test\n\nBody with issue #123"), + ), + true, + ), + ( + Commit::new( + String::from("123123"), + String::from( + "test(commit): add test\n\nImlement RFC456\n\nFixes: #456", + ), + ), + true, + ), + ]; + for (commit, is_conventional) in &test_cases { + assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok()) + } + let commit = Commit::new( + String::from("123123"), + String::from("test(commit): add test\n\nImlement RFC456\n\nFixes: #455"), + ); + let commit = commit + .parse_links(&[ + LinkParser { + pattern: Regex::new("RFC(\\d+)").unwrap(), + href: String::from("rfc://$1"), + }, + LinkParser { + pattern: Regex::new("#(\\d+)").unwrap(), + href: String::from("https://github.com/$1"), + }, + ]) + .unwrap(); + assert_eq!( + vec![ + Link { + text: String::from("RFC456"), + href: String::from("rfc://456"), + }, + Link { + text: String::from("#455"), + href: String::from("https://github.com/455"), + } + ], + commit.links + ); + } } diff --git a/git-cliff-core/src/config.rs b/git-cliff-core/src/config.rs index c6b55ba25f..b1d3031072 100644 --- a/git-cliff-core/src/config.rs +++ b/git-cliff-core/src/config.rs @@ -32,6 +32,8 @@ pub struct GitConfig { pub filter_unconventional: Option, /// Git commit parsers. pub commit_parsers: Option>, + /// Link parsers. + pub link_parsers: Option>, /// Whether to filter out commits. pub filter_commits: Option, /// Blob pattern for git tags. @@ -65,6 +67,16 @@ pub struct CommitParser { pub skip: Option, } +/// Parser for extracting links in commits. +#[derive(Debug, Clone, serde_derive::Serialize, serde_derive::Deserialize)] +pub struct LinkParser { + /// Regex for finding links in the commit message. + #[serde(with = "serde_regex")] + pub pattern: Regex, + /// The string used to generate the link URL. + pub href: String, +} + impl Config { /// Parses the config file and returns the values. pub fn parse(file_name: String) -> Result { diff --git a/git-cliff-core/tests/integration_test.rs b/git-cliff-core/tests/integration_test.rs index 4a1b666947..58f091fa2e 100644 --- a/git-cliff-core/tests/integration_test.rs +++ b/git-cliff-core/tests/integration_test.rs @@ -3,6 +3,7 @@ use git_cliff_core::config::{ ChangelogConfig, CommitParser, GitConfig, + LinkParser, }; use git_cliff_core::error::Result; use git_cliff_core::release::*; @@ -17,19 +18,19 @@ fn generate_changelog() -> Result<()> { header: Some(String::from("this is a changelog")), body: String::from( r#" - ## Release {{ version }} - {% for group, commits in commits | group_by(attribute="group") %} - ### {{ group }} - {% for commit in commits %} - {%- if commit.scope -%} - - *({{commit.scope}})* {{ commit.message }} - {% else -%} - - {{ commit.message }} - {% endif -%} - {% if commit.breaking -%} - {% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}} - {% endif -%} - {% endfor -%} +## Release {{ version }} +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group }} +{% for commit in commits %} +{%- if commit.scope -%} +- *({{commit.scope}})* {{ commit.message }}{%- if commit.links %} ({% for link in commit.links %}[{{link.text}}]({{link.href}}){% endfor -%}){% endif %} +{% else -%} +- {{ commit.message }} +{% endif -%} +{% if commit.breaking -%} +{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}} +{% endif -%} +{% endfor -%} {% endfor %}"#, ), footer: Some(String::from("eoc - end of changelog")), @@ -60,6 +61,10 @@ fn generate_changelog() -> Result<()> { ignore_tags: None, topo_order: None, sort_commits: None, + link_parsers: Some(vec![LinkParser { + pattern: Regex::new("#(\\d+)").unwrap(), + href: String::from("https://github.com/$1"), + }]), }; let releases = vec![ @@ -74,7 +79,9 @@ fn generate_changelog() -> Result<()> { Commit::new(String::from("abc124"), String::from("feat: add zyx")), Commit::new( String::from("abc124"), - String::from("feat(random-scope): add random feature"), + String::from( + "feat(random-scope): add random feature\n\nCloses #123", + ), ), Commit::new(String::from("def789"), String::from("invalid commit")), Commit::new( @@ -134,32 +141,33 @@ fn generate_changelog() -> Result<()> { writeln!(out, "{}", changelog_config.footer.unwrap()).unwrap(); assert_eq!( - "this is a changelog + r#"this is a changelog - ## Release v2.0.0 - - ### fix bugs - - fix abc - - ### shiny features - - add xyz - - add zyx - - *(random-scope)* add random feature - - *(big-feature)* this is a breaking change - - **BREAKING**: this is a breaking change - - ## Release v1.0.0 - - ### chore - - do nothing - - ### feat - - add cool features - - ### fix - - fix stuff - - fix more stuff - eoc - end of changelog\n", +## Release v2.0.0 + +### fix bugs +- fix abc + +### shiny features +- add xyz +- add zyx +- *(random-scope)* add random feature ([#123](https://github.com/123)) +- *(big-feature)* this is a breaking change + - **BREAKING**: this is a breaking change + +## Release v1.0.0 + +### chore +- do nothing + +### feat +- add cool features + +### fix +- fix stuff +- fix more stuff +eoc - end of changelog +"#, out ); diff --git a/git-cliff/src/changelog.rs b/git-cliff/src/changelog.rs index 70d00e63e5..ab9216b1ed 100644 --- a/git-cliff/src/changelog.rs +++ b/git-cliff/src/changelog.rs @@ -206,6 +206,7 @@ mod test { ignore_tags: None, topo_order: Some(false), sort_commits: Some(String::from("oldest")), + link_parsers: None, }, }; let test_release = Release {