From dc503e997589c6d5cf4475ca36e5bbb75aa57b76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=B4=80=E1=B4=8D=E1=B4=9B=E1=B4=8F=E1=B4=80=E1=B4=87?= =?UTF-8?q?=CA=80?= Date: Mon, 3 Nov 2025 14:51:04 +0800 Subject: [PATCH] Support GitLab and self-hosted GitLab avatars in git blame (#41747) Part of #11043. Release Notes: - Added Support for showing GitLab and self-hosted GitLab avatars in git blame --- Cargo.lock | 1 + crates/git_hosting_providers/Cargo.toml | 1 + .../src/providers/gitlab.rs | 131 +++++++++++++++++- 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3dc7b2337edcb1d155a56f241b517db5a2ad8045..05a060a2d430618d00af4933936cdc43f2cd5a40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7078,6 +7078,7 @@ dependencies = [ "serde_json", "settings", "url", + "urlencoding", "util", ] diff --git a/crates/git_hosting_providers/Cargo.toml b/crates/git_hosting_providers/Cargo.toml index 2b3e8f235ff6e5f351c1875107443f51838c6da9..851556151e285975cb1eb7d3d33244d7e11b5663 100644 --- a/crates/git_hosting_providers/Cargo.toml +++ b/crates/git_hosting_providers/Cargo.toml @@ -23,6 +23,7 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true url.workspace = true +urlencoding.workspace = true util.workspace = true [dev-dependencies] diff --git a/crates/git_hosting_providers/src/providers/gitlab.rs b/crates/git_hosting_providers/src/providers/gitlab.rs index d18af7cccae058a7b9746f7dfe86beef8d6fda94..af3bb17494a79056db0fd4c531f67b77a31e0954 100644 --- a/crates/git_hosting_providers/src/providers/gitlab.rs +++ b/crates/git_hosting_providers/src/providers/gitlab.rs @@ -1,6 +1,11 @@ -use std::str::FromStr; - -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 serde::Deserialize; use url::Url; use git::{ @@ -10,6 +15,16 @@ use git::{ use crate::get_host_from_git_remote_url; +#[derive(Debug, Deserialize)] +struct CommitDetails { + author_email: String, +} + +#[derive(Debug, Deserialize)] +struct AvatarInfo { + avatar_url: String, +} + #[derive(Debug)] pub struct Gitlab { name: String, @@ -46,8 +61,79 @@ impl Gitlab { Url::parse(&format!("https://{}", host))?, )) } + + async fn fetch_gitlab_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 gitlab base url"); + }; + let project_path = format!("{}/{}", repo_owner, repo); + let project_path_encoded = urlencoding::encode(&project_path); + let url = format!( + "https://{host}/api/v4/projects/{project_path_encoded}/repository/commits/{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 GitLab 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)?; + + let author_email = serde_json::from_str::(body_str) + .map(|commit| commit.author_email) + .context("failed to deserialize GitLab commit details")?; + + let avatar_info_url = format!("https://{host}/api/v4/avatar?email={author_email}"); + + let request = Request::get(&avatar_info_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 GitLab avatar info 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) + .context("failed to deserialize GitLab avatar info") + } } +#[async_trait] impl GitHostingProvider for Gitlab { fn name(&self) -> String { self.name.clone() @@ -58,7 +144,7 @@ impl GitHostingProvider for Gitlab { } fn supports_avatars(&self) -> bool { - false + true } fn format_line_number(&self, line: u32) -> String { @@ -122,6 +208,39 @@ impl GitHostingProvider for Gitlab { ); 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_gitlab_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("width=128") + } else { + None + }; + url.set_query(size_query); + } + Ok(url) + }) + .transpose()?; + Ok(avatar_url) + } } #[cfg(test)] @@ -134,8 +253,8 @@ mod tests { #[test] fn test_invalid_self_hosted_remote_url() { let remote_url = "https://gitlab.com/zed-industries/zed.git"; - let github = Gitlab::from_remote_url(remote_url); - assert!(github.is_err()); + let gitlab = Gitlab::from_remote_url(remote_url); + assert!(gitlab.is_err()); } #[test]