Add avatar support for codeberg in git blame (#10991)

Stanislav Alekseev created

Release Notes:

- Added support for avatars in git blame for repositories hosted on
codeberg

<img width="1144" alt="Screenshot 2024-04-25 at 16 45 22"
src="https://github.com/zed-industries/zed/assets/43210583/d44770d8-44ea-4c6b-a1c0-ac2d1d49408f">

Questions:
- Should we move git stuff like `Commit`, `Author`, etc outside of
hosting-specific files (I don't think so, as other hostings can have
different stuff)
- Should we also add support for self hosted forgejo instances or should
it be a different PR?

Change summary

crates/git/src/hosting_provider.rs | 35 +++++++------
crates/util/src/codeberg.rs        | 78 ++++++++++++++++++++++++++++++++
crates/util/src/git_author.rs      |  5 ++
crates/util/src/github.rs          | 15 +----
crates/util/src/util.rs            |  2 
5 files changed, 107 insertions(+), 28 deletions(-)

Detailed changes

crates/git/src/hosting_provider.rs 🔗

@@ -3,7 +3,7 @@ use std::{ops::Range, sync::Arc};
 
 use anyhow::Result;
 use url::Url;
-use util::{github, http::HttpClient};
+use util::{codeberg, github, http::HttpClient};
 
 use crate::Oid;
 
@@ -59,7 +59,7 @@ impl HostingProvider {
 
     pub fn supports_avatars(&self) -> bool {
         match self {
-            HostingProvider::Github => true,
+            HostingProvider::Github | HostingProvider::Codeberg => true,
             _ => false,
         }
     }
@@ -71,24 +71,27 @@ impl HostingProvider {
         commit: Oid,
         client: Arc<dyn HttpClient>,
     ) -> Result<Option<Url>> {
-        match self {
+        Ok(match self {
             HostingProvider::Github => {
                 let commit = commit.to_string();
-
-                let author =
-                    github::fetch_github_commit_author(repo_owner, repo, &commit, &client).await?;
-
-                let url = if let Some(author) = author {
-                    let mut url = Url::parse(&author.avatar_url)?;
-                    url.set_query(Some("size=128"));
-                    Some(url)
-                } else {
-                    None
-                };
-                Ok(url)
+                github::fetch_github_commit_author(repo_owner, repo, &commit, &client)
+                    .await?
+                    .map(|author| -> Result<Url, url::ParseError> {
+                        let mut url = Url::parse(&author.avatar_url)?;
+                        url.set_query(Some("size=128"));
+                        Ok(url)
+                    })
+                    .transpose()
+            }
+            HostingProvider::Codeberg => {
+                let commit = commit.to_string();
+                codeberg::fetch_codeberg_commit_author(repo_owner, repo, &commit, &client)
+                    .await?
+                    .map(|author| Url::parse(&author.avatar_url))
+                    .transpose()
             }
             _ => Ok(None),
-        }
+        }?)
     }
 }
 

crates/util/src/codeberg.rs 🔗

@@ -0,0 +1,78 @@
+use crate::{git_author::GitAuthor, http::HttpClient};
+use anyhow::{bail, Context, Result};
+use futures::AsyncReadExt;
+use isahc::{config::Configurable, AsyncBody, Request};
+use serde::Deserialize;
+use std::sync::Arc;
+
+#[derive(Debug, Deserialize)]
+struct CommitDetails {
+    commit: Commit,
+    author: Option<User>,
+}
+
+#[derive(Debug, Deserialize)]
+struct Commit {
+    author: Author,
+}
+
+#[derive(Debug, Deserialize)]
+struct Author {
+    name: String,
+    email: String,
+    date: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct User {
+    pub login: String,
+    pub id: u64,
+    pub avatar_url: String,
+}
+
+pub async fn fetch_codeberg_commit_author(
+    repo_owner: &str,
+    repo: &str,
+    commit: &str,
+    client: &Arc<dyn HttpClient>,
+) -> Result<Option<GitAuthor>> {
+    let url = format!("https://codeberg.org/api/v1/repos/{repo_owner}/{repo}/git/commits/{commit}");
+
+    let mut request = Request::get(&url)
+        .redirect_policy(isahc::config::RedirectPolicy::Follow)
+        .header("Content-Type", "application/json");
+
+    if 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))?;
+
+    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::<CommitDetails>(body_str)
+        .map(|codeberg_commit| {
+            if let Some(author) = codeberg_commit.author {
+                Some(GitAuthor {
+                    avatar_url: author.avatar_url,
+                })
+            } else {
+                None
+            }
+        })
+        .context("deserializing Codeberg commit details failed")
+}

crates/util/src/git_author.rs 🔗

@@ -0,0 +1,5 @@
+/// Represents the common denominator of most git hosting authors
+#[derive(Debug)]
+pub struct GitAuthor {
+    pub avatar_url: String,
+}

crates/util/src/github.rs 🔗

@@ -1,4 +1,4 @@
-use crate::http::HttpClient;
+use crate::{git_author::GitAuthor, http::HttpClient};
 use anyhow::{anyhow, bail, Context, Result};
 use futures::AsyncReadExt;
 use isahc::{config::Configurable, AsyncBody, Request};
@@ -49,19 +49,12 @@ struct User {
     pub avatar_url: String,
 }
 
-#[derive(Debug)]
-pub struct GitHubAuthor {
-    pub id: u64,
-    pub email: String,
-    pub avatar_url: String,
-}
-
 pub async fn fetch_github_commit_author(
     repo_owner: &str,
     repo: &str,
     commit: &str,
     client: &Arc<dyn HttpClient>,
-) -> Result<Option<GitHubAuthor>> {
+) -> Result<Option<GitAuthor>> {
     let url = format!("https://api.github.com/repos/{repo_owner}/{repo}/commits/{commit}");
 
     let mut request = Request::get(&url)
@@ -93,10 +86,8 @@ pub async fn fetch_github_commit_author(
     serde_json::from_str::<CommitDetails>(body_str)
         .map(|github_commit| {
             if let Some(author) = github_commit.author {
-                Some(GitHubAuthor {
-                    id: author.id,
+                Some(GitAuthor {
                     avatar_url: author.avatar_url,
-                    email: github_commit.commit.author.email,
                 })
             } else {
                 None

crates/util/src/util.rs 🔗

@@ -1,5 +1,7 @@
 pub mod arc_cow;
+pub mod codeberg;
 pub mod fs;
+mod git_author;
 pub mod github;
 pub mod http;
 pub mod paths;