diff --git a/crates/forge-forgejo/src/webhook/mod.rs b/crates/forge-forgejo/src/webhook/mod.rs index c8457e21..701d2408 100644 --- a/crates/forge-forgejo/src/webhook/mod.rs +++ b/crates/forge-forgejo/src/webhook/mod.rs @@ -1,7 +1,6 @@ // use git_next_config as config; use git_next_git as git; -// use std::collections::HashMap; mod list; diff --git a/crates/forge-github/src/commit.rs b/crates/forge-github/src/commit.rs new file mode 100644 index 00000000..c1dcbf71 --- /dev/null +++ b/crates/forge-github/src/commit.rs @@ -0,0 +1,69 @@ +// +use crate::{self as github, GithubState}; +use git::forge::commit::Status; +use git_next_git as git; +use github::GitHubStatus; +use kxio::network; + +/// 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 +pub async fn status(github: &github::Github, commit: &git::Commit) -> git::forge::commit::Status { + let repo_details = &github.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 = github.net.get::>(request).await; + match result { + Ok(response) => response.response_body().map_or_else( + || { + tracing::warn!("No status found for commit"); + git::forge::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(|| { + tracing::warn!("No status checks configured for 'next' branch",); + Status::Pass + }) + }, + ), + Err(e) => { + tracing::warn!(?e, "Failed to get commit status"); + Status::Pending // assume issue is transient and allow retry + } + } +} diff --git a/crates/forge-github/src/lib.rs b/crates/forge-github/src/lib.rs index 092a6e3b..9a34b41f 100644 --- a/crates/forge-github/src/lib.rs +++ b/crates/forge-github/src/lib.rs @@ -2,6 +2,7 @@ #[cfg(test)] mod tests; +mod commit; mod webhook; use crate as github; @@ -10,13 +11,11 @@ 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, + net: kxio::network::Network, } #[async_trait::async_trait] impl git::ForgeLike for Github { @@ -29,142 +28,32 @@ impl git::ForgeLike for Github { 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() + github::webhook::is_authorised(msg, webhook_auth) } fn parse_webhook_body( &self, body: &config::webhook::message::Body, ) -> git::forge::webhook::Result { - serde_json::from_str::(body.as_str())?.try_into() + github::webhook::parse_body(body) } - /// 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 - } - } + github::commit::status(self, commit).await } - // 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") + github::webhook::list(self, _webhook_url).await } - // 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(()) + github::webhook::unregister(self, webhook_id).await } // https://docs.github.com/en/rest/repos/webhooks?apiVersion=2022-11-28#create-a-repository-webhook @@ -172,59 +61,7 @@ impl git::ForgeLike for Github { &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())) - } - } + github::webhook::register(self, webhook_url).await } } @@ -250,4 +87,9 @@ enum GithubState { #[derive(Debug, serde::Deserialize)] struct GithubHook { pub id: u64, + pub config: Config, +} +#[derive(Debug, serde::Deserialize)] +struct Config { + pub url: String, } diff --git a/crates/forge-github/src/webhook/authorised.rs b/crates/forge-github/src/webhook/authorised.rs new file mode 100644 index 00000000..d4154a4d --- /dev/null +++ b/crates/forge-github/src/webhook/authorised.rs @@ -0,0 +1,26 @@ +// +use git_next_config as config; + +pub fn is_authorised(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 { + tracing::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 { + tracing::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() +} diff --git a/crates/forge-github/src/webhook/list.rs b/crates/forge-github/src/webhook/list.rs new file mode 100644 index 00000000..3fc91003 --- /dev/null +++ b/crates/forge-github/src/webhook/list.rs @@ -0,0 +1,44 @@ +// +use crate as github; +use git_next_config as config; +use git_next_git as git; +use kxio::network; + +// https://docs.github.com/en/rest/repos/webhooks?apiVersion=2022-11-28#list-repository-webhooks +pub async fn list( + github: &github::Github, + webhook_url: &config::server::WebhookUrl, +) -> git::forge::webhook::Result> { + let net = &github.net; + let repo_details = &github.repo_details; + let request = network::NetRequest::new( + network::RequestMethod::Delete, + network::NetUrl::new(format!( + "https://api.github.com/repos/{}/hooks/", + repo_details.repo_path, + )), + github::webhook::headers(repo_details.forge.token()), + network::RequestBody::None, + network::ResponseType::None, + None, + network::NetRequestLogging::None, + ); + match net.post_json::>(request).await { + Err(e) => { + tracing::warn!("Failed to list webhooks"); + Err(git::forge::webhook::Error::FailedToList(e.to_string())) + } + Ok(response) => response.response_body().map_or_else( + || Ok(vec![]), + |hooks| { + Ok(hooks + .into_iter() + .filter(|hook| &hook.config.url == webhook_url.as_ref()) + .map(|hook| hook.id) + .map(|id| format!("{id}")) + .map(config::WebhookId::new) + .collect::>()) + }, + ), + } +} diff --git a/crates/forge-github/src/webhook/mod.rs b/crates/forge-github/src/webhook/mod.rs index 0e2f74e7..7b0fba26 100644 --- a/crates/forge-github/src/webhook/mod.rs +++ b/crates/forge-github/src/webhook/mod.rs @@ -2,6 +2,33 @@ use git_next_config as config; use git_next_git as git; +mod authorised; +mod list; +mod parse; +mod register; +mod unregister; + +pub use authorised::is_authorised; +pub use list::list; +pub use parse::parse_body; +pub use register::register; +pub use unregister::unregister; + +pub fn headers(token: &config::ApiToken) -> kxio::network::NetRequestHeaders { + use secrecy::ExposeSecret; + kxio::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 {}", token.expose_secret()).as_str(), + ) + .with("X-GitHub-Api-Version", "2022-11-28") +} + #[derive(Debug, serde::Deserialize)] pub struct Push { #[serde(rename = "ref")] diff --git a/crates/forge-github/src/webhook/parse.rs b/crates/forge-github/src/webhook/parse.rs new file mode 100644 index 00000000..6307db4f --- /dev/null +++ b/crates/forge-github/src/webhook/parse.rs @@ -0,0 +1,10 @@ +// +use crate as github; +use git_next_config as config; +use git_next_git as git; + +pub fn parse_body( + body: &config::webhook::message::Body, +) -> git::forge::webhook::Result { + serde_json::from_str::(body.as_str())?.try_into() +} diff --git a/crates/forge-github/src/webhook/register.rs b/crates/forge-github/src/webhook/register.rs new file mode 100644 index 00000000..461c860a --- /dev/null +++ b/crates/forge-github/src/webhook/register.rs @@ -0,0 +1,55 @@ +// +use crate as github; +use git_next_config as config; +use git_next_git as git; + +use kxio::network; + +// https://docs.github.com/en/rest/repos/webhooks?apiVersion=2022-11-28#create-a-repository-webhook +pub async fn register( + github: &github::Github, + webhook_url: &config::server::WebhookUrl, +) -> git::forge::webhook::Result { + let net = &github.net; + let repo_details = &github.repo_details; + let authorisation = config::WebhookAuth::generate(); + let request = network::NetRequest::new( + network::RequestMethod::Post, + network::NetUrl::new(format!( + "https://api.github.com/repos/{}/hooks", + repo_details.repo_path + )), + github::webhook::headers(repo_details.forge.token()), + 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); + }; + tracing::info!(webhook_id = %hook.id, "Webhook registered"); + Ok(config::RegisteredWebhook::new( + config::WebhookId::new(format!("{}", hook.id)), + authorisation, + )) + } + Err(e) => { + tracing::warn!("Failed to register webhook"); + Err(git::forge::webhook::Error::FailedToRegister(e.to_string())) + } + } +} diff --git a/crates/forge-github/src/webhook/unregister.rs b/crates/forge-github/src/webhook/unregister.rs new file mode 100644 index 00000000..e7318bcf --- /dev/null +++ b/crates/forge-github/src/webhook/unregister.rs @@ -0,0 +1,32 @@ +// +use crate as github; +use git_next_config as config; +use git_next_git as git; + +use kxio::network; + +// https://docs.github.com/en/rest/repos/webhooks?apiVersion=2022-11-28#delete-a-repository-webhook +pub async fn unregister( + github: &github::Github, + webhook_id: &config::WebhookId, +) -> git::forge::webhook::Result<()> { + let net = &github.net; + let repo_details = &github.repo_details; + let request = network::NetRequest::new( + network::RequestMethod::Delete, + network::NetUrl::new(format!( + "https://api.github.com/repos/{}/hooks/{}", + repo_details.repo_path, webhook_id + )), + github::webhook::headers(repo_details.forge.token()), + network::RequestBody::None, + network::ResponseType::None, + None, + network::NetRequestLogging::None, + ); + if let Err(e) = net.post_json::(request).await { + tracing::warn!("Failed to register webhook"); + return Err(git::forge::webhook::Error::FailedToRegister(e.to_string())); + } + Ok(()) +} diff --git a/crates/git/src/forge/webhook.rs b/crates/git/src/forge/webhook.rs index c6f3d2d0..8890a090 100644 --- a/crates/git/src/forge/webhook.rs +++ b/crates/git/src/forge/webhook.rs @@ -18,6 +18,7 @@ pub enum Error { Serde(serde_json::error::Error), UnknownBranch(String), + FailedToList(String), } impl std::error::Error for Error {}