254 lines
9.1 KiB
Rust
254 lines
9.1 KiB
Rust
|
//
|
||
|
#[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,
|
||
|
}
|