fix: add missing list webhooks implementation
This commit is contained in:
parent
46b6d8680c
commit
1eb4ed6d23
10 changed files with 277 additions and 172 deletions
|
@ -1,7 +1,6 @@
|
|||
//
|
||||
use git_next_config as config;
|
||||
use git_next_git as git;
|
||||
//
|
||||
use std::collections::HashMap;
|
||||
|
||||
mod list;
|
||||
|
|
69
crates/forge-github/src/commit.rs
Normal file
69
crates/forge-github/src/commit.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
|
|
26
crates/forge-github/src/webhook/authorised.rs
Normal file
26
crates/forge-github/src/webhook/authorised.rs
Normal 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()
|
||||
}
|
44
crates/forge-github/src/webhook/list.rs
Normal file
44
crates/forge-github/src/webhook/list.rs
Normal 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<_>>())
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
|
@ -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")]
|
||||
|
|
10
crates/forge-github/src/webhook/parse.rs
Normal file
10
crates/forge-github/src/webhook/parse.rs
Normal 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()
|
||||
}
|
55
crates/forge-github/src/webhook/register.rs
Normal file
55
crates/forge-github/src/webhook/register.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
}
|
32
crates/forge-github/src/webhook/unregister.rs
Normal file
32
crates/forge-github/src/webhook/unregister.rs
Normal 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(())
|
||||
}
|
|
@ -18,6 +18,7 @@ pub enum Error {
|
|||
Serde(serde_json::error::Error),
|
||||
|
||||
UnknownBranch(String),
|
||||
FailedToList(String),
|
||||
}
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
|
|
Loading…
Reference in a new issue