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}