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 _author_email: Option<SharedString>,
237 http_client: Arc<dyn HttpClient>,
238 ) -> Result<Option<Url>> {
239 let commit = commit.to_string();
240 let avatar_url = self
241 .fetch_forgejo_commit_author(repo_owner, repo, &commit, &http_client)
242 .await?
243 .map(|author| -> Result<Url, url::ParseError> {
244 let mut url = Url::parse(&author.avatar_url)?;
245 if let Some(host) = url.host_str() {
246 let size_query = if host.contains("gravatar") || host.contains("libravatar") {
247 Some("s=128")
248 } else if self
249 .base_url
250 .host_str()
251 .is_some_and(|base_host| host.contains(base_host))
252 {
253 // This parameter exists on Codeberg but does not seem to take effect. setting it anyway
254 Some("size=128")
255 } else {
256 None
257 };
258 url.set_query(size_query);
259 }
260 Ok(url)
261 })
262 .transpose()?;
263 Ok(avatar_url)
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use git::repository::repo_path;
270 use pretty_assertions::assert_eq;
271
272 use super::*;
273
274 #[test]
275 fn test_parse_remote_url_given_ssh_url() {
276 let parsed_remote = Forgejo::public_instance()
277 .parse_remote_url("git@codeberg.org:zed-industries/zed.git")
278 .unwrap();
279
280 assert_eq!(
281 parsed_remote,
282 ParsedGitRemote {
283 owner: "zed-industries".into(),
284 repo: "zed".into(),
285 }
286 );
287 }
288
289 #[test]
290 fn test_parse_remote_url_given_https_url() {
291 let parsed_remote = Forgejo::public_instance()
292 .parse_remote_url("https://codeberg.org/zed-industries/zed.git")
293 .unwrap();
294
295 assert_eq!(
296 parsed_remote,
297 ParsedGitRemote {
298 owner: "zed-industries".into(),
299 repo: "zed".into(),
300 }
301 );
302 }
303
304 #[test]
305 fn test_parse_remote_url_given_self_hosted_ssh_url() {
306 let remote_url = "git@forgejo.my-enterprise.com:zed-industries/zed.git";
307
308 let parsed_remote = Forgejo::from_remote_url(remote_url)
309 .unwrap()
310 .parse_remote_url(remote_url)
311 .unwrap();
312
313 assert_eq!(
314 parsed_remote,
315 ParsedGitRemote {
316 owner: "zed-industries".into(),
317 repo: "zed".into(),
318 }
319 );
320 }
321
322 #[test]
323 fn test_parse_remote_url_given_self_hosted_https_url() {
324 let remote_url = "https://forgejo.my-enterprise.com/zed-industries/zed.git";
325 let parsed_remote = Forgejo::from_remote_url(remote_url)
326 .unwrap()
327 .parse_remote_url(remote_url)
328 .unwrap();
329
330 assert_eq!(
331 parsed_remote,
332 ParsedGitRemote {
333 owner: "zed-industries".into(),
334 repo: "zed".into(),
335 }
336 );
337 }
338
339 #[test]
340 fn test_build_codeberg_permalink() {
341 let permalink = Forgejo::public_instance().build_permalink(
342 ParsedGitRemote {
343 owner: "zed-industries".into(),
344 repo: "zed".into(),
345 },
346 BuildPermalinkParams::new(
347 "faa6f979be417239b2e070dbbf6392b909224e0b",
348 &repo_path("crates/editor/src/git/permalink.rs"),
349 None,
350 ),
351 );
352
353 let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs";
354 assert_eq!(permalink.to_string(), expected_url.to_string())
355 }
356
357 #[test]
358 fn test_build_codeberg_permalink_with_single_line_selection() {
359 let permalink = Forgejo::public_instance().build_permalink(
360 ParsedGitRemote {
361 owner: "zed-industries".into(),
362 repo: "zed".into(),
363 },
364 BuildPermalinkParams::new(
365 "faa6f979be417239b2e070dbbf6392b909224e0b",
366 &repo_path("crates/editor/src/git/permalink.rs"),
367 Some(6..6),
368 ),
369 );
370
371 let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7";
372 assert_eq!(permalink.to_string(), expected_url.to_string())
373 }
374
375 #[test]
376 fn test_build_codeberg_permalink_with_multi_line_selection() {
377 let permalink = Forgejo::public_instance().build_permalink(
378 ParsedGitRemote {
379 owner: "zed-industries".into(),
380 repo: "zed".into(),
381 },
382 BuildPermalinkParams::new(
383 "faa6f979be417239b2e070dbbf6392b909224e0b",
384 &repo_path("crates/editor/src/git/permalink.rs"),
385 Some(23..47),
386 ),
387 );
388
389 let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48";
390 assert_eq!(permalink.to_string(), expected_url.to_string())
391 }
392
393 #[test]
394 fn test_build_forgejo_self_hosted_permalink_from_ssh_url() {
395 let forgejo =
396 Forgejo::from_remote_url("git@forgejo.some-enterprise.com:zed-industries/zed.git")
397 .unwrap();
398 let permalink = forgejo.build_permalink(
399 ParsedGitRemote {
400 owner: "zed-industries".into(),
401 repo: "zed".into(),
402 },
403 BuildPermalinkParams::new(
404 "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
405 &repo_path("crates/editor/src/git/permalink.rs"),
406 None,
407 ),
408 );
409
410 let expected_url = "https://forgejo.some-enterprise.com/zed-industries/zed/src/commit/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
411 assert_eq!(permalink.to_string(), expected_url.to_string())
412 }
413
414 #[test]
415 fn test_build_forgejo_self_hosted_permalink_from_https_url() {
416 let forgejo =
417 Forgejo::from_remote_url("https://forgejo-instance.big-co.com/zed-industries/zed.git")
418 .unwrap();
419 let permalink = forgejo.build_permalink(
420 ParsedGitRemote {
421 owner: "zed-industries".into(),
422 repo: "zed".into(),
423 },
424 BuildPermalinkParams::new(
425 "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
426 &repo_path("crates/zed/src/main.rs"),
427 None,
428 ),
429 );
430
431 let expected_url = "https://forgejo-instance.big-co.com/zed-industries/zed/src/commit/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
432 assert_eq!(permalink.to_string(), expected_url.to_string())
433 }
434}