github.rs

  1use std::str::FromStr;
  2use std::sync::{Arc, LazyLock};
  3
  4use anyhow::{bail, Context, Result};
  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;
 12
 13use git::{
 14    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
 15    PullRequest, RemoteUrl,
 16};
 17
 18use crate::get_host_from_git_remote_url;
 19
 20fn pull_request_number_regex() -> &'static Regex {
 21    static PULL_REQUEST_NUMBER_REGEX: LazyLock<Regex> =
 22        LazyLock::new(|| Regex::new(r"\(#(\d+)\)$").unwrap());
 23    &PULL_REQUEST_NUMBER_REGEX
 24}
 25
 26#[derive(Debug, Deserialize)]
 27struct CommitDetails {
 28    commit: Commit,
 29    author: Option<User>,
 30}
 31
 32#[derive(Debug, Deserialize)]
 33struct Commit {
 34    author: Author,
 35}
 36
 37#[derive(Debug, Deserialize)]
 38struct Author {
 39    email: String,
 40}
 41
 42#[derive(Debug, Deserialize)]
 43struct User {
 44    pub id: u64,
 45    pub avatar_url: String,
 46}
 47
 48pub struct Github {
 49    name: String,
 50    base_url: Url,
 51}
 52
 53impl Github {
 54    pub fn new() -> Self {
 55        Self {
 56            name: "GitHub".to_string(),
 57            base_url: Url::parse("https://github.com").unwrap(),
 58        }
 59    }
 60
 61    pub fn from_remote_url(remote_url: &str) -> Result<Self> {
 62        let host = get_host_from_git_remote_url(remote_url)?;
 63
 64        // TODO: detecting self hosted instances by checking whether "github" is in the url or not
 65        // is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more
 66        // information.
 67        if !host.contains("github") {
 68            bail!("not a GitHub URL");
 69        }
 70
 71        Ok(Self {
 72            name: "GitHub Self-Hosted".to_string(),
 73            base_url: Url::parse(&format!("https://{}", host))?,
 74        })
 75    }
 76
 77    async fn fetch_github_commit_author(
 78        &self,
 79        repo_owner: &str,
 80        repo: &str,
 81        commit: &str,
 82        client: &Arc<dyn HttpClient>,
 83    ) -> Result<Option<User>> {
 84        let Some(host) = self.base_url.host_str() else {
 85            bail!("failed to get host from github base url");
 86        };
 87        let url = format!("https://api.{host}/repos/{repo_owner}/{repo}/commits/{commit}");
 88
 89        let mut request = Request::get(&url)
 90            .header("Content-Type", "application/json")
 91            .follow_redirects(http_client::RedirectPolicy::FollowAll);
 92
 93        if let Ok(github_token) = std::env::var("GITHUB_TOKEN") {
 94            request = request.header("Authorization", format!("Bearer {}", github_token));
 95        }
 96
 97        let mut response = client
 98            .send(request.body(AsyncBody::default())?)
 99            .await
100            .with_context(|| format!("error fetching GitHub commit details at {:?}", url))?;
101
102        let mut body = Vec::new();
103        response.body_mut().read_to_end(&mut body).await?;
104
105        if response.status().is_client_error() {
106            let text = String::from_utf8_lossy(body.as_slice());
107            bail!(
108                "status error {}, response: {text:?}",
109                response.status().as_u16()
110            );
111        }
112
113        let body_str = std::str::from_utf8(&body)?;
114
115        serde_json::from_str::<CommitDetails>(body_str)
116            .map(|commit| commit.author)
117            .context("failed to deserialize GitHub commit details")
118    }
119}
120
121#[async_trait]
122impl GitHostingProvider for Github {
123    fn name(&self) -> String {
124        self.name.clone()
125    }
126
127    fn base_url(&self) -> Url {
128        self.base_url.clone()
129    }
130
131    fn supports_avatars(&self) -> bool {
132        // Avatars are not supported for self-hosted GitHub instances
133        // See tracking issue: https://github.com/zed-industries/zed/issues/11043
134        &self.name == "GitHub"
135    }
136
137    fn format_line_number(&self, line: u32) -> String {
138        format!("L{line}")
139    }
140
141    fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
142        format!("L{start_line}-L{end_line}")
143    }
144
145    fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
146        let url = RemoteUrl::from_str(url).ok()?;
147
148        let host = url.host_str()?;
149        if host != self.base_url.host_str()? {
150            return None;
151        }
152
153        let mut path_segments = url.path_segments()?;
154        let owner = path_segments.next()?;
155        let repo = path_segments.next()?.trim_end_matches(".git");
156
157        Some(ParsedGitRemote {
158            owner: owner.into(),
159            repo: repo.into(),
160        })
161    }
162
163    fn build_commit_permalink(
164        &self,
165        remote: &ParsedGitRemote,
166        params: BuildCommitPermalinkParams,
167    ) -> Url {
168        let BuildCommitPermalinkParams { sha } = params;
169        let ParsedGitRemote { owner, repo } = remote;
170
171        self.base_url()
172            .join(&format!("{owner}/{repo}/commit/{sha}"))
173            .unwrap()
174    }
175
176    fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
177        let ParsedGitRemote { owner, repo } = remote;
178        let BuildPermalinkParams {
179            sha,
180            path,
181            selection,
182        } = params;
183
184        let mut permalink = self
185            .base_url()
186            .join(&format!("{owner}/{repo}/blob/{sha}/{path}"))
187            .unwrap();
188        if path.ends_with(".md") {
189            permalink.set_query(Some("plain=1"));
190        }
191        permalink.set_fragment(
192            selection
193                .map(|selection| self.line_fragment(&selection))
194                .as_deref(),
195        );
196        permalink
197    }
198
199    fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
200        let line = message.lines().next()?;
201        let capture = pull_request_number_regex().captures(line)?;
202        let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
203
204        let mut url = self.base_url();
205        let path = format!("/{}/{}/pull/{}", remote.owner, remote.repo, number);
206        url.set_path(&path);
207
208        Some(PullRequest { number, url })
209    }
210
211    async fn commit_author_avatar_url(
212        &self,
213        repo_owner: &str,
214        repo: &str,
215        commit: SharedString,
216        http_client: Arc<dyn HttpClient>,
217    ) -> Result<Option<Url>> {
218        let commit = commit.to_string();
219        let avatar_url = self
220            .fetch_github_commit_author(repo_owner, repo, &commit, &http_client)
221            .await?
222            .map(|author| -> Result<Url, url::ParseError> {
223                let mut url = Url::parse(&author.avatar_url)?;
224                url.set_query(Some("size=128"));
225                Ok(url)
226            })
227            .transpose()?;
228        Ok(avatar_url)
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use indoc::indoc;
235    use pretty_assertions::assert_eq;
236
237    use super::*;
238
239    #[test]
240    fn test_from_remote_url_ssh() {
241        let remote_url = "git@github.my-enterprise.com:zed-industries/zed.git";
242        let github = Github::from_remote_url(remote_url).unwrap();
243
244        assert!(!github.supports_avatars());
245        assert_eq!(github.name, "GitHub Self-Hosted".to_string());
246        assert_eq!(
247            github.base_url,
248            Url::parse("https://github.my-enterprise.com").unwrap()
249        );
250    }
251
252    #[test]
253    fn test_from_remote_url_https() {
254        let remote_url = "https://github.my-enterprise.com/zed-industries/zed.git";
255        let github = Github::from_remote_url(remote_url).unwrap();
256
257        assert!(!github.supports_avatars());
258        assert_eq!(github.name, "GitHub Self-Hosted".to_string());
259        assert_eq!(
260            github.base_url,
261            Url::parse("https://github.my-enterprise.com").unwrap()
262        );
263    }
264
265    #[test]
266    fn test_parse_remote_url_given_self_hosted_ssh_url() {
267        let remote_url = "git@github.my-enterprise.com:zed-industries/zed.git";
268        let parsed_remote = Github::from_remote_url(remote_url)
269            .unwrap()
270            .parse_remote_url(remote_url)
271            .unwrap();
272
273        assert_eq!(
274            parsed_remote,
275            ParsedGitRemote {
276                owner: "zed-industries".into(),
277                repo: "zed".into(),
278            }
279        );
280    }
281
282    #[test]
283    fn test_parse_remote_url_given_self_hosted_https_url_with_subgroup() {
284        let remote_url = "https://github.my-enterprise.com/zed-industries/zed.git";
285        let parsed_remote = Github::from_remote_url(remote_url)
286            .unwrap()
287            .parse_remote_url(remote_url)
288            .unwrap();
289
290        assert_eq!(
291            parsed_remote,
292            ParsedGitRemote {
293                owner: "zed-industries".into(),
294                repo: "zed".into(),
295            }
296        );
297    }
298
299    #[test]
300    fn test_parse_remote_url_given_ssh_url() {
301        let parsed_remote = Github::new()
302            .parse_remote_url("git@github.com:zed-industries/zed.git")
303            .unwrap();
304
305        assert_eq!(
306            parsed_remote,
307            ParsedGitRemote {
308                owner: "zed-industries".into(),
309                repo: "zed".into(),
310            }
311        );
312    }
313
314    #[test]
315    fn test_parse_remote_url_given_https_url() {
316        let parsed_remote = Github::new()
317            .parse_remote_url("https://github.com/zed-industries/zed.git")
318            .unwrap();
319
320        assert_eq!(
321            parsed_remote,
322            ParsedGitRemote {
323                owner: "zed-industries".into(),
324                repo: "zed".into(),
325            }
326        );
327    }
328
329    #[test]
330    fn test_parse_remote_url_given_https_url_with_username() {
331        let parsed_remote = Github::new()
332            .parse_remote_url("https://jlannister@github.com/some-org/some-repo.git")
333            .unwrap();
334
335        assert_eq!(
336            parsed_remote,
337            ParsedGitRemote {
338                owner: "some-org".into(),
339                repo: "some-repo".into(),
340            }
341        );
342    }
343
344    #[test]
345    fn test_build_github_permalink_from_ssh_url() {
346        let remote = ParsedGitRemote {
347            owner: "zed-industries".into(),
348            repo: "zed".into(),
349        };
350        let permalink = Github::new().build_permalink(
351            remote,
352            BuildPermalinkParams {
353                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
354                path: "crates/editor/src/git/permalink.rs",
355                selection: None,
356            },
357        );
358
359        let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
360        assert_eq!(permalink.to_string(), expected_url.to_string())
361    }
362
363    #[test]
364    fn test_build_github_permalink() {
365        let permalink = Github::new().build_permalink(
366            ParsedGitRemote {
367                owner: "zed-industries".into(),
368                repo: "zed".into(),
369            },
370            BuildPermalinkParams {
371                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
372                path: "crates/zed/src/main.rs",
373                selection: None,
374            },
375        );
376
377        let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
378        assert_eq!(permalink.to_string(), expected_url.to_string())
379    }
380
381    #[test]
382    fn test_build_github_permalink_with_single_line_selection() {
383        let permalink = Github::new().build_permalink(
384            ParsedGitRemote {
385                owner: "zed-industries".into(),
386                repo: "zed".into(),
387            },
388            BuildPermalinkParams {
389                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
390                path: "crates/editor/src/git/permalink.rs",
391                selection: Some(6..6),
392            },
393        );
394
395        let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
396        assert_eq!(permalink.to_string(), expected_url.to_string())
397    }
398
399    #[test]
400    fn test_build_github_permalink_with_multi_line_selection() {
401        let permalink = Github::new().build_permalink(
402            ParsedGitRemote {
403                owner: "zed-industries".into(),
404                repo: "zed".into(),
405            },
406            BuildPermalinkParams {
407                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
408                path: "crates/editor/src/git/permalink.rs",
409                selection: Some(23..47),
410            },
411        );
412
413        let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48";
414        assert_eq!(permalink.to_string(), expected_url.to_string())
415    }
416
417    #[test]
418    fn test_github_pull_requests() {
419        let remote = ParsedGitRemote {
420            owner: "zed-industries".into(),
421            repo: "zed".into(),
422        };
423
424        let github = Github::new();
425        let message = "This does not contain a pull request";
426        assert!(github.extract_pull_request(&remote, message).is_none());
427
428        // Pull request number at end of first line
429        let message = indoc! {r#"
430            project panel: do not expand collapsed worktrees on "collapse all entries" (#10687)
431
432            Fixes #10597
433
434            Release Notes:
435
436            - Fixed "project panel: collapse all entries" expanding collapsed worktrees.
437            "#
438        };
439
440        assert_eq!(
441            github
442                .extract_pull_request(&remote, &message)
443                .unwrap()
444                .url
445                .as_str(),
446            "https://github.com/zed-industries/zed/pull/10687"
447        );
448
449        // Pull request number in middle of line, which we want to ignore
450        let message = indoc! {r#"
451            Follow-up to #10687 to fix problems
452
453            See the original PR, this is a fix.
454            "#
455        };
456        assert_eq!(github.extract_pull_request(&remote, &message), None);
457    }
458}