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