codeberg.rs

  1use std::str::FromStr;
  2use std::sync::Arc;
  3
  4use anyhow::{bail, Context, Result};
  5use async_trait::async_trait;
  6use futures::AsyncReadExt;
  7use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
  8use serde::Deserialize;
  9use url::Url;
 10
 11use git::{
 12    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, Oid, ParsedGitRemote,
 13    RemoteUrl,
 14};
 15
 16#[derive(Debug, Deserialize)]
 17struct CommitDetails {
 18    commit: Commit,
 19    author: Option<User>,
 20}
 21
 22#[derive(Debug, Deserialize)]
 23struct Commit {
 24    author: Author,
 25}
 26
 27#[derive(Debug, Deserialize)]
 28struct Author {
 29    name: String,
 30    email: String,
 31    date: String,
 32}
 33
 34#[derive(Debug, Deserialize)]
 35struct User {
 36    pub login: String,
 37    pub id: u64,
 38    pub avatar_url: String,
 39}
 40
 41pub struct Codeberg;
 42
 43impl Codeberg {
 44    async fn fetch_codeberg_commit_author(
 45        &self,
 46        repo_owner: &str,
 47        repo: &str,
 48        commit: &str,
 49        client: &Arc<dyn HttpClient>,
 50    ) -> Result<Option<User>> {
 51        let url =
 52            format!("https://codeberg.org/api/v1/repos/{repo_owner}/{repo}/git/commits/{commit}");
 53
 54        let mut request = Request::get(&url)
 55            .header("Content-Type", "application/json")
 56            .follow_redirects(http_client::RedirectPolicy::FollowAll);
 57
 58        if let Ok(codeberg_token) = std::env::var("CODEBERG_TOKEN") {
 59            request = request.header("Authorization", format!("Bearer {}", codeberg_token));
 60        }
 61
 62        let mut response = client
 63            .send(request.body(AsyncBody::default())?)
 64            .await
 65            .with_context(|| format!("error fetching Codeberg commit details at {:?}", url))?;
 66
 67        let mut body = Vec::new();
 68        response.body_mut().read_to_end(&mut body).await?;
 69
 70        if response.status().is_client_error() {
 71            let text = String::from_utf8_lossy(body.as_slice());
 72            bail!(
 73                "status error {}, response: {text:?}",
 74                response.status().as_u16()
 75            );
 76        }
 77
 78        let body_str = std::str::from_utf8(&body)?;
 79
 80        serde_json::from_str::<CommitDetails>(body_str)
 81            .map(|commit| commit.author)
 82            .context("failed to deserialize Codeberg commit details")
 83    }
 84}
 85
 86#[async_trait]
 87impl GitHostingProvider for Codeberg {
 88    fn name(&self) -> String {
 89        "Codeberg".to_string()
 90    }
 91
 92    fn base_url(&self) -> Url {
 93        Url::parse("https://codeberg.org").unwrap()
 94    }
 95
 96    fn supports_avatars(&self) -> bool {
 97        true
 98    }
 99
100    fn format_line_number(&self, line: u32) -> String {
101        format!("L{line}")
102    }
103
104    fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
105        format!("L{start_line}-L{end_line}")
106    }
107
108    fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
109        let url = RemoteUrl::from_str(url).ok()?;
110
111        let host = url.host_str()?;
112        if host != "codeberg.org" {
113            return None;
114        }
115
116        let mut path_segments = url.path_segments()?;
117        let owner = path_segments.next()?;
118        let repo = path_segments.next()?.trim_end_matches(".git");
119
120        Some(ParsedGitRemote {
121            owner: owner.into(),
122            repo: repo.into(),
123        })
124    }
125
126    fn build_commit_permalink(
127        &self,
128        remote: &ParsedGitRemote,
129        params: BuildCommitPermalinkParams,
130    ) -> Url {
131        let BuildCommitPermalinkParams { sha } = params;
132        let ParsedGitRemote { owner, repo } = remote;
133
134        self.base_url()
135            .join(&format!("{owner}/{repo}/commit/{sha}"))
136            .unwrap()
137    }
138
139    fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
140        let ParsedGitRemote { owner, repo } = remote;
141        let BuildPermalinkParams {
142            sha,
143            path,
144            selection,
145        } = params;
146
147        let mut permalink = self
148            .base_url()
149            .join(&format!("{owner}/{repo}/src/commit/{sha}/{path}"))
150            .unwrap();
151        permalink.set_fragment(
152            selection
153                .map(|selection| self.line_fragment(&selection))
154                .as_deref(),
155        );
156        permalink
157    }
158
159    async fn commit_author_avatar_url(
160        &self,
161        repo_owner: &str,
162        repo: &str,
163        commit: Oid,
164        http_client: Arc<dyn HttpClient>,
165    ) -> Result<Option<Url>> {
166        let commit = commit.to_string();
167        let avatar_url = self
168            .fetch_codeberg_commit_author(repo_owner, repo, &commit, &http_client)
169            .await?
170            .map(|author| Url::parse(&author.avatar_url))
171            .transpose()?;
172        Ok(avatar_url)
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use pretty_assertions::assert_eq;
179
180    use super::*;
181
182    #[test]
183    fn test_parse_remote_url_given_ssh_url() {
184        let parsed_remote = Codeberg
185            .parse_remote_url("git@codeberg.org:zed-industries/zed.git")
186            .unwrap();
187
188        assert_eq!(
189            parsed_remote,
190            ParsedGitRemote {
191                owner: "zed-industries".into(),
192                repo: "zed".into(),
193            }
194        );
195    }
196
197    #[test]
198    fn test_parse_remote_url_given_https_url() {
199        let parsed_remote = Codeberg
200            .parse_remote_url("https://codeberg.org/zed-industries/zed.git")
201            .unwrap();
202
203        assert_eq!(
204            parsed_remote,
205            ParsedGitRemote {
206                owner: "zed-industries".into(),
207                repo: "zed".into(),
208            }
209        );
210    }
211
212    #[test]
213    fn test_build_codeberg_permalink() {
214        let permalink = Codeberg.build_permalink(
215            ParsedGitRemote {
216                owner: "zed-industries".into(),
217                repo: "zed".into(),
218            },
219            BuildPermalinkParams {
220                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
221                path: "crates/editor/src/git/permalink.rs",
222                selection: None,
223            },
224        );
225
226        let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs";
227        assert_eq!(permalink.to_string(), expected_url.to_string())
228    }
229
230    #[test]
231    fn test_build_codeberg_permalink_with_single_line_selection() {
232        let permalink = Codeberg.build_permalink(
233            ParsedGitRemote {
234                owner: "zed-industries".into(),
235                repo: "zed".into(),
236            },
237            BuildPermalinkParams {
238                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
239                path: "crates/editor/src/git/permalink.rs",
240                selection: Some(6..6),
241            },
242        );
243
244        let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7";
245        assert_eq!(permalink.to_string(), expected_url.to_string())
246    }
247
248    #[test]
249    fn test_build_codeberg_permalink_with_multi_line_selection() {
250        let permalink = Codeberg.build_permalink(
251            ParsedGitRemote {
252                owner: "zed-industries".into(),
253                repo: "zed".into(),
254            },
255            BuildPermalinkParams {
256                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
257                path: "crates/editor/src/git/permalink.rs",
258                selection: Some(23..47),
259            },
260        );
261
262        let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48";
263        assert_eq!(permalink.to_string(), expected_url.to_string())
264    }
265}