git: Use CDN endpoint for GitHub avatars to avoid rate limiting (#47894)

Augustus Otu created

GitHub's commit API endpoint is rate limited to 60 requests/hour for
unauthenticated users. This causes avatar loading to fail after toggling
blame a few times.

This PR uses GitHub's CDN avatar endpoint
`https://avatars.githubusercontent.com/u/e?email={email}&s=128` instead,
which doesn't count against API rate limits. The author email is already
available from local git data (blame output), so
  no API calls are needed.

- When author email is available, constructs the CDN URL directly (zero
API calls)
  - Falls back to existing API-based behavior when email is unavailable
  - Adds unit tests for URL construction

  Closes #47590

  ## Test plan
  - [x] `./script/clippy` passes
- [x] `cargo test -p git_hosting_providers` passes (89 tests including 3
new ones i added)
- [ ] Manual test: Open a file, toggle git blame, verify avatars load
without hitting rate limits

  Release Notes:

- Fixed GitHub avatar rate limiting in git blame by using CDN endpoint
instead of API calls (#47590)

Change summary

crates/git/src/hosting_provider.rs                      |  4 
crates/git_graph/src/git_graph.rs                       |  7 +
crates/git_hosting_providers/src/providers/bitbucket.rs |  1 
crates/git_hosting_providers/src/providers/chromium.rs  |  1 
crates/git_hosting_providers/src/providers/forgejo.rs   |  1 
crates/git_hosting_providers/src/providers/gitea.rs     |  1 
crates/git_hosting_providers/src/providers/gitee.rs     |  1 
crates/git_hosting_providers/src/providers/github.rs    | 41 +++++++++++
crates/git_hosting_providers/src/providers/gitlab.rs    |  1 
crates/git_ui/src/blame_ui.rs                           | 25 ++++++
crates/git_ui/src/commit_tooltip.rs                     | 23 ++++-
crates/git_ui/src/commit_view.rs                        |  6 +
crates/git_ui/src/file_history_view.rs                  | 20 ++++-
13 files changed, 118 insertions(+), 14 deletions(-)

Detailed changes

crates/git/src/hosting_provider.rs 🔗

@@ -42,10 +42,11 @@ impl GitRemote {
     pub async fn avatar_url(
         &self,
         commit: SharedString,
+        author_email: Option<SharedString>,
         client: Arc<dyn HttpClient>,
     ) -> Option<Url> {
         self.host
-            .commit_author_avatar_url(&self.owner, &self.repo, commit, client)
+            .commit_author_avatar_url(&self.owner, &self.repo, commit, author_email, client)
             .await
             .ok()
             .flatten()
@@ -139,6 +140,7 @@ pub trait GitHostingProvider {
         _repo_owner: &str,
         _repo: &str,
         _commit: SharedString,
+        _author_email: Option<SharedString>,
         _http_client: Arc<dyn HttpClient>,
     ) -> Result<Option<Url>> {
         Ok(None)

crates/git_graph/src/git_graph.rs 🔗

@@ -975,7 +975,12 @@ impl GitGraph {
         let remote = repository.update(cx, |repo, cx| self.get_remote(repo, window, cx));
 
         let avatar = {
-            let avatar = CommitAvatar::new(&full_sha, remote.as_ref());
+            let author_email_for_avatar = if author_email.is_empty() {
+                None
+            } else {
+                Some(author_email.clone())
+            };
+            let avatar = CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref());
             v_flex()
                 .w(px(64.))
                 .h(px(64.))

crates/git_hosting_providers/src/providers/bitbucket.rs 🔗

@@ -283,6 +283,7 @@ impl GitHostingProvider for Bitbucket {
         repo_owner: &str,
         repo: &str,
         commit: SharedString,
+        _author_email: Option<SharedString>,
         http_client: Arc<dyn HttpClient>,
     ) -> Result<Option<Url>> {
         let commit = commit.to_string();

crates/git_hosting_providers/src/providers/chromium.rs 🔗

@@ -169,6 +169,7 @@ impl GitHostingProvider for Chromium {
         _repo_owner: &str,
         repo: &str,
         commit: SharedString,
+        _author_email: Option<SharedString>,
         http_client: Arc<dyn HttpClient>,
     ) -> Result<Option<Url>> {
         let commit = commit.to_string();

crates/git_hosting_providers/src/providers/forgejo.rs 🔗

@@ -233,6 +233,7 @@ impl GitHostingProvider for Forgejo {
         repo_owner: &str,
         repo: &str,
         commit: SharedString,
+        _author_email: Option<SharedString>,
         http_client: Arc<dyn HttpClient>,
     ) -> Result<Option<Url>> {
         let commit = commit.to_string();

crates/git_hosting_providers/src/providers/gitea.rs 🔗

@@ -182,6 +182,7 @@ impl GitHostingProvider for Gitea {
         repo_owner: &str,
         repo: &str,
         commit: SharedString,
+        _author_email: Option<SharedString>,
         http_client: Arc<dyn HttpClient>,
     ) -> Result<Option<Url>> {
         let commit = commit.to_string();

crates/git_hosting_providers/src/providers/gitee.rs 🔗

@@ -141,6 +141,7 @@ impl GitHostingProvider for Gitee {
         repo_owner: &str,
         repo: &str,
         commit: SharedString,
+        _author_email: Option<SharedString>,
         http_client: Arc<dyn HttpClient>,
     ) -> Result<Option<Url>> {
         let commit = commit.to_string();

crates/git_hosting_providers/src/providers/github.rs 🔗

@@ -68,6 +68,15 @@ pub struct Github {
     base_url: Url,
 }
 
+fn build_cdn_avatar_url(email: &str) -> Result<Url> {
+    let email = email.trim_start_matches('<').trim_end_matches('>');
+    Url::parse(&format!(
+        "https://avatars.githubusercontent.com/u/e?email={}&s=128",
+        encode(email)
+    ))
+    .context("failed to construct avatar URL")
+}
+
 impl Github {
     pub fn new(name: impl Into<String>, base_url: Url) -> Self {
         Self {
@@ -255,8 +264,13 @@ impl GitHostingProvider for Github {
         repo_owner: &str,
         repo: &str,
         commit: SharedString,
+        author_email: Option<SharedString>,
         http_client: Arc<dyn HttpClient>,
     ) -> Result<Option<Url>> {
+        if let Some(email) = author_email {
+            return Ok(Some(build_cdn_avatar_url(&email)?));
+        }
+
         let commit = commit.to_string();
         let avatar_url = self
             .fetch_github_commit_author(repo_owner, repo, &commit, &http_client)
@@ -588,4 +602,31 @@ mod tests {
             "https://github.zed.com/zed-industries/zed/pull/new/feature%2Fnew-feature"
         );
     }
+
+    #[test]
+    fn test_build_cdn_avatar_url_simple_email() {
+        let url = build_cdn_avatar_url("user@example.com").unwrap();
+        assert_eq!(
+            url.as_str(),
+            "https://avatars.githubusercontent.com/u/e?email=user%40example.com&s=128"
+        );
+    }
+
+    #[test]
+    fn test_build_cdn_avatar_url_with_angle_brackets() {
+        let url = build_cdn_avatar_url("<user@example.com>").unwrap();
+        assert_eq!(
+            url.as_str(),
+            "https://avatars.githubusercontent.com/u/e?email=user%40example.com&s=128"
+        );
+    }
+
+    #[test]
+    fn test_build_cdn_avatar_url_with_special_chars() {
+        let url = build_cdn_avatar_url("user+tag@example.com").unwrap();
+        assert_eq!(
+            url.as_str(),
+            "https://avatars.githubusercontent.com/u/e?email=user%2Btag%40example.com&s=128"
+        );
+    }
 }

crates/git_hosting_providers/src/providers/gitlab.rs 🔗

@@ -234,6 +234,7 @@ impl GitHostingProvider for Gitlab {
         repo_owner: &str,
         repo: &str,
         commit: SharedString,
+        _author_email: Option<SharedString>,
         http_client: Arc<dyn HttpClient>,
     ) -> Result<Option<Url>> {
         let commit = commit.to_string();

crates/git_ui/src/blame_ui.rs 🔗

@@ -44,9 +44,18 @@ impl BlameRenderer for GitBlameRenderer {
         let name = util::truncate_and_trailoff(author_name, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED);
 
         let avatar = if ProjectSettings::get_global(cx).git.blame.show_avatar {
+            let author_email = blame_entry.author_mail.as_ref().map(|email| {
+                SharedString::from(
+                    email
+                        .trim_start_matches('<')
+                        .trim_end_matches('>')
+                        .to_string(),
+                )
+            });
             Some(
                 CommitAvatar::new(
                     &blame_entry.sha.to_string().into(),
+                    author_email,
                     details.as_ref().and_then(|it| it.remote.as_ref()),
                 )
                 .render(window, cx),
@@ -186,8 +195,20 @@ impl BlameRenderer for GitBlameRenderer {
             .unwrap_or("<no name>".to_string())
             .into();
         let author_email = blame.author_mail.as_deref().unwrap_or_default();
-        let avatar = CommitAvatar::new(&sha, details.as_ref().and_then(|it| it.remote.as_ref()))
-            .render(window, cx);
+        let author_email_for_avatar = blame.author_mail.as_ref().map(|email| {
+            SharedString::from(
+                email
+                    .trim_start_matches('<')
+                    .trim_end_matches('>')
+                    .to_string(),
+            )
+        });
+        let avatar = CommitAvatar::new(
+            &sha,
+            author_email_for_avatar,
+            details.as_ref().and_then(|it| it.remote.as_ref()),
+        )
+        .render(window, cx);
 
         let short_commit_id = sha
             .get(..8)

crates/git_ui/src/commit_tooltip.rs 🔗

@@ -28,14 +28,20 @@ pub struct CommitDetails {
 
 pub struct CommitAvatar<'a> {
     sha: &'a SharedString,
+    author_email: Option<SharedString>,
     remote: Option<&'a GitRemote>,
     size: Option<IconSize>,
 }
 
 impl<'a> CommitAvatar<'a> {
-    pub fn new(sha: &'a SharedString, remote: Option<&'a GitRemote>) -> Self {
+    pub fn new(
+        sha: &'a SharedString,
+        author_email: Option<SharedString>,
+        remote: Option<&'a GitRemote>,
+    ) -> Self {
         Self {
             sha,
+            author_email,
             remote,
             size: None,
         }
@@ -44,6 +50,7 @@ impl<'a> CommitAvatar<'a> {
     pub fn from_commit_details(details: &'a CommitDetails) -> Self {
         Self {
             sha: &details.sha,
+            author_email: Some(details.author_email.clone()),
             remote: details
                 .message
                 .as_ref()
@@ -75,7 +82,8 @@ impl<'a> CommitAvatar<'a> {
         let remote = self
             .remote
             .filter(|remote| remote.host_supports_avatars())?;
-        let avatar_url = CommitAvatarAsset::new(remote.clone(), self.sha.clone());
+        let avatar_url =
+            CommitAvatarAsset::new(remote.clone(), self.sha.clone(), self.author_email.clone());
 
         let url = window.use_asset::<CommitAvatarAsset>(&avatar_url, cx)??;
         Some(Avatar::new(url.to_string()))
@@ -85,6 +93,7 @@ impl<'a> CommitAvatar<'a> {
 #[derive(Clone, Debug)]
 struct CommitAvatarAsset {
     sha: SharedString,
+    author_email: Option<SharedString>,
     remote: GitRemote,
 }
 
@@ -96,8 +105,12 @@ impl Hash for CommitAvatarAsset {
 }
 
 impl CommitAvatarAsset {
-    fn new(remote: GitRemote, sha: SharedString) -> Self {
-        Self { remote, sha }
+    fn new(remote: GitRemote, sha: SharedString, author_email: Option<SharedString>) -> Self {
+        Self {
+            remote,
+            sha,
+            author_email,
+        }
     }
 }
 
@@ -114,7 +127,7 @@ impl Asset for CommitAvatarAsset {
         async move {
             source
                 .remote
-                .avatar_url(source.sha, client)
+                .avatar_url(source.sha, source.author_email, client)
                 .await
                 .map(|url| SharedString::from(url.to_string()))
         }

crates/git_ui/src/commit_view.rs 🔗

@@ -406,7 +406,11 @@ impl CommitView {
         cx: &mut App,
     ) -> AnyElement {
         let size = size.into();
-        let avatar = CommitAvatar::new(sha, self.remote.as_ref());
+        let avatar = CommitAvatar::new(
+            sha,
+            Some(self.commit.author_email.clone()),
+            self.remote.as_ref(),
+        );
 
         v_flex()
             .w(size)

crates/git_ui/src/file_history_view.rs 🔗

@@ -283,6 +283,7 @@ impl FileHistoryView {
     fn render_commit_avatar(
         &self,
         sha: &SharedString,
+        author_email: Option<SharedString>,
         window: &mut Window,
         cx: &mut App,
     ) -> impl IntoElement {
@@ -290,7 +291,7 @@ impl FileHistoryView {
         let size = rems_from_px(20.);
 
         if let Some(remote) = remote {
-            let avatar_asset = CommitAvatarAsset::new(remote.clone(), sha.clone());
+            let avatar_asset = CommitAvatarAsset::new(remote.clone(), sha.clone(), author_email);
             if let Some(Some(url)) = window.use_asset::<CommitAvatarAsset>(&avatar_asset, cx) {
                 Avatar::new(url.to_string()).size(size)
             } else {
@@ -349,7 +350,12 @@ impl FileHistoryView {
                             .flex_none()
                             .child(Chip::new(pr_number)),
                     )
-                    .child(self.render_commit_avatar(&entry.sha, window, cx))
+                    .child(self.render_commit_avatar(
+                        &entry.sha,
+                        Some(entry.author_email.clone()),
+                        window,
+                        cx,
+                    ))
                     .child(
                         h_flex()
                             .min_w_0()
@@ -395,6 +401,7 @@ impl FileHistoryView {
 #[derive(Clone, Debug)]
 struct CommitAvatarAsset {
     sha: SharedString,
+    author_email: Option<SharedString>,
     remote: GitRemote,
 }
 
@@ -406,8 +413,12 @@ impl std::hash::Hash for CommitAvatarAsset {
 }
 
 impl CommitAvatarAsset {
-    fn new(remote: GitRemote, sha: SharedString) -> Self {
-        Self { remote, sha }
+    fn new(remote: GitRemote, sha: SharedString, author_email: Option<SharedString>) -> Self {
+        Self {
+            remote,
+            sha,
+            author_email,
+        }
     }
 }
 
@@ -428,6 +439,7 @@ impl Asset for CommitAvatarAsset {
                     &source.remote.owner,
                     &source.remote.repo,
                     source.sha.clone(),
+                    source.author_email.clone(),
                     client,
                 )
                 .await