Repository: MarcoIeni/release-plz
Pull request #372 (2022-12-12 18:25:46)

Add support for Gitea repositories
This PR adds support for self-hosted [Gitea](https://gitea.io) repositories.

This is done by adding a separate Gitea backend for the `release-pr` command, which is the only subcommand that needs to directly talk to the host for managing the release PR. The Gitea API ([example here](https://try.gitea.io/api/swagger)) is just different enough that the existing GitHub backend doesn't work, so this adds a separate Gitea backend to for managing the PR.

MarcoIeni: Wow, thanks for this! 🤩 
I have never used Gitea before, so I will take some time to test this. 👍

 - [ ] It would be nice to update the README to describe Gitea support
sfleener: I added some short documentation to the README for how to use the Gitea backend, let me know if we should expand on it any more
MarcoIeni: I merged #371 which has some conflicts with this PR. I'm sorry :(
Are you ok with resolving them? Otherwise, I can do it.
Also, that PR adds support for a link in the CHANGELOG version. If you want to add support for it in Gitea, we can open an issue :)
sfleener: Accidentally closed the PR, but I managed to sort out the merge conflicts. As a bonus, the new changes let me clean up the API URL handling which is a nice bonus!
MarcoIeni: Last thing..can you fix those clippy issues, please?
sfleener: Fixed! Looks like yesterday's release of rust 1.67 catches more cases of unnecessary `&`

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index bf531ad7..08173b68 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2644,6 +2644,7 @@ dependencies = [
  "chrono",
  "claim",
  "crates-index",
+ "dunce",
  "expect-test",
  "fake",
  "fs_extra",
@@ -2653,7 +2654,9 @@ dependencies = [
  "git_cmd",
  "next_version",
  "octocrab",
+ "reqwest",
  "secrecy",
+ "serde",
  "serde_json",
  "tempfile",
  "test_logs",
@@ -2676,9 +2679,9 @@ dependencies = [
 
 [[package]]
 name = "reqwest"
-version = "0.11.12"
+version = "0.11.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "431949c384f4e2ae07605ccaa56d1d9d2ecdb5cadd4f9577ccfab29f2e5149fc"
+checksum = "68cc60575865c7831548863cc02356512e3f1dc2f3f82cb837d7fc4cc8f3c97c"
 dependencies = [
  "base64",
  "bytes",
@@ -2938,18 +2941,18 @@ dependencies = [
 
 [[package]]
 name = "serde"
-version = "1.0.147"
+version = "1.0.150"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965"
+checksum = "e326c9ec8042f1b5da33252c8a37e9ffbd2c9bef0155215b6e6c80c790e05f91"
 dependencies = [
  "serde_derive",
 ]
 
 [[package]]
 name = "serde_derive"
-version = "1.0.147"
+version = "1.0.150"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4f1d362ca8fc9c3e3a7484440752472d68a6caa98f1ab81d99b5dfe517cec852"
+checksum = "42a3df25b0713732468deadad63ab9da1f1fd75a48a15024b50363f128db627e"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -3221,9 +3224,9 @@ dependencies = [
 
 [[package]]
 name = "syn"
-version = "1.0.103"
+version = "1.0.105"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a864042229133ada95abf3b54fdc62ef5ccabe9515b64717bcb9a1919e59445d"
+checksum = "60b9b43d45702de4c839cb9b51d9f529c5dd26a4aff255b42b1ebc03e88ee908"
 dependencies = [
  "proc-macro2",
  "quote",
diff --git a/Cargo.toml b/Cargo.toml
index c51fb68f..15b20172 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,8 +22,10 @@ git-cliff-core = "0.8.1"
 git-url-parse = "0.4.4"
 octocrab = "0.17.0"
 once_cell = "1.16.0"
+reqwest = "0.11.13"
 secrecy = "0.8.0"
 semver = "1.0.14"
+serde = "1.0.149"
 serde_json = "1.0.89"
 tempfile = "3.3.0"
 tokio = "1.19.2"
diff --git a/README.md b/README.md
index 6108ab6b..9f3daefc 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,12 @@ In the following example, I run `release-plz` on the `release-plz` project itsel
 
 ![release-plz release-pr](https://user-images.githubusercontent.com/11428655/160772903-544c7578-7c17-4311-b6ca-a1aefeabe799.gif)
 
+#### Gitea
+
+`release-plz release-pr` also supports creating PRs for repositories hosted on Gitea with the `--backend` flag:
+
+`release-plz release-pr --token <gitea application token> --backend gitea`
+
 ### Publishing
 
 The goal of release-plz is to create a fully automated release pipeline.
diff --git a/crates/git_cmd/src/lib.rs b/crates/git_cmd/src/lib.rs
index d2459045..04a09038 100644
--- a/crates/git_cmd/src/lib.rs
+++ b/crates/git_cmd/src/lib.rs
@@ -97,6 +97,10 @@ impl Repo {
         Ok(())
     }
 
+    pub fn default_branch(&self) -> &str {
+        &self.default_branch
+    }
+
     #[instrument(skip(self))]
     fn current_commit(&self) -> anyhow::Result<String> {
         self.nth_commit(1)
@@ -273,7 +277,7 @@ mod tests {
         {
             fs::write(&file2, b"Hello, file2!-1").unwrap();
             repo.add_all_and_commit("file2-1").unwrap();
-            fs::write(&file1, b"Hello, file1!").unwrap();
+            fs::write(file1, b"Hello, file1!").unwrap();
             repo.add_all_and_commit("file1").unwrap();
             fs::write(&file2, b"Hello, file2!-2").unwrap();
             repo.add_all_and_commit("file2-2").unwrap();
@@ -290,7 +294,7 @@ mod tests {
         let file1 = repository_dir.as_ref().join("file1.txt");
         let commit_message = "file1 message";
         {
-            fs::write(&file1, b"Hello, file1!").unwrap();
+            fs::write(file1, b"Hello, file1!").unwrap();
             repo.add_all_and_commit(commit_message).unwrap();
         }
         assert_eq!(repo.current_commit_message().unwrap(), commit_message);
@@ -310,7 +314,7 @@ mod tests {
         let repository_dir = tempdir().unwrap();
         let repo = Repo::init(&repository_dir);
         let file1 = repository_dir.as_ref().join("file1.txt");
-        fs::write(&file1, b"Hello, file1!").unwrap();
+        fs::write(file1, b"Hello, file1!").unwrap();
         assert!(repo.is_clean().is_err());
     }
 
@@ -335,7 +339,7 @@ D  crates/git_cmd/CHANGELOG.md
         let repo = Repo::init(&repository_dir);
         let file1 = repository_dir.as_ref().join("file1.txt");
         {
-            fs::write(&file1, b"Hello, file1!").unwrap();
+            fs::write(file1, b"Hello, file1!").unwrap();
             repo.add_all_and_commit("file1").unwrap();
         }
         let version = "v1.0.0";
@@ -350,7 +354,7 @@ D  crates/git_cmd/CHANGELOG.md
         let repo = Repo::init(&repository_dir);
         let file1 = repository_dir.as_ref().join("file1.txt");
         {
-            fs::write(&file1, b"Hello, file1!").unwrap();
+            fs::write(file1, b"Hello, file1!").unwrap();
             repo.add_all_and_commit("file1").unwrap();
         }
         repo.tag("v1.0.0").unwrap();
diff --git a/crates/release_plz/src/args/release_pr.rs b/crates/release_plz/src/args/release_pr.rs
index cf745c4f..5757e65d 100644
--- a/crates/release_plz/src/args/release_pr.rs
+++ b/crates/release_plz/src/args/release_pr.rs
@@ -2,7 +2,8 @@ use std::str::FromStr;
 
 use anyhow::Context;
 use clap::builder::NonEmptyStringValueParser;
-use release_plz_core::GitHub;
+use clap::ValueEnum;
+use release_plz_core::{GitBackend, GitHub, Gitea};
 use secrecy::SecretString;
 
 use super::update::Update;
@@ -11,20 +12,37 @@ use super::update::Update;
 pub struct ReleasePr {
     #[clap(flatten)]
     pub update: Update,
-    /// GitHub token used to create the pull request.
-    #[clap(long, value_parser = NonEmptyStringValueParser::new())]
-    github_token: String,
+    /// Git token used to create the pull request.
+    #[clap(long, value_parser = NonEmptyStringValueParser::new(), visible_alias = "github_token")]
+    git_token: String,
+    /// Kind of git host where your project is hosted.
+    #[clap(long, value_enum, default_value_t = GitBackendKind::Github)]
+    backend: GitBackendKind,
+}
+
+#[derive(ValueEnum, Clone, Copy, Debug, Eq, PartialEq)]
+enum GitBackendKind {
+    #[value(name = "github")]
+    Github,
+    #[value(name = "gitea")]
+    Gitea,
 }
 
 impl ReleasePr {
-    pub fn github(&self) -> anyhow::Result<GitHub> {
+    pub fn git_backend(&self) -> anyhow::Result<GitBackend> {
         let repo = self.update.repo_url()?;
-        anyhow::ensure!(
-            repo.is_on_github(),
-            "Can't create PR: the repository is not hosted in GitHub"
-        );
-        let token = SecretString::from_str(&self.github_token).context("Invalid GitHub token")?;
-        Ok(GitHub::new(repo.owner, repo.name, token))
+
+        let token = SecretString::from_str(&self.git_token).context("Invalid git backend token")?;
+        Ok(match self.backend {
+            GitBackendKind::Github => {
+                anyhow::ensure!(
+                    repo.is_on_github(),
+                    "Can't create PR: the repository is not hosted in GitHub"
+                );
+                GitBackend::Github(GitHub::new(repo.owner, repo.name, token))
+            }
+            GitBackendKind::Gitea => GitBackend::Gitea(Gitea::new(repo, token)?),
+        })
     }
 }
 
@@ -57,4 +75,19 @@ mod tests {
         assert_eq!(GITHUB_COM, repo.host);
         assert!(repo.is_on_github())
     }
+
+    #[test]
+    fn gitea_url_is_parsed() {
+        let host = "example.com";
+        let expected_owner = "MarcoIeni";
+        let expected_repo = "release-plz";
+        let url = format!("https://{host}/{expected_owner}/{expected_repo}");
+        let repo = RepoUrl::new(&url).unwrap();
+        assert_eq!(expected_owner, repo.owner);
+        assert_eq!(expected_repo, repo.name);
+        assert_eq!(host, repo.host);
+        assert_eq!("https", repo.scheme);
+        assert!(!repo.is_on_github());
+        assert_eq!(format!("https://{host}/api/v1"), repo.gitea_api_url());
+    }
 }
diff --git a/crates/release_plz/src/main.rs b/crates/release_plz/src/main.rs
index d056779f..85b307fc 100644
--- a/crates/release_plz/src/main.rs
+++ b/crates/release_plz/src/main.rs
@@ -20,7 +20,9 @@ async fn main() -> anyhow::Result<()> {
         args::Command::ReleasePr(cmd_args) => {
             let update_request = cmd_args.update.update_request()?;
             let request = ReleasePrRequest {
-                github: cmd_args.github().context("invalid github settings")?,
+                git: cmd_args
+                    .git_backend()
+                    .context("invalid git backend settings")?,
                 update_request,
             };
             release_plz_core::release_pr(&request).await?;
diff --git a/crates/release_plz_core/Cargo.toml b/crates/release_plz_core/Cargo.toml
index 9255dc93..65282cc2 100644
--- a/crates/release_plz_core/Cargo.toml
+++ b/crates/release_plz_core/Cargo.toml
@@ -21,12 +21,15 @@ cargo-clone-core.workspace = true
 cargo-edit = { workspace = true, features = ["set-version"] }
 chrono = { workspace = true, features = ["clock"] }
 crates-index.workspace = true
+dunce.workspace = true
 fs_extra.workspace = true
 git-cliff.workspace = true
 git-cliff-core.workspace = true
 git-url-parse.workspace = true
 octocrab.workspace = true
+reqwest = { workspace = true, features = ["json"] }
 secrecy.workspace = true
+serde = { workspace = true, features = ["derive"] }
 tempfile.workspace = true
 tracing.workspace = true
 url.workspace = true
diff --git a/crates/release_plz_core/src/backend.rs b/crates/release_plz_core/src/backend.rs
new file mode 100644
index 00000000..0471cfdd
--- /dev/null
+++ b/crates/release_plz_core/src/backend.rs
@@ -0,0 +1,49 @@
+use crate::gitea_client::{Gitea, GiteaClient};
+use crate::github_client::GitHubClient;
+use crate::GitHub;
+use tracing::instrument;
+
+#[derive(Debug)]
+pub enum GitBackend {
+    Github(GitHub),
+    Gitea(Gitea),
+}
+
+#[derive(Debug)]
+pub enum GitClient<'a> {
+    GitHub(GitHubClient<'a>),
+    Gitea(GiteaClient<'a>),
+}
+
+impl<'a> GitClient<'a> {
+    pub fn new(backend: &'a GitBackend) -> anyhow::Result<Self> {
+        Ok(match backend {
+            GitBackend::Github(g) => GitClient::GitHub(GitHubClient::new(g)?),
+            GitBackend::Gitea(g) => GitClient::Gitea(GiteaClient::new(g)?),
+        })
+    }
+
+    /// Close all Prs which branch starts with the given `branch_prefix`.
+    pub async fn close_prs_on_branches(&self, branch_prefix: &str) -> anyhow::Result<()> {
+        match self {
+            GitClient::GitHub(g) => g.close_prs_on_branches(branch_prefix).await,
+            GitClient::Gitea(g) => g.close_prs_on_branches(branch_prefix).await,
+        }
+    }
+
+    #[instrument(skip(pr))]
+    pub async fn open_pr(&self, pr: &Pr) -> anyhow::Result<()> {
+        match self {
+            GitClient::GitHub(g) => g.open_pr(pr).await,
+            GitClient::Gitea(g) => g.open_pr(pr).await,
+        }
+    }
+}
+
+#[derive(Debug)]
+pub struct Pr {
+    pub base_branch: String,
+    pub branch: String,
+    pub title: String,
+    pub body: String,
+}
diff --git a/crates/release_plz_core/src/gitea_client.rs b/crates/release_plz_core/src/gitea_client.rs
new file mode 100644
index 00000000..16866019
--- /dev/null
+++ b/crates/release_plz_core/src/gitea_client.rs
@@ -0,0 +1,166 @@
+use crate::backend::Pr;
+use crate::RepoUrl;
+use anyhow::bail;
+use reqwest::header::HeaderValue;
+use reqwest::Method;
+use secrecy::{ExposeSecret, SecretString};
+use serde::{Deserialize, Serialize};
+use tracing::{debug, instrument};
+
+#[derive(Debug)]
+pub struct GiteaClient<'a> {
+    gitea: &'a Gitea,
+    client: reqwest::Client,
+}
+
+impl<'a> GiteaClient<'a> {
+    pub fn new(gitea: &'a Gitea) -> anyhow::Result<Self> {
+        let client = reqwest::Client::new();
+        Ok(Self { gitea, client })
+    }
+
+    /// Close all Prs which branch starts with the given `branch_prefix`.
+    pub async fn close_prs_on_branches(&self, branch_prefix: &str) -> anyhow::Result<()> {
+        let mut page = 1;
+        let page_size = 30;
+        loop {
+            let req = self
+                .client
+                .request(
+                    Method::GET,
+                    format!(
+                        "{}/repos/{}/{}/pulls?state=open&page={}&limit={}",
+                        self.gitea.api_url, self.gitea.owner, self.gitea.repo, page, page_size
+                    ),
+                )
+                .header("Authorization", self.get_token_header()?)
+                .header("accept", "application/json")
+                .build()?;
+            debug!(
+                "Loading prs from {}/{}, page {page}",
+                self.gitea.owner, self.gitea.repo
+            );
+            let prs: Vec<RepoPr> = self
+                .client
+                .execute(req)
+                .await?
+                .error_for_status()?
+                .json()
+                .await?;
+
+            for pr in &prs {
+                if pr.head.ref_field.starts_with(branch_prefix) {
+                    debug!(
+                        "Closing pr #{} in {}/{}",
+                        pr.id, self.gitea.owner, self.gitea.repo
+                    );
+                    let req = self
+                        .client
+                        .request(
+                            Method::PATCH,
+                            format!(
+                                "{}/repos/{}/{}/pulls/{}",
+                                self.gitea.api_url, self.gitea.owner, self.gitea.repo, &pr.id,
+                            ),
+                        )
+                        .header("Authorization", self.get_token_header()?)
+                        .json(&EditPullRequest { state: "closed" })
+                        .build()?;
+                    self.client.execute(req).await?.error_for_status()?;
+                }
+            }
+
+            if prs.len() < page_size as usize {
+                break;
+            }
+            page += 1;
+        }
+
+        Ok(())
+    }
+
+    #[instrument(skip(pr))]
+    pub async fn open_pr(&self, pr: &Pr) -> anyhow::Result<()> {
+        let req_body = OpenPrBody {
+            title: &pr.title,
+            body: &pr.body,
+            base: &pr.base_branch,
+            head: &pr.branch,
+        };
+
+        let req = self
+            .client
+            .request(
+                Method::POST,
+                format!(
+                    "{}/repos/{}/{}/pulls",
+                    self.gitea.api_url, self.gitea.owner, self.gitea.repo,
+                ),
+            )
+            .header("Authorization", self.get_token_header()?)
+            .json(&req_body)
+            .build()?;
+        debug!(
+            "Opening PR in {}/{}: {:?}",
+            self.gitea.owner, self.gitea.repo, req
+        );
+        self.client.execute(req).await?.error_for_status()?;
+        Ok(())
+    }
+
+    fn get_token_header(&self) -> anyhow::Result<HeaderValue> {
+        let header = HeaderValue::from_str(&format!("token {}", self.gitea.token.expose_secret()))?;
+        Ok(header)
+    }
+}
+
+#[derive(Debug)]
+pub struct Gitea {
+    pub owner: String,
+    pub repo: String,
+    pub token: SecretString,
+    api_url: String,
+}
+
+impl Gitea {
+    pub fn new(url: RepoUrl, token: SecretString) -> anyhow::Result<Self> {
+        match url.scheme.as_str() {
+            "http" | "https" => {}
+            _ => bail!(
+                "invalid scheme for gitea url, only `http` and `https` are supported: {url:?}"
+            ),
+        }
+
+        Ok(Self {
+            api_url: url.gitea_api_url(),
+            owner: url.owner,
+            repo: url.name,
+            token,
+        })
+    }
+}
+
+#[derive(Serialize, Deserialize)]
+struct OpenPrBody<'a> {
+    title: &'a str,
+    body: &'a str,
+    base: &'a str,
+    head: &'a str,
+}
+
+#[derive(Serialize, Deserialize)]
+struct RepoPr {
+    pub id: u64,
+    pub head: Commit,
+}
+
+#[derive(Serialize, Deserialize)]
+struct Commit {
+    #[serde(rename = "ref")]
+    pub ref_field: String,
+}
+
+#[derive(Serialize, Deserialize)]
+struct EditPullRequest {
+    state: &'static str,
+}
diff --git a/crates/release_plz_core/src/github_client.rs b/crates/release_plz_core/src/github_client.rs
index e309692b..7294a161 100644
--- a/crates/release_plz_core/src/github_client.rs
+++ b/crates/release_plz_core/src/github_client.rs
@@ -1,3 +1,4 @@
+use crate::backend::Pr;
 use anyhow::Context;
 use octocrab::{models::IssueState, params, Octocrab, OctocrabBuilder};
 use secrecy::{ExposeSecret, SecretString};
@@ -10,13 +11,6 @@ pub struct GitHubClient<'a> {
     client: Octocrab,
 }
 
-#[derive(Debug)]
-pub struct Pr {
-    pub branch: String,
-    pub title: String,
-    pub body: String,
-}
-
 impl<'a> GitHubClient<'a> {
     pub fn new(github: &'a GitHub) -> anyhow::Result<Self> {
         let mut octocrab_builder =
diff --git a/crates/release_plz_core/src/lib.rs b/crates/release_plz_core/src/lib.rs
index 8ebfdab1..39d91bc6 100644
--- a/crates/release_plz_core/src/lib.rs
+++ b/crates/release_plz_core/src/lib.rs
@@ -1,7 +1,9 @@
+mod backend;
 mod cargo;
 mod changelog;
 mod diff;
 mod download;
+mod gitea_client;
 mod github_client;
 mod next_ver;
 mod package_compare;
@@ -14,8 +16,10 @@ mod tmp_repo;
 mod update;
 mod version;
 
+pub use backend::GitBackend;
 pub use changelog::*;
 pub use download::read_package;
+pub use gitea_client::Gitea;
 pub use github_client::GitHub;
 pub use next_ver::*;
 pub use package_compare::*;
diff --git a/crates/release_plz_core/src/next_ver.rs b/crates/release_plz_core/src/next_ver.rs
index e77d3010..630a7e1b 100644
--- a/crates/release_plz_core/src/next_ver.rs
+++ b/crates/release_plz_core/src/next_ver.rs
@@ -55,7 +55,7 @@ pub struct ChangelogRequest {
 }
 
 fn canonical_local_manifest(local_manifest: &Path) -> io::Result<PathBuf> {
-    let mut local_manifest = fs::canonicalize(local_manifest)?;
+    let mut local_manifest = dunce::canonicalize(local_manifest)?;
     if !local_manifest.ends_with(CARGO_TOML) {
         local_manifest.push(CARGO_TOML)
     }
@@ -173,6 +173,7 @@ pub fn next_versions(
     Ok((packages_to_update, repository))
 }
 
+#[derive(Debug)]
 struct Project {
     packages: Vec<Package>,
     /// Project root directory
diff --git a/crates/release_plz_core/src/release_pr.rs b/crates/release_plz_core/src/release_pr.rs
index 9aeda053..39005ab4 100644
--- a/crates/release_plz_core/src/release_pr.rs
+++ b/crates/release_plz_core/src/release_pr.rs
@@ -7,17 +7,14 @@ use git_cmd::Repo;
 use anyhow::{anyhow, Context};
 use tracing::instrument;
 
-use crate::{
-    copy_to_temp_dir,
-    github_client::{GitHub, GitHubClient, Pr},
-    update, UpdateRequest, UpdateResult, CARGO_TOML,
-};
+use crate::backend::{GitClient, Pr};
+use crate::{copy_to_temp_dir, update, GitBackend, UpdateRequest, UpdateResult, CARGO_TOML};
 
 const BRANCH_PREFIX: &str = "release-plz/";
 
 #[derive(Debug)]
 pub struct ReleasePrRequest {
-    pub github: GitHub,
+    pub git: GitBackend,
     pub update_request: UpdateRequest,
 }
 
@@ -39,14 +36,14 @@ pub async fn release_pr(input: &ReleasePrRequest) -> anyhow::Result<()> {
         .set_local_manifest(local_manifest)
         .context("can't find temporary project")?;
     let (packages_to_update, _temp_repository) = update(&new_update_request)?;
-    let gh_client = GitHubClient::new(&input.github)?;
+    let gh_client = GitClient::new(&input.git)?;
     gh_client
         .close_prs_on_branches(BRANCH_PREFIX)
         .await
         .context("cannot close old release-plz prs")?;
     if !packages_to_update.is_empty() {
         let repo = Repo::new(new_manifest_dir)?;
-        let pr = Pr::from(packages_to_update.as_ref());
+        let pr = Pr::from((repo.default_branch(), packages_to_update.as_ref()));
         create_release_branch(&repo, &pr.branch)?;
         gh_client.open_pr(&pr).await?;
     }
@@ -54,10 +51,11 @@ pub async fn release_pr(input: &ReleasePrRequest) -> anyhow::Result<()> {
     Ok(())
 }
 
-impl From<&[(Package, UpdateResult)]> for Pr {
-    fn from(packages_to_update: &[(Package, UpdateResult)]) -> Self {
+impl From<(&str, &[(Package, UpdateResult)])> for Pr {
+    fn from((default_branch, packages_to_update): (&str, &[(Package, UpdateResult)])) -> Self {
         Self {
             branch: release_branch(),
+            base_branch: default_branch.to_string(),
             title: pr_title(packages_to_update),
             body: pr_body(packages_to_update),
         }
diff --git a/crates/release_plz_core/src/repo_url.rs b/crates/release_plz_core/src/repo_url.rs
index 8f2fdc3f..466906b9 100644
--- a/crates/release_plz_core/src/repo_url.rs
+++ b/crates/release_plz_core/src/repo_url.rs
@@ -3,23 +3,33 @@ use git_url_parse::GitUrl;
 
 #[derive(Debug, Clone)]
 pub struct RepoUrl {
+    pub scheme: String,
     pub host: String,
+    port: Option<u16>,
     pub owner: String,
     pub name: String,
 }
 
 impl RepoUrl {
-    pub fn new(github_url: &str) -> anyhow::Result<Self> {
-        let git_url = GitUrl::parse(github_url)
-            .map_err(|err| anyhow!("cannot parse github url {}: {}", github_url, err))?;
+    pub fn new(git_host_url: &str) -> anyhow::Result<Self> {
+        let git_url = GitUrl::parse(git_host_url)
+            .map_err(|err| anyhow!("cannot parse git url {}: {}", git_host_url, err))?;
         let owner = git_url
             .owner
-            .with_context(|| format!("cannot find owner in git url {}", github_url))?;
+            .with_context(|| format!("cannot find owner in git url {}", git_host_url))?;
         let name = git_url.name;
         let host = git_url
             .host
-            .with_context(|| format!("cannot find host in git url {}", github_url))?;
-        Ok(RepoUrl { owner, name, host })
+            .with_context(|| format!("cannot find host in git url {}", git_host_url))?;
+        let port = git_url.port;
+        let scheme = git_url.scheme.to_string();
+        Ok(RepoUrl {
+            owner,
+            name,
+            host,
+            port,
+            scheme,
+        })
     }
 
     pub fn is_on_github(&self) -> bool {
@@ -33,4 +43,12 @@ impl RepoUrl {
             self.host, self.owner, self.name
         )
     }
+
+    pub fn gitea_api_url(&self) -> String {
+        if let Some(port) = self.port {
+            format!("{}://{}:{}/api/v1", self.scheme, self.host, port)
+        } else {
+            format!("{}://{}/api/v1", self.scheme, self.host)
+        }
+    }
 }


Test patch:
diff --git a/crates/release_plz_core/tests/all/helpers/comparison_test.rs b/crates/release_plz_core/tests/all/helpers/comparison_test.rs
index 6303d48c..3141302b 100644
--- a/crates/release_plz_core/tests/all/helpers/comparison_test.rs
+++ b/crates/release_plz_core/tests/all/helpers/comparison_test.rs
@@ -1,9 +1,10 @@
 use std::{fs, path::PathBuf};
 
+use crate::helpers::gitea_mock_server::GiteaMockServer;
 use chrono::NaiveDate;
 use release_plz_core::{
-    are_packages_equal, copy_to_temp_dir, ChangelogRequest, GitHub, ReleasePrRequest,
-    UpdateRequest, CARGO_TOML, CHANGELOG_FILENAME,
+    are_packages_equal, copy_to_temp_dir, ChangelogRequest, GitBackend, GitHub, Gitea,
+    ReleasePrRequest, RepoUrl, UpdateRequest, CARGO_TOML, CHANGELOG_FILENAME,
 };
 use secrecy::Secret;
 use tempfile::{tempdir, TempDir};
@@ -16,6 +17,7 @@ pub struct ComparisonTest {
     local_project: TempDir,
     registry_project: TempDir,
     github_mock_server: GitHubMockServer,
+    gitea_mock_server: GiteaMockServer,
 }
 
 const PROJECT_NAME: &str = "myproject";
@@ -34,6 +36,7 @@ impl ComparisonTest {
             local_project: local_project_dir,
             registry_project,
             github_mock_server: GitHubMockServer::start(OWNER, REPO).await,
+            gitea_mock_server: GiteaMockServer::start(OWNER, REPO).await,
         };
         fs::copy(
             comparison.registry_project().join(CARGO_TOML),
@@ -59,22 +62,41 @@ impl ComparisonTest {
         release_plz_core::update(&update_request).unwrap();
     }
 
-    fn release_pr_request(&self, base_url: Url) -> ReleasePrRequest {
-        let github = GitHub::new(
-            OWNER.to_string(),
-            REPO.to_string(),
-            Secret::from("token".to_string()),
-        )
-        .with_base_url(base_url);
+    fn github_release_pr_request(&self, base_url: Url) -> ReleasePrRequest {
+        let github = GitBackend::Github(
+            GitHub::new(
+                OWNER.to_string(),
+                REPO.to_string(),
+                Secret::from("token".to_string()),
+            )
+            .with_base_url(base_url),
+        );
         ReleasePrRequest {
-            github,
+            git: github,
             update_request: self.update_request(),
         }
     }
 
-    pub async fn open_release_pr(&self) -> anyhow::Result<()> {
+    pub async fn github_open_release_pr(&self) -> anyhow::Result<()> {
         let base_url = self.github_mock_server.base_url();
-        let release_pr_request = self.release_pr_request(base_url);
+        let release_pr_request = self.github_release_pr_request(base_url);
+        release_plz_core::release_pr(&release_pr_request).await
+    }
+
+    fn gitea_release_pr_request(&self, base_url: Url) -> anyhow::Result<ReleasePrRequest> {
+        let git = GitBackend::Gitea(Gitea::new(
+            RepoUrl::new(&format!("{}{OWNER}/{REPO}", base_url.as_str()))?,
+            Secret::from("token".to_string()),
+        )?);
+        Ok(ReleasePrRequest {
+            git,
+            update_request: self.update_request(),
+        })
+    }
+
+    pub async fn gitea_open_release_pr(&self) -> anyhow::Result<()> {
+        let base_url = self.gitea_mock_server.base_url();
+        let release_pr_request = self.gitea_release_pr_request(base_url)?;
         release_plz_core::release_pr(&release_pr_request).await
     }
 
@@ -117,4 +139,10 @@ impl ComparisonTest {
     pub fn github_mock_server(&self) -> &GitHubMockServer {
         &self.github_mock_server
     }
+
+    /// Get a reference to the comparison test's github mock server.
+    #[must_use]
+    pub fn gitea_mock_server(&self) -> &GiteaMockServer {
+        &self.gitea_mock_server
+    }
 }
diff --git a/crates/release_plz_core/tests/all/helpers/gitea_mock_server.rs b/crates/release_plz_core/tests/all/helpers/gitea_mock_server.rs
new file mode 100644
index 00000000..303a3416
--- /dev/null
+++ b/crates/release_plz_core/tests/all/helpers/gitea_mock_server.rs
@@ -0,0 +1,53 @@
+use url::Url;
+use wiremock::{
+    matchers::{method, path},
+    Mock, MockServer, ResponseTemplate,
+};
+
+pub struct GiteaMockServer {
+    server: MockServer,
+    owner: &'static str,
+    repo: &'static str,
+}
+
+impl GiteaMockServer {
+    pub async fn start(owner: &'static str, repo: &'static str) -> Self {
+        Self {
+            server: MockServer::start().await,
+            owner,
+            repo,
+        }
+    }
+
+    pub fn base_url(&self) -> Url {
+        Url::parse(&self.server.uri()).unwrap()
+    }
+
+    /// Nobody tried to open a PR.
+    pub async fn expect_no_created_prs(&self) {
+        Mock::given(method("POST"))
+            .and(path(self.pulls_path()))
+            .respond_with(ResponseTemplate::new(200))
+            .expect(0)
+            .mount(&self.server)
+            .await;
+    }
+
+    /// Return an empty list of PRs.
+    pub async fn no_prs(&self) {
+        let no_prs = {
+            let empty_body = serde_json::Value::Array(vec![]);
+            ResponseTemplate::new(200).set_body_json(empty_body)
+        };
+        Mock::given(method("GET"))
+            .and(path(self.pulls_path()))
+            .respond_with(no_prs)
+            .expect(1)
+            .mount(&self.server)
+            .await;
+    }
+
+    fn pulls_path(&self) -> String {
+        format!("/api/v1/repos/{}/{}/pulls", self.owner, self.repo)
+    }
+}
diff --git a/crates/release_plz_core/tests/all/helpers/mod.rs b/crates/release_plz_core/tests/all/helpers/mod.rs
index 05cb87c9..35520716 100644
--- a/crates/release_plz_core/tests/all/helpers/mod.rs
+++ b/crates/release_plz_core/tests/all/helpers/mod.rs
@@ -1,3 +1,4 @@
 pub mod comparison_test;
+pub mod gitea_mock_server;
 pub mod github_mock_server;
 pub mod user_mock;
diff --git a/crates/release_plz_core/tests/all/release_pr.rs b/crates/release_plz_core/tests/all/release_pr.rs
index a81cbf2b..042fee8a 100644
--- a/crates/release_plz_core/tests/all/release_pr.rs
+++ b/crates/release_plz_core/tests/all/release_pr.rs
@@ -1,12 +1,23 @@
 use crate::helpers::comparison_test::ComparisonTest;
 
 #[tokio::test]
-async fn up_to_date_project_should_not_raise_pr() {
+async fn github_up_to_date_project_should_not_raise_pr() {
     let comparison_test = ComparisonTest::new().await;
     comparison_test
         .github_mock_server()
         .expect_no_created_prs()
         .await;
     comparison_test.github_mock_server().no_prs().await;
-    comparison_test.open_release_pr().await.unwrap();
+    comparison_test.github_open_release_pr().await.unwrap();
+}
+
+#[tokio::test]
+async fn gitea_up_to_date_project_should_not_raise_pr() {
+    let comparison_test = ComparisonTest::new().await;
+    comparison_test
+        .gitea_mock_server()
+        .expect_no_created_prs()
+        .await;
+    comparison_test.gitea_mock_server().no_prs().await;
+    comparison_test.gitea_open_release_pr().await.unwrap();
 }