github.rs

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