diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index def1a91bcc..53562a198b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,6 @@ jobs: - name: Check the links uses: lycheeverse/lychee-action@v1 with: - args: --exclude "%7Busername%7D|file:///" -v *.md + args: --exclude "%7Busername%7D|file:///|https://datatracker.ietf.org" -v *.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 541a39d6a0..7e1cc7943e 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+)", text = "ietf-rfc$1", href = "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. The link text will be the matching pattern. +- `{ pattern = "RFC(\\d+)", text = "ietf-rfc$1", href = "https://datatracker.ietf.org/doc/html/rfc$1"}`, + - Extract mentions of IETF RFCs and generate URLs linking to them. It also rewrites the text as `ietf-rfc...`. + ## 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..d61a7c70bf 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,32 @@ 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 m = mat.as_str(); + let text = if let Some(text_replace) = &parser.text { + regex.replace(m, text_replace).to_string() + } else { + m.to_string() + }; + let href = regex.replace(m, replace); + self.links.push(Link { + text, + href: href.to_string(), + }); + } + } + Ok(self) + } } impl Serialize for Commit<'_> { @@ -163,6 +209,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 +254,60 @@ 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"), + text: None, + }, + LinkParser { + pattern: Regex::new("#(\\d+)").unwrap(), + href: String::from("https://github.com/$1"), + text: None, + }, + ]) + .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..79866f4435 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,18 @@ 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, + /// The string used to generate the link text. + pub text: Option, +} + 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..58b5c9fb93 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,18 @@ 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"), + text: None, + }, + LinkParser { + pattern: Regex::new("https://github.com/(.*)").unwrap(), + href: String::from("https://github.com/$1"), + text: Some(String::from("$1")), + }, + ]), }; let releases = vec![ @@ -74,7 +87,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\nThis is related to https://github.com/NixOS/nixpkgs/issues/136814\n\nCloses #123", + ), ), Commit::new(String::from("def789"), String::from("invalid commit")), Commit::new( @@ -134,32 +149,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 ([#123](https://github.com/123) [NixOS/nixpkgs/issues/136814](https://github.com/NixOS/nixpkgs/issues/136814) ) +- *(big-feature)* this is a breaking change + - **BREAKING**: this is a breaking change + +## Release v1.0.0 + +### chore +- do nothing + +### feat +- add cool features - ## 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", +### 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 {