diff --git a/.github/ISSUE_TEMPLATE/integration.yml b/.github/ISSUE_TEMPLATE/integration.yml index 1dca65a7aa..c93f63ebf8 100644 --- a/.github/ISSUE_TEMPLATE/integration.yml +++ b/.github/ISSUE_TEMPLATE/integration.yml @@ -1,5 +1,5 @@ name: Integration ⚙️ -description: Report a bug or request a feature about an integration (e.g GitHub/GitLab/Bitbucket) +description: Report a bug or request a feature about an integration (e.g GitHub/GitLab/Gitea/Bitbucket) labels: ["integration"] assignees: - orhun diff --git a/.github/fixtures/test-gitea-integration/cliff.toml b/.github/fixtures/test-gitea-integration/cliff.toml new file mode 100644 index 0000000000..20c9b4e34f --- /dev/null +++ b/.github/fixtures/test-gitea-integration/cliff.toml @@ -0,0 +1,46 @@ +[remote.gitea] +owner = "ThetaDev" +repo = "git-cliff-readme-example" + +[changelog] +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +## What's Changed +{%- if version %} in {{ version }}{%- endif -%} +{% for commit in commits %} + * {{ commit.message | split(pat="\n") | first | trim }}\ + {% if commit.gitea.username %} by @{{ commit.gitea.username }}{%- endif -%} + {% if commit.gitea.pr_number %} in #{{ commit.gitea.pr_number }}{%- endif %} +{%- endfor -%} + +{% if gitea.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} + {% raw %}\n{% endraw -%} + ## New Contributors +{%- endif %}\ +{% for contributor in gitea.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() }}/pull/{{ contributor.pr_number }}) \ + {%- endif %} +{%- endfor -%} +{% raw %}\n\n{% endraw -%} +""" +# remove the leading and trailing whitespace from the template +trim = true +# changelog footer +footer = """ + +""" + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = false +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }] +# protect breaking changes from being skipped due to matching a skipping commit_parser +protect_breaking_commits = false diff --git a/.github/fixtures/test-gitea-integration/commit.sh b/.github/fixtures/test-gitea-integration/commit.sh new file mode 100755 index 0000000000..d15ef150ec --- /dev/null +++ b/.github/fixtures/test-gitea-integration/commit.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -e + +git remote add origin https://codeberg.org/ThetaDev/git-cliff-readme-example.git +git pull origin master +git fetch --tags diff --git a/.github/fixtures/test-gitea-integration/expected.md b/.github/fixtures/test-gitea-integration/expected.md new file mode 100644 index 0000000000..ff728a5e48 --- /dev/null +++ b/.github/fixtures/test-gitea-integration/expected.md @@ -0,0 +1,15 @@ +## What's Changed +* Initial commit by @ThetaDev +* docs(project): add README.md by @ThetaDev +* feat(parser): add ability to parse arrays by @ThetaDev +* fix(args): rename help argument due to conflict by @ThetaDev +* docs(example)!: add tested usage example by @ThetaDev +* refactor(parser): expose string functions by @ThetaDev +* chore(release): add release script by @ThetaDev +* feat(config): support multiple file formats by @ThetaDev +* feat(cache): use cache while fetching pages by @ThetaDev + +## New Contributors +* @ThetaDev made their first contribution + + diff --git a/.github/workflows/test-fixtures.yml b/.github/workflows/test-fixtures.yml index 2e35cf7010..3bb433671b 100644 --- a/.github/workflows/test-fixtures.yml +++ b/.github/workflows/test-fixtures.yml @@ -18,6 +18,9 @@ jobs: include: - fixtures-name: new-fixture-template - fixtures-name: test-github-integration + - fixtures-name: test-gitlab-integration + - fixtures-name: test-gitea-integration + - fixtures-name: test-bitbucket-integration - fixtures-name: test-ignore-tags - fixtures-name: test-topo-order command: --latest diff --git a/git-cliff-core/Cargo.toml b/git-cliff-core/Cargo.toml index 207bfab93d..0dc7530e62 100644 --- a/git-cliff-core/Cargo.toml +++ b/git-cliff-core/Cargo.toml @@ -17,36 +17,30 @@ default = ["repo"] ## You can turn this off if you already have the commits to put in the ## changelog and you don't need `git-cliff` to parse them. repo = ["dep:git2", "dep:glob", "dep:indexmap"] -## Enable integration with GitHub. -## You can turn this off if you don't use GitHub and don't want -## to make network requests to the GitHub API. -github = [ +# Enable integration with remote repositories. +remote = [ "dep:reqwest", "dep:http-cache-reqwest", "dep:reqwest-middleware", "dep:tokio", "dep:futures", ] +## Enable integration with GitHub. +## You can turn this off if you don't use GitHub and don't want +## to make network requests to the GitHub API. +github = ["remote"] ## Enable integration with GitLab. ## You can turn this off if you don't use GitLab and don't want ## to make network requests to the GitLab API. -gitlab = [ - "dep:reqwest", - "dep:http-cache-reqwest", - "dep:reqwest-middleware", - "dep:tokio", - "dep:futures", -] +gitlab = ["remote"] ## Enable integration with Bitbucket. ## You can turn this off if you don't use Bitbucket and don't want ## to make network requests to the Bitbucket API. -bitbucket = [ - "dep:reqwest", - "dep:http-cache-reqwest", - "dep:reqwest-middleware", - "dep:tokio", - "dep:futures", -] +bitbucket = ["remote"] +## Enable integration with Gitea. +## 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"] [dependencies] glob = { workspace = true, optional = true } diff --git a/git-cliff-core/src/changelog.rs b/git-cliff-core/src/changelog.rs index 777b75ff0b..7707f37596 100644 --- a/git-cliff-core/src/changelog.rs +++ b/git-cliff-core/src/changelog.rs @@ -10,6 +10,8 @@ use crate::release::{ }; #[cfg(feature = "bitbucket")] use crate::remote::bitbucket::BitbucketClient; +#[cfg(feature = "gitea")] +use crate::remote::gitea::GiteaClient; #[cfg(feature = "github")] use crate::remote::github::GitHubClient; #[cfg(feature = "gitlab")] @@ -224,7 +226,10 @@ impl<'a> Changelog<'a> { github_client.get_pull_requests(), )?; debug!("Number of GitHub commits: {}", commits.len()); - debug!("Number of GitHub pull requests: {}", commits.len()); + debug!( + "Number of GitHub pull requests: {}", + pull_requests.len() + ); Ok((commits, pull_requests)) }); info!("{}", github::FINISHED_FETCHING_MSG); @@ -300,6 +305,57 @@ impl<'a> Changelog<'a> { } } + /// Returns the Gitea metadata needed for the changelog. + /// + /// This function creates a multithread async runtime for handling the + /// requests. The following are fetched from the GitHub REST API: + /// + /// - Commits + /// - Pull requests + /// + /// Each of these are paginated requests so they are being run in parallel + /// for speedup. + /// + /// If no Gitea related variable is used in the template then this function + /// returns empty vectors. + #[cfg(feature = "gitea")] + fn get_gitea_metadata(&self) -> Result { + use crate::remote::gitea; + if self + .body_template + .contains_variable(gitea::TEMPLATE_VARIABLES) || + self.footer_template + .as_ref() + .map(|v| v.contains_variable(gitea::TEMPLATE_VARIABLES)) + .unwrap_or(false) + { + warn!("You are using an experimental feature! Please report bugs at "); + let gitea_client = + GiteaClient::try_from(self.config.remote.gitea.clone())?; + info!( + "{} ({})", + gitea::START_FETCHING_MSG, + self.config.remote.gitea + ); + let data = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(async { + let (commits, pull_requests) = tokio::try_join!( + gitea_client.get_commits(), + gitea_client.get_pull_requests(), + )?; + debug!("Number of Gitea commits: {}", commits.len()); + debug!("Number of Gitea pull requests: {}", pull_requests.len()); + Ok((commits, pull_requests)) + }); + info!("{}", gitea::FINISHED_FETCHING_MSG); + data + } else { + Ok((vec![], vec![])) + } + } + /// Returns the Bitbucket metadata needed for the changelog. /// /// This function creates a multithread async runtime for handling the @@ -379,6 +435,13 @@ impl<'a> Changelog<'a> { } else { (vec![], vec![]) }; + #[cfg(feature = "gitea")] + let (gitea_commits, gitea_merge_request) = if self.config.remote.gitea.is_set() { + self.get_gitea_metadata() + .expect("Could not get gitea metadata") + } else { + (vec![], vec![]) + }; #[cfg(feature = "bitbucket")] let (bitbucket_commits, bitbucket_pull_request) = if self.config.remote.bitbucket.is_set() { @@ -398,6 +461,11 @@ impl<'a> Changelog<'a> { gitlab_commits.clone(), gitlab_merge_request.clone(), )?; + #[cfg(feature = "gitea")] + release.update_gitea_metadata( + gitea_commits.clone(), + gitea_merge_request.clone(), + )?; #[cfg(feature = "bitbucket")] release.update_bitbucket_metadata( bitbucket_commits.clone(), @@ -692,6 +760,11 @@ mod test { repo: String::from("awesome"), token: None, }, + gitea: Remote { + owner: String::from("coolguy"), + repo: String::from("awesome"), + token: None, + }, bitbucket: Remote { owner: String::from("coolguy"), repo: String::from("awesome"), @@ -771,6 +844,10 @@ mod test { gitlab: crate::remote::RemoteReleaseMetadata { contributors: vec![], }, + #[cfg(feature = "gitea")] + gitea: crate::remote::RemoteReleaseMetadata { + contributors: vec![], + }, #[cfg(feature = "bitbucket")] bitbucket: crate::remote::RemoteReleaseMetadata { contributors: vec![], @@ -826,6 +903,10 @@ mod test { gitlab: crate::remote::RemoteReleaseMetadata { contributors: vec![], }, + #[cfg(feature = "gitea")] + gitea: crate::remote::RemoteReleaseMetadata { + contributors: vec![], + }, #[cfg(feature = "bitbucket")] bitbucket: crate::remote::RemoteReleaseMetadata { contributors: vec![], diff --git a/git-cliff-core/src/commit.rs b/git-cliff-core/src/commit.rs index e34612877a..1a74d1e0d4 100644 --- a/git-cliff-core/src/commit.rs +++ b/git-cliff-core/src/commit.rs @@ -128,6 +128,9 @@ pub struct Commit<'a> { /// GitLab metadata of the commit. #[cfg(feature = "gitlab")] pub gitlab: crate::remote::RemoteContributor, + /// Gitea metadata of the commit. + #[cfg(feature = "gitea")] + pub gitea: crate::remote::RemoteContributor, /// Bitbucket metadata of the commit. #[cfg(feature = "bitbucket")] pub bitbucket: crate::remote::RemoteContributor, @@ -446,6 +449,8 @@ impl Serialize for Commit<'_> { commit.serialize_field("github", &self.github)?; #[cfg(feature = "gitlab")] commit.serialize_field("gitlab", &self.gitlab)?; + #[cfg(feature = "gitea")] + commit.serialize_field("gitea", &self.gitea)?; #[cfg(feature = "bitbucket")] commit.serialize_field("bitbucket", &self.bitbucket)?; commit.end() diff --git a/git-cliff-core/src/config.rs b/git-cliff-core/src/config.rs index bd20264faf..a2bbc56e47 100644 --- a/git-cliff-core/src/config.rs +++ b/git-cliff-core/src/config.rs @@ -126,6 +126,9 @@ pub struct RemoteConfig { /// GitLab remote. #[serde(default)] pub gitlab: Remote, + /// Gitea remote. + #[serde(default)] + pub gitea: Remote, /// Bitbucket remote. #[serde(default)] pub bitbucket: Remote, diff --git a/git-cliff-core/src/error.rs b/git-cliff-core/src/error.rs index dc31658d37..5f96b589d3 100644 --- a/git-cliff-core/src/error.rs +++ b/git-cliff-core/src/error.rs @@ -77,17 +77,17 @@ pub enum Error { SemverError(#[from] semver::Error), /// The errors that may occur when processing a HTTP request. #[error("HTTP client error: `{0}`")] - #[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))] + #[cfg(feature = "remote")] HttpClientError(#[from] reqwest::Error), /// The errors that may occur while constructing the HTTP client with /// middleware. #[error("HTTP client with middleware error: `{0}`")] - #[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))] + #[cfg(feature = "remote")] HttpClientMiddlewareError(#[from] reqwest_middleware::Error), /// A possible error when converting a HeaderValue from a string or byte /// slice. #[error("HTTP header error: `{0}`")] - #[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))] + #[cfg(feature = "remote")] HttpHeaderError(#[from] reqwest::header::InvalidHeaderValue), /// Error that may occur during handling pages. #[error("Pagination error: `{0}`")] diff --git a/git-cliff-core/src/lib.rs b/git-cliff-core/src/lib.rs index 49a2639fdd..6f78c91ea4 100644 --- a/git-cliff-core/src/lib.rs +++ b/git-cliff-core/src/lib.rs @@ -27,7 +27,7 @@ pub mod error; /// Common release type. pub mod release; /// Remote handler. -#[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))] +#[cfg(feature = "remote")] #[allow(async_fn_in_trait)] pub mod remote; /// Git repository. diff --git a/git-cliff-core/src/release.rs b/git-cliff-core/src/release.rs index b256813e16..1cea7580b6 100644 --- a/git-cliff-core/src/release.rs +++ b/git-cliff-core/src/release.rs @@ -1,7 +1,7 @@ use crate::commit::Commit; use crate::config::Bump; use crate::error::Result; -#[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))] +#[cfg(feature = "remote")] use crate::remote::{ RemoteCommit, RemoteContributor, @@ -37,6 +37,9 @@ pub struct Release<'a> { #[cfg(feature = "gitlab")] pub gitlab: RemoteReleaseMetadata, /// Contributors. + #[cfg(feature = "gitea")] + pub gitea: RemoteReleaseMetadata, + /// Contributors. #[cfg(feature = "bitbucket")] pub bitbucket: RemoteReleaseMetadata, } @@ -47,6 +50,9 @@ crate::update_release_metadata!(github, update_github_metadata); #[cfg(feature = "gitlab")] crate::update_release_metadata!(gitlab, update_gitlab_metadata); +#[cfg(feature = "gitea")] +crate::update_release_metadata!(gitea, update_gitea_metadata); + #[cfg(feature = "bitbucket")] crate::update_release_metadata!(bitbucket, update_bitbucket_metadata); @@ -162,6 +168,10 @@ mod test { gitlab: crate::remote::RemoteReleaseMetadata { contributors: vec![], }, + #[cfg(feature = "gitea")] + gitea: crate::remote::RemoteReleaseMetadata { + contributors: vec![], + }, #[cfg(feature = "bitbucket")] bitbucket: crate::remote::RemoteReleaseMetadata { contributors: vec![], @@ -316,8 +326,8 @@ mod test { }; let mut release = Release { - version: None, - commits: vec![ + version: None, + commits: vec![ Commit::from(String::from( "1d244937ee6ceb8e0314a4a201ba93a7a61f2071 add github \ integration", @@ -341,16 +351,22 @@ mod test { ], commit_id: None, timestamp: 0, - previous: Some(Box::new(Release { + previous: Some(Box::new(Release { version: Some(String::from("1.0.0")), ..Default::default() })), - github: RemoteReleaseMetadata { + github: RemoteReleaseMetadata { + contributors: vec![], + }, + #[cfg(feature = "gitlab")] + gitlab: RemoteReleaseMetadata { contributors: vec![], }, - gitlab: RemoteReleaseMetadata { + #[cfg(feature = "gitea")] + gitea: RemoteReleaseMetadata { contributors: vec![], }, + #[cfg(feature = "bitbucket")] bitbucket: RemoteReleaseMetadata { contributors: vec![], }, @@ -595,8 +611,8 @@ mod test { }; let mut release = Release { - version: None, - commits: vec![ + version: None, + commits: vec![ Commit::from(String::from( "1d244937ee6ceb8e0314a4a201ba93a7a61f2071 add github \ integration", @@ -620,16 +636,22 @@ mod test { ], commit_id: None, timestamp: 0, - previous: Some(Box::new(Release { + previous: Some(Box::new(Release { version: Some(String::from("1.0.0")), ..Default::default() })), - github: RemoteReleaseMetadata { + #[cfg(feature = "github")] + github: RemoteReleaseMetadata { contributors: vec![], }, - gitlab: RemoteReleaseMetadata { + gitlab: RemoteReleaseMetadata { contributors: vec![], }, + #[cfg(feature = "gitea")] + gitea: RemoteReleaseMetadata { + contributors: vec![], + }, + #[cfg(feature = "bitbucket")] bitbucket: RemoteReleaseMetadata { contributors: vec![], }, @@ -920,4 +942,290 @@ mod test { Ok(()) } + + #[cfg(feature = "gitea")] + #[test] + fn update_gitea_metadata() -> Result<()> { + use crate::remote::gitea::{ + GiteaCommit, + GiteaCommitAuthor, + GiteaPullRequest, + PullRequestLabel, + }; + + let mut release = Release { + version: None, + commits: vec![ + Commit::from(String::from( + "1d244937ee6ceb8e0314a4a201ba93a7a61f2071 add github \ + integration", + )), + Commit::from(String::from( + "21f6aa587fcb772de13f2fde0e92697c51f84162 fix github \ + integration", + )), + Commit::from(String::from( + "35d8c6b6329ecbcf131d7df02f93c3bbc5ba5973 update metadata", + )), + Commit::from(String::from( + "4d3ffe4753b923f4d7807c490e650e6624a12074 do some stuff", + )), + Commit::from(String::from( + "5a55e92e5a62dc5bf9872ffb2566959fad98bd05 alright", + )), + Commit::from(String::from( + "6c34967147560ea09658776d4901709139b4ad66 should be fine", + )), + ], + commit_id: None, + timestamp: 0, + previous: Some(Box::new(Release { + version: Some(String::from("1.0.0")), + ..Default::default() + })), + #[cfg(feature = "github")] + github: RemoteReleaseMetadata { + contributors: vec![], + }, + #[cfg(feature = "gitlab")] + gitlab: RemoteReleaseMetadata { + contributors: vec![], + }, + gitea: RemoteReleaseMetadata { + contributors: vec![], + }, + #[cfg(feature = "bitbucket")] + bitbucket: RemoteReleaseMetadata { + contributors: vec![], + }, + }; + release.update_gitea_metadata( + vec![ + GiteaCommit { + sha: String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071"), + author: Some(GiteaCommitAuthor { + login: Some(String::from("orhun")), + }), + }, + GiteaCommit { + sha: String::from("21f6aa587fcb772de13f2fde0e92697c51f84162"), + author: Some(GiteaCommitAuthor { + login: Some(String::from("orhun")), + }), + }, + GiteaCommit { + sha: String::from("35d8c6b6329ecbcf131d7df02f93c3bbc5ba5973"), + author: Some(GiteaCommitAuthor { + login: Some(String::from("nuhro")), + }), + }, + GiteaCommit { + sha: String::from("4d3ffe4753b923f4d7807c490e650e6624a12074"), + author: Some(GiteaCommitAuthor { + login: Some(String::from("awesome_contributor")), + }), + }, + GiteaCommit { + sha: String::from("5a55e92e5a62dc5bf9872ffb2566959fad98bd05"), + author: Some(GiteaCommitAuthor { + login: Some(String::from("orhun")), + }), + }, + GiteaCommit { + sha: String::from("6c34967147560ea09658776d4901709139b4ad66"), + author: Some(GiteaCommitAuthor { + login: Some(String::from("someone")), + }), + }, + GiteaCommit { + sha: String::from("0c34967147560e809658776d4901709139b4ad68"), + author: Some(GiteaCommitAuthor { + login: Some(String::from("idk")), + }), + }, + GiteaCommit { + sha: String::from("kk34967147560e809658776d4901709139b4ad68"), + author: None, + }, + GiteaCommit { + sha: String::new(), + author: None, + }, + ] + .into_iter() + .map(|v| Box::new(v) as Box) + .collect(), + vec![ + GiteaPullRequest { + title: Some(String::from("1")), + number: 42, + merge_commit_sha: Some(String::from( + "1d244937ee6ceb8e0314a4a201ba93a7a61f2071", + )), + labels: vec![PullRequestLabel { + name: String::from("rust"), + }], + }, + GiteaPullRequest { + title: Some(String::from("2")), + number: 66, + merge_commit_sha: Some(String::from( + "21f6aa587fcb772de13f2fde0e92697c51f84162", + )), + labels: vec![PullRequestLabel { + name: String::from("rust"), + }], + }, + GiteaPullRequest { + title: Some(String::from("3")), + number: 53, + merge_commit_sha: Some(String::from( + "35d8c6b6329ecbcf131d7df02f93c3bbc5ba5973", + )), + labels: vec![PullRequestLabel { + name: String::from("deps"), + }], + }, + GiteaPullRequest { + title: Some(String::from("4")), + number: 1000, + merge_commit_sha: Some(String::from( + "4d3ffe4753b923f4d7807c490e650e6624a12074", + )), + labels: vec![PullRequestLabel { + name: String::from("deps"), + }], + }, + GiteaPullRequest { + title: Some(String::from("5")), + number: 999999, + merge_commit_sha: Some(String::from( + "5a55e92e5a62dc5bf9872ffb2566959fad98bd05", + )), + labels: vec![PullRequestLabel { + name: String::from("github"), + }], + }, + ] + .into_iter() + .map(|v| Box::new(v) as Box) + .collect(), + )?; + let expected_commits = vec![ + Commit { + id: String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071"), + message: String::from("add github integration"), + gitea: RemoteContributor { + username: Some(String::from("orhun")), + pr_title: Some(String::from("1")), + pr_number: Some(42), + pr_labels: vec![String::from("rust")], + is_first_time: false, + }, + ..Default::default() + }, + Commit { + id: String::from("21f6aa587fcb772de13f2fde0e92697c51f84162"), + message: String::from("fix github integration"), + gitea: RemoteContributor { + username: Some(String::from("orhun")), + pr_title: Some(String::from("2")), + pr_number: Some(66), + pr_labels: vec![String::from("rust")], + is_first_time: false, + }, + ..Default::default() + }, + Commit { + id: String::from("35d8c6b6329ecbcf131d7df02f93c3bbc5ba5973"), + message: String::from("update metadata"), + gitea: RemoteContributor { + username: Some(String::from("nuhro")), + pr_title: Some(String::from("3")), + pr_number: Some(53), + pr_labels: vec![String::from("deps")], + is_first_time: false, + }, + ..Default::default() + }, + Commit { + id: String::from("4d3ffe4753b923f4d7807c490e650e6624a12074"), + message: String::from("do some stuff"), + gitea: RemoteContributor { + username: Some(String::from("awesome_contributor")), + pr_title: Some(String::from("4")), + pr_number: Some(1000), + pr_labels: vec![String::from("deps")], + is_first_time: false, + }, + ..Default::default() + }, + Commit { + id: String::from("5a55e92e5a62dc5bf9872ffb2566959fad98bd05"), + message: String::from("alright"), + gitea: RemoteContributor { + username: Some(String::from("orhun")), + pr_title: Some(String::from("5")), + pr_number: Some(999999), + pr_labels: vec![String::from("github")], + is_first_time: false, + }, + ..Default::default() + }, + Commit { + id: String::from("6c34967147560ea09658776d4901709139b4ad66"), + message: String::from("should be fine"), + gitea: RemoteContributor { + username: Some(String::from("someone")), + pr_title: None, + pr_number: None, + pr_labels: vec![], + is_first_time: false, + }, + ..Default::default() + }, + ]; + assert_eq!(expected_commits, release.commits); + + release + .gitea + .contributors + .sort_by(|a, b| a.pr_number.cmp(&b.pr_number)); + + let expected_metadata = RemoteReleaseMetadata { + contributors: vec![ + RemoteContributor { + username: Some(String::from("someone")), + pr_title: None, + pr_number: None, + pr_labels: vec![], + is_first_time: true, + }, + RemoteContributor { + username: Some(String::from("orhun")), + pr_title: Some(String::from("1")), + pr_number: Some(42), + pr_labels: vec![String::from("rust")], + is_first_time: true, + }, + RemoteContributor { + username: Some(String::from("nuhro")), + pr_title: Some(String::from("3")), + pr_number: Some(53), + pr_labels: vec![String::from("deps")], + is_first_time: true, + }, + RemoteContributor { + username: Some(String::from("awesome_contributor")), + pr_title: Some(String::from("4")), + pr_number: Some(1000), + pr_labels: vec![String::from("deps")], + is_first_time: true, + }, + ], + }; + assert_eq!(expected_metadata, release.gitea); + + Ok(()) + } } diff --git a/git-cliff-core/src/remote/gitea.rs b/git-cliff-core/src/remote/gitea.rs new file mode 100644 index 0000000000..251776c300 --- /dev/null +++ b/git-cliff-core/src/remote/gitea.rs @@ -0,0 +1,184 @@ +use crate::config::Remote; +use crate::error::*; +use reqwest_middleware::ClientWithMiddleware; +use serde::{ + Deserialize, + Serialize, +}; +use std::env; + +use super::*; + +/// Gitea API url. +const GITEA_API_URL: &str = "https://codeberg.org"; + +/// Environment variable for overriding the Gitea REST API url. +const GITEA_API_URL_ENV: &str = "GITEA_API_URL"; + +/// Log message to show while fetching data from Gitea. +pub const START_FETCHING_MSG: &str = "Retrieving data from Gitea..."; + +/// Log message to show when done fetching from Gitea. +pub const FINISHED_FETCHING_MSG: &str = "Done fetching Gitea data."; + +/// Template variables related to this remote. +pub(crate) const TEMPLATE_VARIABLES: &[&str] = &["gitea", "commit.gitea"]; + +/// Representation of a single commit. +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GiteaCommit { + /// SHA. + pub sha: String, + /// Author of the commit. + pub author: Option, +} + +impl RemoteCommit for GiteaCommit { + fn id(&self) -> String { + self.sha.clone() + } + + fn username(&self) -> Option { + self.author.clone().and_then(|v| v.login) + } +} + +impl RemoteEntry for GiteaCommit { + fn url(_id: i64, api_url: &str, remote: &Remote, page: i32) -> String { + format!( + "{}/api/v1/repos/{}/{}/commits?limit={MAX_PAGE_SIZE}&page={page}", + api_url, remote.owner, remote.repo + ) + } + fn buffer_size() -> usize { + 10 + } + + fn early_exit(&self) -> bool { + false + } +} + +/// Author of the commit. +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GiteaCommitAuthor { + /// Username. + pub login: Option, +} + +/// Label of the pull request. +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PullRequestLabel { + /// Name of the label. + pub name: String, +} + +/// Representation of a single pull request. +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GiteaPullRequest { + /// Pull request number. + pub number: i64, + /// Pull request title. + pub title: Option, + /// SHA of the merge commit. + pub merge_commit_sha: Option, + /// Labels of the pull request. + pub labels: Vec, +} + +impl RemotePullRequest for GiteaPullRequest { + fn number(&self) -> i64 { + self.number + } + + fn title(&self) -> Option { + self.title.clone() + } + + fn labels(&self) -> Vec { + self.labels.iter().map(|v| v.name.clone()).collect() + } + + fn merge_commit(&self) -> Option { + self.merge_commit_sha.clone() + } +} + +impl RemoteEntry for GiteaPullRequest { + fn url(_id: i64, api_url: &str, remote: &Remote, page: i32) -> String { + format!( + "{}/api/v1/repos/{}/{}/pulls?limit={MAX_PAGE_SIZE}&page={page}&\ + state=closed", + api_url, remote.owner, remote.repo + ) + } + + fn buffer_size() -> usize { + 5 + } + + fn early_exit(&self) -> bool { + false + } +} + +/// HTTP client for handling Gitea REST API requests. +#[derive(Debug, Clone)] +pub struct GiteaClient { + /// Remote. + remote: Remote, + /// HTTP client. + client: ClientWithMiddleware, +} + +/// Constructs a Gitea client from the remote configuration. +impl TryFrom for GiteaClient { + type Error = Error; + fn try_from(remote: Remote) -> Result { + Ok(Self { + client: create_remote_client(&remote, "application/json")?, + remote, + }) + } +} + +impl RemoteClient for GiteaClient { + fn api_url() -> String { + env::var(GITEA_API_URL_ENV) + .ok() + .unwrap_or_else(|| GITEA_API_URL.to_string()) + } + + fn remote(&self) -> Remote { + self.remote.clone() + } + + fn client(&self) -> ClientWithMiddleware { + self.client.clone() + } +} + +impl GiteaClient { + /// Fetches the Gitea API and returns the commits. + pub async fn get_commits(&self) -> Result>> { + Ok(self + .fetch::(0) + .await? + .into_iter() + .map(|v| Box::new(v) as Box) + .collect()) + } + + /// Fetches the Gitea API and returns the pull requests. + pub async fn get_pull_requests( + &self, + ) -> Result>> { + Ok(self + .fetch::(0) + .await? + .into_iter() + .map(|v| Box::new(v) as Box) + .collect()) + } +} diff --git a/git-cliff-core/src/remote/mod.rs b/git-cliff-core/src/remote/mod.rs index 3349df0c2d..f4bf29c982 100644 --- a/git-cliff-core/src/remote/mod.rs +++ b/git-cliff-core/src/remote/mod.rs @@ -10,6 +10,10 @@ pub mod gitlab; #[cfg(feature = "bitbucket")] pub mod bitbucket; +/// Gitea client. +#[cfg(feature = "gitea")] +pub mod gitea; + use crate::config::Remote; use crate::error::{ Error, @@ -43,6 +47,7 @@ use serde::{ Deserialize, Serialize, }; +use std::fmt::Debug; use std::hash::{ Hash, Hasher, diff --git a/git-cliff-core/src/template.rs b/git-cliff-core/src/template.rs index 93506b0103..d5dbf93012 100644 --- a/git-cliff-core/src/template.rs +++ b/git-cliff-core/src/template.rs @@ -132,7 +132,7 @@ impl Template { } /// Returns `true` if the template contains one of the given variables. - #[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))] + #[cfg(feature = "remote")] pub(crate) fn contains_variable(&self, variables: &[&str]) -> bool { variables .iter() @@ -213,6 +213,10 @@ mod test { gitlab: crate::remote::RemoteReleaseMetadata { contributors: vec![], }, + #[cfg(feature = "gitea")] + gitea: crate::remote::RemoteReleaseMetadata { + contributors: vec![], + }, #[cfg(feature = "bitbucket")] bitbucket: crate::remote::RemoteReleaseMetadata { contributors: vec![], diff --git a/git-cliff-core/tests/integration_test.rs b/git-cliff-core/tests/integration_test.rs index e93aeba64b..99d790dc2f 100644 --- a/git-cliff-core/tests/integration_test.rs +++ b/git-cliff-core/tests/integration_test.rs @@ -196,6 +196,10 @@ fn generate_changelog() -> Result<()> { gitlab: git_cliff_core::remote::RemoteReleaseMetadata { contributors: vec![], }, + #[cfg(feature = "gitea")] + gitea: git_cliff_core::remote::RemoteReleaseMetadata { + contributors: vec![], + }, #[cfg(feature = "bitbucket")] bitbucket: git_cliff_core::remote::RemoteReleaseMetadata { contributors: vec![], @@ -232,6 +236,10 @@ fn generate_changelog() -> Result<()> { gitlab: git_cliff_core::remote::RemoteReleaseMetadata { contributors: vec![], }, + #[cfg(feature = "gitea")] + gitea: git_cliff_core::remote::RemoteReleaseMetadata { + contributors: vec![], + }, #[cfg(feature = "bitbucket")] bitbucket: git_cliff_core::remote::RemoteReleaseMetadata { contributors: vec![], diff --git a/git-cliff/Cargo.toml b/git-cliff/Cargo.toml index af9ae0e3be..96d86a3157 100644 --- a/git-cliff/Cargo.toml +++ b/git-cliff/Cargo.toml @@ -23,15 +23,19 @@ path = "src/bin/mangen.rs" [features] # check for new versions -default = ["update-informer", "github", "gitlab", "bitbucket"] +default = ["update-informer", "github", "gitlab", "gitea", "bitbucket"] # inform about new releases update-informer = ["dep:update-informer"] +# enable remote repository integration +remote = ["dep:indicatif"] # enable GitHub integration -github = ["git-cliff-core/github", "dep:indicatif"] +github = ["remote", "git-cliff-core/github"] # enable GitLab integration -gitlab = ["git-cliff-core/gitlab", "dep:indicatif"] +gitlab = ["remote", "git-cliff-core/gitlab"] +# enable Gitea integration +gitea = ["remote", "git-cliff-core/gitea"] # enable Bitbucket integration -bitbucket = ["git-cliff-core/bitbucket", "dep:indicatif"] +bitbucket = ["remote", "git-cliff-core/bitbucket"] [dependencies] glob.workspace = true diff --git a/git-cliff/src/args.rs b/git-cliff/src/args.rs index 175df1d998..3d52a7b697 100644 --- a/git-cliff/src/args.rs +++ b/git-cliff/src/args.rs @@ -261,6 +261,24 @@ pub struct Opt { hide = !cfg!(feature = "gitlab"), )] pub gitlab_repo: Option, + /// Sets the Gitea API token. + #[arg( + long, + env = "GITEA_TOKEN", + value_name = "TOKEN", + hide_env_values = true, + hide = !cfg!(feature = "gitea"), + )] + pub gitea_token: Option, + /// Sets the GitLab repository. + #[arg( + long, + env = "GITEA_REPO", + value_parser = clap::value_parser!(RemoteValue), + value_name = "OWNER/REPO", + hide = !cfg!(feature = "gitea"), + )] + pub gitea_repo: Option, /// Sets the Bitbucket API token. #[arg( long, diff --git a/git-cliff/src/lib.rs b/git-cliff/src/lib.rs index f8cc86c026..3939919a41 100644 --- a/git-cliff/src/lib.rs +++ b/git-cliff/src/lib.rs @@ -134,6 +134,17 @@ fn process_repository<'a>( debug!("Failed to get remote from GitLab repository: {:?}", e); } } + } else if !config.remote.gitea.is_set() { + match repository.upstream_remote() { + Ok(remote) => { + debug!("No Gitea remote is set, using remote: {}", remote); + config.remote.gitea.owner = remote.owner; + config.remote.gitea.repo = remote.repo; + } + Err(e) => { + debug!("Failed to get remote from Gitea repository: {:?}", e); + } + } } else if !config.remote.bitbucket.is_set() { match repository.upstream_remote() { Ok(remote) => { @@ -437,6 +448,9 @@ pub fn run(mut args: Opt) -> Result<()> { if args.gitlab_token.is_some() { config.remote.gitlab.token.clone_from(&args.gitlab_token); } + if args.gitea_token.is_some() { + config.remote.gitea.token.clone_from(&args.gitea_token); + } if args.bitbucket_token.is_some() { config .remote diff --git a/git-cliff/src/logger.rs b/git-cliff/src/logger.rs index 76869b5093..17e80d399f 100644 --- a/git-cliff/src/logger.rs +++ b/git-cliff/src/logger.rs @@ -10,7 +10,7 @@ use git_cliff_core::error::{ Error, Result, }; -#[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))] +#[cfg(feature = "remote")] use indicatif::{ ProgressBar, ProgressStyle, @@ -66,7 +66,7 @@ fn colored_level(style: &mut Style, level: Level) -> StyledValue<'_, &'static st } } -#[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))] +#[cfg(feature = "remote")] lazy_static::lazy_static! { /// Lazily initialized progress bar. pub static ref PROGRESS_BAR: ProgressBar = { @@ -144,6 +144,23 @@ pub fn init() -> Result<()> { } } + #[cfg(feature = "gitea")] + { + let message = record.args().to_string(); + if message.starts_with(git_cliff_core::remote::gitea::START_FETCHING_MSG) + { + PROGRESS_BAR + .enable_steady_tick(std::time::Duration::from_millis(80)); + PROGRESS_BAR.set_message(message); + return Ok(()); + } else if message + .starts_with(git_cliff_core::remote::gitea::FINISHED_FETCHING_MSG) + { + PROGRESS_BAR.finish_and_clear(); + return Ok(()); + } + } + #[cfg(feature = "bitbucket")] { let message = record.args().to_string(); diff --git a/website/docs/configuration/remote.md b/website/docs/configuration/remote.md index fa085c8362..7d5706917c 100644 --- a/website/docs/configuration/remote.md +++ b/website/docs/configuration/remote.md @@ -2,7 +2,7 @@ This section contains the Git remote related configuration options. -You can configure a remote for GitHub, GitLab or Bitbucket as follows: +You can configure a remote for GitHub, GitLab, Gitea/Forgejo or Bitbucket as follows: ```toml [remote.github] @@ -11,12 +11,13 @@ repo = "git-cliff" token = "" ``` -Change this to `remote.gitlab` or `remote.bitbucket` accordingly to your project. +Change this to `remote.gitlab`, `remote.gitea` or `remote.bitbucket` accordingly to your project. :::tip - See the [GitHub integration](/docs/integration/github). - See the [GitLab integration](/docs/integration/gitlab). +- See the [Gitea integration](/docs/integration/gitea). - See the [Bitbucket integration](/docs/integration/bitbucket). ::: @@ -37,7 +38,7 @@ e.g. git cliff --github-repo orhun/git-cliff ``` -Same applies for GitLab/Bitbucket with `--gitlab-repo`/`--bitbucket-repo` and `GITLAB_REPO`/`BITBUCKET_REPO` environment variables. +Same applies for GitLab/Bitbucket with `--gitlab-repo`/`--gitea-repo`/`--bitbucket-repo` and `GITLAB_REPO`/`GITEA_REPO`/`BITBUCKET_REPO` environment variables. ### token @@ -49,4 +50,4 @@ If you are using GitHub, then you can also pass this value via `--github-token` git cliff --github-token ``` -Same applies for GitLab/Bitbucket with `--gitlab-token`/`--bitbucket-token` and `GITLAB_TOKEN`/`BITBUCKET_TOKEN` environment variables. +Same applies for GitLab/Bitbucket with `--gitlab-token`/`--gitea-token`/`--bitbucket-token` and `GITLAB_TOKEN`/`GITEA_TOKEN`/`BITBUCKET_TOKEN` environment variables. diff --git a/website/docs/integration/bitbucket.md b/website/docs/integration/bitbucket.md index 459dd5d5e3..7295bb9ce3 100644 --- a/website/docs/integration/bitbucket.md +++ b/website/docs/integration/bitbucket.md @@ -1,5 +1,5 @@ --- -sidebar_position: 3 +sidebar_position: 4 --- # Bitbucket Integration 📘 diff --git a/website/docs/integration/gitea.md b/website/docs/integration/gitea.md new file mode 100644 index 0000000000..8020bd243f --- /dev/null +++ b/website/docs/integration/gitea.md @@ -0,0 +1,179 @@ +--- +sidebar_position: 3 +--- + +# Gitea Integration 🍵 + +:::warning + +This is still an experimental feature, please [report bugs](https://github.com/orhun/git-cliff/issues/new/choose). + +::: + +:::note + +If you have built from source, enable the `gitea` feature flag for the integration to work. + +::: + +For projects hosted on Gitea/Forgejo, you can use **git-cliff** to add the following to your changelog: + +- Gitea usernames +- Contributors list (all contributors / first time) +- Pull request links (associated with the commits) + +## Setting up the remote + +As default, remote upstream URL is automatically retrieved from the Git repository. + +If that doesn't work or if you want to set a custom remote, there are a couple of ways of doing it: + +- Use the [remote option](/docs/configuration/remote) in the configuration file: + +```toml +[remote.gitea] +owner = "orhun" +repo = "git-cliff" +token = "***" +``` + +- Use the `--gitea-repo` argument (takes values in `OWNER/REPO` format, e.g. "orhun/git-cliff") + +- Use the `GITEA_REPO` environment variable (same format as `--gitea-repo`) + +## Authentication + +:::tip + +[Gitea REST API](https://gitea.com/api/swagger) is being used to retrieve data from Gitea. +It does not require authentication for public repositories. If your project uses a private +repository, you need to create an access token under *Settings* > *Applications* > *Access tokens*. + +::: + +To set an access token, you can use the [configuration file](/docs/configuration/remote) (not recommended), `--gitea-token` argument or `GITEA_TOKEN` environment variable. + +For example: + +```bash +GITEA_TOKEN="***" git cliff --gitea-repo "orhun/git-cliff" +``` + +:::tip + +You can use the `GITEA_API_URL` environment variable want to override the API URL. This is useful if you are using your own Gitea instance. + +::: + +## Templating + +:::tip + +See the [templating documentation](/docs/category/templating) for general information about how the template engine works. + +::: + +### Remote + +You can use the following [context](/docs/templating/context) for adding the remote to the changelog: + +```json +{ + "gitea": { + "owner": "orhun", + "repo": "git-cliff" + } +} +``` + +For example: + +```jinja2 +https://codeberg.org/{{ remote.gitea.owner }}/{{ remote.gitea.repo }}/commits/tag/{{ version }} +``` + +### Commit authors + +For each commit, Gitea related values are added as a nested object (named `gitea`) to the [template context](/docs/templating/context): + +```json +{ + "id": "8edec7fd50f703811d55f14a3c5f0fd02b43d9e7", + "message": "refactor(config): remove unnecessary newline from configs\n", + "group": "🚜 Refactor", + + "...": "", + + "gitea": { + "username": "orhun", + "pr_title": "some things have changed", + "pr_number": 420, + "pr_labels": ["rust"], + "is_first_time": false + } +} +``` + +This can be used in the template as follows: + +``` +{% for commit in commits %} + * {{ commit.message | split(pat="\n") | first | trim }}\ + {% if commit.gitea.username %} by @{{ commit.gitea.username }}{%- endif %}\ + {% if commit.gitea.pr_number %} in #{{ commit.gitea.pr_number }}{%- endif %} +{%- endfor -%} +``` + +The will result in: + +```md +- feat(commit): add merge_commit flag to the context by @orhun in #389 +- feat(args): set `CHANGELOG.md` as default missing value for output option by @sh-cho in #354 +``` + +### Contributors + +For each release, following contributors data is added to the [template context](/docs/templating/context) as a nested object: + +```json +{ + "version": "v1.4.0", + "commits": [], + "commit_id": "0af9eb24888d1a8c9b2887fbe5427985582a0f26", + "timestamp": 0, + "previous": null, + "gitea": { + "contributors": [ + { + "username": "orhun", + "pr_title": "some things have changed", + "pr_number": 420, + "pr_labels": ["rust"], + "is_first_time": true + }, + { + "username": "cliffjumper", + "pr_title": "I love jumping", + "pr_number": 999, + "pr_labels": ["rust"], + "is_first_time": true + } + ] + } +} +``` + +This can be used in the template as follows: + +``` +{% for contributor in gitea.contributors | filter(attribute="is_first_time", value=true) %} + * @{{ contributor.username }} made their first contribution in #{{ contributor.pr_number }} +{%- endfor -%} +``` + +The will result in: + +```md +- @orhun made their first contribution in #420 +- @cliffjumper made their first contribution in #999 +``` diff --git a/website/docs/integration/python.md b/website/docs/integration/python.md index 623ec8500a..6615e1cdfc 100644 --- a/website/docs/integration/python.md +++ b/website/docs/integration/python.md @@ -1,5 +1,5 @@ --- -sidebar_position: 5 +sidebar_position: 6 --- # Python 🐍 diff --git a/website/docs/integration/rust.md b/website/docs/integration/rust.md index be3c150181..75a1b68690 100644 --- a/website/docs/integration/rust.md +++ b/website/docs/integration/rust.md @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 5 --- # Rust/Cargo 🦀