// #[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; 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 { serde_json::from_str::(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::>(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> { 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::(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 { 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::(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, }