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        if path.ends_with(".md") {
149            permalink.set_query(Some("plain=1"));
150        }
151        permalink.set_fragment(
152            selection
153                .map(|selection| self.line_fragment(&selection))
154                .as_deref(),
155        );
156        permalink
157    }
158
159    fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
160        let line = message.lines().next()?;
161        let capture = pull_request_number_regex().captures(line)?;
162        let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
163
164        let mut url = self.base_url();
165        let path = format!("/{}/{}/pull/{}", remote.owner, remote.repo, number);
166        url.set_path(&path);
167
168        Some(PullRequest { number, url })
169    }
170
171    async fn commit_author_avatar_url(
172        &self,
173        repo_owner: &str,
174        repo: &str,
175        commit: Oid,
176        http_client: Arc<dyn HttpClient>,
177    ) -> Result<Option<Url>> {
178        let commit = commit.to_string();
179        let avatar_url = self
180            .fetch_github_commit_author(repo_owner, repo, &commit, &http_client)
181            .await?
182            .map(|author| -> Result<Url, url::ParseError> {
183                let mut url = Url::parse(&author.avatar_url)?;
184                url.set_query(Some("size=128"));
185                Ok(url)
186            })
187            .transpose()?;
188        Ok(avatar_url)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    // TODO: Replace with `indoc`.
195    use unindent::Unindent;
196
197    use super::*;
198
199    #[test]
200    fn test_build_github_permalink_from_ssh_url() {
201        let remote = ParsedGitRemote {
202            owner: "zed-industries",
203            repo: "zed",
204        };
205        let permalink = Github.build_permalink(
206            remote,
207            BuildPermalinkParams {
208                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
209                path: "crates/editor/src/git/permalink.rs",
210                selection: None,
211            },
212        );
213
214        let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
215        assert_eq!(permalink.to_string(), expected_url.to_string())
216    }
217
218    #[test]
219    fn test_build_github_permalink_from_ssh_url_single_line_selection() {
220        let remote = ParsedGitRemote {
221            owner: "zed-industries",
222            repo: "zed",
223        };
224        let permalink = Github.build_permalink(
225            remote,
226            BuildPermalinkParams {
227                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
228                path: "crates/editor/src/git/permalink.rs",
229                selection: Some(6..6),
230            },
231        );
232
233        let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
234        assert_eq!(permalink.to_string(), expected_url.to_string())
235    }
236
237    #[test]
238    fn test_build_github_permalink_from_ssh_url_multi_line_selection() {
239        let remote = ParsedGitRemote {
240            owner: "zed-industries",
241            repo: "zed",
242        };
243        let permalink = Github.build_permalink(
244            remote,
245            BuildPermalinkParams {
246                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
247                path: "crates/editor/src/git/permalink.rs",
248                selection: Some(23..47),
249            },
250        );
251
252        let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48";
253        assert_eq!(permalink.to_string(), expected_url.to_string())
254    }
255
256    #[test]
257    fn test_build_github_permalink_from_https_url() {
258        let remote = ParsedGitRemote {
259            owner: "zed-industries",
260            repo: "zed",
261        };
262        let permalink = Github.build_permalink(
263            remote,
264            BuildPermalinkParams {
265                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
266                path: "crates/zed/src/main.rs",
267                selection: None,
268            },
269        );
270
271        let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
272        assert_eq!(permalink.to_string(), expected_url.to_string())
273    }
274
275    #[test]
276    fn test_build_github_permalink_from_https_url_single_line_selection() {
277        let remote = ParsedGitRemote {
278            owner: "zed-industries",
279            repo: "zed",
280        };
281        let permalink = Github.build_permalink(
282            remote,
283            BuildPermalinkParams {
284                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
285                path: "crates/zed/src/main.rs",
286                selection: Some(6..6),
287            },
288        );
289
290        let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L7";
291        assert_eq!(permalink.to_string(), expected_url.to_string())
292    }
293
294    #[test]
295    fn test_build_github_permalink_from_https_url_multi_line_selection() {
296        let remote = ParsedGitRemote {
297            owner: "zed-industries",
298            repo: "zed",
299        };
300        let permalink = Github.build_permalink(
301            remote,
302            BuildPermalinkParams {
303                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
304                path: "crates/zed/src/main.rs",
305                selection: Some(23..47),
306            },
307        );
308
309        let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L24-L48";
310        assert_eq!(permalink.to_string(), expected_url.to_string())
311    }
312
313    #[test]
314    fn test_github_pull_requests() {
315        let remote = ParsedGitRemote {
316            owner: "zed-industries",
317            repo: "zed",
318        };
319
320        let message = "This does not contain a pull request";
321        assert!(Github.extract_pull_request(&remote, message).is_none());
322
323        // Pull request number at end of first line
324        let message = r#"
325            project panel: do not expand collapsed worktrees on "collapse all entries" (#10687)
326
327            Fixes #10597
328
329            Release Notes:
330
331            - Fixed "project panel: collapse all entries" expanding collapsed worktrees.
332            "#
333        .unindent();
334
335        assert_eq!(
336            Github
337                .extract_pull_request(&remote, &message)
338                .unwrap()
339                .url
340                .as_str(),
341            "https://github.com/zed-industries/zed/pull/10687"
342        );
343
344        // Pull request number in middle of line, which we want to ignore
345        let message = r#"
346            Follow-up to #10687 to fix problems
347
348            See the original PR, this is a fix.
349            "#
350        .unindent();
351        assert_eq!(Github.extract_pull_request(&remote, &message), None);
352    }
353}