forgejo.rs

  1use std::str::FromStr;
  2use std::sync::Arc;
  3
  4use anyhow::{Context as _, Result, bail};
  5use async_trait::async_trait;
  6use futures::AsyncReadExt;
  7use gpui::SharedString;
  8use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
  9use serde::Deserialize;
 10use url::Url;
 11
 12use git::{
 13    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
 14    RemoteUrl,
 15};
 16
 17use crate::get_host_from_git_remote_url;
 18
 19#[derive(Debug, Deserialize)]
 20struct CommitDetails {
 21    #[expect(
 22        unused,
 23        reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
 24    )]
 25    commit: Commit,
 26    author: Option<User>,
 27}
 28
 29#[derive(Debug, Deserialize)]
 30struct Commit {
 31    #[expect(
 32        unused,
 33        reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
 34    )]
 35    author: Author,
 36}
 37
 38#[derive(Debug, Deserialize)]
 39struct Author {
 40    #[expect(
 41        unused,
 42        reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
 43    )]
 44    name: String,
 45    #[expect(
 46        unused,
 47        reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
 48    )]
 49    email: String,
 50    #[expect(
 51        unused,
 52        reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
 53    )]
 54    date: String,
 55}
 56
 57#[derive(Debug, Deserialize)]
 58struct User {
 59    #[expect(
 60        unused,
 61        reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
 62    )]
 63    pub login: String,
 64    #[expect(
 65        unused,
 66        reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
 67    )]
 68    pub id: u64,
 69    pub avatar_url: String,
 70}
 71
 72pub struct Forgejo {
 73    name: String,
 74    base_url: Url,
 75}
 76
 77impl Forgejo {
 78    pub fn new(name: impl Into<String>, base_url: Url) -> Self {
 79        Self {
 80            name: name.into(),
 81            base_url,
 82        }
 83    }
 84
 85    pub fn public_instance() -> Self {
 86        Self::new("Codeberg", Url::parse("https://codeberg.org").unwrap())
 87    }
 88
 89    pub fn from_remote_url(remote_url: &str) -> Result<Self> {
 90        let host = get_host_from_git_remote_url(remote_url)?;
 91        if host == "codeberg.org" {
 92            bail!("the Forgejo instance is not self-hosted");
 93        }
 94
 95        // TODO: detecting self hosted instances by checking whether "forgejo" is in the url or not
 96        // is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more
 97        // information.
 98        if !host.contains("forgejo") {
 99            bail!("not a Forgejo URL");
100        }
101
102        Ok(Self::new(
103            "Forgejo Self-Hosted",
104            Url::parse(&format!("https://{}", host))?,
105        ))
106    }
107
108    async fn fetch_forgejo_commit_author(
109        &self,
110        repo_owner: &str,
111        repo: &str,
112        commit: &str,
113        client: &Arc<dyn HttpClient>,
114    ) -> Result<Option<User>> {
115        let Some(host) = self.base_url.host_str() else {
116            bail!("failed to get host from forgejo base url");
117        };
118        let url = format!(
119            "https://{host}/api/v1/repos/{repo_owner}/{repo}/git/commits/{commit}?stat=false&verification=false&files=false"
120        );
121
122        let mut request = Request::get(&url)
123            .header("Content-Type", "application/json")
124            .follow_redirects(http_client::RedirectPolicy::FollowAll);
125
126        // TODO: not renamed yet for compatibility reasons, may require a refactor later
127        // see https://github.com/zed-industries/zed/issues/11043#issuecomment-3480446231
128        if host == "codeberg.org"
129            && let Ok(codeberg_token) = std::env::var("CODEBERG_TOKEN")
130        {
131            request = request.header("Authorization", format!("Bearer {}", codeberg_token));
132        }
133
134        let mut response = client
135            .send(request.body(AsyncBody::default())?)
136            .await
137            .with_context(|| format!("error fetching Forgejo commit details at {:?}", url))?;
138
139        let mut body = Vec::new();
140        response.body_mut().read_to_end(&mut body).await?;
141
142        if response.status().is_client_error() {
143            let text = String::from_utf8_lossy(body.as_slice());
144            bail!(
145                "status error {}, response: {text:?}",
146                response.status().as_u16()
147            );
148        }
149
150        let body_str = std::str::from_utf8(&body)?;
151
152        serde_json::from_str::<CommitDetails>(body_str)
153            .map(|commit| commit.author)
154            .context("failed to deserialize Forgejo commit details")
155    }
156}
157
158#[async_trait]
159impl GitHostingProvider for Forgejo {
160    fn name(&self) -> String {
161        self.name.clone()
162    }
163
164    fn base_url(&self) -> Url {
165        self.base_url.clone()
166    }
167
168    fn supports_avatars(&self) -> bool {
169        true
170    }
171
172    fn format_line_number(&self, line: u32) -> String {
173        format!("L{line}")
174    }
175
176    fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
177        format!("L{start_line}-L{end_line}")
178    }
179
180    fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
181        let url = RemoteUrl::from_str(url).ok()?;
182
183        let host = url.host_str()?;
184        if host != self.base_url.host_str()? {
185            return None;
186        }
187
188        let mut path_segments = url.path_segments()?;
189        let owner = path_segments.next()?;
190        let repo = path_segments.next()?.trim_end_matches(".git");
191
192        Some(ParsedGitRemote {
193            owner: owner.into(),
194            repo: repo.into(),
195        })
196    }
197
198    fn build_commit_permalink(
199        &self,
200        remote: &ParsedGitRemote,
201        params: BuildCommitPermalinkParams,
202    ) -> Url {
203        let BuildCommitPermalinkParams { sha } = params;
204        let ParsedGitRemote { owner, repo } = remote;
205
206        self.base_url()
207            .join(&format!("{owner}/{repo}/commit/{sha}"))
208            .unwrap()
209    }
210
211    fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
212        let ParsedGitRemote { owner, repo } = remote;
213        let BuildPermalinkParams {
214            sha,
215            path,
216            selection,
217        } = params;
218
219        let mut permalink = self
220            .base_url()
221            .join(&format!("{owner}/{repo}/src/commit/{sha}/{path}"))
222            .unwrap();
223        permalink.set_fragment(
224            selection
225                .map(|selection| self.line_fragment(&selection))
226                .as_deref(),
227        );
228        permalink
229    }
230
231    async fn commit_author_avatar_url(
232        &self,
233        repo_owner: &str,
234        repo: &str,
235        commit: SharedString,
236        http_client: Arc<dyn HttpClient>,
237    ) -> Result<Option<Url>> {
238        let commit = commit.to_string();
239        let avatar_url = self
240            .fetch_forgejo_commit_author(repo_owner, repo, &commit, &http_client)
241            .await?
242            .map(|author| -> Result<Url, url::ParseError> {
243                let mut url = Url::parse(&author.avatar_url)?;
244                if let Some(host) = url.host_str() {
245                    let size_query = if host.contains("gravatar") || host.contains("libravatar") {
246                        Some("s=128")
247                    } else if self
248                        .base_url
249                        .host_str()
250                        .is_some_and(|base_host| host.contains(base_host))
251                    {
252                        // This parameter exists on Codeberg but does not seem to take effect. setting it anyway
253                        Some("size=128")
254                    } else {
255                        None
256                    };
257                    url.set_query(size_query);
258                }
259                Ok(url)
260            })
261            .transpose()?;
262        Ok(avatar_url)
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use git::repository::repo_path;
269    use pretty_assertions::assert_eq;
270
271    use super::*;
272
273    #[test]
274    fn test_parse_remote_url_given_ssh_url() {
275        let parsed_remote = Forgejo::public_instance()
276            .parse_remote_url("git@codeberg.org:zed-industries/zed.git")
277            .unwrap();
278
279        assert_eq!(
280            parsed_remote,
281            ParsedGitRemote {
282                owner: "zed-industries".into(),
283                repo: "zed".into(),
284            }
285        );
286    }
287
288    #[test]
289    fn test_parse_remote_url_given_https_url() {
290        let parsed_remote = Forgejo::public_instance()
291            .parse_remote_url("https://codeberg.org/zed-industries/zed.git")
292            .unwrap();
293
294        assert_eq!(
295            parsed_remote,
296            ParsedGitRemote {
297                owner: "zed-industries".into(),
298                repo: "zed".into(),
299            }
300        );
301    }
302
303    #[test]
304    fn test_parse_remote_url_given_self_hosted_ssh_url() {
305        let remote_url = "git@forgejo.my-enterprise.com:zed-industries/zed.git";
306
307        let parsed_remote = Forgejo::from_remote_url(remote_url)
308            .unwrap()
309            .parse_remote_url(remote_url)
310            .unwrap();
311
312        assert_eq!(
313            parsed_remote,
314            ParsedGitRemote {
315                owner: "zed-industries".into(),
316                repo: "zed".into(),
317            }
318        );
319    }
320
321    #[test]
322    fn test_parse_remote_url_given_self_hosted_https_url() {
323        let remote_url = "https://forgejo.my-enterprise.com/zed-industries/zed.git";
324        let parsed_remote = Forgejo::from_remote_url(remote_url)
325            .unwrap()
326            .parse_remote_url(remote_url)
327            .unwrap();
328
329        assert_eq!(
330            parsed_remote,
331            ParsedGitRemote {
332                owner: "zed-industries".into(),
333                repo: "zed".into(),
334            }
335        );
336    }
337
338    #[test]
339    fn test_build_codeberg_permalink() {
340        let permalink = Forgejo::public_instance().build_permalink(
341            ParsedGitRemote {
342                owner: "zed-industries".into(),
343                repo: "zed".into(),
344            },
345            BuildPermalinkParams::new(
346                "faa6f979be417239b2e070dbbf6392b909224e0b",
347                &repo_path("crates/editor/src/git/permalink.rs"),
348                None,
349            ),
350        );
351
352        let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs";
353        assert_eq!(permalink.to_string(), expected_url.to_string())
354    }
355
356    #[test]
357    fn test_build_codeberg_permalink_with_single_line_selection() {
358        let permalink = Forgejo::public_instance().build_permalink(
359            ParsedGitRemote {
360                owner: "zed-industries".into(),
361                repo: "zed".into(),
362            },
363            BuildPermalinkParams::new(
364                "faa6f979be417239b2e070dbbf6392b909224e0b",
365                &repo_path("crates/editor/src/git/permalink.rs"),
366                Some(6..6),
367            ),
368        );
369
370        let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7";
371        assert_eq!(permalink.to_string(), expected_url.to_string())
372    }
373
374    #[test]
375    fn test_build_codeberg_permalink_with_multi_line_selection() {
376        let permalink = Forgejo::public_instance().build_permalink(
377            ParsedGitRemote {
378                owner: "zed-industries".into(),
379                repo: "zed".into(),
380            },
381            BuildPermalinkParams::new(
382                "faa6f979be417239b2e070dbbf6392b909224e0b",
383                &repo_path("crates/editor/src/git/permalink.rs"),
384                Some(23..47),
385            ),
386        );
387
388        let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48";
389        assert_eq!(permalink.to_string(), expected_url.to_string())
390    }
391
392    #[test]
393    fn test_build_forgejo_self_hosted_permalink_from_ssh_url() {
394        let forgejo =
395            Forgejo::from_remote_url("git@forgejo.some-enterprise.com:zed-industries/zed.git")
396                .unwrap();
397        let permalink = forgejo.build_permalink(
398            ParsedGitRemote {
399                owner: "zed-industries".into(),
400                repo: "zed".into(),
401            },
402            BuildPermalinkParams::new(
403                "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
404                &repo_path("crates/editor/src/git/permalink.rs"),
405                None,
406            ),
407        );
408
409        let expected_url = "https://forgejo.some-enterprise.com/zed-industries/zed/src/commit/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
410        assert_eq!(permalink.to_string(), expected_url.to_string())
411    }
412
413    #[test]
414    fn test_build_forgejo_self_hosted_permalink_from_https_url() {
415        let forgejo =
416            Forgejo::from_remote_url("https://forgejo-instance.big-co.com/zed-industries/zed.git")
417                .unwrap();
418        let permalink = forgejo.build_permalink(
419            ParsedGitRemote {
420                owner: "zed-industries".into(),
421                repo: "zed".into(),
422            },
423            BuildPermalinkParams::new(
424                "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
425                &repo_path("crates/zed/src/main.rs"),
426                None,
427            ),
428        );
429
430        let expected_url = "https://forgejo-instance.big-co.com/zed-industries/zed/src/commit/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
431        assert_eq!(permalink.to_string(), expected_url.to_string())
432    }
433}