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