1use std::str::FromStr;
2use std::sync::{Arc, LazyLock};
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 regex::Regex;
10use serde::Deserialize;
11use url::Url;
12use urlencoding::encode;
13
14use git::{
15 BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
16 PullRequest, RemoteUrl,
17};
18
19use crate::get_host_from_git_remote_url;
20
21fn pull_request_number_regex() -> &'static Regex {
22 static PULL_REQUEST_NUMBER_REGEX: LazyLock<Regex> =
23 LazyLock::new(|| Regex::new(r"\(#(\d+)\)$").unwrap());
24 &PULL_REQUEST_NUMBER_REGEX
25}
26
27#[derive(Debug, Deserialize)]
28struct CommitDetails {
29 #[expect(
30 unused,
31 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"
32 )]
33 commit: Commit,
34 author: Option<User>,
35}
36
37#[derive(Debug, Deserialize)]
38struct Commit {
39 #[expect(
40 unused,
41 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"
42 )]
43 author: Author,
44}
45
46#[derive(Debug, Deserialize)]
47struct Author {
48 #[expect(
49 unused,
50 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"
51 )]
52 email: String,
53}
54
55#[derive(Debug, Deserialize)]
56struct User {
57 #[expect(
58 unused,
59 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"
60 )]
61 pub id: u64,
62 pub avatar_url: String,
63}
64
65#[derive(Debug)]
66pub struct Github {
67 name: String,
68 base_url: Url,
69}
70
71fn build_cdn_avatar_url(email: &str) -> Result<Url> {
72 let email = email.trim_start_matches('<').trim_end_matches('>');
73 Url::parse(&format!(
74 "https://avatars.githubusercontent.com/u/e?email={}&s=128",
75 encode(email)
76 ))
77 .context("failed to construct avatar URL")
78}
79
80impl Github {
81 pub fn new(name: impl Into<String>, base_url: Url) -> Self {
82 Self {
83 name: name.into(),
84 base_url,
85 }
86 }
87
88 pub fn public_instance() -> Self {
89 Self::new("GitHub", Url::parse("https://github.com").unwrap())
90 }
91
92 pub fn from_remote_url(remote_url: &str) -> Result<Self> {
93 let host = get_host_from_git_remote_url(remote_url)?;
94 if host == "github.com" {
95 bail!("the GitHub instance is not self-hosted");
96 }
97
98 // TODO: detecting self hosted instances by checking whether "github" is in the url or not
99 // is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more
100 // information.
101 if !host.contains("github") {
102 bail!("not a GitHub URL");
103 }
104
105 Ok(Self::new(
106 "GitHub Self-Hosted",
107 Url::parse(&format!("https://{}", host))?,
108 ))
109 }
110
111 async fn fetch_github_commit_author(
112 &self,
113 repo_owner: &str,
114 repo: &str,
115 commit: &str,
116 client: &Arc<dyn HttpClient>,
117 ) -> Result<Option<User>> {
118 let Some(host) = self.base_url.host_str() else {
119 bail!("failed to get host from github base url");
120 };
121 let url = format!("https://api.{host}/repos/{repo_owner}/{repo}/commits/{commit}");
122
123 let mut request = Request::get(&url)
124 .header("Content-Type", "application/json")
125 .follow_redirects(http_client::RedirectPolicy::FollowAll);
126
127 if let Ok(github_token) = std::env::var("GITHUB_TOKEN") {
128 request = request.header("Authorization", format!("Bearer {}", github_token));
129 }
130
131 let mut response = client
132 .send(request.body(AsyncBody::default())?)
133 .await
134 .with_context(|| format!("error fetching GitHub commit details at {:?}", url))?;
135
136 let mut body = Vec::new();
137 response.body_mut().read_to_end(&mut body).await?;
138
139 if response.status().is_client_error() {
140 let text = String::from_utf8_lossy(body.as_slice());
141 bail!(
142 "status error {}, response: {text:?}",
143 response.status().as_u16()
144 );
145 }
146
147 let body_str = std::str::from_utf8(&body)?;
148
149 serde_json::from_str::<CommitDetails>(body_str)
150 .map(|commit| commit.author)
151 .context("failed to deserialize GitHub commit details")
152 }
153}
154
155#[async_trait]
156impl GitHostingProvider for Github {
157 fn name(&self) -> String {
158 self.name.clone()
159 }
160
161 fn base_url(&self) -> Url {
162 self.base_url.clone()
163 }
164
165 fn supports_avatars(&self) -> bool {
166 // Avatars are not supported for self-hosted GitHub instances
167 // See tracking issue: https://github.com/zed-industries/zed/issues/11043
168 &self.name == "GitHub"
169 }
170
171 fn format_line_number(&self, line: u32) -> String {
172 format!("L{line}")
173 }
174
175 fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
176 format!("L{start_line}-L{end_line}")
177 }
178
179 fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
180 let url = RemoteUrl::from_str(url).ok()?;
181
182 let host = url.host_str()?;
183 if host != self.base_url.host_str()? {
184 return None;
185 }
186
187 let mut path_segments = url.path_segments()?;
188 let mut owner = path_segments.next()?;
189 if owner.is_empty() {
190 owner = path_segments.next()?;
191 }
192
193 let repo = path_segments.next()?.trim_end_matches(".git");
194
195 Some(ParsedGitRemote {
196 owner: owner.into(),
197 repo: repo.into(),
198 })
199 }
200
201 fn build_commit_permalink(
202 &self,
203 remote: &ParsedGitRemote,
204 params: BuildCommitPermalinkParams,
205 ) -> Url {
206 let BuildCommitPermalinkParams { sha } = params;
207 let ParsedGitRemote { owner, repo } = remote;
208
209 self.base_url()
210 .join(&format!("{owner}/{repo}/commit/{sha}"))
211 .unwrap()
212 }
213
214 fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
215 let ParsedGitRemote { owner, repo } = remote;
216 let BuildPermalinkParams {
217 sha,
218 path,
219 selection,
220 } = params;
221
222 let mut permalink = self
223 .base_url()
224 .join(&format!("{owner}/{repo}/blob/{sha}/{path}"))
225 .unwrap();
226 if path.ends_with(".md") {
227 permalink.set_query(Some("plain=1"));
228 }
229 permalink.set_fragment(
230 selection
231 .map(|selection| self.line_fragment(&selection))
232 .as_deref(),
233 );
234 permalink
235 }
236
237 fn build_create_pull_request_url(
238 &self,
239 remote: &ParsedGitRemote,
240 source_branch: &str,
241 ) -> Option<Url> {
242 let ParsedGitRemote { owner, repo } = remote;
243 let encoded_source = encode(source_branch);
244
245 self.base_url()
246 .join(&format!("{owner}/{repo}/pull/new/{encoded_source}"))
247 .ok()
248 }
249
250 fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
251 let line = message.lines().next()?;
252 let capture = pull_request_number_regex().captures(line)?;
253 let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
254
255 let mut url = self.base_url();
256 let path = format!("/{}/{}/pull/{}", remote.owner, remote.repo, number);
257 url.set_path(&path);
258
259 Some(PullRequest { number, url })
260 }
261
262 async fn commit_author_avatar_url(
263 &self,
264 repo_owner: &str,
265 repo: &str,
266 commit: SharedString,
267 author_email: Option<SharedString>,
268 http_client: Arc<dyn HttpClient>,
269 ) -> Result<Option<Url>> {
270 if let Some(email) = author_email {
271 return Ok(Some(build_cdn_avatar_url(&email)?));
272 }
273
274 let commit = commit.to_string();
275 let avatar_url = self
276 .fetch_github_commit_author(repo_owner, repo, &commit, &http_client)
277 .await?
278 .map(|author| -> Result<Url, url::ParseError> {
279 let mut url = Url::parse(&author.avatar_url)?;
280 url.set_query(Some("size=128"));
281 Ok(url)
282 })
283 .transpose()?;
284 Ok(avatar_url)
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use git::repository::repo_path;
291 use indoc::indoc;
292 use pretty_assertions::assert_eq;
293
294 use super::*;
295
296 #[test]
297 fn test_remote_url_with_root_slash() {
298 let remote_url = "git@github.com:/zed-industries/zed";
299 let parsed_remote = Github::public_instance()
300 .parse_remote_url(remote_url)
301 .unwrap();
302
303 assert_eq!(
304 parsed_remote,
305 ParsedGitRemote {
306 owner: "zed-industries".into(),
307 repo: "zed".into(),
308 }
309 );
310 }
311
312 #[test]
313 fn test_invalid_self_hosted_remote_url() {
314 let remote_url = "git@github.com:zed-industries/zed.git";
315 let github = Github::from_remote_url(remote_url);
316 assert!(github.is_err());
317 }
318
319 #[test]
320 fn test_from_remote_url_ssh() {
321 let remote_url = "git@github.my-enterprise.com:zed-industries/zed.git";
322 let github = Github::from_remote_url(remote_url).unwrap();
323
324 assert!(!github.supports_avatars());
325 assert_eq!(github.name, "GitHub Self-Hosted".to_string());
326 assert_eq!(
327 github.base_url,
328 Url::parse("https://github.my-enterprise.com").unwrap()
329 );
330 }
331
332 #[test]
333 fn test_from_remote_url_https() {
334 let remote_url = "https://github.my-enterprise.com/zed-industries/zed.git";
335 let github = Github::from_remote_url(remote_url).unwrap();
336
337 assert!(!github.supports_avatars());
338 assert_eq!(github.name, "GitHub Self-Hosted".to_string());
339 assert_eq!(
340 github.base_url,
341 Url::parse("https://github.my-enterprise.com").unwrap()
342 );
343 }
344
345 #[test]
346 fn test_parse_remote_url_given_self_hosted_ssh_url() {
347 let remote_url = "git@github.my-enterprise.com:zed-industries/zed.git";
348 let parsed_remote = Github::from_remote_url(remote_url)
349 .unwrap()
350 .parse_remote_url(remote_url)
351 .unwrap();
352
353 assert_eq!(
354 parsed_remote,
355 ParsedGitRemote {
356 owner: "zed-industries".into(),
357 repo: "zed".into(),
358 }
359 );
360 }
361
362 #[test]
363 fn test_parse_remote_url_given_self_hosted_https_url_with_subgroup() {
364 let remote_url = "https://github.my-enterprise.com/zed-industries/zed.git";
365 let parsed_remote = Github::from_remote_url(remote_url)
366 .unwrap()
367 .parse_remote_url(remote_url)
368 .unwrap();
369
370 assert_eq!(
371 parsed_remote,
372 ParsedGitRemote {
373 owner: "zed-industries".into(),
374 repo: "zed".into(),
375 }
376 );
377 }
378
379 #[test]
380 fn test_parse_remote_url_given_ssh_url() {
381 let parsed_remote = Github::public_instance()
382 .parse_remote_url("git@github.com:zed-industries/zed.git")
383 .unwrap();
384
385 assert_eq!(
386 parsed_remote,
387 ParsedGitRemote {
388 owner: "zed-industries".into(),
389 repo: "zed".into(),
390 }
391 );
392 }
393
394 #[test]
395 fn test_parse_remote_url_given_https_url() {
396 let parsed_remote = Github::public_instance()
397 .parse_remote_url("https://github.com/zed-industries/zed.git")
398 .unwrap();
399
400 assert_eq!(
401 parsed_remote,
402 ParsedGitRemote {
403 owner: "zed-industries".into(),
404 repo: "zed".into(),
405 }
406 );
407 }
408
409 #[test]
410 fn test_parse_remote_url_given_https_url_with_username() {
411 let parsed_remote = Github::public_instance()
412 .parse_remote_url("https://jlannister@github.com/some-org/some-repo.git")
413 .unwrap();
414
415 assert_eq!(
416 parsed_remote,
417 ParsedGitRemote {
418 owner: "some-org".into(),
419 repo: "some-repo".into(),
420 }
421 );
422 }
423
424 #[test]
425 fn test_build_github_permalink_from_ssh_url() {
426 let remote = ParsedGitRemote {
427 owner: "zed-industries".into(),
428 repo: "zed".into(),
429 };
430 let permalink = Github::public_instance().build_permalink(
431 remote,
432 BuildPermalinkParams::new(
433 "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
434 &repo_path("crates/editor/src/git/permalink.rs"),
435 None,
436 ),
437 );
438
439 let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
440 assert_eq!(permalink.to_string(), expected_url.to_string())
441 }
442
443 #[test]
444 fn test_build_github_permalink() {
445 let permalink = Github::public_instance().build_permalink(
446 ParsedGitRemote {
447 owner: "zed-industries".into(),
448 repo: "zed".into(),
449 },
450 BuildPermalinkParams::new(
451 "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
452 &repo_path("crates/zed/src/main.rs"),
453 None,
454 ),
455 );
456
457 let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
458 assert_eq!(permalink.to_string(), expected_url.to_string())
459 }
460
461 #[test]
462 fn test_build_github_permalink_with_single_line_selection() {
463 let permalink = Github::public_instance().build_permalink(
464 ParsedGitRemote {
465 owner: "zed-industries".into(),
466 repo: "zed".into(),
467 },
468 BuildPermalinkParams::new(
469 "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
470 &repo_path("crates/editor/src/git/permalink.rs"),
471 Some(6..6),
472 ),
473 );
474
475 let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
476 assert_eq!(permalink.to_string(), expected_url.to_string())
477 }
478
479 #[test]
480 fn test_build_github_permalink_with_multi_line_selection() {
481 let permalink = Github::public_instance().build_permalink(
482 ParsedGitRemote {
483 owner: "zed-industries".into(),
484 repo: "zed".into(),
485 },
486 BuildPermalinkParams::new(
487 "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
488 &repo_path("crates/editor/src/git/permalink.rs"),
489 Some(23..47),
490 ),
491 );
492
493 let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48";
494 assert_eq!(permalink.to_string(), expected_url.to_string())
495 }
496
497 #[test]
498 fn test_build_github_create_pr_url() {
499 let remote = ParsedGitRemote {
500 owner: "zed-industries".into(),
501 repo: "zed".into(),
502 };
503
504 let provider = Github::public_instance();
505
506 let url = provider
507 .build_create_pull_request_url(&remote, "feature/something cool")
508 .expect("url should be constructed");
509
510 assert_eq!(
511 url.as_str(),
512 "https://github.com/zed-industries/zed/pull/new/feature%2Fsomething%20cool"
513 );
514 }
515
516 #[test]
517 fn test_github_pull_requests() {
518 let remote = ParsedGitRemote {
519 owner: "zed-industries".into(),
520 repo: "zed".into(),
521 };
522
523 let github = Github::public_instance();
524 let message = "This does not contain a pull request";
525 assert!(github.extract_pull_request(&remote, message).is_none());
526
527 // Pull request number at end of first line
528 let message = indoc! {r#"
529 project panel: do not expand collapsed worktrees on "collapse all entries" (#10687)
530
531 Fixes #10597
532
533 Release Notes:
534
535 - Fixed "project panel: collapse all entries" expanding collapsed worktrees.
536 "#
537 };
538
539 assert_eq!(
540 github
541 .extract_pull_request(&remote, message)
542 .unwrap()
543 .url
544 .as_str(),
545 "https://github.com/zed-industries/zed/pull/10687"
546 );
547
548 // Pull request number in middle of line, which we want to ignore
549 let message = indoc! {r#"
550 Follow-up to #10687 to fix problems
551
552 See the original PR, this is a fix.
553 "#
554 };
555 assert_eq!(github.extract_pull_request(&remote, message), None);
556 }
557
558 /// Regression test for issue #39875
559 #[test]
560 fn test_git_permalink_url_escaping() {
561 let permalink = Github::public_instance().build_permalink(
562 ParsedGitRemote {
563 owner: "zed-industries".into(),
564 repo: "nonexistent".into(),
565 },
566 BuildPermalinkParams::new(
567 "3ef1539900037dd3601be7149b2b39ed6d0ce3db",
568 &repo_path("app/blog/[slug]/page.tsx"),
569 Some(7..7),
570 ),
571 );
572
573 let expected_url = "https://github.com/zed-industries/nonexistent/blob/3ef1539900037dd3601be7149b2b39ed6d0ce3db/app/blog/%5Bslug%5D/page.tsx#L8";
574 assert_eq!(permalink.to_string(), expected_url.to_string())
575 }
576
577 #[test]
578 fn test_build_create_pull_request_url() {
579 let remote = ParsedGitRemote {
580 owner: "zed-industries".into(),
581 repo: "zed".into(),
582 };
583
584 let github = Github::public_instance();
585 let url = github
586 .build_create_pull_request_url(&remote, "feature/new-feature")
587 .unwrap();
588
589 assert_eq!(
590 url.as_str(),
591 "https://github.com/zed-industries/zed/pull/new/feature%2Fnew-feature"
592 );
593
594 let base_url = Url::parse("https://github.zed.com").unwrap();
595 let github = Github::new("GitHub Self-Hosted", base_url);
596 let url = github
597 .build_create_pull_request_url(&remote, "feature/new-feature")
598 .expect("should be able to build pull request url");
599
600 assert_eq!(
601 url.as_str(),
602 "https://github.zed.com/zed-industries/zed/pull/new/feature%2Fnew-feature"
603 );
604 }
605
606 #[test]
607 fn test_build_cdn_avatar_url_simple_email() {
608 let url = build_cdn_avatar_url("user@example.com").unwrap();
609 assert_eq!(
610 url.as_str(),
611 "https://avatars.githubusercontent.com/u/e?email=user%40example.com&s=128"
612 );
613 }
614
615 #[test]
616 fn test_build_cdn_avatar_url_with_angle_brackets() {
617 let url = build_cdn_avatar_url("<user@example.com>").unwrap();
618 assert_eq!(
619 url.as_str(),
620 "https://avatars.githubusercontent.com/u/e?email=user%40example.com&s=128"
621 );
622 }
623
624 #[test]
625 fn test_build_cdn_avatar_url_with_special_chars() {
626 let url = build_cdn_avatar_url("user+tag@example.com").unwrap();
627 assert_eq!(
628 url.as_str(),
629 "https://avatars.githubusercontent.com/u/e?email=user%2Btag%40example.com&s=128"
630 );
631 }
632}