fix: add missing list webhooks implementation

This commit is contained in:
Paul Campbell 2024-05-31 07:37:40 +01:00
parent 46b6d8680c
commit 1eb4ed6d23
10 changed files with 277 additions and 172 deletions

View file

@ -1,7 +1,6 @@
//
use git_next_config as config;
use git_next_git as git;
//
use std::collections::HashMap;
mod list;

View file

@ -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::<Vec<GitHubStatus>>(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
}
}
}

View file

@ -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<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()
github::webhook::is_authorised(msg, webhook_auth)
}
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()
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::<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
}
}
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<Vec<config::WebhookId>> {
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::<GithubHook>(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<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()))
}
}
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,
}

View file

@ -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<sha2::Sha256>;
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()
}

View file

@ -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<Vec<config::WebhookId>> {
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::<Vec<github::GithubHook>>(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::<Vec<_>>())
},
),
}
}

View file

@ -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")]

View file

@ -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<config::webhook::push::Push> {
serde_json::from_str::<github::webhook::Push>(body.as_str())?.try_into()
}

View file

@ -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<config::RegisteredWebhook> {
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::<github::GithubHook>(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()))
}
}
}

View file

@ -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::<github::GithubHook>(request).await {
tracing::warn!("Failed to register webhook");
return Err(git::forge::webhook::Error::FailedToRegister(e.to_string()));
}
Ok(())
}

View file

@ -18,6 +18,7 @@ pub enum Error {
Serde(serde_json::error::Error),
UnknownBranch(String),
FailedToList(String),
}
impl std::error::Error for Error {}