diff --git a/.github/ISSUE_TEMPLATE/integration.yml b/.github/ISSUE_TEMPLATE/integration.yml index 40d646229a..1dca65a7aa 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 GitHub/GitLab integration +description: Report a bug or request a feature about an integration (e.g GitHub/GitLab/Bitbucket) labels: ["integration"] assignees: - orhun @@ -8,7 +8,7 @@ body: attributes: value: | Thanks for taking the time to fill out this bug report! - Please see https://git-cliff.org/docs/category/integration for more information about GitHub/GitLab integration. + Please see https://git-cliff.org/docs/category/integration for more information about integrations. - type: checkboxes id: new-bug attributes: diff --git a/.github/fixtures/test-bitbucket-integration/cliff.toml b/.github/fixtures/test-bitbucket-integration/cliff.toml new file mode 100644 index 0000000000..b3a6c73abb --- /dev/null +++ b/.github/fixtures/test-bitbucket-integration/cliff.toml @@ -0,0 +1,46 @@ +[remote.bitbucket] +owner = "orhunp" +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.bitbucket.username %} by @{{ commit.bitbucket.username }}{%- endif -%} + {% if commit.bitbucket.pr_number %} in #{{ commit.bitbucket.pr_number }}{%- endif %} +{%- endfor -%} + +{% if bitbucket.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} + {% raw %}\n{% endraw -%} + ## New Contributors +{%- endif %}\ +{% for contributor in bitbucket.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-bitbucket-integration/commit.sh b/.github/fixtures/test-bitbucket-integration/commit.sh new file mode 100755 index 0000000000..5bb810071b --- /dev/null +++ b/.github/fixtures/test-bitbucket-integration/commit.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -e + +git remote add origin https://bitbucket.org/orhunp/git-cliff-readme-example +git pull origin master +git fetch --tags diff --git a/.github/fixtures/test-bitbucket-integration/expected.md b/.github/fixtures/test-bitbucket-integration/expected.md new file mode 100644 index 0000000000..0c72638ad1 --- /dev/null +++ b/.github/fixtures/test-bitbucket-integration/expected.md @@ -0,0 +1,16 @@ +## 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 + + diff --git a/Dockerfile b/Dockerfile index 820e58f73f..6175d2360f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ COPY --from=planner /app/recipe.json recipe.json ENV CARGO_NET_GIT_FETCH_WITH_CLI=true RUN cargo chef cook --release --recipe-path recipe.json COPY . . -RUN cargo build --release --locked --no-default-features --features github --features gitlab +RUN cargo build --release --locked --no-default-features --features github --features gitlab --features bitbucket RUN rm -f target/release/deps/git_cliff* FROM debian:buster-slim as runner diff --git a/git-cliff-core/Cargo.toml b/git-cliff-core/Cargo.toml index 37cbeff00d..58e8bc1f36 100644 --- a/git-cliff-core/Cargo.toml +++ b/git-cliff-core/Cargo.toml @@ -37,6 +37,16 @@ gitlab = [ "dep:tokio", "dep:futures", ] +## 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", +] [dependencies] glob = { workspace = true, optional = true } diff --git a/git-cliff-core/src/changelog.rs b/git-cliff-core/src/changelog.rs index 97380bda3e..c781930d05 100644 --- a/git-cliff-core/src/changelog.rs +++ b/git-cliff-core/src/changelog.rs @@ -8,6 +8,8 @@ use crate::release::{ Release, Releases, }; +#[cfg(feature = "bitbucket")] +use crate::remote::bitbucket::BitbucketClient; #[cfg(feature = "github")] use crate::remote::github::GitHubClient; #[cfg(feature = "gitlab")] @@ -297,6 +299,62 @@ impl<'a> Changelog<'a> { } } + /// Returns the Bitbucket metadata needed for the changelog. + /// + /// This function creates a multithread async runtime for handling the + /// + /// requests. The following are fetched from the bitbucket REST API: + /// + /// - Commits + /// - Pull requests + /// + /// Each of these are paginated requests so they are being run in parallel + /// for speedup. + /// + /// + /// If no bitbucket related variable is used in the template then this + /// function returns empty vectors. + #[cfg(feature = "bitbucket")] + fn get_bitbucket_metadata(&self) -> Result { + use crate::remote::bitbucket; + if self + .body_template + .contains_variable(bitbucket::TEMPLATE_VARIABLES) || + self.footer_template + .as_ref() + .map(|v| v.contains_variable(bitbucket::TEMPLATE_VARIABLES)) + .unwrap_or(false) + { + warn!("You are using an experimental feature! Please report bugs at "); + let bitbucket_client = + BitbucketClient::try_from(self.config.remote.bitbucket.clone())?; + info!( + "{} ({})", + bitbucket::START_FETCHING_MSG, + self.config.remote.bitbucket + ); + let data = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(async { + let (commits, pull_requests) = tokio::try_join!( + bitbucket_client.get_commits(), + bitbucket_client.get_pull_requests() + )?; + debug!("Number of Bitbucket commits: {}", commits.len()); + debug!( + "Number of Bitbucket pull requests: {}", + pull_requests.len() + ); + Ok((commits, pull_requests)) + }); + info!("{}", bitbucket::FINISHED_FETCHING_MSG); + data + } else { + Ok((vec![], vec![])) + } + } + /// Increments the version for the unreleased changes based on semver. pub fn bump_version(&mut self) -> Result> { if let Some(ref mut last_release) = self.releases.iter_mut().next() { @@ -337,6 +395,14 @@ impl<'a> Changelog<'a> { } else { (vec![], vec![]) }; + #[cfg(feature = "bitbucket")] + let (bitbucket_commits, bitbucket_pull_request) = + if self.config.remote.bitbucket.is_set() { + self.get_bitbucket_metadata() + .expect("Could not get bitbucket metadata") + } else { + (vec![], vec![]) + }; let postprocessors = self .config .changelog @@ -363,6 +429,11 @@ impl<'a> Changelog<'a> { gitlab_commits.clone(), gitlab_merge_request.clone(), )?; + #[cfg(feature = "bitbucket")] + release.update_bitbucket_metadata( + bitbucket_commits.clone(), + bitbucket_pull_request.clone(), + )?; let write_result = write!( out, "{}", @@ -601,12 +672,17 @@ mod test { limit_commits: None, }, remote: RemoteConfig { - github: Remote { + github: Remote { owner: String::from("coolguy"), repo: String::from("awesome"), token: None, }, - gitlab: Remote { + gitlab: Remote { + owner: String::from("coolguy"), + repo: String::from("awesome"), + token: None, + }, + bitbucket: Remote { owner: String::from("coolguy"), repo: String::from("awesome"), token: None, @@ -685,6 +761,10 @@ mod test { gitlab: crate::remote::RemoteReleaseMetadata { contributors: vec![], }, + #[cfg(feature = "bitbucket")] + bitbucket: crate::remote::RemoteReleaseMetadata { + contributors: vec![], + }, }; let releases = vec![ test_release.clone(), @@ -736,6 +816,10 @@ mod test { gitlab: crate::remote::RemoteReleaseMetadata { contributors: vec![], }, + #[cfg(feature = "bitbucket")] + bitbucket: crate::remote::RemoteReleaseMetadata { + contributors: vec![], + }, }, ]; (config, releases) diff --git a/git-cliff-core/src/commit.rs b/git-cliff-core/src/commit.rs index bc9de538e3..e34612877a 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, + /// Bitbucket metadata of the commit. + #[cfg(feature = "bitbucket")] + pub bitbucket: crate::remote::RemoteContributor, } impl<'a> From for Commit<'a> { @@ -443,6 +446,8 @@ impl Serialize for Commit<'_> { commit.serialize_field("github", &self.github)?; #[cfg(feature = "gitlab")] commit.serialize_field("gitlab", &self.gitlab)?; + #[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 9dd23b6426..bd20264faf 100644 --- a/git-cliff-core/src/config.rs +++ b/git-cliff-core/src/config.rs @@ -122,10 +122,13 @@ pub struct GitConfig { pub struct RemoteConfig { /// GitHub remote. #[serde(default)] - pub github: Remote, + pub github: Remote, /// GitLab remote. #[serde(default)] - pub gitlab: Remote, + pub gitlab: Remote, + /// Bitbucket remote. + #[serde(default)] + pub bitbucket: Remote, } /// A single remote. diff --git a/git-cliff-core/src/error.rs b/git-cliff-core/src/error.rs index 1435c05c4a..dc31658d37 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"))] + #[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))] 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"))] + #[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))] 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"))] + #[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))] 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 50878ba404..49a2639fdd 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"))] +#[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))] #[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 6f58e3462a..b256813e16 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"))] +#[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))] use crate::remote::{ RemoteCommit, RemoteContributor, @@ -36,6 +36,9 @@ pub struct Release<'a> { /// Contributors. #[cfg(feature = "gitlab")] pub gitlab: RemoteReleaseMetadata, + /// Contributors. + #[cfg(feature = "bitbucket")] + pub bitbucket: RemoteReleaseMetadata, } #[cfg(feature = "github")] @@ -44,6 +47,9 @@ crate::update_release_metadata!(github, update_github_metadata); #[cfg(feature = "gitlab")] crate::update_release_metadata!(gitlab, update_gitlab_metadata); +#[cfg(feature = "bitbucket")] +crate::update_release_metadata!(bitbucket, update_bitbucket_metadata); + impl<'a> Release<'a> { /// Calculates the next version based on the commits. /// @@ -156,6 +162,10 @@ mod test { gitlab: crate::remote::RemoteReleaseMetadata { contributors: vec![], }, + #[cfg(feature = "bitbucket")] + bitbucket: crate::remote::RemoteReleaseMetadata { + contributors: vec![], + }, } } @@ -341,6 +351,9 @@ mod test { gitlab: RemoteReleaseMetadata { contributors: vec![], }, + bitbucket: RemoteReleaseMetadata { + contributors: vec![], + }, }; release.update_github_metadata( vec![ @@ -617,6 +630,9 @@ mod test { gitlab: RemoteReleaseMetadata { contributors: vec![], }, + bitbucket: RemoteReleaseMetadata { + contributors: vec![], + }, }; release.update_gitlab_metadata( vec![ diff --git a/git-cliff-core/src/remote/bitbucket.rs b/git-cliff-core/src/remote/bitbucket.rs new file mode 100644 index 0000000000..7597711b39 --- /dev/null +++ b/git-cliff-core/src/remote/bitbucket.rs @@ -0,0 +1,222 @@ +use crate::config::Remote; +use crate::error::*; +use reqwest_middleware::ClientWithMiddleware; +use serde::{ + Deserialize, + Serialize, +}; +use std::env; + +use super::*; + +/// Bitbucket REST API url. +const BITBUCKET_API_URL: &str = "https://api.bitbucket.org/2.0/repositories"; + +/// Environment variable for overriding the Bitbucket REST API url. +const BITBUCKET_API_URL_ENV: &str = "BITBUCKET_API_URL"; + +/// Log message to show while fetching data from Bitbucket. +pub const START_FETCHING_MSG: &str = "Retrieving data from Bitbucket..."; + +/// Log message to show when done fetching from Bitbucket. +pub const FINISHED_FETCHING_MSG: &str = "Done fetching Bitbucket data."; + +/// Template variables related to this remote. +pub(crate) const TEMPLATE_VARIABLES: &[&str] = &["bitbucket", "commit.bitbucket"]; + +/// Maximum number of entries to fetch for bitbucket pull requests. +pub(crate) const BITBUCKET_MAX_PAGE_PRS: usize = 50; + +/// Representation of a single commit. +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BitbucketCommit { + /// SHA. + pub hash: String, + /// Author of the commit. + pub author: Option, +} + +impl RemoteCommit for BitbucketCommit { + fn id(&self) -> String { + self.hash.clone() + } + + fn username(&self) -> Option { + self.author.clone().and_then(|v| v.login) + } +} + +/// +impl RemoteEntry for BitbucketPagination { + fn url(_id: i64, api_url: &str, remote: &Remote, page: i32) -> String { + let commit_page = page + 1; + format!( + "{}/{}/{}/commits?pagelen={MAX_PAGE_SIZE}&page={commit_page}", + api_url, remote.owner, remote.repo + ) + } + + fn buffer_size() -> usize { + 10 + } + + fn early_exit(&self) -> bool { + self.values.is_empty() + } +} + +/// Bitbucket Pagination Header +/// +/// +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BitbucketPagination { + /// Total number of objects in the response. + pub size: Option, + /// Page number of the current results. + pub page: Option, + /// Current number of objects on the existing page. Globally, the minimum + /// length is 10 and the maximum is 100. + pub pagelen: Option, + /// Link to the next page if it exists. + pub next: Option, + /// Link to the previous page if it exists. + pub previous: Option, + /// List of Objects. + pub values: Vec, +} + +/// Author of the commit. +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BitbucketCommitAuthor { + /// Username. + #[serde(rename = "raw")] + 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's merge commit +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BitbucketPullRequestMergeCommit { + /// SHA of the merge commit. + pub hash: String, +} + +/// Representation of a single pull request. +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BitbucketPullRequest { + /// Pull request number. + pub id: i64, + /// Pull request title. + pub title: Option, + /// Bitbucket Pull Request Merge Commit + pub merge_commit_sha: BitbucketPullRequestMergeCommit, + /// Author of Pull Request + pub author: BitbucketCommitAuthor, +} + +impl RemotePullRequest for BitbucketPullRequest { + fn number(&self) -> i64 { + self.id + } + + fn title(&self) -> Option { + self.title.clone() + } + + fn labels(&self) -> Vec { + vec![] + } + + fn merge_commit(&self) -> Option { + Some(self.merge_commit_sha.hash.clone()) + } +} + +/// +impl RemoteEntry for BitbucketPagination { + fn url(_id: i64, api_url: &str, remote: &Remote, page: i32) -> String { + let pr_page = page + 1; + format!( + "{}/{}/{}/pullrequests?&pagelen={BITBUCKET_MAX_PAGE_PRS}&\ + page={pr_page}&state=MERGED", + api_url, remote.owner, remote.repo + ) + } + + fn buffer_size() -> usize { + 5 + } + + fn early_exit(&self) -> bool { + self.values.is_empty() + } +} + +/// HTTP client for handling Bitbucket REST API requests. +#[derive(Debug, Clone)] +pub struct BitbucketClient { + /// Remote. + remote: Remote, + /// HTTP client. + client: ClientWithMiddleware, +} + +/// Constructs a Bitbucket client from the remote configuration. +impl TryFrom for BitbucketClient { + type Error = Error; + fn try_from(remote: Remote) -> Result { + Ok(Self { + client: create_remote_client(&remote, "application/json")?, + remote, + }) + } +} + +impl RemoteClient for BitbucketClient { + fn api_url() -> String { + env::var(BITBUCKET_API_URL_ENV) + .ok() + .unwrap_or_else(|| BITBUCKET_API_URL.to_string()) + } + + fn remote(&self) -> Remote { + self.remote.clone() + } + + fn client(&self) -> ClientWithMiddleware { + self.client.clone() + } +} + +impl BitbucketClient { + /// Fetches the Bitbucket API and returns the commits. + pub async fn get_commits(&self) -> Result>> { + Ok(self + .fetch_with_early_exit::>(0) + .await? + .into_iter() + .flat_map(|v| v.values) + .map(|v| Box::new(v) as Box) + .collect()) + } + + /// Fetches the Bitbucket API and returns the pull requests. + pub async fn get_pull_requests( + &self, + ) -> Result>> { + Ok(self + .fetch_with_early_exit::>(0) + .await? + .into_iter() + .flat_map(|v| v.values) + .map(|v| Box::new(v) as Box) + .collect()) + } +} diff --git a/git-cliff-core/src/remote/github.rs b/git-cliff-core/src/remote/github.rs index f46e01fbd3..91e53956c2 100644 --- a/git-cliff-core/src/remote/github.rs +++ b/git-cliff-core/src/remote/github.rs @@ -53,6 +53,10 @@ impl RemoteEntry for GitHubCommit { fn buffer_size() -> usize { 10 } + + fn early_exit(&self) -> bool { + false + } } /// Author of the commit. @@ -112,6 +116,10 @@ impl RemoteEntry for GitHubPullRequest { fn buffer_size() -> usize { 5 } + + fn early_exit(&self) -> bool { + false + } } /// HTTP client for handling GitHub REST API requests. diff --git a/git-cliff-core/src/remote/gitlab.rs b/git-cliff-core/src/remote/gitlab.rs index f5356bd220..9a211efee6 100644 --- a/git-cliff-core/src/remote/gitlab.rs +++ b/git-cliff-core/src/remote/gitlab.rs @@ -49,9 +49,14 @@ impl RemoteEntry for GitLabProject { fn url(_id: i64, api_url: &str, remote: &Remote, _page: i32) -> String { format!("{}/projects/{}%2F{}", api_url, remote.owner, remote.repo) } + fn buffer_size() -> usize { 1 } + + fn early_exit(&self) -> bool { + false + } } /// Representation of a single commit. @@ -109,6 +114,10 @@ impl RemoteEntry for GitLabCommit { fn buffer_size() -> usize { 10 } + + fn early_exit(&self) -> bool { + false + } } /// Representation of a single pull request. @@ -174,6 +183,10 @@ impl RemoteEntry for GitLabMergeRequest { fn buffer_size() -> usize { 5 } + + fn early_exit(&self) -> bool { + false + } } /// Representation of a GitLab User. @@ -213,7 +226,7 @@ pub struct GitLabClient { client: ClientWithMiddleware, } -/// Constructs a GitHub client from the remote configuration. +/// Constructs a GitLab client from the remote configuration. impl TryFrom for GitLabClient { type Error = Error; fn try_from(remote: Remote) -> Result { @@ -243,7 +256,7 @@ impl RemoteClient for GitLabClient { impl GitLabClient { /// Fetches the GitLab API and returns the pull requests. pub async fn get_project(&self) -> Result { - self.get_entry::().await + self.get_entry::(0, 1).await } /// Fetches the GitLab API and returns the commits. diff --git a/git-cliff-core/src/remote/mod.rs b/git-cliff-core/src/remote/mod.rs index 8e8e40503e..3349df0c2d 100644 --- a/git-cliff-core/src/remote/mod.rs +++ b/git-cliff-core/src/remote/mod.rs @@ -6,6 +6,10 @@ pub mod github; #[cfg(feature = "gitlab")] pub mod gitlab; +/// Bitbucket client. +#[cfg(feature = "bitbucket")] +pub mod bitbucket; + use crate::config::Remote; use crate::error::{ Error, @@ -66,6 +70,8 @@ pub trait RemoteEntry { fn url(project_id: i64, api_url: &str, remote: &Remote, page: i32) -> String; /// Returns the request buffer size. fn buffer_size() -> usize; + /// Whether if exit early. + fn early_exit(&self) -> bool; } /// Trait for handling remote commits. @@ -178,6 +184,32 @@ pub trait RemoteClient { /// Returns the HTTP client for making requests. fn client(&self) -> ClientWithMiddleware; + /// Returns true if the client should early exit. + fn early_exit(&self, page: &T) -> bool { + page.early_exit() + } + + /// Retrieves a single object. + async fn get_entry( + &self, + project_id: i64, + page: i32, + ) -> Result { + let url = T::url(project_id, &Self::api_url(), &self.remote(), page); + debug!("Sending request to: {url}"); + let response = self.client().get(&url).send().await?; + let response_text = if response.status().is_success() { + let text = response.text().await?; + trace!("Response: {:?}", text); + text + } else { + let text = response.text().await?; + error!("Request error: {}", text); + text + }; + Ok(serde_json::from_str::(&response_text)?) + } + /// Retrieves a single page of entries. async fn get_entries_with_page( &self, @@ -205,6 +237,8 @@ pub trait RemoteClient { } /// Fetches the remote API and returns the given entry. + /// + /// See `fetch_with_early_exit` for the early exit version of this method. async fn fetch( &self, project_id: i64, @@ -230,21 +264,36 @@ pub trait RemoteClient { Ok(entries.into_iter().flatten().collect()) } - /// Retrieves a single object. - async fn get_entry(&self) -> Result { - let url = T::url(0, &Self::api_url(), &self.remote(), 1); - debug!("Sending request to: {url}"); - let response = self.client().get(&url).send().await?; - let response_text = if response.status().is_success() { - let text = response.text().await?; - trace!("Response: {:?}", text); - text - } else { - let text = response.text().await?; - error!("Request error: {}", text); - text - }; - Ok(serde_json::from_str::(&response_text)?) + /// Fetches the remote API and returns the given entry. + /// + /// Early exits based on the response. + async fn fetch_with_early_exit( + &self, + project_id: i64, + ) -> Result> { + let entries: Vec = stream::iter(0..) + .map(|i| self.get_entry::(project_id, i)) + .buffered(T::buffer_size()) + .take_while(|page| { + let status = match page { + Ok(v) => !self.early_exit(v), + Err(e) => { + debug!("Error while fetching page: {:?}", e); + true + } + }; + future::ready(status && page.is_ok()) + }) + .map(|page| match page { + Ok(v) => v, + Err(ref e) => { + log::error!("{:#?}", e); + page.expect("failed to fetch page: {}") + } + }) + .collect() + .await; + Ok(entries) } } diff --git a/git-cliff-core/src/template.rs b/git-cliff-core/src/template.rs index 2d3fc25274..caacc6a854 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"))] + #[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))] pub(crate) fn contains_variable(&self, variables: &[&str]) -> bool { variables .iter() @@ -232,6 +232,10 @@ mod test { gitlab: crate::remote::RemoteReleaseMetadata { contributors: vec![], }, + #[cfg(feature = "bitbucket")] + bitbucket: crate::remote::RemoteReleaseMetadata { + contributors: vec![], + }, }, Option::>::None.as_ref(), &[TextProcessor { diff --git a/git-cliff-core/tests/integration_test.rs b/git-cliff-core/tests/integration_test.rs index cc0217b8e9..e93aeba64b 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 = "bitbucket")] + bitbucket: git_cliff_core::remote::RemoteReleaseMetadata { + contributors: vec![], + }, }, Release { version: Some(String::from("v1.0.0")), @@ -228,6 +232,10 @@ fn generate_changelog() -> Result<()> { gitlab: 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 885ec22070..6d361169ee 100644 --- a/git-cliff/Cargo.toml +++ b/git-cliff/Cargo.toml @@ -23,13 +23,15 @@ path = "src/bin/mangen.rs" [features] # check for new versions -default = ["update-informer", "github", "gitlab"] +default = ["update-informer", "github", "gitlab", "bitbucket"] # inform about new releases update-informer = ["dep:update-informer"] # enable GitHub integration github = ["git-cliff-core/github", "dep:indicatif"] # enable GitLab integration gitlab = ["git-cliff-core/gitlab", "dep:indicatif"] +# enable Bitbucket integration +bitbucket = ["git-cliff-core/bitbucket", "dep:indicatif"] [dependencies] glob.workspace = true diff --git a/git-cliff/src/args.rs b/git-cliff/src/args.rs index a32b1299d4..175df1d998 100644 --- a/git-cliff/src/args.rs +++ b/git-cliff/src/args.rs @@ -64,7 +64,7 @@ pub struct Opt { help = "Prints help information", help_heading = "FLAGS" )] - pub help: Option, + pub help: Option, #[arg( short = 'V', long, @@ -73,10 +73,10 @@ pub struct Opt { help = "Prints version information", help_heading = "FLAGS" )] - pub version: Option, + pub version: Option, /// Increases the logging verbosity. #[arg(short, long, action = ArgAction::Count, alias = "debug", help_heading = Some("FLAGS"))] - pub verbose: u8, + pub verbose: u8, /// Writes the default configuration file to cliff.toml #[arg( short, @@ -85,7 +85,7 @@ pub struct Opt { num_args = 0..=1, required = false )] - pub init: Option>, + pub init: Option>, /// Sets the configuration file. #[arg( short, @@ -95,7 +95,7 @@ pub struct Opt { default_value = DEFAULT_CONFIG, value_parser = Opt::parse_dir )] - pub config: PathBuf, + pub config: PathBuf, /// Sets the working directory. #[arg( short, @@ -104,7 +104,7 @@ pub struct Opt { value_name = "PATH", value_parser = Opt::parse_dir )] - pub workdir: Option, + pub workdir: Option, /// Sets the git repository. #[arg( short, @@ -114,7 +114,7 @@ pub struct Opt { num_args(1..), value_parser = Opt::parse_dir )] - pub repository: Option>, + pub repository: Option>, /// Sets the path to include related commits. #[arg( long, @@ -122,7 +122,7 @@ pub struct Opt { value_name = "PATTERN", num_args(1..) )] - pub include_path: Option>, + pub include_path: Option>, /// Sets the path to exclude related commits. #[arg( long, @@ -130,10 +130,10 @@ pub struct Opt { value_name = "PATTERN", num_args(1..) )] - pub exclude_path: Option>, + pub exclude_path: Option>, /// Sets the regex for matching git tags. #[arg(long, env = "GIT_CLIFF_TAG_PATTERN", value_name = "PATTERN")] - pub tag_pattern: Option, + pub tag_pattern: Option, /// Sets custom commit messages to include in the changelog. #[arg( long, @@ -141,7 +141,7 @@ pub struct Opt { value_name = "MSG", num_args(1..) )] - pub with_commit: Option>, + pub with_commit: Option>, /// Sets commits that will be skipped in the changelog. #[arg( long, @@ -149,7 +149,7 @@ pub struct Opt { value_name = "SHA1", num_args(1..) )] - pub skip_commit: Option>, + pub skip_commit: Option>, /// Prepends entries to the given changelog file. #[arg( short, @@ -158,7 +158,7 @@ pub struct Opt { value_name = "PATH", value_parser = Opt::parse_dir )] - pub prepend: Option, + pub prepend: Option, /// Writes output to the given file. #[arg( short, @@ -169,7 +169,7 @@ pub struct Opt { num_args = 0..=1, default_missing_value = DEFAULT_OUTPUT )] - pub output: Option, + pub output: Option, /// Sets the tag for the latest version. #[arg( short, @@ -178,13 +178,13 @@ pub struct Opt { value_name = "TAG", allow_hyphen_values = true )] - pub tag: Option, + pub tag: Option, /// Bumps the version for unreleased changes. #[arg(long, help_heading = Some("FLAGS"))] - pub bump: bool, + pub bump: bool, /// Prints bumped version for unreleased changes. #[arg(long, help_heading = Some("FLAGS"))] - pub bumped_version: bool, + pub bumped_version: bool, /// Sets the template for the changelog body. #[arg( short, @@ -193,38 +193,38 @@ pub struct Opt { value_name = "TEMPLATE", allow_hyphen_values = true )] - pub body: Option, + pub body: Option, /// Processes the commits starting from the latest tag. #[arg(short, long, help_heading = Some("FLAGS"))] - pub latest: bool, + pub latest: bool, /// Processes the commits that belong to the current tag. #[arg(long, help_heading = Some("FLAGS"))] - pub current: bool, + pub current: bool, /// Processes the commits that do not belong to a tag. #[arg(short, long, help_heading = Some("FLAGS"))] - pub unreleased: bool, + pub unreleased: bool, /// Sorts the tags topologically. #[arg(long, help_heading = Some("FLAGS"))] - pub topo_order: bool, + pub topo_order: bool, /// Disables the external command execution. #[arg(long, help_heading = Some("FLAGS"))] - pub no_exec: bool, + pub no_exec: bool, /// Prints changelog context as JSON. #[arg(short = 'x', long, help_heading = Some("FLAGS"))] - pub context: bool, + pub context: bool, /// Strips the given parts from the changelog. #[arg(short, long, value_name = "PART", value_enum)] - pub strip: Option, + pub strip: Option, /// Sets sorting of the commits inside sections. #[arg( long, value_enum, default_value_t = Sort::Oldest )] - pub sort: Sort, + pub sort: Sort, /// Sets the commit range to process. #[arg(value_name = "RANGE", help_heading = Some("ARGS"))] - pub range: Option, + pub range: Option, /// Sets the GitHub API token. #[arg( long, @@ -233,7 +233,7 @@ pub struct Opt { hide_env_values = true, hide = !cfg!(feature = "github"), )] - pub github_token: Option, + pub github_token: Option, /// Sets the GitHub repository. #[arg( long, @@ -242,7 +242,7 @@ pub struct Opt { value_name = "OWNER/REPO", hide = !cfg!(feature = "github"), )] - pub github_repo: Option, + pub github_repo: Option, /// Sets the GitLab API token. #[arg( long, @@ -251,7 +251,7 @@ pub struct Opt { hide_env_values = true, hide = !cfg!(feature = "gitlab"), )] - pub gitlab_token: Option, + pub gitlab_token: Option, /// Sets the GitLab repository. #[arg( long, @@ -260,7 +260,25 @@ pub struct Opt { value_name = "OWNER/REPO", hide = !cfg!(feature = "gitlab"), )] - pub gitlab_repo: Option, + pub gitlab_repo: Option, + /// Sets the Bitbucket API token. + #[arg( + long, + env = "BITBUCKET_TOKEN", + value_name = "TOKEN", + hide_env_values = true, + hide = !cfg!(feature = "bitbucket"), + )] + pub bitbucket_token: Option, + /// Sets the Bitbucket repository. + #[arg( + long, + env = "BITBUCKET_REPO", + value_parser = clap::value_parser!(RemoteValue), + value_name = "OWNER/REPO", + hide = !cfg!(feature = "bitbucket"), + )] + pub bitbucket_repo: Option, } /// Custom type for the remote value. diff --git a/git-cliff/src/lib.rs b/git-cliff/src/lib.rs index 7a26d76f11..f8cc86c026 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.bitbucket.is_set() { + match repository.upstream_remote() { + Ok(remote) => { + debug!("No Bitbucket remote is set, using remote: {}", remote); + config.remote.bitbucket.owner = remote.owner; + config.remote.bitbucket.repo = remote.repo; + } + Err(e) => { + debug!("Failed to get remote from Bitbucket repository: {:?}", e); + } + } } // Print debug information about configuration and arguments. @@ -426,6 +437,13 @@ pub fn run(mut args: Opt) -> Result<()> { if args.gitlab_token.is_some() { config.remote.gitlab.token.clone_from(&args.gitlab_token); } + if args.bitbucket_token.is_some() { + config + .remote + .bitbucket + .token + .clone_from(&args.bitbucket_token); + } if let Some(ref remote) = args.github_repo { config.remote.github.owner = remote.0.owner.to_string(); config.remote.github.repo = remote.0.repo.to_string(); @@ -434,6 +452,10 @@ pub fn run(mut args: Opt) -> Result<()> { config.remote.gitlab.owner = remote.0.owner.to_string(); config.remote.gitlab.repo = remote.0.repo.to_string(); } + if let Some(ref remote) = args.bitbucket_repo { + config.remote.bitbucket.owner = remote.0.owner.to_string(); + config.remote.bitbucket.repo = remote.0.repo.to_string(); + } if args.no_exec { if let Some(ref mut preprocessors) = config.git.commit_preprocessors { preprocessors diff --git a/git-cliff/src/logger.rs b/git-cliff/src/logger.rs index 4f7cc60fa7..76869b5093 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"))] +#[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))] 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"))] +#[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))] lazy_static::lazy_static! { /// Lazily initialized progress bar. pub static ref PROGRESS_BAR: ProgressBar = { @@ -144,6 +144,24 @@ pub fn init() -> Result<()> { } } + #[cfg(feature = "bitbucket")] + { + let message = record.args().to_string(); + if message + .starts_with(git_cliff_core::remote::bitbucket::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::bitbucket::FINISHED_FETCHING_MSG, + ) { + PROGRESS_BAR.finish_and_clear(); + return Ok(()); + } + } + writeln!(f, " {} {} > {}", level, target, record.args()) }); diff --git a/website/docs/configuration/remote.md b/website/docs/configuration/remote.md index ccddd57d04..fa085c8362 100644 --- a/website/docs/configuration/remote.md +++ b/website/docs/configuration/remote.md @@ -2,6 +2,8 @@ This section contains the Git remote related configuration options. +You can configure a remote for GitHub, GitLab or Bitbucket as follows: + ```toml [remote.github] owner = "orhun" @@ -9,24 +11,13 @@ repo = "git-cliff" token = "" ``` -:::tip - -See the [GitHub integration](/docs/integration/github). - -::: - -Or if you are using GitLab: - -```toml -[remote.gitlab] -owner = "orhun" -repo = "git-cliff" -token = "" -``` +Change this to `remote.gitlab` or `remote.bitbucket` accordingly to your project. :::tip -See the [GitLab integration](/docs/integration/gitlab). +- See the [GitHub integration](/docs/integration/github). +- See the [GitLab integration](/docs/integration/gitlab). +- See the [Bitbucket integration](/docs/integration/bitbucket). ::: @@ -46,7 +37,7 @@ e.g. git cliff --github-repo orhun/git-cliff ``` -Same applies for GitLab with `--gitlab-repo` and `GITLAB_REPO` environment variables. +Same applies for GitLab/Bitbucket with `--gitlab-repo`/`--bitbucket-repo` and `GITLAB_REPO`/`BITBUCKET_REPO` environment variables. ### token @@ -58,4 +49,4 @@ If you are using GitHub, then you can also pass this value via `--github-token` git cliff --github-token ``` -Same applies for GitLab with `--gitlab-token` and `GITLAB_TOKEN` environment variables. +Same applies for GitLab/Bitbucket with `--gitlab-token`/`--bitbucket-token` and `GITLAB_TOKEN`/`BITBUCKET_TOKEN` environment variables. diff --git a/website/docs/installation/crates-io.md b/website/docs/installation/crates-io.md index 491dbd7d44..084800c025 100644 --- a/website/docs/installation/crates-io.md +++ b/website/docs/installation/crates-io.md @@ -20,8 +20,12 @@ The minimum supported Rust version is `1.70.0`. Also, **git-cliff** has the following feature flags which can be enabled via `--features` argument: -- `update-informer`: inform about the new releases of **git-cliff** (enabled as default) -- `github`: enables the [GitHub integration](/docs/integration/github) (enabled as default) +- `update-informer`: inform about the new releases of **git-cliff** +- `github`: enables the [GitHub integration](/docs/integration/github) +- `gitlab`: enables the [GitLab integration](/docs/integration/gitlab) +- `bitbucket`: enables the [Bitbucket integration](/docs/integration/bitbucket) + +All these features are enabled as default. To install without these features: diff --git a/website/docs/integration/bitbucket.md b/website/docs/integration/bitbucket.md new file mode 100644 index 0000000000..2cb8ecbb3a --- /dev/null +++ b/website/docs/integration/bitbucket.md @@ -0,0 +1,179 @@ +--- +sidebar_position: 3 +--- + +# Bitbucket 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 `bitbucket` feature flag for the integration to work. + +::: + +For projects hosted on Bitbucket, you can use **git-cliff** to add the following to your changelog: + +- Bitbucket 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.bitbucket] +owner = "orhun" +repo = "git-cliff" +token = "***" +``` + +- Use the `--bitbucket-repo` argument (takes values in `OWNER/REPO` format, e.g. "orhun/git-cliff") + +- Use the `BITBUCKET_REPO` environment variable (same format as `--bitbucket-repo`) + +## Authentication + +:::tip + +[Bitbucket REST API](https://developer.atlassian.com/cloud/bitbucket/rest/) is being used to retrieve data from Bitbucket and it has [rate limiting](https://support.atlassian.com/bitbucket-cloud/docs/api-request-limits/) rules. + +You can follow [this guide](https://developer.atlassian.com/cloud/bitbucket/rest/intro/#authentication) for creating an access token. + +::: + +To set an access token, you can use the [configuration file](/docs/configuration/remote) (not recommended), `--bitbucket-token` argument or `BITBUCKET_TOKEN` environment variable. + +For example: + +```bash +BITBUCKET_TOKEN="***" git cliff --bitbucket-repo "orhun/git-cliff" +``` + +:::tip + +You can use the `BITBUCKET_API_URL` environment variable want to override the API URL. This is useful if you are using your own Bitbucket 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 +{ + "bitbucket": { + "owner": "orhun", + "repo": "git-cliff" + } +} +``` + +For example: + +```jinja2 +https://bitbucket.org/{{ remote.bitbucket.owner }}/{{ remote.bitbucket.repo }}/commits/tag/{{ version }} +``` + +### Commit authors + +For each commit, Bitbucket related values are added as a nested object (named `bitbucket`) to the [template context](/docs/templating/context): + +```json +{ + "id": "8edec7fd50f703811d55f14a3c5f0fd02b43d9e7", + "message": "refactor(config): remove unnecessary newline from configs\n", + "group": "🚜 Refactor", + + "...": "", + + "bitbucket": { + "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.bitbucket.username %} by @{{ commit.bitbucket.username }}{%- endif %}\ + {% if commit.bitbucket.pr_number %} in #{{ commit.bitbucket.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, + "bitbucket": { + "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 bitbucket.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 e8c9893996..9fe787db9a 100644 --- a/website/docs/integration/python.md +++ b/website/docs/integration/python.md @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 5 --- # Python diff --git a/website/docs/integration/rust.md b/website/docs/integration/rust.md index fb70d8dff2..c7af1f981a 100644 --- a/website/docs/integration/rust.md +++ b/website/docs/integration/rust.md @@ -1,5 +1,5 @@ --- -sidebar_position: 3 +sidebar_position: 4 --- # Rust/Cargo