diff --git a/crates/git_hosting_providers/src/git_hosting_providers.rs b/crates/git_hosting_providers/src/git_hosting_providers.rs index 1d88c47f2e26fc9ad4e27b1e36351198c4365caf..307db49a8ebd33228e6c1bccfbffc065b5f563b3 100644 --- a/crates/git_hosting_providers/src/git_hosting_providers.rs +++ b/crates/git_hosting_providers/src/git_hosting_providers.rs @@ -21,7 +21,8 @@ pub fn init(cx: &mut App) { let provider_registry = GitHostingProviderRegistry::global(cx); provider_registry.register_hosting_provider(Arc::new(Bitbucket::public_instance())); provider_registry.register_hosting_provider(Arc::new(Chromium)); - provider_registry.register_hosting_provider(Arc::new(Codeberg)); + provider_registry.register_hosting_provider(Arc::new(Forgejo::public_instance())); + provider_registry.register_hosting_provider(Arc::new(Gitea::public_instance())); provider_registry.register_hosting_provider(Arc::new(Gitee)); provider_registry.register_hosting_provider(Arc::new(Github::public_instance())); provider_registry.register_hosting_provider(Arc::new(Gitlab::public_instance())); @@ -44,6 +45,10 @@ pub fn register_additional_providers( provider_registry.register_hosting_provider(Arc::new(gitlab_self_hosted)); } else if let Ok(github_self_hosted) = Github::from_remote_url(&origin_url) { provider_registry.register_hosting_provider(Arc::new(github_self_hosted)); + } else if let Ok(forgejo_self_hosted) = Forgejo::from_remote_url(&origin_url) { + provider_registry.register_hosting_provider(Arc::new(forgejo_self_hosted)); + } else if let Ok(gitea_self_hosted) = Gitea::from_remote_url(&origin_url) { + provider_registry.register_hosting_provider(Arc::new(gitea_self_hosted)); } } diff --git a/crates/git_hosting_providers/src/providers.rs b/crates/git_hosting_providers/src/providers.rs index c94b830f5827793c5793d66fa13df2e6317953a4..f3b2fe4794484a6707325dde90b5a00f550d63a0 100644 --- a/crates/git_hosting_providers/src/providers.rs +++ b/crates/git_hosting_providers/src/providers.rs @@ -1,6 +1,7 @@ mod bitbucket; mod chromium; -mod codeberg; +mod forgejo; +mod gitea; mod gitee; mod github; mod gitlab; @@ -8,7 +9,8 @@ mod sourcehut; pub use bitbucket::*; pub use chromium::*; -pub use codeberg::*; +pub use forgejo::*; +pub use gitea::*; pub use gitee::*; pub use github::*; pub use gitlab::*; diff --git a/crates/git_hosting_providers/src/providers/codeberg.rs b/crates/git_hosting_providers/src/providers/forgejo.rs similarity index 57% rename from crates/git_hosting_providers/src/providers/codeberg.rs rename to crates/git_hosting_providers/src/providers/forgejo.rs index 4cd7dd2c04aa30973d6409300eadd9fbc980ddc4..3944b7a165b724d96bbf6fc315a7b3f213457bf4 100644 --- a/crates/git_hosting_providers/src/providers/codeberg.rs +++ b/crates/git_hosting_providers/src/providers/forgejo.rs @@ -14,6 +14,8 @@ use git::{ RemoteUrl, }; +use crate::get_host_from_git_remote_url; + #[derive(Debug, Deserialize)] struct CommitDetails { #[expect( @@ -67,31 +69,72 @@ struct User { pub avatar_url: String, } -pub struct Codeberg; +pub struct Forgejo { + name: String, + base_url: Url, +} + +impl Forgejo { + pub fn new(name: impl Into, base_url: Url) -> Self { + Self { + name: name.into(), + base_url, + } + } + + pub fn public_instance() -> Self { + Self::new("Codeberg", Url::parse("https://codeberg.org").unwrap()) + } + + pub fn from_remote_url(remote_url: &str) -> Result { + let host = get_host_from_git_remote_url(remote_url)?; + if host == "codeberg.org" { + bail!("the Forgejo instance is not self-hosted"); + } + + // TODO: detecting self hosted instances by checking whether "forgejo" is in the url or not + // is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more + // information. + if !host.contains("forgejo") { + bail!("not a Forgejo URL"); + } + + Ok(Self::new( + "Forgejo Self-Hosted", + Url::parse(&format!("https://{}", host))?, + )) + } -impl Codeberg { - async fn fetch_codeberg_commit_author( + async fn fetch_forgejo_commit_author( &self, repo_owner: &str, repo: &str, commit: &str, client: &Arc, ) -> Result> { - let url = - format!("https://codeberg.org/api/v1/repos/{repo_owner}/{repo}/git/commits/{commit}"); + let Some(host) = self.base_url.host_str() else { + bail!("failed to get host from forgejo base url"); + }; + let url = format!( + "https://{host}/api/v1/repos/{repo_owner}/{repo}/git/commits/{commit}?stat=false&verification=false&files=false" + ); let mut request = Request::get(&url) .header("Content-Type", "application/json") .follow_redirects(http_client::RedirectPolicy::FollowAll); - if let Ok(codeberg_token) = std::env::var("CODEBERG_TOKEN") { + // TODO: not renamed yet for compatibility reasons, may require a refactor later + // see https://github.com/zed-industries/zed/issues/11043#issuecomment-3480446231 + if host == "codeberg.org" + && let Ok(codeberg_token) = std::env::var("CODEBERG_TOKEN") + { request = request.header("Authorization", format!("Bearer {}", codeberg_token)); } let mut response = client .send(request.body(AsyncBody::default())?) .await - .with_context(|| format!("error fetching Codeberg commit details at {:?}", url))?; + .with_context(|| format!("error fetching Forgejo commit details at {:?}", url))?; let mut body = Vec::new(); response.body_mut().read_to_end(&mut body).await?; @@ -108,18 +151,18 @@ impl Codeberg { serde_json::from_str::(body_str) .map(|commit| commit.author) - .context("failed to deserialize Codeberg commit details") + .context("failed to deserialize Forgejo commit details") } } #[async_trait] -impl GitHostingProvider for Codeberg { +impl GitHostingProvider for Forgejo { fn name(&self) -> String { - "Codeberg".to_string() + self.name.clone() } fn base_url(&self) -> Url { - Url::parse("https://codeberg.org").unwrap() + self.base_url.clone() } fn supports_avatars(&self) -> bool { @@ -138,7 +181,7 @@ impl GitHostingProvider for Codeberg { let url = RemoteUrl::from_str(url).ok()?; let host = url.host_str()?; - if host != "codeberg.org" { + if host != self.base_url.host_str()? { return None; } @@ -194,9 +237,27 @@ impl GitHostingProvider for Codeberg { ) -> Result> { let commit = commit.to_string(); let avatar_url = self - .fetch_codeberg_commit_author(repo_owner, repo, &commit, &http_client) + .fetch_forgejo_commit_author(repo_owner, repo, &commit, &http_client) .await? - .map(|author| Url::parse(&author.avatar_url)) + .map(|author| -> Result { + let mut url = Url::parse(&author.avatar_url)?; + if let Some(host) = url.host_str() { + let size_query = if host.contains("gravatar") || host.contains("libravatar") { + Some("s=128") + } else if self + .base_url + .host_str() + .is_some_and(|base_host| host.contains(base_host)) + { + // This parameter exists on Codeberg but does not seem to take effect. setting it anyway + Some("size=128") + } else { + None + }; + url.set_query(size_query); + } + Ok(url) + }) .transpose()?; Ok(avatar_url) } @@ -211,7 +272,7 @@ mod tests { #[test] fn test_parse_remote_url_given_ssh_url() { - let parsed_remote = Codeberg + let parsed_remote = Forgejo::public_instance() .parse_remote_url("git@codeberg.org:zed-industries/zed.git") .unwrap(); @@ -226,7 +287,7 @@ mod tests { #[test] fn test_parse_remote_url_given_https_url() { - let parsed_remote = Codeberg + let parsed_remote = Forgejo::public_instance() .parse_remote_url("https://codeberg.org/zed-industries/zed.git") .unwrap(); @@ -239,9 +300,44 @@ mod tests { ); } + #[test] + fn test_parse_remote_url_given_self_hosted_ssh_url() { + let remote_url = "git@forgejo.my-enterprise.com:zed-industries/zed.git"; + + let parsed_remote = Forgejo::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_self_hosted_https_url() { + let remote_url = "https://forgejo.my-enterprise.com/zed-industries/zed.git"; + let parsed_remote = Forgejo::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + #[test] fn test_build_codeberg_permalink() { - let permalink = Codeberg.build_permalink( + let permalink = Forgejo::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed".into(), @@ -259,7 +355,7 @@ mod tests { #[test] fn test_build_codeberg_permalink_with_single_line_selection() { - let permalink = Codeberg.build_permalink( + let permalink = Forgejo::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed".into(), @@ -277,7 +373,7 @@ mod tests { #[test] fn test_build_codeberg_permalink_with_multi_line_selection() { - let permalink = Codeberg.build_permalink( + let permalink = Forgejo::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed".into(), @@ -292,4 +388,46 @@ mod tests { let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48"; assert_eq!(permalink.to_string(), expected_url.to_string()) } + + #[test] + fn test_build_forgejo_self_hosted_permalink_from_ssh_url() { + let forgejo = + Forgejo::from_remote_url("git@forgejo.some-enterprise.com:zed-industries/zed.git") + .unwrap(); + let permalink = forgejo.build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), + ); + + let expected_url = "https://forgejo.some-enterprise.com/zed-industries/zed/src/commit/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_forgejo_self_hosted_permalink_from_https_url() { + let forgejo = + Forgejo::from_remote_url("https://forgejo-instance.big-co.com/zed-industries/zed.git") + .unwrap(); + let permalink = forgejo.build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", + &repo_path("crates/zed/src/main.rs"), + None, + ), + ); + + let expected_url = "https://forgejo-instance.big-co.com/zed-industries/zed/src/commit/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } } diff --git a/crates/git_hosting_providers/src/providers/gitea.rs b/crates/git_hosting_providers/src/providers/gitea.rs new file mode 100644 index 0000000000000000000000000000000000000000..d3e62fe6a85c0f2b126dfd4bdb703195c7ec4b38 --- /dev/null +++ b/crates/git_hosting_providers/src/providers/gitea.rs @@ -0,0 +1,380 @@ +use std::str::FromStr; +use std::sync::Arc; + +use anyhow::{Context as _, Result, bail}; +use async_trait::async_trait; +use futures::AsyncReadExt; +use gpui::SharedString; +use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request}; +use serde::Deserialize; +use url::Url; + +use git::{ + BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote, + RemoteUrl, +}; + +use crate::get_host_from_git_remote_url; + +#[derive(Debug, Deserialize)] +struct CommitDetails { + author: Option, +} + +#[derive(Debug, Deserialize)] +struct User { + pub avatar_url: String, +} + +pub struct Gitea { + name: String, + base_url: Url, +} + +impl Gitea { + pub fn new(name: impl Into, base_url: Url) -> Self { + Self { + name: name.into(), + base_url, + } + } + + pub fn public_instance() -> Self { + Self::new("Gitea", Url::parse("https://gitea.com").unwrap()) + } + + pub fn from_remote_url(remote_url: &str) -> Result { + let host = get_host_from_git_remote_url(remote_url)?; + if host == "gitea.com" { + bail!("the Gitea instance is not self-hosted"); + } + + // TODO: detecting self hosted instances by checking whether "gitea" is in the url or not + // is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more + // information. + if !host.contains("gitea") { + bail!("not a Gitea URL"); + } + + Ok(Self::new( + "Gitea Self-Hosted", + Url::parse(&format!("https://{}", host))?, + )) + } + + async fn fetch_gitea_commit_author( + &self, + repo_owner: &str, + repo: &str, + commit: &str, + client: &Arc, + ) -> Result> { + let Some(host) = self.base_url.host_str() else { + bail!("failed to get host from gitea base url"); + }; + let url = format!( + "https://{host}/api/v1/repos/{repo_owner}/{repo}/git/commits/{commit}?stat=false&verification=false&files=false" + ); + + let request = Request::get(&url) + .header("Content-Type", "application/json") + .follow_redirects(http_client::RedirectPolicy::FollowAll); + + let mut response = client + .send(request.body(AsyncBody::default())?) + .await + .with_context(|| format!("error fetching Gitea commit details at {:?}", url))?; + + let mut body = Vec::new(); + response.body_mut().read_to_end(&mut body).await?; + + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + bail!( + "status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let body_str = std::str::from_utf8(&body)?; + + serde_json::from_str::(body_str) + .map(|commit| commit.author) + .context("failed to deserialize Gitea commit details") + } +} + +#[async_trait] +impl GitHostingProvider for Gitea { + fn name(&self) -> String { + self.name.clone() + } + + fn base_url(&self) -> Url { + self.base_url.clone() + } + + fn supports_avatars(&self) -> bool { + true + } + + fn format_line_number(&self, line: u32) -> String { + format!("L{line}") + } + + fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String { + format!("L{start_line}-L{end_line}") + } + + fn parse_remote_url(&self, url: &str) -> Option { + let url = RemoteUrl::from_str(url).ok()?; + + let host = url.host_str()?; + if host != self.base_url.host_str()? { + return None; + } + + let mut path_segments = url.path_segments()?; + let owner = path_segments.next()?; + let repo = path_segments.next()?.trim_end_matches(".git"); + + Some(ParsedGitRemote { + owner: owner.into(), + repo: repo.into(), + }) + } + + fn build_commit_permalink( + &self, + remote: &ParsedGitRemote, + params: BuildCommitPermalinkParams, + ) -> Url { + let BuildCommitPermalinkParams { sha } = params; + let ParsedGitRemote { owner, repo } = remote; + + self.base_url() + .join(&format!("{owner}/{repo}/commit/{sha}")) + .unwrap() + } + + fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url { + let ParsedGitRemote { owner, repo } = remote; + let BuildPermalinkParams { + sha, + path, + selection, + } = params; + + let mut permalink = self + .base_url() + .join(&format!("{owner}/{repo}/src/commit/{sha}/{path}")) + .unwrap(); + permalink.set_fragment( + selection + .map(|selection| self.line_fragment(&selection)) + .as_deref(), + ); + permalink + } + + async fn commit_author_avatar_url( + &self, + repo_owner: &str, + repo: &str, + commit: SharedString, + http_client: Arc, + ) -> Result> { + let commit = commit.to_string(); + let avatar_url = self + .fetch_gitea_commit_author(repo_owner, repo, &commit, &http_client) + .await? + .map(|author| -> Result { + let mut url = Url::parse(&author.avatar_url)?; + if let Some(host) = url.host_str() { + let size_query = if host.contains("gravatar") || host.contains("libravatar") { + Some("s=128") + } else if self + .base_url + .host_str() + .is_some_and(|base_host| host.contains(base_host)) + { + Some("size=128") + } else { + None + }; + url.set_query(size_query); + } + Ok(url) + }) + .transpose()?; + Ok(avatar_url) + } +} + +#[cfg(test)] +mod tests { + use git::repository::repo_path; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_parse_remote_url_given_ssh_url() { + let parsed_remote = Gitea::public_instance() + .parse_remote_url("git@gitea.com:zed-industries/zed.git") + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_https_url() { + let parsed_remote = Gitea::public_instance() + .parse_remote_url("https://gitea.com/zed-industries/zed.git") + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_self_hosted_ssh_url() { + let remote_url = "git@gitea.my-enterprise.com:zed-industries/zed.git"; + + let parsed_remote = Gitea::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_self_hosted_https_url() { + let remote_url = "https://gitea.my-enterprise.com/zed-industries/zed.git"; + let parsed_remote = Gitea::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + + #[test] + fn test_build_codeberg_permalink() { + let permalink = Gitea::public_instance().build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), + ); + + let expected_url = "https://gitea.com/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_codeberg_permalink_with_single_line_selection() { + let permalink = Gitea::public_instance().build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(6..6), + ), + ); + + let expected_url = "https://gitea.com/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_codeberg_permalink_with_multi_line_selection() { + let permalink = Gitea::public_instance().build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(23..47), + ), + ); + + let expected_url = "https://gitea.com/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_gitea_self_hosted_permalink_from_ssh_url() { + let gitea = + Gitea::from_remote_url("git@gitea.some-enterprise.com:zed-industries/zed.git").unwrap(); + let permalink = gitea.build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), + ); + + let expected_url = "https://gitea.some-enterprise.com/zed-industries/zed/src/commit/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_gitea_self_hosted_permalink_from_https_url() { + let gitea = + Gitea::from_remote_url("https://gitea-instance.big-co.com/zed-industries/zed.git") + .unwrap(); + let permalink = gitea.build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", + &repo_path("crates/zed/src/main.rs"), + None, + ), + ); + + let expected_url = "https://gitea-instance.big-co.com/zed-industries/zed/src/commit/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } +}