github.rs

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