diff --git a/Cargo.lock b/Cargo.lock index a0a5c7e27cf76822a3f7f985332d6c59a3871806..5276a9eac963964531e7e03dceac2713d361443b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7008,6 +7008,7 @@ dependencies = [ "gpui", "http_client", "indoc", + "itertools 0.14.0", "pretty_assertions", "regex", "serde", diff --git a/crates/git_hosting_providers/Cargo.toml b/crates/git_hosting_providers/Cargo.toml index 851556151e285975cb1eb7d3d33244d7e11b5663..9480e0ec28c0ffa61c1126b2a627f22dc445d7d3 100644 --- a/crates/git_hosting_providers/Cargo.toml +++ b/crates/git_hosting_providers/Cargo.toml @@ -18,6 +18,7 @@ futures.workspace = true git.workspace = true gpui.workspace = true http_client.workspace = true +itertools.workspace = true regex.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/git_hosting_providers/src/git_hosting_providers.rs b/crates/git_hosting_providers/src/git_hosting_providers.rs index 98ea301ec984298df54ec8bca7e28f9474e373bd..37cf5882059d7a274661f5a083a23e3f25e676ff 100644 --- a/crates/git_hosting_providers/src/git_hosting_providers.rs +++ b/crates/git_hosting_providers/src/git_hosting_providers.rs @@ -26,7 +26,7 @@ pub fn init(cx: &mut App) { 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())); - provider_registry.register_hosting_provider(Arc::new(Sourcehut)); + provider_registry.register_hosting_provider(Arc::new(SourceHut::public_instance())); } /// Registers additional Git hosting providers. @@ -51,6 +51,8 @@ pub async fn register_additional_providers( provider_registry.register_hosting_provider(Arc::new(gitea_self_hosted)); } else if let Ok(bitbucket_self_hosted) = Bitbucket::from_remote_url(&origin_url) { provider_registry.register_hosting_provider(Arc::new(bitbucket_self_hosted)); + } else if let Ok(sourcehut_self_hosted) = SourceHut::from_remote_url(&origin_url) { + provider_registry.register_hosting_provider(Arc::new(sourcehut_self_hosted)); } } diff --git a/crates/git_hosting_providers/src/providers/bitbucket.rs b/crates/git_hosting_providers/src/providers/bitbucket.rs index 0c30a13758a8339087ebb146f0029baee0d3ea7e..07c6898d4e0affc3bdde4d8290607897cf2cd5be 100644 --- a/crates/git_hosting_providers/src/providers/bitbucket.rs +++ b/crates/git_hosting_providers/src/providers/bitbucket.rs @@ -1,8 +1,14 @@ -use std::str::FromStr; use std::sync::LazyLock; - -use anyhow::{Result, bail}; +use std::{str::FromStr, 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 itertools::Itertools as _; use regex::Regex; +use serde::Deserialize; use url::Url; use git::{ @@ -20,6 +26,42 @@ fn pull_request_regex() -> &'static Regex { &PULL_REQUEST_REGEX } +#[derive(Debug, Deserialize)] +struct CommitDetails { + author: Author, +} + +#[derive(Debug, Deserialize)] +struct Author { + user: Account, +} + +#[derive(Debug, Deserialize)] +struct Account { + links: AccountLinks, +} + +#[derive(Debug, Deserialize)] +struct AccountLinks { + avatar: Option, +} + +#[derive(Debug, Deserialize)] +struct Link { + href: String, +} + +#[derive(Debug, Deserialize)] +struct CommitDetailsSelfHosted { + author: AuthorSelfHosted, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AuthorSelfHosted { + avatar_url: Option, +} + pub struct Bitbucket { name: String, base_url: Url, @@ -61,8 +103,60 @@ impl Bitbucket { .host_str() .is_some_and(|host| host != "bitbucket.org") } + + async fn fetch_bitbucket_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 bitbucket base url"); + }; + let is_self_hosted = self.is_self_hosted(); + let url = if is_self_hosted { + format!( + "https://{host}/rest/api/latest/projects/{repo_owner}/repos/{repo}/commits/{commit}?avatarSize=128" + ) + } else { + format!("https://api.{host}/2.0/repositories/{repo_owner}/{repo}/commit/{commit}") + }; + + 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 BitBucket 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)?; + + if is_self_hosted { + serde_json::from_str::(body_str) + .map(|commit| commit.author.avatar_url) + } else { + serde_json::from_str::(body_str) + .map(|commit| commit.author.user.links.avatar.map(|link| link.href)) + } + .context("failed to deserialize BitBucket commit details") + } } +#[async_trait] impl GitHostingProvider for Bitbucket { fn name(&self) -> String { self.name.clone() @@ -73,7 +167,7 @@ impl GitHostingProvider for Bitbucket { } fn supports_avatars(&self) -> bool { - false + true } fn format_line_number(&self, line: u32) -> String { @@ -98,9 +192,16 @@ impl GitHostingProvider for Bitbucket { return None; } - let mut path_segments = url.path_segments()?; - let owner = path_segments.next()?; - let repo = path_segments.next()?.trim_end_matches(".git"); + let mut path_segments = url.path_segments()?.collect::>(); + let repo = path_segments.pop()?.trim_end_matches(".git"); + let owner = if path_segments.get(0).is_some_and(|v| *v == "scm") && path_segments.len() > 1 + { + // Skip the "scm" segment if it's not the only segment + // https://github.com/gitkraken/vscode-gitlens/blob/a6e3c6fbb255116507eaabaa9940c192ed7bb0e1/src/git/remotes/bitbucket-server.ts#L72-L74 + path_segments.into_iter().skip(1).join("/") + } else { + path_segments.into_iter().join("/") + }; Some(ParsedGitRemote { owner: owner.into(), @@ -176,6 +277,22 @@ impl GitHostingProvider for Bitbucket { Some(PullRequest { number, url }) } + + 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_bitbucket_commit_author(repo_owner, repo, &commit, &http_client) + .await? + .map(|avatar_url| Url::parse(&avatar_url)) + .transpose()?; + Ok(avatar_url) + } } #[cfg(test)] @@ -264,6 +381,38 @@ mod tests { repo: "zed".into(), } ); + + // Test with "scm" in the path + let remote_url = "https://bitbucket.company.com/scm/zed-industries/zed.git"; + + let parsed_remote = Bitbucket::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 with only "scm" as owner + let remote_url = "https://bitbucket.company.com/scm/zed.git"; + + let parsed_remote = Bitbucket::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "scm".into(), + repo: "zed".into(), + } + ); } #[test] diff --git a/crates/git_hosting_providers/src/providers/sourcehut.rs b/crates/git_hosting_providers/src/providers/sourcehut.rs index 55bff551846b5f69bad8ccaeaccf3ad55868303f..41011b023bef01a06138ead26d93ba447d6a4ba1 100644 --- a/crates/git_hosting_providers/src/providers/sourcehut.rs +++ b/crates/git_hosting_providers/src/providers/sourcehut.rs @@ -1,5 +1,6 @@ use std::str::FromStr; +use anyhow::{Result, bail}; use url::Url; use git::{ @@ -7,15 +8,52 @@ use git::{ RemoteUrl, }; -pub struct Sourcehut; +use crate::get_host_from_git_remote_url; -impl GitHostingProvider for Sourcehut { +pub struct SourceHut { + name: String, + base_url: Url, +} + +impl SourceHut { + pub fn new(name: &str, base_url: Url) -> Self { + Self { + name: name.to_string(), + base_url, + } + } + + pub fn public_instance() -> Self { + Self::new("SourceHut", Url::parse("https://git.sr.ht").unwrap()) + } + + pub fn from_remote_url(remote_url: &str) -> Result { + let host = get_host_from_git_remote_url(remote_url)?; + if host == "git.sr.ht" { + bail!("the SourceHut instance is not self-hosted"); + } + + // TODO: detecting self hosted instances by checking whether "sourcehut" 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("sourcehut") { + bail!("not a SourceHut URL"); + } + + Ok(Self::new( + "SourceHut Self-Hosted", + Url::parse(&format!("https://{}", host))?, + )) + } +} + +impl GitHostingProvider for SourceHut { fn name(&self) -> String { - "SourceHut".to_string() + self.name.clone() } fn base_url(&self) -> Url { - Url::parse("https://git.sr.ht").unwrap() + self.base_url.clone() } fn supports_avatars(&self) -> bool { @@ -34,7 +72,7 @@ impl GitHostingProvider for Sourcehut { let url = RemoteUrl::from_str(url).ok()?; let host = url.host_str()?; - if host != "git.sr.ht" { + if host != self.base_url.host_str()? { return None; } @@ -96,7 +134,7 @@ mod tests { #[test] fn test_parse_remote_url_given_ssh_url() { - let parsed_remote = Sourcehut + let parsed_remote = SourceHut::public_instance() .parse_remote_url("git@git.sr.ht:~zed-industries/zed") .unwrap(); @@ -111,7 +149,7 @@ mod tests { #[test] fn test_parse_remote_url_given_ssh_url_with_git_suffix() { - let parsed_remote = Sourcehut + let parsed_remote = SourceHut::public_instance() .parse_remote_url("git@git.sr.ht:~zed-industries/zed.git") .unwrap(); @@ -126,7 +164,7 @@ mod tests { #[test] fn test_parse_remote_url_given_https_url() { - let parsed_remote = Sourcehut + let parsed_remote = SourceHut::public_instance() .parse_remote_url("https://git.sr.ht/~zed-industries/zed") .unwrap(); @@ -139,9 +177,63 @@ mod tests { ); } + #[test] + fn test_parse_remote_url_given_self_hosted_ssh_url() { + let remote_url = "git@sourcehut.org:~zed-industries/zed"; + + let parsed_remote = SourceHut::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_ssh_url_with_git_suffix() { + let remote_url = "git@sourcehut.org:~zed-industries/zed.git"; + + let parsed_remote = SourceHut::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed.git".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_self_hosted_https_url() { + let remote_url = "https://sourcehut.org/~zed-industries/zed"; + + let parsed_remote = SourceHut::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_sourcehut_permalink() { - let permalink = Sourcehut.build_permalink( + let permalink = SourceHut::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed".into(), @@ -159,7 +251,7 @@ mod tests { #[test] fn test_build_sourcehut_permalink_with_git_suffix() { - let permalink = Sourcehut.build_permalink( + let permalink = SourceHut::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed.git".into(), @@ -175,9 +267,49 @@ mod tests { assert_eq!(permalink.to_string(), expected_url.to_string()) } + #[test] + fn test_build_sourcehut_self_hosted_permalink() { + let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed") + .unwrap() + .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://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_sourcehut_self_hosted_permalink_with_git_suffix() { + let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed.git") + .unwrap() + .build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed.git".into(), + }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), + ); + + let expected_url = "https://sourcehut.org/~zed-industries/zed.git/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + #[test] fn test_build_sourcehut_permalink_with_single_line_selection() { - let permalink = Sourcehut.build_permalink( + let permalink = SourceHut::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed".into(), @@ -195,7 +327,7 @@ mod tests { #[test] fn test_build_sourcehut_permalink_with_multi_line_selection() { - let permalink = Sourcehut.build_permalink( + let permalink = SourceHut::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed".into(), @@ -210,4 +342,44 @@ mod tests { let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48"; assert_eq!(permalink.to_string(), expected_url.to_string()) } + + #[test] + fn test_build_sourcehut_self_hosted_permalink_with_single_line_selection() { + let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed") + .unwrap() + .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://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L7"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_sourcehut_self_hosted_permalink_with_multi_line_selection() { + let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed") + .unwrap() + .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://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } } diff --git a/crates/git_hosting_providers/src/settings.rs b/crates/git_hosting_providers/src/settings.rs index 9bf6c1022b04cc60b0fbaead5177f9fe8e6e0280..95243cbe4e4ba68249ba89b948a5ed2644909364 100644 --- a/crates/git_hosting_providers/src/settings.rs +++ b/crates/git_hosting_providers/src/settings.rs @@ -8,7 +8,7 @@ use settings::{ use url::Url; use util::ResultExt as _; -use crate::{Bitbucket, Github, Gitlab}; +use crate::{Bitbucket, Forgejo, Gitea, Github, Gitlab, SourceHut}; pub(crate) fn init(cx: &mut App) { init_git_hosting_provider_settings(cx); @@ -46,6 +46,11 @@ fn update_git_hosting_providers_from_settings(cx: &mut App) { } GitHostingProviderKind::Github => Arc::new(Github::new(&provider.name, url)) as _, GitHostingProviderKind::Gitlab => Arc::new(Gitlab::new(&provider.name, url)) as _, + GitHostingProviderKind::Gitea => Arc::new(Gitea::new(&provider.name, url)) as _, + GitHostingProviderKind::Forgejo => Arc::new(Forgejo::new(&provider.name, url)) as _, + GitHostingProviderKind::SourceHut => { + Arc::new(SourceHut::new(&provider.name, url)) as _ + } }) }); diff --git a/crates/settings/src/settings_content/project.rs b/crates/settings/src/settings_content/project.rs index 25a36580b007a8d41c9a90523298ca9f2b52b8d2..5cd708694d0cfd3699fdc822509d0209f9a96fd1 100644 --- a/crates/settings/src/settings_content/project.rs +++ b/crates/settings/src/settings_content/project.rs @@ -543,7 +543,7 @@ pub enum DiagnosticSeverityContent { pub struct GitHostingProviderConfig { /// The type of the provider. /// - /// Must be one of `github`, `gitlab`, or `bitbucket`. + /// Must be one of `github`, `gitlab`, `bitbucket`, `gitea`, `forgejo`, or `source_hut`. pub provider: GitHostingProviderKind, /// The base URL for the provider (e.g., "https://code.corp.big.com"). @@ -559,4 +559,7 @@ pub enum GitHostingProviderKind { Github, Gitlab, Bitbucket, + Gitea, + Forgejo, + SourceHut, }