Repository: orhun/git-cliff (language: python)

Issue:
Support overriding the API URL in config
### Is there an existing issue or pull request for this?

- [X] I have searched the existing issues and pull requests

### Feature description

Overriding the API URL for remotes is only possible via environment variables, e.g. `GITLAB_API_URL`. This is not very convenient.

This can be doable via config as well.

### Desired solution

```toml
[remote.gitlab]
api_url="https://gitlab.archlinux.org/api/v4"
owner = "archlinux"
repo = "arch-repro-status"
```

### Alternatives considered

None.

### Additional context

_No response_


Discussion / hints:

Fix patch:
diff --git a/git-cliff-core/src/changelog.rs b/git-cliff-core/src/changelog.rs
index a505a127a5..4688d587d1 100644
--- a/git-cliff-core/src/changelog.rs
+++ b/git-cliff-core/src/changelog.rs
@@ -842,24 +842,28 @@ mod test {
 					repo:      String::from("awesome"),
 					token:     None,
 					is_custom: false,
+					api_url:   None,
 				},
 				gitlab:    Remote {
 					owner:     String::from("coolguy"),
 					repo:      String::from("awesome"),
 					token:     None,
 					is_custom: false,
+					api_url:   None,
 				},
 				gitea:     Remote {
 					owner:     String::from("coolguy"),
 					repo:      String::from("awesome"),
 					token:     None,
 					is_custom: false,
+					api_url:   None,
 				},
 				bitbucket: Remote {
 					owner:     String::from("coolguy"),
 					repo:      String::from("awesome"),
 					token:     None,
 					is_custom: false,
+					api_url:   None,
 				},
 			},
 			bump:      Bump::default(),
diff --git a/git-cliff-core/src/command.rs b/git-cliff-core/src/command.rs
index 71a3c652a1..8f4038bc4d 100644
--- a/git-cliff-core/src/command.rs
+++ b/git-cliff-core/src/command.rs
@@ -72,6 +72,7 @@ pub fn run(
 #[cfg(test)]
 mod test {
 	use super::*;
+
 	#[test]
 	#[cfg(target_family = "unix")]
 	fn run_os_command() -> Result<()> {
diff --git a/git-cliff-core/src/config.rs b/git-cliff-core/src/config.rs
index 2266bb3444..54fc56a94c 100644
--- a/git-cliff-core/src/config.rs
+++ b/git-cliff-core/src/config.rs
@@ -182,6 +182,8 @@ pub struct Remote {
 	/// Whether if the remote is set manually.
 	#[serde(skip_deserializing, default = "default_true")]
 	pub is_custom: bool,
+	/// Remote API URL.
+	pub api_url:   Option<String>,
 }
 
 /// Returns `true` for serde's `default` attribute.
@@ -209,6 +211,7 @@ impl Remote {
 			repo:      repo.into(),
 			token:     None,
 			is_custom: false,
+			api_url:   None,
 		}
 	}
 
diff --git a/git-cliff-core/src/remote/bitbucket.rs b/git-cliff-core/src/remote/bitbucket.rs
index 5fe0a763ae..d12e9d70dd 100644
--- a/git-cliff-core/src/remote/bitbucket.rs
+++ b/git-cliff-core/src/remote/bitbucket.rs
@@ -5,16 +5,9 @@ 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...";
 
@@ -187,11 +180,8 @@ impl TryFrom<Remote> for BitbucketClient {
 }
 
 impl RemoteClient for BitbucketClient {
-	fn api_url() -> String {
-		env::var(BITBUCKET_API_URL_ENV)
-			.ok()
-			.unwrap_or_else(|| BITBUCKET_API_URL.to_string())
-	}
+	const API_URL: &str = "https://api.bitbucket.org/2.0/repositories";
+	const API_URL_ENV: &str = "BITBUCKET_API_URL";
 
 	fn remote(&self) -> Remote {
 		self.remote.clone()
diff --git a/git-cliff-core/src/remote/gitea.rs b/git-cliff-core/src/remote/gitea.rs
index cc9cf349cc..c7a6b09a89 100644
--- a/git-cliff-core/src/remote/gitea.rs
+++ b/git-cliff-core/src/remote/gitea.rs
@@ -5,16 +5,9 @@ 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...";
 
@@ -151,11 +144,8 @@ impl TryFrom<Remote> for GiteaClient {
 }
 
 impl RemoteClient for GiteaClient {
-	fn api_url() -> String {
-		env::var(GITEA_API_URL_ENV)
-			.ok()
-			.unwrap_or_else(|| GITEA_API_URL.to_string())
-	}
+	const API_URL: &str = "https://codeberg.org";
+	const API_URL_ENV: &str = "GITEA_API_URL";
 
 	fn remote(&self) -> Remote {
 		self.remote.clone()
diff --git a/git-cliff-core/src/remote/github.rs b/git-cliff-core/src/remote/github.rs
index 80fc2125d7..2548844b8b 100644
--- a/git-cliff-core/src/remote/github.rs
+++ b/git-cliff-core/src/remote/github.rs
@@ -5,16 +5,9 @@ use serde::{
 	Deserialize,
 	Serialize,
 };
-use std::env;
 
 use super::*;
 
-/// GitHub REST API url.
-const GITHUB_API_URL: &str = "https://api.github.com";
-
-/// Environment variable for overriding the GitHub REST API url.
-const GITHUB_API_URL_ENV: &str = "GITHUB_API_URL";
-
 /// Log message to show while fetching data from GitHub.
 pub const START_FETCHING_MSG: &str = "Retrieving data from GitHub...";
 
@@ -166,11 +159,8 @@ impl TryFrom<Remote> for GitHubClient {
 }
 
 impl RemoteClient for GitHubClient {
-	fn api_url() -> String {
-		env::var(GITHUB_API_URL_ENV)
-			.ok()
-			.unwrap_or_else(|| GITHUB_API_URL.to_string())
-	}
+	const API_URL: &str = "https://api.github.com";
+	const API_URL_ENV: &str = "GITHUB_API_URL";
 
 	fn remote(&self) -> Remote {
 		self.remote.clone()
diff --git a/git-cliff-core/src/remote/gitlab.rs b/git-cliff-core/src/remote/gitlab.rs
index 84c2553966..78c9bde8c6 100644
--- a/git-cliff-core/src/remote/gitlab.rs
+++ b/git-cliff-core/src/remote/gitlab.rs
@@ -5,16 +5,9 @@ use serde::{
 	Deserialize,
 	Serialize,
 };
-use std::env;
 
 use super::*;
 
-/// GitLab REST API url.
-const GITLAB_API_URL: &str = "https://gitlab.com/api/v4";
-
-/// Environment variable for overriding the GitLab REST API url.
-const GITLAB_API_URL_ENV: &str = "GITLAB_API_URL";
-
 /// Log message to show while fetching data from GitLab.
 pub const START_FETCHING_MSG: &str = "Retrieving data from GitLab...";
 
@@ -248,11 +241,8 @@ impl TryFrom<Remote> for GitLabClient {
 }
 
 impl RemoteClient for GitLabClient {
-	fn api_url() -> String {
-		env::var(GITLAB_API_URL_ENV)
-			.ok()
-			.unwrap_or_else(|| GITLAB_API_URL.to_string())
-	}
+	const API_URL: &str = "https://gitlab.com/api/v4";
+	const API_URL_ENV: &str = "GITLAB_API_URL";
 
 	fn remote(&self) -> Remote {
 		self.remote.clone()
diff --git a/git-cliff-core/src/remote/mod.rs b/git-cliff-core/src/remote/mod.rs
index 5ae8d114c5..5527d645cf 100644
--- a/git-cliff-core/src/remote/mod.rs
+++ b/git-cliff-core/src/remote/mod.rs
@@ -48,6 +48,7 @@ use serde::{
 	Deserialize,
 	Serialize,
 };
+use std::env;
 use std::fmt::Debug;
 use std::time::Duration;
 use time::{
@@ -168,8 +169,20 @@ fn create_remote_client(
 
 /// Trait for handling the API connection and fetching.
 pub trait RemoteClient {
+	/// API URL for a particular client
+	const API_URL: &'static str;
+
+	/// Name of the environment variable used to set the API URL to a
+	/// self-hosted instance (if applicable).
+	const API_URL_ENV: &'static str;
+
 	/// Returns the API url.
-	fn api_url() -> String;
+	fn api_url(&self) -> String {
+		env::var(Self::API_URL_ENV)
+			.ok()
+			.or(self.remote().api_url)
+			.unwrap_or_else(|| Self::API_URL.to_string())
+	}
 
 	/// Returns the remote repository information.
 	fn remote(&self) -> Remote;
@@ -188,7 +201,7 @@ pub trait RemoteClient {
 		project_id: i64,
 		page: i32,
 	) -> Result<T> {
-		let url = T::url(project_id, &Self::api_url(), &self.remote(), page);
+		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() {
@@ -209,7 +222,7 @@ pub trait RemoteClient {
 		project_id: i64,
 		page: i32,
 	) -> Result<Vec<T>> {
-		let url = T::url(project_id, &Self::api_url(), &self.remote(), page);
+		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() {
diff --git a/git-cliff-core/src/repo.rs b/git-cliff-core/src/repo.rs
index bcd55e048c..e76ded3650 100644
--- a/git-cliff-core/src/repo.rs
+++ b/git-cliff-core/src/repo.rs
@@ -475,6 +475,7 @@ fn url_path_segments(url: &str) -> Result<Remote> {
 		repo:      repo.to_string(),
 		token:     None,
 		is_custom: false,
+		api_url:   None,
 	})
 }
 
@@ -504,6 +505,7 @@ fn ssh_path_segments(url: &str) -> Result<Remote> {
 		repo:      repo.to_string(),
 		token:     None,
 		is_custom: false,
+		api_url:   None,
 	})
 }
 
@@ -664,6 +666,7 @@ mod test {
 				repo:      String::from("git-cliff"),
 				token:     None,
 				is_custom: false,
+				api_url:   remote.api_url.clone(),
 			},
 			remote
 		);
diff --git a/website/docs/configuration/remote.md b/website/docs/configuration/remote.md
index 7d5706917c..e0a0e1ff03 100644
--- a/website/docs/configuration/remote.md
+++ b/website/docs/configuration/remote.md
@@ -51,3 +51,19 @@ git cliff --github-token <TOKEN>
 ```
 
 Same applies for GitLab/Bitbucket with `--gitlab-token`/`--gitea-token`/`--bitbucket-token` and `GITLAB_TOKEN`/`GITEA_TOKEN`/`BITBUCKET_TOKEN` environment variables.
+
+### api_url
+
+Sets the API URL for a particular remote.
+
+---
+
+Here is a complete example for a project hosted on GitLab:
+
+```toml
+[remote.gitlab]
+owner = "archlinux"
+repo = "arch-repro-status"
+api_url = "https://gitlab.archlinux.org/api/v4"
+token = "deadbeef"
+```


Test patch:
diff --git a/.github/fixtures/test-custom-remote-api-url/cliff.toml b/.github/fixtures/test-custom-remote-api-url/cliff.toml
new file mode 100644
index 0000000000..4136d1fcb8
--- /dev/null
+++ b/.github/fixtures/test-custom-remote-api-url/cliff.toml
@@ -0,0 +1,55 @@
+[remote.gitlab]
+owner = "archlinux"
+repo = "arch-repro-status"
+api_url = "https://gitlab.archlinux.org/api/v4"
+
+[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.remote.username %} by @{{ commit.remote.username }}{%- endif -%}
+    {% if commit.remote.pr_number %} in #{{ commit.remote.pr_number }}{%- endif %}
+{%- endfor -%}
+
+{% if gitlab.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
+  {% raw %}\n{% endraw -%}
+  ### New Contributors
+{%- endif %}\
+{% for contributor in gitlab.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 -%}
+
+{% if version %}
+    {% if previous.version %}
+      **Full Changelog**: https://gitlab.com/{{ remote.gitlab.owner }}/{{ remote.gitlab.repo }}/compare/{{ previous.version }}...{{ version }}
+    {% endif %}
+{% else -%}
+  {% raw %}\n{% endraw %}
+{% endif %}
+"""
+# remove the leading and trailing whitespace from the template
+trim = true
+# changelog footer
+footer = """
+<!-- generated by git-cliff -->
+"""
+
+[git]
+# parse the commits based on https://www.conventionalcommits.org
+conventional_commits = true
+# filter out the commits that are not conventional
+filter_unconventional = false
+# 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-custom-remote-api-url/commit.sh b/.github/fixtures/test-custom-remote-api-url/commit.sh
new file mode 100755
index 0000000000..fc3b7b7016
--- /dev/null
+++ b/.github/fixtures/test-custom-remote-api-url/commit.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+set -e
+
+git remote add origin https://gitlab.archlinux.org/archlinux/arch-repro-status
+git fetch
+git checkout 5fe2f324db566756ccaf066fe186100a09a87625
diff --git a/.github/fixtures/test-custom-remote-api-url/expected.md b/.github/fixtures/test-custom-remote-api-url/expected.md
new file mode 100644
index 0000000000..d20126a97f
--- /dev/null
+++ b/.github/fixtures/test-custom-remote-api-url/expected.md
@@ -0,0 +1,9 @@
+## What's Changed in v1.4.1
+* Update the copyright year in license by @Orhun Parmaksız
+* bump dependencies by @Orhun Parmaksız
+* update cargo-deny config by @Orhun Parmaksız
+* prepare for 1.4.1 by @Orhun Parmaksız
+
+**Full Changelog**: https://gitlab.com/archlinux/arch-repro-status/compare/v1.4.0...v1.4.1
+
+<!-- generated by git-cliff -->
diff --git a/.github/workflows/test-fixtures.yml b/.github/workflows/test-fixtures.yml
index 09e7d138d8..00b09a68f9 100644
--- a/.github/workflows/test-fixtures.yml
+++ b/.github/workflows/test-fixtures.yml
@@ -102,6 +102,8 @@ jobs:
             command: --unreleased --tag v0.2.0
           - fixtures-name: test-unchanged-tag-date
             command: --tag v0.2.0
+          - fixtures-name: test-custom-remote-api-url
+            command: --latest
 
     steps:
       - name: Checkout