github.rs

  1use std::sync::{Arc, OnceLock};
  2
  3use anyhow::Result;
  4use async_trait::async_trait;
  5use regex::Regex;
  6use url::Url;
  7use util::github;
  8use util::http::HttpClient;
  9
 10use crate::{
 11    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, Oid, ParsedGitRemote,
 12    PullRequest,
 13};
 14
 15fn pull_request_number_regex() -> &'static Regex {
 16    static PULL_REQUEST_NUMBER_REGEX: OnceLock<Regex> = OnceLock::new();
 17
 18    PULL_REQUEST_NUMBER_REGEX.get_or_init(|| Regex::new(r"\(#(\d+)\)$").unwrap())
 19}
 20
 21pub struct Github;
 22
 23#[async_trait]
 24impl GitHostingProvider for Github {
 25    fn name(&self) -> String {
 26        "GitHub".to_string()
 27    }
 28
 29    fn base_url(&self) -> Url {
 30        Url::parse("https://github.com").unwrap()
 31    }
 32
 33    fn supports_avatars(&self) -> bool {
 34        true
 35    }
 36
 37    fn format_line_number(&self, line: u32) -> String {
 38        format!("L{line}")
 39    }
 40
 41    fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
 42        format!("L{start_line}-L{end_line}")
 43    }
 44
 45    fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
 46        if url.starts_with("git@github.com:") || url.starts_with("https://github.com/") {
 47            let repo_with_owner = url
 48                .trim_start_matches("git@github.com:")
 49                .trim_start_matches("https://github.com/")
 50                .trim_end_matches(".git");
 51
 52            let (owner, repo) = repo_with_owner.split_once('/')?;
 53
 54            return Some(ParsedGitRemote { owner, repo });
 55        }
 56
 57        None
 58    }
 59
 60    fn build_commit_permalink(
 61        &self,
 62        remote: &ParsedGitRemote,
 63        params: BuildCommitPermalinkParams,
 64    ) -> Url {
 65        let BuildCommitPermalinkParams { sha } = params;
 66        let ParsedGitRemote { owner, repo } = remote;
 67
 68        self.base_url()
 69            .join(&format!("{owner}/{repo}/commit/{sha}"))
 70            .unwrap()
 71    }
 72
 73    fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
 74        let ParsedGitRemote { owner, repo } = remote;
 75        let BuildPermalinkParams {
 76            sha,
 77            path,
 78            selection,
 79        } = params;
 80
 81        let mut permalink = self
 82            .base_url()
 83            .join(&format!("{owner}/{repo}/blob/{sha}/{path}"))
 84            .unwrap();
 85        permalink.set_fragment(
 86            selection
 87                .map(|selection| self.line_fragment(&selection))
 88                .as_deref(),
 89        );
 90        permalink
 91    }
 92
 93    fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
 94        let line = message.lines().next()?;
 95        let capture = pull_request_number_regex().captures(line)?;
 96        let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
 97
 98        let mut url = self.base_url();
 99        let path = format!("/{}/{}/pull/{}", remote.owner, remote.repo, number);
100        url.set_path(&path);
101
102        Some(PullRequest { number, url })
103    }
104
105    async fn commit_author_avatar_url(
106        &self,
107        repo_owner: &str,
108        repo: &str,
109        commit: Oid,
110        http_client: Arc<dyn HttpClient>,
111    ) -> Result<Option<Url>> {
112        let commit = commit.to_string();
113        let avatar_url =
114            github::fetch_github_commit_author(repo_owner, repo, &commit, &http_client)
115                .await?
116                .map(|author| -> Result<Url, url::ParseError> {
117                    let mut url = Url::parse(&author.avatar_url)?;
118                    url.set_query(Some("size=128"));
119                    Ok(url)
120                })
121                .transpose()?;
122        Ok(avatar_url)
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    // TODO: Replace with `indoc`.
129    use unindent::Unindent;
130
131    use super::*;
132
133    #[test]
134    fn test_build_github_permalink_from_ssh_url() {
135        let remote = ParsedGitRemote {
136            owner: "zed-industries",
137            repo: "zed",
138        };
139        let permalink = Github.build_permalink(
140            remote,
141            BuildPermalinkParams {
142                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
143                path: "crates/editor/src/git/permalink.rs",
144                selection: None,
145            },
146        );
147
148        let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
149        assert_eq!(permalink.to_string(), expected_url.to_string())
150    }
151
152    #[test]
153    fn test_build_github_permalink_from_ssh_url_single_line_selection() {
154        let remote = ParsedGitRemote {
155            owner: "zed-industries",
156            repo: "zed",
157        };
158        let permalink = Github.build_permalink(
159            remote,
160            BuildPermalinkParams {
161                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
162                path: "crates/editor/src/git/permalink.rs",
163                selection: Some(6..6),
164            },
165        );
166
167        let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
168        assert_eq!(permalink.to_string(), expected_url.to_string())
169    }
170
171    #[test]
172    fn test_build_github_permalink_from_ssh_url_multi_line_selection() {
173        let remote = ParsedGitRemote {
174            owner: "zed-industries",
175            repo: "zed",
176        };
177        let permalink = Github.build_permalink(
178            remote,
179            BuildPermalinkParams {
180                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
181                path: "crates/editor/src/git/permalink.rs",
182                selection: Some(23..47),
183            },
184        );
185
186        let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48";
187        assert_eq!(permalink.to_string(), expected_url.to_string())
188    }
189
190    #[test]
191    fn test_build_github_permalink_from_https_url() {
192        let remote = ParsedGitRemote {
193            owner: "zed-industries",
194            repo: "zed",
195        };
196        let permalink = Github.build_permalink(
197            remote,
198            BuildPermalinkParams {
199                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
200                path: "crates/zed/src/main.rs",
201                selection: None,
202            },
203        );
204
205        let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
206        assert_eq!(permalink.to_string(), expected_url.to_string())
207    }
208
209    #[test]
210    fn test_build_github_permalink_from_https_url_single_line_selection() {
211        let remote = ParsedGitRemote {
212            owner: "zed-industries",
213            repo: "zed",
214        };
215        let permalink = Github.build_permalink(
216            remote,
217            BuildPermalinkParams {
218                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
219                path: "crates/zed/src/main.rs",
220                selection: Some(6..6),
221            },
222        );
223
224        let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L7";
225        assert_eq!(permalink.to_string(), expected_url.to_string())
226    }
227
228    #[test]
229    fn test_build_github_permalink_from_https_url_multi_line_selection() {
230        let remote = ParsedGitRemote {
231            owner: "zed-industries",
232            repo: "zed",
233        };
234        let permalink = Github.build_permalink(
235            remote,
236            BuildPermalinkParams {
237                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
238                path: "crates/zed/src/main.rs",
239                selection: Some(23..47),
240            },
241        );
242
243        let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L24-L48";
244        assert_eq!(permalink.to_string(), expected_url.to_string())
245    }
246
247    #[test]
248    fn test_github_pull_requests() {
249        let remote = ParsedGitRemote {
250            owner: "zed-industries",
251            repo: "zed",
252        };
253
254        let message = "This does not contain a pull request";
255        assert!(Github.extract_pull_request(&remote, message).is_none());
256
257        // Pull request number at end of first line
258        let message = r#"
259            project panel: do not expand collapsed worktrees on "collapse all entries" (#10687)
260
261            Fixes #10597
262
263            Release Notes:
264
265            - Fixed "project panel: collapse all entries" expanding collapsed worktrees.
266            "#
267        .unindent();
268
269        assert_eq!(
270            Github
271                .extract_pull_request(&remote, &message)
272                .unwrap()
273                .url
274                .as_str(),
275            "https://github.com/zed-industries/zed/pull/10687"
276        );
277
278        // Pull request number in middle of line, which we want to ignore
279        let message = r#"
280            Follow-up to #10687 to fix problems
281
282            See the original PR, this is a fix.
283            "#
284        .unindent();
285        assert_eq!(Github.extract_pull_request(&remote, &message), None);
286    }
287}