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
356 lines
11 KiB
Rust
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);
|
|
}
|
|
}
|