github.rs

  1use std::sync::{Arc, OnceLock};
  2
  3use anyhow::{bail, Context, Result};
  4use async_trait::async_trait;
  5use futures::AsyncReadExt;
  6use http_client::{AsyncBody, HttpClient, Request};
  7use regex::Regex;
  8use serde::Deserialize;
  9use url::Url;
 10
 11use git::{
 12    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, Oid, ParsedGitRemote,
 13    PullRequest,
 14};
 15
 16fn pull_request_number_regex() -> &'static Regex {
 17    static PULL_REQUEST_NUMBER_REGEX: OnceLock<Regex> = OnceLock::new();
 18
 19    PULL_REQUEST_NUMBER_REGEX.get_or_init(|| Regex::new(r"\(#(\d+)\)$").unwrap())
 20}
 21
 22#[derive(Debug, Deserialize)]
 23struct CommitDetails {
 24    commit: Commit,
 25    author: Option<User>,
 26}
 27
 28#[derive(Debug, Deserialize)]
 29struct Commit {
 30    author: Author,
 31}
 32
 33#[derive(Debug, Deserialize)]
 34struct Author {
 35    email: String,
 36}
 37
 38#[derive(Debug, Deserialize)]
 39struct User {
 40    pub id: u64,
 41    pub avatar_url: String,
 42}
 43
 44pub struct Github;
 45
 46impl Github {
 47    async fn fetch_github_commit_author(
 48        &self,
 49        repo_owner: &str,
 50        repo: &str,
 51        commit: &str,
 52        client: &Arc<dyn HttpClient>,
 53    ) -> Result<Option<User>> {
 54        let url = format!("https://api.github.com/repos/{repo_owner}/{repo}/commits/{commit}");
 55
 56        let mut request = Request::get(&url).header("Content-Type", "application/json");
 57
 58        if let Ok(github_token) = std::env::var("GITHUB_TOKEN") {
 59            request = request.header("Authorization", format!("Bearer {}", github_token));
 60        }
 61
 62        let mut response = client
 63            .send_with_redirect_policy(request.body(AsyncBody::default())?, true)
 64            .await
 65            .with_context(|| format!("error fetching GitHub 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 GitHub commit details")
 83    }
 84}
 85
 86#[async_trait]
 87impl GitHostingProvider for Github {
 88    fn name(&self) -> String {
 89        "GitHub".to_string()
 90    }
 91
 92    fn base_url(&self) -> Url {
 93        Url::parse("https://github.com").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<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
109        if url.starts_with("git@github.com:") || url.starts_with("https://github.com/") {
110            let repo_with_owner = url
111                .trim_start_matches("git@github.com:")
112                .trim_start_matches("https://github.com/")
113                .trim_end_matches(".git");
114
115            let (owner, repo) = repo_with_owner.split_once('/')?;
116
117            return Some(ParsedGitRemote { owner, repo });
118        }
119
120        None
121    }
122
123    fn build_commit_permalink(
124        &self,
125        remote: &ParsedGitRemote,
126        params: BuildCommitPermalinkParams,
127    ) -> Url {
128        let BuildCommitPermalinkParams { sha } = params;
129        let ParsedGitRemote { owner, repo } = remote;
130
131        self.base_url()
132            .join(&format!("{owner}/{repo}/commit/{sha}"))
133            .unwrap()
134    }
135
136    fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
137        let ParsedGitRemote { owner, repo } = remote;
138        let BuildPermalinkParams {
139            sha,
140            path,
141            selection,
142        } = params;
143
144        let mut permalink = self
145            .base_url()
146            .join(&format!("{owner}/{repo}/blob/{sha}/{path}"))
147            .unwrap();
148        permalink.set_fragment(
149            selection
150                .map(|selection| self.line_fragment(&selection))
151                .as_deref(),
152        );
153        permalink
154    }
155
156    fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
157        let line = message.lines().next()?;
158        let capture = pull_request_number_regex().captures(line)?;
159        let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
160
161        let mut url = self.base_url();
162        let path = format!("/{}/{}/pull/{}", remote.owner, remote.repo, number);
163        url.set_path(&path);
164
165        Some(PullRequest { number, url })
166    }
167
168    async fn commit_author_avatar_url(
169        &self,
170        repo_owner: &str,
171        repo: &str,
172        commit: Oid,
173        http_client: Arc<dyn HttpClient>,
174    ) -> Result<Option<Url>> {
175        let commit = commit.to_string();
176        let avatar_url = self
177            .fetch_github_commit_author(repo_owner, repo, &commit, &http_client)
178            .await?
179            .map(|author| -> Result<Url, url::ParseError> {
180                let mut url = Url::parse(&author.avatar_url)?;
181                url.set_query(Some("size=128"));
182                Ok(url)
183            })
184            .transpose()?;
185        Ok(avatar_url)
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    // TODO: Replace with `indoc`.
192    use unindent::Unindent;
193
194    use super::*;
195
196    #[test]
197    fn test_build_github_permalink_from_ssh_url() {
198        let remote = ParsedGitRemote {
199            owner: "zed-industries",
200            repo: "zed",
201        };
202        let permalink = Github.build_permalink(
203            remote,
204            BuildPermalinkParams {
205                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
206                path: "crates/editor/src/git/permalink.rs",
207                selection: None,
208            },
209        );
210
211        let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
212        assert_eq!(permalink.to_string(), expected_url.to_string())
213    }
214
215    #[test]
216    fn test_build_github_permalink_from_ssh_url_single_line_selection() {
217        let remote = ParsedGitRemote {
218            owner: "zed-industries",
219            repo: "zed",
220        };
221        let permalink = Github.build_permalink(
222            remote,
223            BuildPermalinkParams {
224                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
225                path: "crates/editor/src/git/permalink.rs",
226                selection: Some(6..6),
227            },
228        );
229
230        let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
231        assert_eq!(permalink.to_string(), expected_url.to_string())
232    }
233
234    #[test]
235    fn test_build_github_permalink_from_ssh_url_multi_line_selection() {
236        let remote = ParsedGitRemote {
237            owner: "zed-industries",
238            repo: "zed",
239        };
240        let permalink = Github.build_permalink(
241            remote,
242            BuildPermalinkParams {
243                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
244                path: "crates/editor/src/git/permalink.rs",
245                selection: Some(23..47),
246            },
247        );
248
249        let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48";
250        assert_eq!(permalink.to_string(), expected_url.to_string())
251    }
252
253    #[test]
254    fn test_build_github_permalink_from_https_url() {
255        let remote = ParsedGitRemote {
256            owner: "zed-industries",
257            repo: "zed",
258        };
259        let permalink = Github.build_permalink(
260            remote,
261            BuildPermalinkParams {
262                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
263                path: "crates/zed/src/main.rs",
264                selection: None,
265            },
266        );
267
268        let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
269        assert_eq!(permalink.to_string(), expected_url.to_string())
270    }
271
272    #[test]
273    fn test_build_github_permalink_from_https_url_single_line_selection() {
274        let remote = ParsedGitRemote {
275            owner: "zed-industries",
276            repo: "zed",
277        };
278        let permalink = Github.build_permalink(
279            remote,
280            BuildPermalinkParams {
281                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
282                path: "crates/zed/src/main.rs",
283                selection: Some(6..6),
284            },
285        );
286
287        let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L7";
288        assert_eq!(permalink.to_string(), expected_url.to_string())
289    }
290
291    #[test]
292    fn test_build_github_permalink_from_https_url_multi_line_selection() {
293        let remote = ParsedGitRemote {
294            owner: "zed-industries",
295            repo: "zed",
296        };
297        let permalink = Github.build_permalink(
298            remote,
299            BuildPermalinkParams {
300                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
301                path: "crates/zed/src/main.rs",
302                selection: Some(23..47),
303            },
304        );
305
306        let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L24-L48";
307        assert_eq!(permalink.to_string(), expected_url.to_string())
308    }
309
310    #[test]
311    fn test_github_pull_requests() {
312        let remote = ParsedGitRemote {
313            owner: "zed-industries",
314            repo: "zed",
315        };
316
317        let message = "This does not contain a pull request";
318        assert!(Github.extract_pull_request(&remote, message).is_none());
319
320        // Pull request number at end of first line
321        let message = r#"
322            project panel: do not expand collapsed worktrees on "collapse all entries" (#10687)
323
324            Fixes #10597
325
326            Release Notes:
327
328            - Fixed "project panel: collapse all entries" expanding collapsed worktrees.
329            "#
330        .unindent();
331
332        assert_eq!(
333            Github
334                .extract_pull_request(&remote, &message)
335                .unwrap()
336                .url
337                .as_str(),
338            "https://github.com/zed-industries/zed/pull/10687"
339        );
340
341        // Pull request number in middle of line, which we want to ignore
342        let message = r#"
343            Follow-up to #10687 to fix problems
344
345            See the original PR, this is a fix.
346            "#
347        .unindent();
348        assert_eq!(Github.extract_pull_request(&remote, &message), None);
349    }
350}