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 super::*;
179
180    #[test]
181    fn test_build_codeberg_permalink_from_ssh_url() {
182        let remote = ParsedGitRemote {
183            owner: "rajveermalviya".into(),
184            repo: "zed".into(),
185        };
186        let permalink = Codeberg.build_permalink(
187            remote,
188            BuildPermalinkParams {
189                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
190                path: "crates/editor/src/git/permalink.rs",
191                selection: None,
192            },
193        );
194
195        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs";
196        assert_eq!(permalink.to_string(), expected_url.to_string())
197    }
198
199    #[test]
200    fn test_build_codeberg_permalink_from_ssh_url_single_line_selection() {
201        let remote = ParsedGitRemote {
202            owner: "rajveermalviya".into(),
203            repo: "zed".into(),
204        };
205        let permalink = Codeberg.build_permalink(
206            remote,
207            BuildPermalinkParams {
208                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
209                path: "crates/editor/src/git/permalink.rs",
210                selection: Some(6..6),
211            },
212        );
213
214        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7";
215        assert_eq!(permalink.to_string(), expected_url.to_string())
216    }
217
218    #[test]
219    fn test_build_codeberg_permalink_from_ssh_url_multi_line_selection() {
220        let remote = ParsedGitRemote {
221            owner: "rajveermalviya".into(),
222            repo: "zed".into(),
223        };
224        let permalink = Codeberg.build_permalink(
225            remote,
226            BuildPermalinkParams {
227                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
228                path: "crates/editor/src/git/permalink.rs",
229                selection: Some(23..47),
230            },
231        );
232
233        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48";
234        assert_eq!(permalink.to_string(), expected_url.to_string())
235    }
236
237    #[test]
238    fn test_build_codeberg_permalink_from_https_url() {
239        let remote = ParsedGitRemote {
240            owner: "rajveermalviya".into(),
241            repo: "zed".into(),
242        };
243        let permalink = Codeberg.build_permalink(
244            remote,
245            BuildPermalinkParams {
246                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
247                path: "crates/zed/src/main.rs",
248                selection: None,
249            },
250        );
251
252        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/zed/src/main.rs";
253        assert_eq!(permalink.to_string(), expected_url.to_string())
254    }
255
256    #[test]
257    fn test_build_codeberg_permalink_from_https_url_single_line_selection() {
258        let remote = ParsedGitRemote {
259            owner: "rajveermalviya".into(),
260            repo: "zed".into(),
261        };
262        let permalink = Codeberg.build_permalink(
263            remote,
264            BuildPermalinkParams {
265                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
266                path: "crates/zed/src/main.rs",
267                selection: Some(6..6),
268            },
269        );
270
271        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/zed/src/main.rs#L7";
272        assert_eq!(permalink.to_string(), expected_url.to_string())
273    }
274
275    #[test]
276    fn test_build_codeberg_permalink_from_https_url_multi_line_selection() {
277        let remote = ParsedGitRemote {
278            owner: "rajveermalviya".into(),
279            repo: "zed".into(),
280        };
281        let permalink = Codeberg.build_permalink(
282            remote,
283            BuildPermalinkParams {
284                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
285                path: "crates/zed/src/main.rs",
286                selection: Some(23..47),
287            },
288        );
289
290        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/zed/src/main.rs#L24-L48";
291        assert_eq!(permalink.to_string(), expected_url.to_string())
292    }
293}