use serde::{Deserialize, Serialize};
use super::github::{CreatedIssue, ExistingIssue, GithubApi, GithubFilingError};
const GITHUB_API: &str = "https://api.github.com";
const REPO: &str = "bobmatnyc/trusty-tools";
const API_VERSION: &str = "2022-11-28";
const USER_AGENT: &str = concat!("trusty-mpm/", env!("CARGO_PKG_VERSION"));
const GITHUB_CONNECT_TIMEOUT_SECS: u64 = 10;
const GITHUB_REQUEST_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Deserialize)]
struct SearchItem {
html_url: String,
number: u64,
}
#[derive(Debug, Deserialize)]
struct SearchResponse {
items: Vec<SearchItem>,
}
#[derive(Debug, Deserialize)]
struct IssueResponse {
html_url: String,
number: u64,
}
#[derive(Debug, Serialize)]
struct CreateIssueBody<'a> {
title: &'a str,
body: &'a str,
labels: &'a [String],
}
#[derive(Debug, Serialize)]
struct CreateCommentBody<'a> {
body: &'a str,
}
fn build_github_client(
token: &str,
connect_timeout: std::time::Duration,
request_timeout: std::time::Duration,
) -> Result<reqwest::blocking::Client, GithubFilingError> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::ACCEPT,
"application/vnd.github+json".parse().map_err(
|e: reqwest::header::InvalidHeaderValue| GithubFilingError::Transport(e.to_string()),
)?,
);
headers.insert(
reqwest::header::AUTHORIZATION,
format!("Bearer {token}")
.parse()
.map_err(|e: reqwest::header::InvalidHeaderValue| {
GithubFilingError::Transport(e.to_string())
})?,
);
headers.insert(
"X-GitHub-Api-Version",
API_VERSION
.parse()
.map_err(|e: reqwest::header::InvalidHeaderValue| {
GithubFilingError::Transport(e.to_string())
})?,
);
reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.default_headers(headers)
.connect_timeout(connect_timeout)
.timeout(request_timeout)
.build()
.map_err(|e| GithubFilingError::Transport(e.to_string()))
}
pub struct RealGithubClient {
token: String,
}
impl RealGithubClient {
pub fn new(token: String) -> Self {
Self { token }
}
fn http_client(&self) -> Result<reqwest::blocking::Client, GithubFilingError> {
build_github_client(
&self.token,
std::time::Duration::from_secs(GITHUB_CONNECT_TIMEOUT_SECS),
std::time::Duration::from_secs(GITHUB_REQUEST_TIMEOUT_SECS),
)
}
}
impl GithubApi for RealGithubClient {
fn search_open_issues(
&self,
fingerprint: &str,
) -> Result<Vec<ExistingIssue>, GithubFilingError> {
let client = self.http_client()?;
let query =
format!(r#"repo:{REPO} is:issue is:open "trusty-bug-fingerprint: {fingerprint}""#);
let url = format!("{GITHUB_API}/search/issues");
let resp = client
.get(&url)
.query(&[("q", &query)])
.send()
.map_err(|e| GithubFilingError::Transport(e.to_string()))?;
let status = resp.status().as_u16();
if !resp.status().is_success() {
let body = resp.text().unwrap_or_default();
return Err(GithubFilingError::ApiError { status, body });
}
let search: SearchResponse = resp
.json()
.map_err(|e| GithubFilingError::Parse(e.to_string()))?;
Ok(search
.items
.into_iter()
.map(|item| ExistingIssue {
html_url: item.html_url,
number: item.number,
})
.collect())
}
fn create_issue(
&self,
title: &str,
body: &str,
labels: &[String],
) -> Result<CreatedIssue, GithubFilingError> {
let client = self.http_client()?;
let url = format!("{GITHUB_API}/repos/{REPO}/issues");
let payload = CreateIssueBody {
title,
body,
labels,
};
let resp = client
.post(&url)
.json(&payload)
.send()
.map_err(|e| GithubFilingError::Transport(e.to_string()))?;
let status = resp.status().as_u16();
if !resp.status().is_success() {
let body = resp.text().unwrap_or_default();
return Err(GithubFilingError::ApiError { status, body });
}
let issue: IssueResponse = resp
.json()
.map_err(|e| GithubFilingError::Parse(e.to_string()))?;
Ok(CreatedIssue {
html_url: issue.html_url,
number: issue.number,
})
}
fn add_comment(&self, issue_number: u64, body: &str) -> Result<(), GithubFilingError> {
let client = self.http_client()?;
let url = format!("{GITHUB_API}/repos/{REPO}/issues/{issue_number}/comments");
let payload = CreateCommentBody { body };
let resp = client
.post(&url)
.json(&payload)
.send()
.map_err(|e| GithubFilingError::Transport(e.to_string()))?;
let status = resp.status().as_u16();
if !resp.status().is_success() {
let body = resp.text().unwrap_or_default();
return Err(GithubFilingError::ApiError { status, body });
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn http_client_bounds_a_stalled_connection() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind stalling listener");
let addr = listener.local_addr().expect("read local_addr");
let client = build_github_client(
"test-token",
std::time::Duration::from_millis(200),
std::time::Duration::from_millis(300),
)
.expect("build client");
let url = format!("http://{addr}/");
let start = std::time::Instant::now();
let result = client.get(&url).send();
let elapsed = start.elapsed();
assert!(result.is_err(), "expected the stalled request to time out");
assert!(
elapsed < std::time::Duration::from_secs(5),
"request took {elapsed:?}, expected it to be bounded by the ~300ms timeout"
);
}
#[test]
fn timeout_constants_are_finite() {
assert_eq!(GITHUB_CONNECT_TIMEOUT_SECS, 10);
assert_eq!(GITHUB_REQUEST_TIMEOUT_SECS, 30);
}
}