#![allow(clippy::doc_markdown)]
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::single_match_else)]
use crate::{Comment, DEFAULT_GITHUB_API_URL, GitHubApi, GithubError};
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use log::info;
use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) const SQUAWK_USER_AGENT: &str = "squawk/2.47.0";
#[derive(Debug, Serialize)]
struct CommentBody {
pub body: String,
}
#[derive(Debug)]
pub struct CommentArgs {
pub owner: String,
pub repo: String,
pub issue: i64,
pub body: String,
}
#[derive(Debug, Deserialize)]
pub struct GithubAccessToken {
pub expires_at: String,
pub permissions: Value,
pub repository_selection: String,
pub token: String,
}
fn create_access_token(
github_api_url: &str,
jwt: &str,
install_id: i64,
) -> Result<GithubAccessToken, GithubError> {
Ok(reqwest::blocking::Client::new()
.post(format!(
"{github_api_url}/app/installations/{install_id}/access_tokens",
))
.header(AUTHORIZATION, format!("Bearer {jwt}"))
.header(ACCEPT, "application/vnd.github.machine-man-preview+json")
.header(USER_AGENT, SQUAWK_USER_AGENT)
.send()?
.error_for_status()?
.json::<GithubAccessToken>()?)
}
pub(crate) fn create_comment(
github_api_url: &str,
comment: CommentArgs,
secret: &str,
) -> Result<(), GithubError> {
if comment.body.len() > 65_536 {
return Err(GithubError::CommentTooLarge(format!(
"Comment body is too large ({} characters). GitHub API limit is 65,536 characters.",
comment.body.len()
)));
}
let comment_body = CommentBody { body: comment.body };
reqwest::blocking::Client::new()
.post(format!(
"{github_api_url}/repos/{owner}/{repo}/issues/{issue_number}/comments",
owner = comment.owner,
repo = comment.repo,
issue_number = comment.issue
))
.header(AUTHORIZATION, format!("Bearer {secret}"))
.header(USER_AGENT, SQUAWK_USER_AGENT)
.json(&comment_body)
.send()?
.error_for_status()?;
Ok(())
}
#[derive(Debug, Deserialize)]
pub struct GitHubAppInfo {
pub id: i64,
pub slug: String,
}
pub fn get_app_info(github_api_url: &str, jwt: &str) -> Result<GitHubAppInfo, GithubError> {
Ok(reqwest::blocking::Client::new()
.get(format!("{github_api_url}/app"))
.header(AUTHORIZATION, format!("Bearer {jwt}"))
.header(USER_AGENT, SQUAWK_USER_AGENT)
.send()?
.error_for_status()?
.json::<GitHubAppInfo>()?)
}
impl std::fmt::Display for GithubError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match *self {
Self::JsonWebTokenCreation(ref err) => {
write!(f, "Could not create JWT: {err}")
}
Self::HttpError(ref err) => {
write!(f, "Problem calling GitHub API: {err}")
}
Self::CommentTooLarge(ref msg) => {
write!(f, "Comment size error: {msg}")
}
}
}
}
impl std::convert::From<reqwest::Error> for GithubError {
fn from(e: reqwest::Error) -> Self {
Self::HttpError(e)
}
}
impl std::convert::From<jsonwebtoken::errors::Error> for GithubError {
fn from(e: jsonwebtoken::errors::Error) -> Self {
Self::JsonWebTokenCreation(e)
}
}
#[derive(Debug, Serialize, Deserialize)]
struct Claim {
iat: u64,
exp: u64,
iss: String,
}
fn generate_jwt(
private_key: &str,
app_identifier: i64,
) -> Result<String, jsonwebtoken::errors::Error> {
let now_unix_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("problem getting current time");
let claim = Claim {
iat: now_unix_time.as_secs(),
exp: (now_unix_time + Duration::from_secs(10 * 60)).as_secs(),
iss: app_identifier.to_string(),
};
jsonwebtoken::encode(
&Header::new(Algorithm::RS256),
&claim,
&EncodingKey::from_rsa_pem(private_key.as_ref())?,
)
}
pub struct PullRequest {
pub owner: String,
pub repo: String,
pub issue: i64,
}
pub(crate) fn list_comments(
github_api_url: &str,
pr: &PullRequest,
secret: &str,
) -> Result<Vec<Comment>, GithubError> {
Ok(reqwest::blocking::Client::new()
.get(format!(
"{github_api_url}/repos/{owner}/{repo}/issues/{issue_number}/comments",
owner = pr.owner,
repo = pr.repo,
issue_number = pr.issue
))
.query(&[("per_page", 100)])
.header(AUTHORIZATION, format!("Bearer {secret}",))
.header(USER_AGENT, SQUAWK_USER_AGENT)
.send()?
.error_for_status()?
.json::<Vec<Comment>>()?)
}
pub(crate) fn update_comment(
github_api_url: &str,
owner: &str,
repo: &str,
comment_id: i64,
body: String,
secret: &str,
) -> Result<(), GithubError> {
if body.len() > 65_536 {
return Err(GithubError::CommentTooLarge(format!(
"Comment body is too large ({} characters). GitHub API limit is 65,536 characters.",
body.len()
)));
}
reqwest::blocking::Client::new()
.patch(format!(
"{github_api_url}/repos/{owner}/{repo}/issues/comments/{comment_id}",
))
.header(AUTHORIZATION, format!("Bearer {secret}"))
.header(USER_AGENT, SQUAWK_USER_AGENT)
.json(&CommentBody { body })
.send()?
.error_for_status()?;
Ok(())
}
pub struct GitHub {
github_api_url: String,
slug_name: String,
installation_access_token: String,
}
impl GitHub {
pub fn new(private_key: &str, app_id: i64, installation_id: i64) -> Result<Self, GithubError> {
Self::new_with_url(DEFAULT_GITHUB_API_URL, private_key, app_id, installation_id)
}
pub fn new_with_url(
github_api_url: &str,
private_key: &str,
app_id: i64,
installation_id: i64,
) -> Result<Self, GithubError> {
info!("generating jwt");
let jwt = generate_jwt(private_key, app_id)?;
info!("getting app info");
let app_info = get_app_info(github_api_url, &jwt)?;
let access_token = create_access_token(github_api_url, &jwt, installation_id)?;
Ok(GitHub {
github_api_url: github_api_url.to_string(),
slug_name: format!("{}[bot]", app_info.slug),
installation_access_token: access_token.token,
})
}
}
impl GitHubApi for GitHub {
fn app_slug(&self) -> String {
self.slug_name.clone()
}
fn create_issue_comment(
&self,
owner: &str,
repo: &str,
issue_id: i64,
body: &str,
) -> Result<(), GithubError> {
create_comment(
&self.github_api_url,
CommentArgs {
owner: owner.to_string(),
repo: repo.to_string(),
issue: issue_id,
body: body.to_string(),
},
&self.installation_access_token,
)
}
fn list_issue_comments(
&self,
owner: &str,
repo: &str,
issue_id: i64,
) -> Result<Vec<Comment>, GithubError> {
list_comments(
&self.github_api_url,
&PullRequest {
owner: owner.to_string(),
repo: repo.to_string(),
issue: issue_id,
},
&self.installation_access_token,
)
}
fn update_issue_comment(
&self,
owner: &str,
repo: &str,
comment_id: i64,
body: &str,
) -> Result<(), GithubError> {
update_comment(
&self.github_api_url,
owner,
repo,
comment_id,
body.to_string(),
&self.installation_access_token,
)
}
}