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 pretty_assertions::assert_eq;
179
180 use super::*;
181
182 #[test]
183 fn test_parse_remote_url_given_ssh_url() {
184 let parsed_remote = Codeberg
185 .parse_remote_url("git@codeberg.org:zed-industries/zed.git")
186 .unwrap();
187
188 assert_eq!(
189 parsed_remote,
190 ParsedGitRemote {
191 owner: "zed-industries".into(),
192 repo: "zed".into(),
193 }
194 );
195 }
196
197 #[test]
198 fn test_parse_remote_url_given_https_url() {
199 let parsed_remote = Codeberg
200 .parse_remote_url("https://codeberg.org/zed-industries/zed.git")
201 .unwrap();
202
203 assert_eq!(
204 parsed_remote,
205 ParsedGitRemote {
206 owner: "zed-industries".into(),
207 repo: "zed".into(),
208 }
209 );
210 }
211
212 #[test]
213 fn test_build_codeberg_permalink() {
214 let permalink = Codeberg.build_permalink(
215 ParsedGitRemote {
216 owner: "zed-industries".into(),
217 repo: "zed".into(),
218 },
219 BuildPermalinkParams {
220 sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
221 path: "crates/editor/src/git/permalink.rs",
222 selection: None,
223 },
224 );
225
226 let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs";
227 assert_eq!(permalink.to_string(), expected_url.to_string())
228 }
229
230 #[test]
231 fn test_build_codeberg_permalink_with_single_line_selection() {
232 let permalink = Codeberg.build_permalink(
233 ParsedGitRemote {
234 owner: "zed-industries".into(),
235 repo: "zed".into(),
236 },
237 BuildPermalinkParams {
238 sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
239 path: "crates/editor/src/git/permalink.rs",
240 selection: Some(6..6),
241 },
242 );
243
244 let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7";
245 assert_eq!(permalink.to_string(), expected_url.to_string())
246 }
247
248 #[test]
249 fn test_build_codeberg_permalink_with_multi_line_selection() {
250 let permalink = Codeberg.build_permalink(
251 ParsedGitRemote {
252 owner: "zed-industries".into(),
253 repo: "zed".into(),
254 },
255 BuildPermalinkParams {
256 sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
257 path: "crates/editor/src/git/permalink.rs",
258 selection: Some(23..47),
259 },
260 );
261
262 let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48";
263 assert_eq!(permalink.to_string(), expected_url.to_string())
264 }
265}