diff --git a/crates/git_hosting_providers/src/git_hosting_providers.rs b/crates/git_hosting_providers/src/git_hosting_providers.rs index 8916d7a42e3cf2..6dc846d9f30cb8 100644 --- a/crates/git_hosting_providers/src/git_hosting_providers.rs +++ b/crates/git_hosting_providers/src/git_hosting_providers.rs @@ -12,6 +12,7 @@ pub use crate::providers::*; pub fn init(cx: &App) { let provider_registry = GitHostingProviderRegistry::global(cx); provider_registry.register_hosting_provider(Arc::new(Bitbucket)); + provider_registry.register_hosting_provider(Arc::new(Chromium)); provider_registry.register_hosting_provider(Arc::new(Codeberg)); provider_registry.register_hosting_provider(Arc::new(Gitee)); provider_registry.register_hosting_provider(Arc::new(Github)); diff --git a/crates/git_hosting_providers/src/providers.rs b/crates/git_hosting_providers/src/providers.rs index 68541541cf7108..c94b830f582779 100644 --- a/crates/git_hosting_providers/src/providers.rs +++ b/crates/git_hosting_providers/src/providers.rs @@ -1,4 +1,5 @@ mod bitbucket; +mod chromium; mod codeberg; mod gitee; mod github; @@ -6,6 +7,7 @@ mod gitlab; mod sourcehut; pub use bitbucket::*; +pub use chromium::*; pub use codeberg::*; pub use gitee::*; pub use github::*; diff --git a/crates/git_hosting_providers/src/providers/chromium.rs b/crates/git_hosting_providers/src/providers/chromium.rs new file mode 100644 index 00000000000000..b651144b666b00 --- /dev/null +++ b/crates/git_hosting_providers/src/providers/chromium.rs @@ -0,0 +1,302 @@ +use std::str::FromStr; +use std::sync::{Arc, LazyLock}; + +use anyhow::{bail, Context, Result}; +use async_trait::async_trait; +use futures::AsyncReadExt; +use git::{ + BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote, + PullRequest, RemoteUrl, +}; +use gpui::SharedString; +use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request}; +use regex::Regex; +use serde::Deserialize; +use url::Url; + +const CHROMIUM_REVIEW_URL: &str = "https://chromium-review.googlesource.com"; + +/// Parses Gerrit URLs like +/// https://chromium-review.googlesource.com/c/chromium/src/+/3310961. +fn pull_request_regex() -> &'static Regex { + static PULL_REQUEST_NUMBER_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(&format!( + r#"Reviewed-on: ({CHROMIUM_REVIEW_URL}/c/(.*)/\+/(\d+))"# + )) + .unwrap() + }); + &PULL_REQUEST_NUMBER_REGEX +} + +/// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html +#[derive(Debug, Deserialize)] +struct ChangeInfo { + owner: AccountInfo, +} + +#[derive(Debug, Deserialize)] +pub struct AccountInfo { + #[serde(rename = "_account_id")] + id: u64, +} + +pub struct Chromium; + +impl Chromium { + async fn fetch_chromium_commit_author( + &self, + _repo: &str, + commit: &str, + client: &Arc, + ) -> Result> { + let url = format!("{CHROMIUM_REVIEW_URL}/changes/{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 Gerrit 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() + ); + } + + // Remove XSSI protection prefix. + let body_str = std::str::from_utf8(&body)?.trim_start_matches(")]}'"); + + serde_json::from_str::(body_str) + .map(|change| Some(change.owner)) + .context("failed to deserialize Gerrit change info") + } +} + +#[async_trait] +impl GitHostingProvider for Chromium { + fn name(&self) -> String { + "Chromium".to_string() + } + + fn base_url(&self) -> Url { + Url::parse("https://chromium.googlesource.com").unwrap() + } + + fn supports_avatars(&self) -> bool { + true + } + + fn format_line_number(&self, line: u32) -> String { + format!("{line}") + } + + fn format_line_numbers(&self, start_line: u32, _end_line: u32) -> String { + format!("{start_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 path_segments = url.path_segments()?.collect::>(); + let joined_path = path_segments.join("/"); + let repo = joined_path.trim_end_matches(".git"); + + Some(ParsedGitRemote { + owner: Arc::from(""), + 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!("{repo}/+/{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!("{repo}/+/{sha}/{path}")) + .unwrap(); + permalink.set_fragment( + selection + .map(|selection| self.line_fragment(&selection)) + .as_deref(), + ); + permalink + } + + fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option { + let capture = pull_request_regex().captures(message)?; + let url = Url::parse(capture.get(1)?.as_str()).unwrap(); + let repo = capture.get(2)?.as_str(); + if repo != remote.repo.as_ref() { + return None; + } + + let number = capture.get(3)?.as_str().parse::().ok()?; + + 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 Some(author) = self + .fetch_chromium_commit_author(repo, &commit, &http_client) + .await? + else { + return Ok(None); + }; + + let mut avatar_url = Url::parse(&format!( + "{CHROMIUM_REVIEW_URL}/accounts/{}/avatar", + &author.id + ))?; + avatar_url.set_query(Some("size=128")); + + Ok(Some(avatar_url)) + } +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_parse_remote_url_given_https_url() { + let parsed_remote = Chromium + .parse_remote_url("https://chromium.googlesource.com/chromium/src") + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: Arc::from(""), + repo: "chromium/src".into(), + } + ); + } + + #[test] + fn test_build_chromium_permalink() { + let permalink = Chromium.build_permalink( + ParsedGitRemote { + owner: Arc::from(""), + repo: "chromium/src".into(), + }, + BuildPermalinkParams { + sha: "fea5080b182fc92e3be0c01c5dece602fe70b588", + path: "ui/base/cursor/cursor.h", + selection: None, + }, + ); + + let expected_url = "https://chromium.googlesource.com/chromium/src/+/fea5080b182fc92e3be0c01c5dece602fe70b588/ui/base/cursor/cursor.h"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_chromium_permalink_with_single_line_selection() { + let permalink = Chromium.build_permalink( + ParsedGitRemote { + owner: Arc::from(""), + repo: "chromium/src".into(), + }, + BuildPermalinkParams { + sha: "fea5080b182fc92e3be0c01c5dece602fe70b588", + path: "ui/base/cursor/cursor.h", + selection: Some(18..18), + }, + ); + + let expected_url = "https://chromium.googlesource.com/chromium/src/+/fea5080b182fc92e3be0c01c5dece602fe70b588/ui/base/cursor/cursor.h#19"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_chromium_permalink_with_multi_line_selection() { + let permalink = Chromium.build_permalink( + ParsedGitRemote { + owner: Arc::from(""), + repo: "chromium/src".into(), + }, + BuildPermalinkParams { + sha: "fea5080b182fc92e3be0c01c5dece602fe70b588", + path: "ui/base/cursor/cursor.h", + selection: Some(18..30), + }, + ); + + let expected_url = "https://chromium.googlesource.com/chromium/src/+/fea5080b182fc92e3be0c01c5dece602fe70b588/ui/base/cursor/cursor.h#19"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_chromium_pull_requests() { + let remote = ParsedGitRemote { + owner: Arc::from(""), + repo: "chromium/src".into(), + }; + + let message = "This does not contain a pull request"; + assert!(Chromium.extract_pull_request(&remote, message).is_none()); + + // Pull request number at end of "Reviewed-on:" line + let message = indoc! {r#" + Test commit header + + Test commit description with multiple + lines. + + Bug: 1193775, 1270302 + Change-Id: Id15e9b4d75cce43ebd5fe34f0fb37d5e1e811b66 + Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3310961 + Reviewed-by: Test reviewer + Cr-Commit-Position: refs/heads/main@{#1054973} + "# + }; + + assert_eq!( + Chromium + .extract_pull_request(&remote, &message) + .unwrap() + .url + .as_str(), + "https://chromium-review.googlesource.com/c/chromium/src/+/3310961" + ); + } +}