git-next/crates/forge-github/src/lib.rs

254 lines
9.1 KiB
Rust
Raw Normal View History

//
#[cfg(test)]
mod tests;
mod webhook;
use crate as github;
use git::forge::commit::Status;
use git_next_config as config;
use git_next_git as git;
use derive_more::Constructor;
use kxio::network::{self, Network};
use tracing::{error, info, warn};
#[derive(Clone, Debug, Constructor)]
pub struct Github {
repo_details: git::RepoDetails,
net: Network,
}
#[async_trait::async_trait]
impl git::ForgeLike for Github {
fn name(&self) -> String {
"github".to_string()
}
fn is_message_authorised(
&self,
msg: &config::WebhookMessage,
webhook_auth: &config::WebhookAuth,
) -> bool {
let Some(github_signature) = msg
.header("x-hub-signature-256")
.map(|x| x.trim_matches('"').to_string())
.and_then(|sha| sha.strip_prefix("sha256=").map(|k| k.to_string()))
else {
warn!("no signature header found");
return false;
};
let Ok(gh_sig) = hex::decode(github_signature) else {
eprintln!("can't decode github signature");
return false;
};
let payload = msg.body().as_str();
use hmac::Mac;
type HmacSha256 = hmac::Hmac<sha2::Sha256>;
let Ok(mut hmac) = HmacSha256::new_from_slice(webhook_auth.to_string().as_bytes()) else {
error!("failed to parse webhook auth token");
return false;
};
hmac::Mac::update(&mut hmac, payload.as_ref());
hmac::Mac::verify_slice(hmac, gh_sig.as_ref()).is_ok()
}
fn parse_webhook_body(
&self,
body: &config::webhook::message::Body,
) -> git::forge::webhook::Result<config::webhook::push::Push> {
serde_json::from_str::<github::webhook::Push>(body.as_str())?.try_into()
}
/// Checks the results of any (e.g. CI) status checks for the commit.
///
/// GitHub: https://docs.github.com/en/rest/commits/statuses?apiVersion=2022-11-28#list-commit-statuses-for-a-reference
async fn commit_status(&self, commit: &git::Commit) -> Status {
let repo_details = &self.repo_details;
let repo_path = &repo_details.repo_path;
let api_token = &repo_details.forge.token();
use secrecy::ExposeSecret;
let token = api_token.expose_secret();
let url = network::NetUrl::new(format!(
"https://api.github.com/repos/${repo_path}/commits/{commit}/check-runs"
));
let headers = network::NetRequestHeaders::new()
.with("X-GitHub-Api-Version", "2022-11-28")
.with("Authorization", format!("Bearer: {token}").as_str());
let request = network::NetRequest::new(
network::RequestMethod::Get,
url,
headers,
network::RequestBody::None,
network::ResponseType::Json,
None,
network::NetRequestLogging::None,
);
let result = self.net.get::<Vec<GitHubStatus>>(request).await;
match result {
Ok(response) => response.response_body().map_or_else(
|| {
warn!("No status found for commit");
Status::Pending // assume issue is transient and allow retry
},
|statuses| {
statuses
.into_iter()
.map(|status| match status.state {
GithubState::Success => Status::Pass,
GithubState::Pending => Status::Pending,
GithubState::Failure => Status::Fail,
GithubState::Error => Status::Fail,
GithubState::Blank => Status::Pending,
})
.reduce(|l, r| match (l, r) {
(Status::Pass, Status::Pass) => Status::Pass,
(_, Status::Fail) => Status::Fail,
(_, Status::Pending) => Status::Pending,
(Status::Fail, _) => Status::Fail,
(Status::Pending, _) => Status::Pending,
})
.unwrap_or_else(|| {
warn!("No status checks configured for 'next' branch",);
Status::Pass
})
},
),
Err(e) => {
warn!(?e, "Failed to get commit status");
Status::Pending // assume issue is transient and allow retry
}
}
}
// https://docs.github.com/en/rest/repos/webhooks?apiVersion=2022-11-28#list-repository-webhooks
async fn list_webhooks(
&self,
_webhook_url: &config::server::WebhookUrl,
) -> git::forge::webhook::Result<Vec<config::WebhookId>> {
todo!("list_webhooks")
}
// https://docs.github.com/en/rest/repos/webhooks?apiVersion=2022-11-28#delete-a-repository-webhook
async fn unregister_webhook(
&self,
webhook_id: &config::WebhookId,
) -> git::forge::webhook::Result<()> {
let net = &self.net;
let repo_details = &self.repo_details;
use secrecy::ExposeSecret;
let request = network::NetRequest::new(
network::RequestMethod::Delete,
network::NetUrl::new(format!(
"https://api.github.com/repos/{}/hooks/{}",
repo_details.repo_path, webhook_id
)),
network::NetRequestHeaders::default()
.with("Accept", "application/vnd.github+json")
.with(
"User-Agent",
format!("git-next/server/{}", clap::crate_version!()).as_str(),
)
.with(
"Authorization",
format!("Bearer {}", repo_details.forge.token().expose_secret()).as_str(),
)
.with("X-GitHub-Api-Version", "2022-11-28"),
network::RequestBody::None,
network::ResponseType::None,
None,
network::NetRequestLogging::None,
);
if let Err(e) = net.post_json::<GithubHook>(request).await {
warn!("Failed to register webhook");
return Err(git::forge::webhook::Error::FailedToRegister(e.to_string()));
}
Ok(())
}
// https://docs.github.com/en/rest/repos/webhooks?apiVersion=2022-11-28#create-a-repository-webhook
async fn register_webhook(
&self,
webhook_url: &config::server::WebhookUrl,
) -> git::forge::webhook::Result<config::RegisteredWebhook> {
let net = &self.net;
let repo_details = &self.repo_details;
let authorisation = config::WebhookAuth::generate();
use secrecy::ExposeSecret;
let request = network::NetRequest::new(
network::RequestMethod::Post,
network::NetUrl::new(format!(
"https://api.github.com/repos/{}/hooks",
repo_details.repo_path
)),
network::NetRequestHeaders::default()
.with("Accept", "application/vnd.github+json")
.with(
"User-Agent",
format!("git-next/server/{}", clap::crate_version!()).as_str(),
)
.with(
"Authorization",
format!("Bearer {}", repo_details.forge.token().expose_secret()).as_str(),
)
.with("X-GitHub-Api-Version", "2022-11-28"),
network::RequestBody::Json(network::json!({
"name": "web",
"active": true,
"events": ["push"],
"config": {
"url": webhook_url.as_ref(),
"content_type": "json",
"secret": authorisation.to_string(),
"insecure_ssl": "0",
}
})),
network::ResponseType::Json,
None,
network::NetRequestLogging::None,
);
let result = net.post_json::<GithubHook>(request).await;
match result {
Ok(response) => {
let Some(hook) = response.response_body() else {
return Err(git::forge::webhook::Error::NetworkResponseEmpty);
};
info!(webhook_id = %hook.id, "Webhook registered");
Ok(config::RegisteredWebhook::new(
config::WebhookId::new(format!("{}", hook.id)),
authorisation,
))
}
Err(e) => {
warn!("Failed to register webhook");
Err(git::forge::webhook::Error::FailedToRegister(e.to_string()))
}
}
}
}
#[derive(Debug, serde::Deserialize)]
struct GitHubStatus {
pub state: GithubState,
// other fields that we ignore
}
#[derive(Debug, serde::Deserialize)]
enum GithubState {
#[serde(rename = "success")]
Success,
#[serde(rename = "pending")]
Pending,
#[serde(rename = "failure")]
Failure,
#[serde(rename = "error")]
Error,
#[serde(rename = "")]
Blank,
}
#[derive(Debug, serde::Deserialize)]
struct GithubHook {
pub id: u64,
}