diff --git a/.github/fixtures/test-azure-devops-integration-custom-range/cliff.toml b/.github/fixtures/test-azure-devops-integration-custom-range/cliff.toml new file mode 100644 index 0000000000..8e623cf824 --- /dev/null +++ b/.github/fixtures/test-azure-devops-integration-custom-range/cliff.toml @@ -0,0 +1,50 @@ +# git-cliff ~ configuration file +# https://git-cliff.org/docs/configuration + +# Azure DevOps integration for fetching commit metadata. +[remote.azure_devops] +owner = "shiftme/gitcliff" +repo = "git-cliff-readme-example" + +[changelog] +# A Tera template to be rendered for each release in the changelog. +# See https://keats.github.io/tera/docs/#introduction +body = """ +{%- macro remote_url() -%} + https://dev.azure.com/{{ remote.azure_devops.owner }}/_git/{{ remote.azure_devops.repo }} +{%- endmacro -%} + +## What's Changed +{%- if version %} in {{ version }}{%- endif -%} +{% for commit in commits %} + * {{ commit.message | split(pat="\n") | first | trim }}\ + {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif -%} + {% if commit.remote.pr_number %} in \ + [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pullrequest/{{ commit.remote.pr_number }})\ + {%- endif %} +{%- endfor -%} + +{% if azureDevops.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} + {% raw %}\n{% endraw -%} + ### New Contributors +{%- endif %}\ +{% for contributor in azureDevops.contributors | filter(attribute="is_first_time", value=true) %} + * @{{ contributor.username }} made their first contribution + {%- if contributor.pr_number %} in \ + [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pullrequest/{{ contributor.pr_number }}) \ + {%- endif %} +{% endfor %}\ +\n\n +""" + +[git] +# Parse commits according to the conventional commits specification. +# See https://www.conventionalcommits.org +conventional_commits = false +# Exclude commits that do not match the conventional commits specification. +filter_unconventional = true +# An array of regex based parsers to modify commit messages prior to further processing. +commit_preprocessors = [ + # Remove issue numbers. + { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }, +] diff --git a/.github/fixtures/test-azure-devops-integration-custom-range/commit.sh b/.github/fixtures/test-azure-devops-integration-custom-range/commit.sh new file mode 100755 index 0000000000..2bc58c078a --- /dev/null +++ b/.github/fixtures/test-azure-devops-integration-custom-range/commit.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -e + +git remote add origin https://dev.azure.com/shiftme/gitcliff/_git/git-cliff-readme-example +git pull origin master +git fetch --tags diff --git a/.github/fixtures/test-azure-devops-integration-custom-range/expected.md b/.github/fixtures/test-azure-devops-integration-custom-range/expected.md new file mode 100644 index 0000000000..e74dc598ae --- /dev/null +++ b/.github/fixtures/test-azure-devops-integration-custom-range/expected.md @@ -0,0 +1,4 @@ +## What's Changed in v1.0.1 +* refactor(parser): expose string functions by @orhun +* chore(release): add release script by @orhun + diff --git a/.github/fixtures/test-azure-devops-integration/cliff.toml b/.github/fixtures/test-azure-devops-integration/cliff.toml new file mode 100644 index 0000000000..8e623cf824 --- /dev/null +++ b/.github/fixtures/test-azure-devops-integration/cliff.toml @@ -0,0 +1,50 @@ +# git-cliff ~ configuration file +# https://git-cliff.org/docs/configuration + +# Azure DevOps integration for fetching commit metadata. +[remote.azure_devops] +owner = "shiftme/gitcliff" +repo = "git-cliff-readme-example" + +[changelog] +# A Tera template to be rendered for each release in the changelog. +# See https://keats.github.io/tera/docs/#introduction +body = """ +{%- macro remote_url() -%} + https://dev.azure.com/{{ remote.azure_devops.owner }}/_git/{{ remote.azure_devops.repo }} +{%- endmacro -%} + +## What's Changed +{%- if version %} in {{ version }}{%- endif -%} +{% for commit in commits %} + * {{ commit.message | split(pat="\n") | first | trim }}\ + {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif -%} + {% if commit.remote.pr_number %} in \ + [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pullrequest/{{ commit.remote.pr_number }})\ + {%- endif %} +{%- endfor -%} + +{% if azureDevops.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} + {% raw %}\n{% endraw -%} + ### New Contributors +{%- endif %}\ +{% for contributor in azureDevops.contributors | filter(attribute="is_first_time", value=true) %} + * @{{ contributor.username }} made their first contribution + {%- if contributor.pr_number %} in \ + [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pullrequest/{{ contributor.pr_number }}) \ + {%- endif %} +{% endfor %}\ +\n\n +""" + +[git] +# Parse commits according to the conventional commits specification. +# See https://www.conventionalcommits.org +conventional_commits = false +# Exclude commits that do not match the conventional commits specification. +filter_unconventional = true +# An array of regex based parsers to modify commit messages prior to further processing. +commit_preprocessors = [ + # Remove issue numbers. + { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }, +] diff --git a/.github/fixtures/test-azure-devops-integration/commit.sh b/.github/fixtures/test-azure-devops-integration/commit.sh new file mode 100755 index 0000000000..2bc58c078a --- /dev/null +++ b/.github/fixtures/test-azure-devops-integration/commit.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -e + +git remote add origin https://dev.azure.com/shiftme/gitcliff/_git/git-cliff-readme-example +git pull origin master +git fetch --tags diff --git a/.github/fixtures/test-azure-devops-integration/expected.md b/.github/fixtures/test-azure-devops-integration/expected.md new file mode 100644 index 0000000000..34066834b3 --- /dev/null +++ b/.github/fixtures/test-azure-devops-integration/expected.md @@ -0,0 +1,19 @@ +## What's Changed +* feat(config): support multiple file formats by @orhun +* feat(cache): use cache while fetching pages by @orhun + +## What's Changed in v1.0.1 +* refactor(parser): expose string functions by @orhun +* chore(release): add release script by @orhun + +## What's Changed in v1.0.0 +* Initial commit by @orhun +* docs(project): add README.md by @orhun +* feat(parser): add ability to parse arrays by @orhun +* fix(args): rename help argument due to conflict by @orhun +* docs(example)!: add tested usage example by @orhun + +### New Contributors +* @orhun made their first contribution + + diff --git a/.github/fixtures/test-from-context-does-not-discard-fields/context.json b/.github/fixtures/test-from-context-does-not-discard-fields/context.json index 1029b0de78..06b881f6c3 100644 --- a/.github/fixtures/test-from-context-does-not-discard-fields/context.json +++ b/.github/fixtures/test-from-context-does-not-discard-fields/context.json @@ -73,6 +73,13 @@ "pr_labels": [], "is_first_time": false }, + "azure_devops": { + "username": null, + "pr_title": null, + "pr_number": null, + "pr_labels": [], + "is_first_time": false + }, "raw_message": "feat(web): feature 1, breaking change in footer\n\nBody feature 1\n\nBREAKING CHANGE: breaking change description feature 1\nSigned-off-by: user1 \nReviewed-by: user2" }, { @@ -133,6 +140,13 @@ "pr_labels": [], "is_first_time": false }, + "azure_devops": { + "username": null, + "pr_title": null, + "pr_number": null, + "pr_labels": [], + "is_first_time": false + }, "raw_message": "feat(web)!: feature 2, breaking chain in description\n\nBody feature 2\n\nSigned-off-by: user3 " }, { @@ -193,6 +207,13 @@ "pr_labels": [], "is_first_time": false }, + "azure_devops": { + "username": null, + "pr_title": null, + "pr_number": null, + "pr_labels": [], + "is_first_time": false + }, "raw_message": "feat!: feature 3, use default scope = app\n\nBody feature 2\n\nSigned-off-by: user3 " }, { @@ -253,6 +274,13 @@ "pr_labels": [], "is_first_time": false }, + "azure_devops": { + "username": null, + "pr_title": null, + "pr_number": null, + "pr_labels": [], + "is_first_time": false + }, "raw_message": "fix(scope): fix 1, use scope as group\n\nBody fix 1\n\nFix: #1" }, { @@ -306,6 +334,13 @@ "pr_labels": [], "is_first_time": false }, + "azure_devops": { + "username": null, + "pr_title": null, + "pr_number": null, + "pr_labels": [], + "is_first_time": false + }, "raw_message": "fix(front-end): fix 2, no footer\n\nBody fix 2" }, { @@ -372,6 +407,13 @@ "pr_labels": [], "is_first_time": false }, + "azure_devops": { + "username": null, + "pr_title": null, + "pr_number": null, + "pr_labels": [], + "is_first_time": false + }, "raw_message": "fix(front-end): fix 3 and 4, no body but footer\n\nFix: #3\nFix: #4" } ], @@ -399,6 +441,9 @@ }, "bitbucket": { "contributors": [] + }, + "azure_devops": { + "contributors": [] } }, "repository": "/path/to/repository", @@ -416,6 +461,9 @@ }, "bitbucket": { "contributors": [] + }, + "azure_devops": { + "contributors": [] } } ] \ No newline at end of file diff --git a/.github/fixtures/test-from-context/context.json b/.github/fixtures/test-from-context/context.json index d7dd0532cf..1e978ce40b 100644 --- a/.github/fixtures/test-from-context/context.json +++ b/.github/fixtures/test-from-context/context.json @@ -52,6 +52,13 @@ "pr_number": null, "pr_labels": [], "is_first_time": false + }, + "azure_devops": { + "username": null, + "pr_title": null, + "pr_number": null, + "pr_labels": [], + "is_first_time": false } } ], @@ -106,6 +113,13 @@ "pr_number": null, "pr_labels": [], "is_first_time": false + }, + "azure_devops": { + "username": null, + "pr_title": null, + "pr_number": null, + "pr_labels": [], + "is_first_time": false } }, { @@ -153,6 +167,13 @@ "pr_number": null, "pr_labels": [], "is_first_time": false + }, + "azure_devops": { + "username": null, + "pr_title": null, + "pr_number": null, + "pr_labels": [], + "is_first_time": false } } ], @@ -173,6 +194,9 @@ }, "bitbucket": { "contributors": [] + }, + "azure_devops": { + "contributors": [] } }, "repository": "/home/johndoe/repo/", @@ -189,6 +213,9 @@ }, "bitbucket": { "contributors": [] + }, + "azure_devops": { + "contributors": [] } }, { @@ -244,6 +271,13 @@ "pr_number": null, "pr_labels": [], "is_first_time": false + }, + "azure_devops": { + "username": null, + "pr_title": null, + "pr_number": null, + "pr_labels": [], + "is_first_time": false } }, { @@ -298,6 +332,13 @@ "pr_number": null, "pr_labels": [], "is_first_time": false + }, + "azure_devops": { + "username": null, + "pr_title": null, + "pr_number": null, + "pr_labels": [], + "is_first_time": false } } ], @@ -352,6 +393,13 @@ "pr_number": null, "pr_labels": [], "is_first_time": false + }, + "azure_devops": { + "username": null, + "pr_title": null, + "pr_number": null, + "pr_labels": [], + "is_first_time": false } }, { @@ -399,6 +447,13 @@ "pr_number": null, "pr_labels": [], "is_first_time": false + }, + "azure_devops": { + "username": null, + "pr_title": null, + "pr_number": null, + "pr_labels": [], + "is_first_time": false } }, { @@ -446,6 +501,13 @@ "pr_number": null, "pr_labels": [], "is_first_time": false + }, + "azure_devops": { + "username": null, + "pr_title": null, + "pr_number": null, + "pr_labels": [], + "is_first_time": false } } ], @@ -466,6 +528,9 @@ }, "bitbucket": { "contributors": [] + }, + "azure_devops": { + "contributors": [] } }, "extra": { @@ -485,6 +550,9 @@ }, "bitbucket": { "contributors": [] + }, + "azure_devops": { + "contributors": [] } }, { @@ -540,6 +608,13 @@ "pr_number": null, "pr_labels": [], "is_first_time": false + }, + "azure_devops": { + "username": null, + "pr_title": null, + "pr_number": null, + "pr_labels": [], + "is_first_time": false } }, { @@ -591,6 +666,13 @@ "pr_number": null, "pr_labels": [], "is_first_time": false + }, + "azure_devops": { + "username": null, + "pr_title": null, + "pr_number": null, + "pr_labels": [], + "is_first_time": false } } ], @@ -617,6 +699,9 @@ }, "bitbucket": { "contributors": [] + }, + "azure_devops": { + "contributors": [] } }, "repository": "/home/johndoe/repo/", @@ -633,6 +718,9 @@ }, "bitbucket": { "contributors": [] + }, + "azure_devops": { + "contributors": [] } } ] \ No newline at end of file diff --git a/.github/workflows/test-fixtures.yml b/.github/workflows/test-fixtures.yml index 4b0d714cc6..883375a4e2 100644 --- a/.github/workflows/test-fixtures.yml +++ b/.github/workflows/test-fixtures.yml @@ -29,6 +29,9 @@ jobs: - fixtures-name: test-bitbucket-integration - fixtures-name: test-bitbucket-integration-custom-range command: v1.0.0..v1.0.1 + - fixtures-name: test-azure-devops-integration + - fixtures-name: test-azure-devops-integration-custom-range + command: v1.0.0..v1.0.1 - fixtures-name: test-ignore-tags - fixtures-name: test-invert-ignore-tags - fixtures-name: test-topo-order diff --git a/examples/azure-devops-keepachangelog.toml b/examples/azure-devops-keepachangelog.toml new file mode 100644 index 0000000000..c7ed02c9cb --- /dev/null +++ b/examples/azure-devops-keepachangelog.toml @@ -0,0 +1,85 @@ +# git-cliff ~ configuration file +# https://git-cliff.org/docs/configuration + +# [remote.azure_devops] +# owner = "Owner/Project" +# repo = "Repo" +# api_url = "https://dev.azure.com" +# token = "" + +[changelog] +# A Tera template to be rendered as the changelog's header. +# See https://keats.github.io/tera/docs/#introduction +header = """ +# Changelog\n +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).\n +""" +# A Tera template to be rendered for each release in the changelog. +# See https://keats.github.io/tera/docs/#introduction +body = """ +{% if version -%} + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else -%} + ## [Unreleased] +{% endif -%} +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | upper_first }} + {% for commit in commits %} + - {{ commit.message | split(pat="\n") | first | upper_first | trim }}\ + {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif %}\ + {% if commit.remote.pr_number %} in #{{ commit.remote.pr_number }}{%- endif %} + {% endfor %} +{% endfor %}\n +""" +# A Tera template to be rendered as the changelog's footer. +# See https://keats.github.io/tera/docs/#introduction +footer = """ +{% for release in releases -%} + {% if release.version -%} + {% if release.previous.version -%} + [{{ release.version | trim_start_matches(pat="v") }}]: \ + https://dev.azure.com/{{ remote.azure_devops.owner }}\ + /_git/{{ remote.azure_devops.repo }}/branchCompare?baseVersion=GT{{ release.previous.version }}&targetVersion=GT{{ release.version }} + {% endif -%} + {% else -%} + [unreleased]: https://dev.azure.com/{{ remote.azure_devops.owner }}\ + /_git/{{ remote.azure_devops.repo }}/branchCompare?baseVersion=GT{{ release.previous.version }}&targetVersion=GBmaster&_a=commits + {% endif -%} +{% endfor %} + +""" +# Remove leading and trailing whitespaces from the changelog's body. +trim = true + +[git] +# Parse commits according to the conventional commits specification. +# See https://www.conventionalcommits.org +conventional_commits = true +# Exclude commits that do not match the conventional commits specification. +filter_unconventional = false +# An array of regex based parsers for extracting data from the commit message. +# Assigns commits to groups. +# Optionally sets the commit's scope and can decide to exclude commits from further processing. +commit_parsers = [ + { message = "^[a|A]dd", group = "Added" }, + { message = "^[s|S]upport", group = "Added" }, + { message = "^[r|R]emove", group = "Removed" }, + { message = "^.*: add", group = "Added" }, + { message = "^.*: support", group = "Added" }, + { message = "^.*: remove", group = "Removed" }, + { message = "^.*: delete", group = "Removed" }, + { message = "^test", group = "Fixed" }, + { message = "^fix", group = "Fixed" }, + { message = "^.*: fix", group = "Fixed" }, + { message = "^.*", group = "Changed" }, +] +# Prevent commits that are breaking from being excluded by commit parsers. +filter_commits = false +# Order releases topologically instead of chronologically. +topo_order = false +# Order of commits in each group/release within the changelog. +# Allowed values: newest, oldest +sort_commits = "oldest" diff --git a/git-cliff-core/Cargo.toml b/git-cliff-core/Cargo.toml index 5a40bfb90c..e0368d9008 100644 --- a/git-cliff-core/Cargo.toml +++ b/git-cliff-core/Cargo.toml @@ -42,6 +42,10 @@ bitbucket = ["remote"] ## You can turn this off if you don't use Gitea and don't want ## to make network requests to the Gitea API. gitea = ["remote"] +## Enable integration with Azure DevOps. +## You can turn this off if you don't use Azure DevOps and don't want +## to make network requests to the Azure DevOps API. +azure_devops = ["remote"] [dependencies] glob.workspace = true diff --git a/git-cliff-core/src/changelog.rs b/git-cliff-core/src/changelog.rs index 4c7cfa2202..3d24d7b7f9 100644 --- a/git-cliff-core/src/changelog.rs +++ b/git-cliff-core/src/changelog.rs @@ -6,6 +6,8 @@ use crate::commit::Commit; use crate::config::{Config, GitConfig}; use crate::error::{Error, Result}; use crate::release::{Release, Releases}; +#[cfg(feature = "azure_devops")] +use crate::remote::azure_devops::AzureDevOpsClient; #[cfg(feature = "bitbucket")] use crate::remote::bitbucket::BitbucketClient; #[cfg(feature = "gitea")] @@ -441,6 +443,62 @@ impl<'a> Changelog<'a> { } } + /// Returns the Azure DevOps metadata needed for the changelog. + /// + /// This function creates a multithread async runtime for handling the + /// requests. The following are fetched from the Azure DevOps REST API: + /// + /// - Commits + /// - Pull requests + /// + /// Each of these are paginated requests so they are being run in parallel + /// for speedup. + /// + /// If no Azure DevOps related variable is used in the template then this + /// function returns empty vectors. + #[cfg(feature = "azure_devops")] + fn get_azure_devops_metadata( + &self, + ref_name: Option<&str>, + ) -> Result { + use crate::remote::azure_devops; + if self.config.remote.azure_devops.is_custom || + self.body_template + .contains_variable(azure_devops::TEMPLATE_VARIABLES) || + self.footer_template + .as_ref() + .map(|v| v.contains_variable(azure_devops::TEMPLATE_VARIABLES)) + .unwrap_or(false) + { + let azure_devops_client = + AzureDevOpsClient::try_from(self.config.remote.azure_devops.clone())?; + log::info!( + "{} ({})", + azure_devops::START_FETCHING_MSG, + self.config.remote.azure_devops + ); + let data = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(async { + let (commits, pull_requests) = tokio::try_join!( + azure_devops_client.get_commits(ref_name), + azure_devops_client.get_pull_requests() + )?; + log::debug!("Number of Azure DevOps commits: {}", commits.len()); + log::debug!( + "Number of Azure DevOps pull requests: {}", + pull_requests.len() + ); + Ok((commits, pull_requests)) + }); + log::info!("{}", azure_devops::FINISHED_FETCHING_MSG); + data + } else { + Ok((vec![], vec![])) + } + } + /// Adds information about the remote to the template context. pub fn add_remote_context(&mut self) -> Result<()> { self.additional_context.insert( @@ -492,6 +550,14 @@ impl<'a> Changelog<'a> { } else { (vec![], vec![]) }; + #[cfg(feature = "azure_devops")] + let (azure_devops_commits, azure_devops_pull_request) = + if self.config.remote.azure_devops.is_set() { + self.get_azure_devops_metadata(ref_name) + .expect("Could not get azure_devops metadata") + } else { + (vec![], vec![]) + }; #[cfg(feature = "remote")] for release in &mut self.releases { #[cfg(feature = "github")] @@ -505,6 +571,11 @@ impl<'a> Changelog<'a> { bitbucket_commits.clone(), bitbucket_pull_request.clone(), )?; + #[cfg(feature = "azure_devops")] + release.update_azure_devops_metadata( + azure_devops_commits.clone(), + azure_devops_pull_request.clone(), + )?; } Ok(()) } @@ -621,6 +692,7 @@ fn get_body_template(config: &Config, trim: bool) -> Result