Files
oak-gpui/crates/git_hosting_providers/src/providers/github.rs
T
Conrad Irwin e28496d4e2 Stop leaking isahc assumption (#18408)
Users of our http_client crate knew they were interacting with isahc as
they set its extensions on the request. This change adds our own
equivalents for their APIs in preparation for changing the default http
client.

Release Notes:

- N/A
2024-09-26 14:01:05 -06:00

356 lines
11 KiB
Rust

use std::sync::{Arc, OnceLock};
use anyhow::{bail, Context, Result};
use async_trait::async_trait;
use futures::AsyncReadExt;
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
use regex::Regex;
use serde::Deserialize;
use url::Url;
use git::{
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, Oid, ParsedGitRemote,
PullRequest,
};
fn pull_request_number_regex() -> &'static Regex {
static PULL_REQUEST_NUMBER_REGEX: OnceLock<Regex> = OnceLock::new();
PULL_REQUEST_NUMBER_REGEX.get_or_init(|| Regex::new(r"\(#(\d+)\)$").unwrap())
}
#[derive(Debug, Deserialize)]
struct CommitDetails {
commit: Commit,
author: Option<User>,
}
#[derive(Debug, Deserialize)]
struct Commit {
author: Author,
}
#[derive(Debug, Deserialize)]
struct Author {
email: String,
}
#[derive(Debug, Deserialize)]
struct User {
pub id: u64,
pub avatar_url: String,
}
pub struct Github;
impl Github {
async fn fetch_github_commit_author(
&self,
repo_owner: &str,
repo: &str,
commit: &str,
client: &Arc<dyn HttpClient>,
) -> Result<Option<User>> {
let url = format!("https://api.github.com/repos/{repo_owner}/{repo}/commits/{commit}");
let mut request = Request::get(&url)
.header("Content-Type", "application/json")
.follow_redirects(http_client::RedirectPolicy::FollowAll);
if let Ok(github_token) = std::env::var("GITHUB_TOKEN") {
request = request.header("Authorization", format!("Bearer {}", github_token));
}
let mut response = client
.send(request.body(AsyncBody::default())?)
.await
.with_context(|| format!("error fetching GitHub commit details at {:?}", url))?;
let mut body = Vec::new();
response.body_mut().read_to_end(&mut body).await?;
if response.status().is_client_error() {
let text = String::from_utf8_lossy(body.as_slice());
bail!(
"status error {}, response: {text:?}",
response.status().as_u16()
);
}
let body_str = std::str::from_utf8(&body)?;
serde_json::from_str::<CommitDetails>(body_str)
.map(|commit| commit.author)
.context("failed to deserialize GitHub commit details")
}
}
#[async_trait]
impl GitHostingProvider for Github {
fn name(&self) -> String {
"GitHub".to_string()
}
fn base_url(&self) -> Url {
Url::parse("https://github.com").unwrap()
}
fn supports_avatars(&self) -> bool {
true
}
fn format_line_number(&self, line: u32) -> String {
format!("L{line}")
}
fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
format!("L{start_line}-L{end_line}")
}
fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
if url.starts_with("git@github.com:") || url.starts_with("https://github.com/") {
let repo_with_owner = url
.trim_start_matches("git@github.com:")
.trim_start_matches("https://github.com/")
.trim_end_matches(".git");
let (owner, repo) = repo_with_owner.split_once('/')?;
return Some(ParsedGitRemote { owner, repo });
}
None
}
fn build_commit_permalink(
&self,
remote: &ParsedGitRemote,
params: BuildCommitPermalinkParams,
) -> Url {
let BuildCommitPermalinkParams { sha } = params;
let ParsedGitRemote { owner, repo } = remote;
self.base_url()
.join(&format!("{owner}/{repo}/commit/{sha}"))
.unwrap()
}
fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
let ParsedGitRemote { owner, repo } = remote;
let BuildPermalinkParams {
sha,
path,
selection,
} = params;
let mut permalink = self
.base_url()
.join(&format!("{owner}/{repo}/blob/{sha}/{path}"))
.unwrap();
if path.ends_with(".md") {
permalink.set_query(Some("plain=1"));
}
permalink.set_fragment(
selection
.map(|selection| self.line_fragment(&selection))
.as_deref(),
);
permalink
}
fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
let line = message.lines().next()?;
let capture = pull_request_number_regex().captures(line)?;
let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
let mut url = self.base_url();
let path = format!("/{}/{}/pull/{}", remote.owner, remote.repo, number);
url.set_path(&path);
Some(PullRequest { number, url })
}
async fn commit_author_avatar_url(
&self,
repo_owner: &str,
repo: &str,
commit: Oid,
http_client: Arc<dyn HttpClient>,
) -> Result<Option<Url>> {
let commit = commit.to_string();
let avatar_url = self
.fetch_github_commit_author(repo_owner, repo, &commit, &http_client)
.await?
.map(|author| -> Result<Url, url::ParseError> {
let mut url = Url::parse(&author.avatar_url)?;
url.set_query(Some("size=128"));
Ok(url)
})
.transpose()?;
Ok(avatar_url)
}
}
#[cfg(test)]
mod tests {
// TODO: Replace with `indoc`.
use unindent::Unindent;
use super::*;
#[test]
fn test_build_github_permalink_from_ssh_url() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
let permalink = Github.build_permalink(
remote,
BuildPermalinkParams {
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
path: "crates/editor/src/git/permalink.rs",
selection: None,
},
);
let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_github_permalink_from_ssh_url_single_line_selection() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
let permalink = Github.build_permalink(
remote,
BuildPermalinkParams {
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
path: "crates/editor/src/git/permalink.rs",
selection: Some(6..6),
},
);
let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_github_permalink_from_ssh_url_multi_line_selection() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
let permalink = Github.build_permalink(
remote,
BuildPermalinkParams {
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
path: "crates/editor/src/git/permalink.rs",
selection: Some(23..47),
},
);
let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_github_permalink_from_https_url() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
let permalink = Github.build_permalink(
remote,
BuildPermalinkParams {
sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
path: "crates/zed/src/main.rs",
selection: None,
},
);
let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_github_permalink_from_https_url_single_line_selection() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
let permalink = Github.build_permalink(
remote,
BuildPermalinkParams {
sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
path: "crates/zed/src/main.rs",
selection: Some(6..6),
},
);
let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L7";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_github_permalink_from_https_url_multi_line_selection() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
let permalink = Github.build_permalink(
remote,
BuildPermalinkParams {
sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
path: "crates/zed/src/main.rs",
selection: Some(23..47),
},
);
let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L24-L48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_github_pull_requests() {
let remote = ParsedGitRemote {
owner: "zed-industries",
repo: "zed",
};
let message = "This does not contain a pull request";
assert!(Github.extract_pull_request(&remote, message).is_none());
// Pull request number at end of first line
let message = r#"
project panel: do not expand collapsed worktrees on "collapse all entries" (#10687)
Fixes #10597
Release Notes:
- Fixed "project panel: collapse all entries" expanding collapsed worktrees.
"#
.unindent();
assert_eq!(
Github
.extract_pull_request(&remote, &message)
.unwrap()
.url
.as_str(),
"https://github.com/zed-industries/zed/pull/10687"
);
// Pull request number in middle of line, which we want to ignore
let message = r#"
Follow-up to #10687 to fix problems
See the original PR, this is a fix.
"#
.unindent();
assert_eq!(Github.extract_pull_request(&remote, &message), None);
}
}