From 4bd7f255b67afdd0c1a02422852c0c0929b11ed3 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Sat, 25 May 2024 11:25:13 +0100 Subject: [PATCH] feat: Add support for GitHub This doesn't include GitHub Enterprise Closes kemitix/git-next#86 --- Cargo.toml | 7 + README.md | 46 ++- crates/config/Cargo.toml | 6 +- crates/config/src/forge_type.rs | 3 +- crates/config/src/lib.rs | 6 + crates/config/src/registered_webhook.rs | 16 + crates/config/src/webhook/auth.rs | 50 +++ crates/config/src/webhook/id.rs | 4 + crates/config/src/webhook/message.rs | 45 ++ crates/config/src/webhook/mod.rs | 6 + crates/config/src/webhook/push.rs | 39 ++ crates/forge-forgejo/src/lib.rs | 75 +++- crates/forge-forgejo/src/webhook/list.rs | 54 +++ crates/forge-forgejo/src/webhook/mod.rs | 52 +++ crates/forge-forgejo/src/webhook/parse.rs | 10 + crates/forge-forgejo/src/webhook/register.rs | 76 ++++ .../forge-forgejo/src/webhook/unregister.rs | 30 ++ crates/forge-github/Cargo.toml | 67 +++ crates/forge-github/message.json | 1 + crates/forge-github/src/lib.rs | 253 ++++++++++++ crates/forge-github/src/tests/mod.rs | 73 ++++ crates/forge-github/src/tests/payload.json | 1 + crates/forge-github/src/webhook/mod.rs | 28 ++ crates/forge/Cargo.toml | 5 +- crates/forge/src/github.rs | 21 - crates/forge/src/lib.rs | 26 +- crates/forge/src/mock_forge.rs | 55 ++- crates/forge/src/tests/github.rs | 8 - crates/forge/src/tests/mod.rs | 7 +- crates/git/Cargo.toml | 8 +- crates/git/src/commit.rs | 19 +- crates/git/src/forge/commit.rs | 6 + crates/git/src/forge/like.rs | 41 ++ crates/git/src/forge/mod.rs | 3 + crates/git/src/forge/webhook.rs | 34 ++ crates/git/src/forge_like.rs | 9 - crates/git/src/lib.rs | 4 +- crates/git/src/repository/mod.rs | 7 +- crates/git/src/repository/open/oreal.rs | 3 - crates/git/src/repository/real.rs | 3 + crates/repo-actor/Cargo.toml | 2 +- crates/repo-actor/src/lib.rs | 56 ++- crates/repo-actor/src/status.rs | 6 +- crates/repo-actor/src/tests.rs | 9 - crates/repo-actor/src/webhook.rs | 389 ++++-------------- crates/server/Cargo.toml | 5 - crates/server/src/actors/webhook/mod.rs | 15 +- crates/server/src/actors/webhook/router.rs | 2 +- crates/server/src/actors/webhook/server.rs | 102 ++--- 49 files changed, 1254 insertions(+), 539 deletions(-) create mode 100644 crates/config/src/registered_webhook.rs create mode 100644 crates/config/src/webhook/auth.rs create mode 100644 crates/config/src/webhook/id.rs create mode 100644 crates/config/src/webhook/message.rs create mode 100644 crates/config/src/webhook/mod.rs create mode 100644 crates/config/src/webhook/push.rs create mode 100644 crates/forge-forgejo/src/webhook/list.rs create mode 100644 crates/forge-forgejo/src/webhook/mod.rs create mode 100644 crates/forge-forgejo/src/webhook/parse.rs create mode 100644 crates/forge-forgejo/src/webhook/register.rs create mode 100644 crates/forge-forgejo/src/webhook/unregister.rs create mode 100644 crates/forge-github/Cargo.toml create mode 100644 crates/forge-github/message.json create mode 100644 crates/forge-github/src/lib.rs create mode 100644 crates/forge-github/src/tests/mod.rs create mode 100644 crates/forge-github/src/tests/payload.json create mode 100644 crates/forge-github/src/webhook/mod.rs delete mode 100644 crates/forge/src/github.rs delete mode 100644 crates/forge/src/tests/github.rs create mode 100644 crates/git/src/forge/commit.rs create mode 100644 crates/git/src/forge/like.rs create mode 100644 crates/git/src/forge/mod.rs create mode 100644 crates/git/src/forge/webhook.rs delete mode 100644 crates/git/src/forge_like.rs diff --git a/Cargo.toml b/Cargo.toml index eff0522..aa7c4f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/git", "crates/forge", "crates/forge-forgejo", + "crates/forge-github", "crates/repo-actor", ] @@ -26,6 +27,7 @@ git-next-config = { path = "crates/config" } git-next-git = { path = "crates/git" } git-next-forge = { path = "crates/forge" } git-next-forge-forgejo = { path = "crates/forge-forgejo" } +git-next-forge-github = { path = "crates/forge-github" } git-next-repo-actor = { path = "crates/repo-actor" } # CLI parsing @@ -39,6 +41,11 @@ tracing-subscriber = "0.3" # base64 decoding base64 = "0.22" +# sha256 encoding (e.g. verify github webhooks) +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" + # git # gix = "0.62" gix = { version = "0.63", features = [ diff --git a/README.md b/README.md index 61d74e6..5bf6d94 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,46 @@ In the directory with your `git-next-server.toml` file, run the command: git next server start ``` +### Forges + +The following forges are supported: [ForgeJo](https://forgejo.org) and [GitHub](https://github.com/). + +#### ForgeJo + +Configure the forge in `git-next-server.toml` like: + +```toml +[forge.jo] +forge_type = "ForgeJo" +hostname = "git.myforgejo.com" +user = "bob" +token = "..." + +[forge.jo.repos] +hello = { repo = "user/hello", branch = "main", gitdir = "/opt/git/projects/user/hello.git" } # maps to https://git.example.net/user/hello on the branch 'main' +world = { repo = "user/world", branch = "master", main = "master", next = "upcoming", "dev" = "develop" } # maps to the 'master' branch +``` + +The token is created `/user/settings/applications` and requires the `write:repository` permission. + +#### GitHub + +Configure the forge in `git-next-server.toml` like: + +```toml +[forge.gh] +forge_type = "GitHub" +hostname = "github.com" +user = "bob" +token = "..." + +[forge.gh.repos] +hello = { repo = "user/hello", branch = "main", gitdir = "/opt/git/projects/user/hello.git" } # maps to https://github.com/user/hello on the branch 'main' +world = { repo = "user/world", branch = "master", main = "master", next = "upcoming", "dev" = "develop" } # maps to the 'master' branch +``` + +The token is created [here](https://github.com/settings/tokens/new) and requires the `repo` and `admin:repo_hook` permissions. + ## Contributing Contributions to `git-next` are welcome! If you find a bug or have a feature @@ -201,15 +241,17 @@ stateDiagram-v2 forge --> config forge --> git forge --> forgejo + forge --> github forgejo --> config forgejo --> git + github --> config + github --> git + repo_actor --> config repo_actor --> git repo_actor --> forge - - ``` ## License diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 30d5810..fd8f3be 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -4,7 +4,7 @@ version = { workspace = true } edition = { workspace = true } [features] -default = ["forgejo"] +default = ["forgejo", "github"] forgejo = [] github = [] @@ -36,9 +36,9 @@ secrecy = { workspace = true } # # Conventional Commit check # git-conventional = { workspace = true } # -# # Webhooks +# Webhooks # bytes = { workspace = true } -# ulid = { workspace = true } +ulid = { workspace = true } # warp = { workspace = true } # boilerplate diff --git a/crates/config/src/forge_type.rs b/crates/config/src/forge_type.rs index a92c477..fda6c1d 100644 --- a/crates/config/src/forge_type.rs +++ b/crates/config/src/forge_type.rs @@ -4,7 +4,8 @@ pub enum ForgeType { #[cfg(feature = "forgejo")] ForgeJo, // Gitea, - // GitHub, + #[cfg(feature = "github")] + GitHub, // GitLab, // BitBucket, #[default] diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 5477ed5..bdbcba5 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -8,6 +8,7 @@ mod forge_name; mod forge_type; pub mod git_dir; mod host_name; +mod registered_webhook; mod repo_alias; mod repo_branches; mod repo_config; @@ -16,6 +17,7 @@ mod repo_path; pub mod server; mod server_repo_config; mod user; +pub mod webhook; #[cfg(test)] mod tests; @@ -28,6 +30,7 @@ pub use forge_name::ForgeAlias; pub use forge_type::ForgeType; pub use git_dir::GitDir; pub use host_name::Hostname; +pub use registered_webhook::RegisteredWebhook; pub use repo_alias::RepoAlias; pub use repo_branches::RepoBranches; pub use repo_config::RepoConfig; @@ -35,3 +38,6 @@ pub use repo_config_source::RepoConfigSource; pub use repo_path::RepoPath; pub use server_repo_config::ServerRepoConfig; pub use user::User; +pub use webhook::auth::WebhookAuth; +pub use webhook::id::WebhookId; +pub use webhook::message::WebhookMessage; diff --git a/crates/config/src/registered_webhook.rs b/crates/config/src/registered_webhook.rs new file mode 100644 index 0000000..171e75d --- /dev/null +++ b/crates/config/src/registered_webhook.rs @@ -0,0 +1,16 @@ +// +use crate as config; + +#[derive(Debug, derive_more::Constructor)] +pub struct RegisteredWebhook { + id: config::WebhookId, + auth: config::WebhookAuth, +} +impl RegisteredWebhook { + pub const fn id(&self) -> &config::WebhookId { + &self.id + } + pub const fn auth(&self) -> &config::WebhookAuth { + &self.auth + } +} diff --git a/crates/config/src/webhook/auth.rs b/crates/config/src/webhook/auth.rs new file mode 100644 index 0000000..ab619b8 --- /dev/null +++ b/crates/config/src/webhook/auth.rs @@ -0,0 +1,50 @@ +use std::str::FromStr as _; + +#[derive(Clone, Debug, PartialEq, Eq, derive_more::Deref, derive_more::Display)] +pub struct WebhookAuth(ulid::Ulid); +impl WebhookAuth { + pub fn new(authorisation: &str) -> Result { + let id = ulid::Ulid::from_str(authorisation)?; + tracing::info!("Parse auth token: {}", id); + Ok(Self(id)) + } + + pub fn generate() -> Self { + Self(ulid::Ulid::new()) + } + + pub fn header_value(&self) -> String { + format!("Basic {}", self) + } + + pub const fn to_bytes(&self) -> [u8; 16] { + self.0.to_bytes() + } +} + +#[cfg(test)] +mod tests { + use crate::WebhookAuth; + + #[test] + fn bytes() -> Result<(), Box> { + let ulid = ulid::Ulid::new(); + + let wa = WebhookAuth::new(ulid.to_string().as_str())?; + + assert_eq!(ulid.to_bytes(), wa.to_bytes()); + + Ok(()) + } + + #[test] + fn string() -> Result<(), Box> { + let ulid = ulid::Ulid::new(); + + let wa = WebhookAuth::new(ulid.to_string().as_str())?; + + assert_eq!(ulid.to_string(), wa.to_string()); + + Ok(()) + } +} diff --git a/crates/config/src/webhook/id.rs b/crates/config/src/webhook/id.rs new file mode 100644 index 0000000..ed7fb32 --- /dev/null +++ b/crates/config/src/webhook/id.rs @@ -0,0 +1,4 @@ +use derive_more::{Constructor, Deref, Display}; + +#[derive(Clone, Debug, PartialEq, Eq, Constructor, Deref, Display)] +pub struct WebhookId(String); diff --git a/crates/config/src/webhook/message.rs b/crates/config/src/webhook/message.rs new file mode 100644 index 0000000..da4d7cc --- /dev/null +++ b/crates/config/src/webhook/message.rs @@ -0,0 +1,45 @@ +// +use actix::prelude::*; + +use crate as config; + +use std::collections::HashMap; + +#[derive(Message, Debug, Clone, derive_more::Constructor)] +#[rtype(result = "()")] +pub struct WebhookMessage { + forge_alias: config::ForgeAlias, + repo_alias: config::RepoAlias, + headers: HashMap, + body: Body, +} +impl WebhookMessage { + pub const fn forge_alias(&self) -> &config::ForgeAlias { + &self.forge_alias + } + pub const fn repo_alias(&self) -> &config::RepoAlias { + &self.repo_alias + } + pub const fn body(&self) -> &Body { + &self.body + } + #[deprecated] + pub const fn authorisation(&self) -> &config::WebhookAuth { + todo!() + } + pub fn header(&self, header: &str) -> Option { + self.headers.get(header).map(|value| value.to_string()) + } +} + +#[derive(Clone, Debug, derive_more::Constructor)] +pub struct Body(String); +impl Body { + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} diff --git a/crates/config/src/webhook/mod.rs b/crates/config/src/webhook/mod.rs new file mode 100644 index 0000000..34295c0 --- /dev/null +++ b/crates/config/src/webhook/mod.rs @@ -0,0 +1,6 @@ +pub mod auth; +pub mod id; +pub mod message; +pub mod push; + +pub use push::Push; diff --git a/crates/config/src/webhook/push.rs b/crates/config/src/webhook/push.rs new file mode 100644 index 0000000..ce67acb --- /dev/null +++ b/crates/config/src/webhook/push.rs @@ -0,0 +1,39 @@ +// +use crate as config; + +use derive_more::Constructor; + +#[derive(Debug, Constructor)] +pub struct Push { + branch: config::BranchName, + sha: String, + message: String, +} +impl Push { + pub fn branch(&self, repo_branches: &crate::RepoBranches) -> Option { + if self.branch == repo_branches.main() { + return Some(Branch::Main); + } + if self.branch == repo_branches.next() { + return Some(Branch::Next); + } + if self.branch == repo_branches.dev() { + return Some(Branch::Dev); + } + tracing::warn!(branch = %self.branch, "Unexpected branch"); + None + } + pub fn sha(&self) -> &str { + &self.sha + } + pub fn message(&self) -> &str { + &self.message + } +} + +#[derive(Debug)] +pub enum Branch { + Main, + Next, + Dev, +} diff --git a/crates/forge-forgejo/src/lib.rs b/crates/forge-forgejo/src/lib.rs index c1437df..8a25a6d 100644 --- a/crates/forge-forgejo/src/lib.rs +++ b/crates/forge-forgejo/src/lib.rs @@ -1,7 +1,12 @@ +// +mod webhook; + +use git::forge::commit::Status; +use git_next_config as config; use git_next_git as git; use kxio::network::{self, Network}; -use tracing::{error, warn}; +use tracing::warn; #[derive(Clone, Debug)] pub struct ForgeJo { @@ -15,11 +20,32 @@ impl ForgeJo { } #[async_trait::async_trait] impl git::ForgeLike for ForgeJo { - fn forge_alias(&self) -> String { + fn name(&self) -> String { "forgejo".to_string() } - async fn commit_status(&self, commit: &git::Commit) -> git::commit::Status { + fn is_message_authorised( + &self, + msg: &config::WebhookMessage, + expected: &config::WebhookAuth, + ) -> bool { + let authorization = msg.header("authorization"); + tracing::info!(?authorization, %expected, "is message authorised?"); + authorization + .and_then(|header| header.strip_prefix("Basic ").map(|v| v.to_owned())) + .and_then(|value| config::WebhookAuth::new(value.as_str()).ok()) + .map(|auth| &auth == expected) + .unwrap_or(false) + } + + fn parse_webhook_body( + &self, + body: &config::webhook::message::Body, + ) -> git::forge::webhook::Result { + webhook::parse_body(body) + } + + async fn commit_status(&self, commit: &git::Commit) -> Status { let repo_details = &self.repo_details; let hostname = &repo_details.forge.hostname(); let repo_path = &repo_details.repo_path; @@ -44,33 +70,54 @@ impl git::ForgeLike for ForgeJo { Ok(response) => { match response.response_body() { Some(status) => match status.state { - CommitStatusState::Success => git::commit::Status::Pass, - CommitStatusState::Pending => git::commit::Status::Pending, - CommitStatusState::Failure => git::commit::Status::Fail, - CommitStatusState::Error => git::commit::Status::Fail, - CommitStatusState::Blank => git::commit::Status::Pending, + ForgejoState::Success => Status::Pass, + ForgejoState::Pending => Status::Pending, + ForgejoState::Failure => Status::Fail, + ForgejoState::Error => Status::Fail, + ForgejoState::Blank => Status::Pending, }, None => { warn!("No status found for commit"); - git::commit::Status::Pending // assume issue is transient and allow retry + Status::Pending // assume issue is transient and allow retry } } } Err(e) => { - error!(?e, "Failed to get commit status"); - git::commit::Status::Pending // assume issue is transient and allow retry + warn!(?e, "Failed to get commit status"); + Status::Pending // assume issue is transient and allow retry } } } + + async fn list_webhooks( + &self, + webhook_url: &config::server::WebhookUrl, + ) -> git::forge::webhook::Result> { + webhook::list(&self.repo_details, webhook_url, &self.net).await + } + + async fn unregister_webhook( + &self, + webhook_id: &config::WebhookId, + ) -> git::forge::webhook::Result<()> { + webhook::unregister(webhook_id, &self.repo_details, &self.net).await + } + + async fn register_webhook( + &self, + webhook_url: &config::server::WebhookUrl, + ) -> git::forge::webhook::Result { + webhook::register(&self.repo_details, webhook_url, &self.net).await + } } #[derive(Debug, serde::Deserialize)] -pub struct CombinedStatus { - pub state: CommitStatusState, +struct CombinedStatus { + pub state: ForgejoState, } #[derive(Debug, serde::Deserialize)] -pub enum CommitStatusState { +enum ForgejoState { #[serde(rename = "success")] Success, #[serde(rename = "pending")] diff --git a/crates/forge-forgejo/src/webhook/list.rs b/crates/forge-forgejo/src/webhook/list.rs new file mode 100644 index 0000000..614fc9d --- /dev/null +++ b/crates/forge-forgejo/src/webhook/list.rs @@ -0,0 +1,54 @@ +// +use git_next_config as config; +use git_next_git as git; +use kxio::network; + +use crate::webhook::Hook; + +pub async fn list( + repo_details: &git::RepoDetails, + webhook_url: &config::server::WebhookUrl, + net: &network::Network, +) -> git::forge::webhook::Result> { + let mut ids: Vec = vec![]; + let hostname = &repo_details.forge.hostname(); + let repo_path = &repo_details.repo_path; + let mut page = 1; + loop { + use secrecy::ExposeSecret; + let token = &repo_details.forge.token().expose_secret(); + let url = + format!("https://{hostname}/api/v1/repos/{repo_path}/hooks?page={page}&token={token}"); + let net_url = network::NetUrl::new(url); + let request = network::NetRequest::new( + network::RequestMethod::Get, + net_url, + network::NetRequestHeaders::new(), + network::RequestBody::None, + network::ResponseType::Json, + None, + network::NetRequestLogging::None, + ); + let result = net.get::>(request).await; + match result { + Ok(response) => { + if let Some(list) = response.response_body() { + if list.is_empty() { + return Ok(ids); + } + for hook in list { + if let Some(existing_url) = hook.config.get("url") { + if existing_url.starts_with(webhook_url.as_ref()) { + ids.push(hook.id()); + } + } + } + page += 1; + } + } + Err(e) => { + return Err(git::forge::webhook::Error::Network(e)); + } + }; + } +} diff --git a/crates/forge-forgejo/src/webhook/mod.rs b/crates/forge-forgejo/src/webhook/mod.rs new file mode 100644 index 0000000..c8457e2 --- /dev/null +++ b/crates/forge-forgejo/src/webhook/mod.rs @@ -0,0 +1,52 @@ +// +use git_next_config as config; +use git_next_git as git; +// +use std::collections::HashMap; + +mod list; +mod parse; +mod register; +mod unregister; + +pub use list::list; +pub use parse::parse_body; +pub use register::register; +pub use unregister::unregister; + +#[derive(Debug, serde::Deserialize)] +struct Hook { + id: i64, + config: HashMap, +} +impl Hook { + fn id(&self) -> config::WebhookId { + config::WebhookId::new(format!("{}", self.id)) + } +} + +#[derive(Debug, serde::Deserialize)] +pub struct Push { + #[serde(rename = "ref")] + reference: String, + after: String, + head_commit: HeadCommit, +} + +impl TryFrom for config::webhook::Push { + type Error = git::forge::webhook::Error; + fn try_from(push: Push) -> Result { + let branch = push + .reference + .splitn(3, '/') // should be of the form 'refs/heads/branchname' + .nth(2) + .map(config::BranchName::new) + .ok_or(git::forge::webhook::Error::UnknownBranch(push.reference))?; + Ok(Self::new(branch, push.after, push.head_commit.message)) + } +} + +#[derive(Debug, serde::Deserialize)] +struct HeadCommit { + message: String, +} diff --git a/crates/forge-forgejo/src/webhook/parse.rs b/crates/forge-forgejo/src/webhook/parse.rs new file mode 100644 index 0000000..7b87b52 --- /dev/null +++ b/crates/forge-forgejo/src/webhook/parse.rs @@ -0,0 +1,10 @@ +// +use crate as forgejo; +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-forgejo/src/webhook/register.rs b/crates/forge-forgejo/src/webhook/register.rs new file mode 100644 index 0000000..2de9f71 --- /dev/null +++ b/crates/forge-forgejo/src/webhook/register.rs @@ -0,0 +1,76 @@ +// + +use git_next_config as config; +use git_next_git as git; + +use kxio::network; +use tracing::{info, warn}; + +use crate::webhook; +use crate::webhook::Hook; + +#[tracing::instrument(skip_all)] +// TODO: caller must do this: addr.do_send(WebhookRegistered(hook.id(), authorisation)); +pub async fn register( + repo_details: &git::RepoDetails, + webhook_url: &config::server::WebhookUrl, + net: &network::Network, +) -> git::forge::webhook::Result { + let Some(repo_config) = repo_details.repo_config.clone() else { + return Err(git::forge::webhook::Error::NoRepoConfig); + }; + + // remove any lingering webhooks for the same URL + let existing_webhook_ids = webhook::list(repo_details, webhook_url, net).await?; + for webhook_id in existing_webhook_ids { + webhook::unregister(&webhook_id, repo_details, net).await?; + } + + let hostname = &repo_details.forge.hostname(); + let repo_path = &repo_details.repo_path; + use secrecy::ExposeSecret; + let token = repo_details.forge.token().expose_secret(); + let url = network::NetUrl::new(format!( + "https://{hostname}/api/v1/repos/{repo_path}/hooks?token={token}" + )); + let repo_alias = &repo_details.repo_alias; + let headers = network::NetRequestHeaders::new().with("Content-Type", "application/json"); + let authorisation = config::WebhookAuth::generate(); + let body = network::json!({ + "active": true, + "authorization_header": authorisation.header_value(), + "branch_filter": format!("{{{},{},{}}}", repo_config.branches().main(), repo_config.branches().next(), repo_config.branches().dev()), + "config": { + "content_type": "json", + "url": format!("{}/{}", webhook_url.as_ref(), repo_alias), + }, + "events": [ "push" ], + "type": "forgejo" + }); + let request = network::NetRequest::new( + network::RequestMethod::Post, + url, + headers, + network::RequestBody::Json(body), + 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())) + } + } +} diff --git a/crates/forge-forgejo/src/webhook/unregister.rs b/crates/forge-forgejo/src/webhook/unregister.rs new file mode 100644 index 0000000..3b3e646 --- /dev/null +++ b/crates/forge-forgejo/src/webhook/unregister.rs @@ -0,0 +1,30 @@ +// +use git_next_config as config; +use git_next_git as git; + +use kxio::network; + +pub async fn unregister( + webhook_id: &config::WebhookId, + repo_details: &git::RepoDetails, + net: &network::Network, +) -> git::forge::webhook::Result<()> { + let hostname = &repo_details.forge.hostname(); + let repo_path = &repo_details.repo_path; + use secrecy::ExposeSecret; + let token = repo_details.forge.token().expose_secret(); + let url = network::NetUrl::new(format!( + "https://{hostname}/api/v1/repos/{repo_path}/hooks/{webhook_id}?token={token}" + )); + let request = network::NetRequest::new( + network::RequestMethod::Delete, + url, + network::NetRequestHeaders::new(), + network::RequestBody::None, + network::ResponseType::None, + None, + network::NetRequestLogging::None, + ); + let result = net.delete(request).await; + Ok(result.map(|_| ())?) +} diff --git a/crates/forge-github/Cargo.toml b/crates/forge-github/Cargo.toml new file mode 100644 index 0000000..8e20769 --- /dev/null +++ b/crates/forge-github/Cargo.toml @@ -0,0 +1,67 @@ +[package] +name = "git-next-forge-github" +version = { workspace = true } +edition = { workspace = true } + +[dependencies] +git-next-config = { workspace = true } +git-next-git = { workspace = true } + +# own version +clap = { workspace = true } + +# logging +console-subscriber = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +# sha256 encoding (e.g. verify github webhooks) +hmac = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } + +# base64 decoding +base64 = { workspace = true } + +# git +async-trait = { workspace = true } + +# fs/network +kxio = { workspace = true } + +# TOML parsing +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } + +# Secrets and Password +secrecy = { workspace = true } + +# Conventional Commit check +git-conventional = { workspace = true } + +# Webhooks +bytes = { workspace = true } +ulid = { workspace = true } +warp = { workspace = true } + +# boilerplate +derive_more = { workspace = true } + +# file watcher +inotify = { workspace = true } + +# # Actors +# actix = { workspace = true } +# actix-rt = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +# Testing +assert2 = { workspace = true } + +[lints.clippy] +nursery = { level = "warn", priority = -1 } +# pedantic = "warn" +unwrap_used = "warn" +expect_used = "warn" diff --git a/crates/forge-github/message.json b/crates/forge-github/message.json new file mode 100644 index 0000000..6ca8fa1 --- /dev/null +++ b/crates/forge-github/message.json @@ -0,0 +1 @@ +{"zen":"Non-blocking is better than blocking.","hook_id":481361453,"hook":{"type":"Repository","id":481361453,"name":"web","active":true,"events":["push"],"config":{"content_type":"json","insecure_ssl":"0","secret":"********","url":"https://dorida.git-next.kemitix.net/gh/test"},"updated_at":"2024-05-30T17:37:41Z","created_at":"2024-05-30T17:37:41Z","url":"https://api.github.com/repos/kemitix/test-runner/hooks/481361453","test_url":"https://api.github.com/repos/kemitix/test-runner/hooks/481361453/test","ping_url":"https://api.github.com/repos/kemitix/test-runner/hooks/481361453/pings","deliveries_url":"https://api.github.com/repos/kemitix/test-runner/hooks/481361453/deliveries","last_response":{"code":null,"status":"unused","message":null}},"repository":{"id":807560108,"node_id":"R_kgDOMCJjrA","name":"test-runner","full_name":"kemitix/test-runner","private":true,"owner":{"login":"kemitix","id":1147749,"node_id":"MDQ6VXNlcjExNDc3NDk=","avatar_url":"https://avatars.githubusercontent.com/u/1147749?v=4","gravatar_id":"","url":"https://api.github.com/users/kemitix","html_url":"https://github.com/kemitix","followers_url":"https://api.github.com/users/kemitix/followers","following_url":"https://api.github.com/users/kemitix/following{/other_user}","gists_url":"https://api.github.com/users/kemitix/gists{/gist_id}","starred_url":"https://api.github.com/users/kemitix/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/kemitix/subscriptions","organizations_url":"https://api.github.com/users/kemitix/orgs","repos_url":"https://api.github.com/users/kemitix/repos","events_url":"https://api.github.com/users/kemitix/events{/privacy}","received_events_url":"https://api.github.com/users/kemitix/received_events","type":"User","site_admin":false},"html_url":"https://github.com/kemitix/test-runner","description":null,"fork":false,"url":"https://api.github.com/repos/kemitix/test-runner","forks_url":"https://api.github.com/repos/kemitix/test-runner/forks","keys_url":"https://api.github.com/repos/kemitix/test-runner/keys{/key_id}","collaborators_url":"https://api.github.com/repos/kemitix/test-runner/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/kemitix/test-runner/teams","hooks_url":"https://api.github.com/repos/kemitix/test-runner/hooks","issue_events_url":"https://api.github.com/repos/kemitix/test-runner/issues/events{/number}","events_url":"https://api.github.com/repos/kemitix/test-runner/events","assignees_url":"https://api.github.com/repos/kemitix/test-runner/assignees{/user}","branches_url":"https://api.github.com/repos/kemitix/test-runner/branches{/branch}","tags_url":"https://api.github.com/repos/kemitix/test-runner/tags","blobs_url":"https://api.github.com/repos/kemitix/test-runner/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/kemitix/test-runner/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/kemitix/test-runner/git/refs{/sha}","trees_url":"https://api.github.com/repos/kemitix/test-runner/git/trees{/sha}","statuses_url":"https://api.github.com/repos/kemitix/test-runner/statuses/{sha}","languages_url":"https://api.github.com/repos/kemitix/test-runner/languages","stargazers_url":"https://api.github.com/repos/kemitix/test-runner/stargazers","contributors_url":"https://api.github.com/repos/kemitix/test-runner/contributors","subscribers_url":"https://api.github.com/repos/kemitix/test-runner/subscribers","subscription_url":"https://api.github.com/repos/kemitix/test-runner/subscription","commits_url":"https://api.github.com/repos/kemitix/test-runner/commits{/sha}","git_commits_url":"https://api.github.com/repos/kemitix/test-runner/git/commits{/sha}","comments_url":"https://api.github.com/repos/kemitix/test-runner/comments{/number}","issue_comment_url":"https://api.github.com/repos/kemitix/test-runner/issues/comments{/number}","contents_url":"https://api.github.com/repos/kemitix/test-runner/contents/{+path}","compare_url":"https://api.github.com/repos/kemitix/test-runner/compare/{base}...{head}","merges_url":"https://api.github.com/repos/kemitix/test-runner/merges","archive_url":"https://api.github.com/repos/kemitix/test-runner/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/kemitix/test-runner/downloads","issues_url":"https://api.github.com/repos/kemitix/test-runner/issues{/number}","pulls_url":"https://api.github.com/repos/kemitix/test-runner/pulls{/number}","milestones_url":"https://api.github.com/repos/kemitix/test-runner/milestones{/number}","notifications_url":"https://api.github.com/repos/kemitix/test-runner/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/kemitix/test-runner/labels{/name}","releases_url":"https://api.github.com/repos/kemitix/test-runner/releases{/id}","deployments_url":"https://api.github.com/repos/kemitix/test-runner/deployments","created_at":"2024-05-29T10:36:06Z","updated_at":"2024-05-29T10:37:14Z","pushed_at":"2024-05-29T10:37:36Z","git_url":"git://github.com/kemitix/test-runner.git","ssh_url":"git@github.com:kemitix/test-runner.git","clone_url":"https://github.com/kemitix/test-runner.git","svn_url":"https://github.com/kemitix/test-runner","homepage":null,"size":0,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":false,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":0,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"private","forks":0,"open_issues":0,"watchers":0,"default_branch":"main"},"sender":{"login":"kemitix","id":1147749,"node_id":"MDQ6VXNlcjExNDc3NDk=","avatar_url":"https://avatars.githubusercontent.com/u/1147749?v=4","gravatar_id":"","url":"https://api.github.com/users/kemitix","html_url":"https://github.com/kemitix","followers_url":"https://api.github.com/users/kemitix/followers","following_url":"https://api.github.com/users/kemitix/following{/other_user}","gists_url":"https://api.github.com/users/kemitix/gists{/gist_id}","starred_url":"https://api.github.com/users/kemitix/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/kemitix/subscriptions","organizations_url":"https://api.github.com/users/kemitix/orgs","repos_url":"https://api.github.com/users/kemitix/repos","events_url":"https://api.github.com/users/kemitix/events{/privacy}","received_events_url":"https://api.github.com/users/kemitix/received_events","type":"User","site_admin":false}} \ No newline at end of file diff --git a/crates/forge-github/src/lib.rs b/crates/forge-github/src/lib.rs new file mode 100644 index 0000000..ea868b5 --- /dev/null +++ b/crates/forge-github/src/lib.rs @@ -0,0 +1,253 @@ +// +#[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::Both, // TODO: change this to 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, +} diff --git a/crates/forge-github/src/tests/mod.rs b/crates/forge-github/src/tests/mod.rs new file mode 100644 index 0000000..662e92b --- /dev/null +++ b/crates/forge-github/src/tests/mod.rs @@ -0,0 +1,73 @@ +// + +use git::ForgeLike; +use git_next_config as config; +use git_next_git as git; + +use std::collections::HashMap; + +type TestResult = Result<(), Box>; + +#[test] +fn accepts_valid_webhook_signature() -> TestResult { + //given + // we registered a webhook with this secret: + let webhook_auth = config::WebhookAuth::new("01HZ598CS1K9E0C193ND175XHJ")?; + // then recorded the following test message from github: + let headers = HashMap::from([( + "x-hub-signature-256".to_string(), + "sha256=6c801b0730b1ce06bf38f901de40206d3b0e93ef7b9bf09a5cf28ad9c4221bab".to_string(), + )]); + let payload = config::webhook::message::Body::new(include_str!("payload.json").to_string()); + // this reproduces that message: + let message = message(headers, payload); + + //when + // now, we attempt to recreate the signature in the header given the same message: + let result = forge().is_message_authorised(&message, &webhook_auth); + + //then + // if we succeed: then result will be true: + assert!(result); + + Ok(()) +} + +fn message( + headers: HashMap, + payload: config::webhook::message::Body, +) -> config::WebhookMessage { + config::WebhookMessage::new( + config::ForgeAlias::new("".to_string()), + config::RepoAlias::new(""), + headers, + payload, + ) +} + +fn forge() -> crate::Github { + crate::Github::new( + git::RepoDetails::new( + git::Generation::new(), + &config::RepoAlias::new(""), + &config::ServerRepoConfig::new( + "a".to_string(), + "b".to_string(), + None, + None, + None, + None, + ), + &config::ForgeAlias::new("c".to_string()), + &config::ForgeConfig::new( + config::ForgeType::GitHub, + "d".to_string(), + "e".to_string(), + "f".to_string(), + std::collections::BTreeMap::default(), + ), + config::GitDir::default(), + ), + kxio::network::Network::new_mock(), + ) +} diff --git a/crates/forge-github/src/tests/payload.json b/crates/forge-github/src/tests/payload.json new file mode 100644 index 0000000..6ca8fa1 --- /dev/null +++ b/crates/forge-github/src/tests/payload.json @@ -0,0 +1 @@ +{"zen":"Non-blocking is better than blocking.","hook_id":481361453,"hook":{"type":"Repository","id":481361453,"name":"web","active":true,"events":["push"],"config":{"content_type":"json","insecure_ssl":"0","secret":"********","url":"https://dorida.git-next.kemitix.net/gh/test"},"updated_at":"2024-05-30T17:37:41Z","created_at":"2024-05-30T17:37:41Z","url":"https://api.github.com/repos/kemitix/test-runner/hooks/481361453","test_url":"https://api.github.com/repos/kemitix/test-runner/hooks/481361453/test","ping_url":"https://api.github.com/repos/kemitix/test-runner/hooks/481361453/pings","deliveries_url":"https://api.github.com/repos/kemitix/test-runner/hooks/481361453/deliveries","last_response":{"code":null,"status":"unused","message":null}},"repository":{"id":807560108,"node_id":"R_kgDOMCJjrA","name":"test-runner","full_name":"kemitix/test-runner","private":true,"owner":{"login":"kemitix","id":1147749,"node_id":"MDQ6VXNlcjExNDc3NDk=","avatar_url":"https://avatars.githubusercontent.com/u/1147749?v=4","gravatar_id":"","url":"https://api.github.com/users/kemitix","html_url":"https://github.com/kemitix","followers_url":"https://api.github.com/users/kemitix/followers","following_url":"https://api.github.com/users/kemitix/following{/other_user}","gists_url":"https://api.github.com/users/kemitix/gists{/gist_id}","starred_url":"https://api.github.com/users/kemitix/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/kemitix/subscriptions","organizations_url":"https://api.github.com/users/kemitix/orgs","repos_url":"https://api.github.com/users/kemitix/repos","events_url":"https://api.github.com/users/kemitix/events{/privacy}","received_events_url":"https://api.github.com/users/kemitix/received_events","type":"User","site_admin":false},"html_url":"https://github.com/kemitix/test-runner","description":null,"fork":false,"url":"https://api.github.com/repos/kemitix/test-runner","forks_url":"https://api.github.com/repos/kemitix/test-runner/forks","keys_url":"https://api.github.com/repos/kemitix/test-runner/keys{/key_id}","collaborators_url":"https://api.github.com/repos/kemitix/test-runner/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/kemitix/test-runner/teams","hooks_url":"https://api.github.com/repos/kemitix/test-runner/hooks","issue_events_url":"https://api.github.com/repos/kemitix/test-runner/issues/events{/number}","events_url":"https://api.github.com/repos/kemitix/test-runner/events","assignees_url":"https://api.github.com/repos/kemitix/test-runner/assignees{/user}","branches_url":"https://api.github.com/repos/kemitix/test-runner/branches{/branch}","tags_url":"https://api.github.com/repos/kemitix/test-runner/tags","blobs_url":"https://api.github.com/repos/kemitix/test-runner/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/kemitix/test-runner/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/kemitix/test-runner/git/refs{/sha}","trees_url":"https://api.github.com/repos/kemitix/test-runner/git/trees{/sha}","statuses_url":"https://api.github.com/repos/kemitix/test-runner/statuses/{sha}","languages_url":"https://api.github.com/repos/kemitix/test-runner/languages","stargazers_url":"https://api.github.com/repos/kemitix/test-runner/stargazers","contributors_url":"https://api.github.com/repos/kemitix/test-runner/contributors","subscribers_url":"https://api.github.com/repos/kemitix/test-runner/subscribers","subscription_url":"https://api.github.com/repos/kemitix/test-runner/subscription","commits_url":"https://api.github.com/repos/kemitix/test-runner/commits{/sha}","git_commits_url":"https://api.github.com/repos/kemitix/test-runner/git/commits{/sha}","comments_url":"https://api.github.com/repos/kemitix/test-runner/comments{/number}","issue_comment_url":"https://api.github.com/repos/kemitix/test-runner/issues/comments{/number}","contents_url":"https://api.github.com/repos/kemitix/test-runner/contents/{+path}","compare_url":"https://api.github.com/repos/kemitix/test-runner/compare/{base}...{head}","merges_url":"https://api.github.com/repos/kemitix/test-runner/merges","archive_url":"https://api.github.com/repos/kemitix/test-runner/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/kemitix/test-runner/downloads","issues_url":"https://api.github.com/repos/kemitix/test-runner/issues{/number}","pulls_url":"https://api.github.com/repos/kemitix/test-runner/pulls{/number}","milestones_url":"https://api.github.com/repos/kemitix/test-runner/milestones{/number}","notifications_url":"https://api.github.com/repos/kemitix/test-runner/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/kemitix/test-runner/labels{/name}","releases_url":"https://api.github.com/repos/kemitix/test-runner/releases{/id}","deployments_url":"https://api.github.com/repos/kemitix/test-runner/deployments","created_at":"2024-05-29T10:36:06Z","updated_at":"2024-05-29T10:37:14Z","pushed_at":"2024-05-29T10:37:36Z","git_url":"git://github.com/kemitix/test-runner.git","ssh_url":"git@github.com:kemitix/test-runner.git","clone_url":"https://github.com/kemitix/test-runner.git","svn_url":"https://github.com/kemitix/test-runner","homepage":null,"size":0,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":false,"has_pages":false,"has_discussions":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":0,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"private","forks":0,"open_issues":0,"watchers":0,"default_branch":"main"},"sender":{"login":"kemitix","id":1147749,"node_id":"MDQ6VXNlcjExNDc3NDk=","avatar_url":"https://avatars.githubusercontent.com/u/1147749?v=4","gravatar_id":"","url":"https://api.github.com/users/kemitix","html_url":"https://github.com/kemitix","followers_url":"https://api.github.com/users/kemitix/followers","following_url":"https://api.github.com/users/kemitix/following{/other_user}","gists_url":"https://api.github.com/users/kemitix/gists{/gist_id}","starred_url":"https://api.github.com/users/kemitix/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/kemitix/subscriptions","organizations_url":"https://api.github.com/users/kemitix/orgs","repos_url":"https://api.github.com/users/kemitix/repos","events_url":"https://api.github.com/users/kemitix/events{/privacy}","received_events_url":"https://api.github.com/users/kemitix/received_events","type":"User","site_admin":false}} \ No newline at end of file diff --git a/crates/forge-github/src/webhook/mod.rs b/crates/forge-github/src/webhook/mod.rs new file mode 100644 index 0000000..0e2f74e --- /dev/null +++ b/crates/forge-github/src/webhook/mod.rs @@ -0,0 +1,28 @@ +// +use git_next_config as config; +use git_next_git as git; + +#[derive(Debug, serde::Deserialize)] +pub struct Push { + #[serde(rename = "ref")] + reference: String, + after: String, + head_commit: HeadCommit, +} +#[derive(Debug, serde::Deserialize)] +pub struct HeadCommit { + message: String, +} + +impl TryFrom for config::webhook::Push { + type Error = git::forge::webhook::Error; + fn try_from(push: Push) -> Result { + let branch = push + .reference + .splitn(3, '/') // should be of the form 'refs/heads/branchname' + .nth(2) + .map(config::BranchName::new) + .ok_or(git::forge::webhook::Error::UnknownBranch(push.reference))?; + Ok(Self::new(branch, push.after, push.head_commit.message)) + } +} diff --git a/crates/forge/Cargo.toml b/crates/forge/Cargo.toml index 407f512..551b277 100644 --- a/crates/forge/Cargo.toml +++ b/crates/forge/Cargo.toml @@ -4,14 +4,15 @@ version = { workspace = true } edition = { workspace = true } [features] -default = ["forgejo"] +default = ["forgejo", "github"] forgejo = ["git-next-forge-forgejo"] -github = [] +github = ["git-next-forge-github"] [dependencies] git-next-config = { workspace = true } git-next-git = { workspace = true } git-next-forge-forgejo = { workspace = true, optional = true } +git-next-forge-github = { workspace = true, optional = true } # logging console-subscriber = { workspace = true } diff --git a/crates/forge/src/github.rs b/crates/forge/src/github.rs deleted file mode 100644 index 7398d5a..0000000 --- a/crates/forge/src/github.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::network::Network; - -struct Github; -pub(super) struct GithubEnv { - net: Network, -} -impl GithubEnv { - pub(crate) const fn new(net: Network) -> GithubEnv { - Self { net } - } -} -#[async_trait::async_trait] -impl super::ForgeLike for GithubEnv { - fn name(&self) -> String { - "github".to_string() - } - - async fn branches_get_all(&self) -> Vec { - todo!() - } -} diff --git a/crates/forge/src/lib.rs b/crates/forge/src/lib.rs index 12c47ab..5aacc1f 100644 --- a/crates/forge/src/lib.rs +++ b/crates/forge/src/lib.rs @@ -1,34 +1,34 @@ -#![allow(dead_code)] - +// use git_next_forge_forgejo as forgejo; +use git_next_forge_github as github; use git_next_git as git; use kxio::network::Network; -#[cfg(feature = "github")] -mod github; - mod mock_forge; #[derive(Clone, Debug)] pub enum Forge { - Mock(mock_forge::MockForgeEnv), - #[allow(clippy::enum_variant_names)] + Mock(mock_forge::MockForge), + #[cfg(feature = "forgejo")] - ForgeJo(forgejo::ForgeJo), + ForgeJo(git_next_forge_forgejo::ForgeJo), + #[cfg(feature = "github")] - Github(github::GithubEnv), + Github(git_next_forge_github::Github), } impl Forge { pub const fn new_mock() -> Self { - Self::Mock(mock_forge::MockForgeEnv::new()) + Self::Mock(mock_forge::MockForge::new()) } + #[cfg(feature = "forgejo")] pub const fn new_forgejo(repo_details: git::RepoDetails, net: Network) -> Self { Self::ForgeJo(forgejo::ForgeJo::new(repo_details, net)) } + #[cfg(feature = "github")] - pub const fn new_github(net: Network) -> Self { - Self::Github(github::GithubEnv::new(net)) + pub const fn new_github(repo_details: git::RepoDetails, net: Network) -> Self { + Self::Github(github::Github::new(repo_details, net)) } } impl std::ops::Deref for Forge { @@ -39,7 +39,7 @@ impl std::ops::Deref for Forge { #[cfg(feature = "forgejo")] Self::ForgeJo(env) => env, #[cfg(feature = "github")] - Forge::Github(env) => env, + Self::Github(env) => env, } } } diff --git a/crates/forge/src/mock_forge.rs b/crates/forge/src/mock_forge.rs index 78b482f..b0136e5 100644 --- a/crates/forge/src/mock_forge.rs +++ b/crates/forge/src/mock_forge.rs @@ -1,23 +1,56 @@ // #![cfg(not(tarpaulin_include))] +use derive_more::Constructor; +use git_next_config as config; use git_next_git as git; -struct MockForge; -#[derive(Clone, Debug)] -pub struct MockForgeEnv; -impl MockForgeEnv { - pub(crate) const fn new() -> Self { - Self - } -} +#[derive(Clone, Debug, Constructor)] +pub struct MockForge; + #[async_trait::async_trait] -impl git::ForgeLike for MockForgeEnv { - fn forge_alias(&self) -> String { +impl git::ForgeLike for MockForge { + fn name(&self) -> String { "mock".to_string() } - async fn commit_status(&self, _commit: &git::Commit) -> git::commit::Status { + fn is_message_authorised( + &self, + _msg: &config::WebhookMessage, + _expected: &config::WebhookAuth, + ) -> bool { + todo!() + } + + fn parse_webhook_body( + &self, + _body: &config::webhook::message::Body, + ) -> git::forge::webhook::Result { + todo!() + } + + async fn commit_status(&self, _commit: &git::Commit) -> git::forge::commit::Status { + todo!() + } + + async fn list_webhooks( + &self, + _webhook_url: &config::server::WebhookUrl, + ) -> git::forge::webhook::Result> { + todo!() + } + + async fn unregister_webhook( + &self, + _webhook_id: &config::WebhookId, + ) -> git::forge::webhook::Result<()> { + todo!() + } + + async fn register_webhook( + &self, + _webhook_url: &config::server::WebhookUrl, + ) -> git::forge::webhook::Result { todo!() } } diff --git a/crates/forge/src/tests/github.rs b/crates/forge/src/tests/github.rs deleted file mode 100644 index 6d355c9..0000000 --- a/crates/forge/src/tests/github.rs +++ /dev/null @@ -1,8 +0,0 @@ -use super::*; - -#[test] -fn test_name() { - let net = Network::new_mock(); - let forge = Forge::new_github(net); - assert_eq!(forge.name(), "github"); -} diff --git a/crates/forge/src/tests/mod.rs b/crates/forge/src/tests/mod.rs index 0488c5d..6436d9d 100644 --- a/crates/forge/src/tests/mod.rs +++ b/crates/forge/src/tests/mod.rs @@ -4,13 +4,10 @@ use super::*; use git_next_config as config; use git_next_git as git; -#[cfg(feature = "github")] -mod github; - #[test] fn test_mock_name() { let forge = Forge::new_mock(); - assert_eq!(forge.forge_alias(), "mock"); + assert_eq!(forge.name(), "mock"); } #[test] @@ -30,5 +27,5 @@ fn test_forgejo_name() { config::GitDir::new(fs.base()), ); let forge = Forge::new_forgejo(repo_details, net); - assert_eq!(forge.forge_alias(), "forgejo"); + assert_eq!(forge.name(), "forgejo"); } diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 3c2d9c2..f5757d6 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -24,7 +24,7 @@ kxio = { workspace = true } # # TOML parsing # serde = { workspace = true } -# # serde_json = { workspace = true } +serde_json = { workspace = true } # toml = { workspace = true } # Secrets and Password @@ -44,9 +44,9 @@ derive-with = { workspace = true } # # file watcher # inotify = { workspace = true } -# -# # Actors -# actix = { workspace = true } + +# Actors +actix = { workspace = true } # actix-rt = { workspace = true } # tokio = { workspace = true } # diff --git a/crates/git/src/commit.rs b/crates/git/src/commit.rs index 45bb6c3..6249e07 100644 --- a/crates/git/src/commit.rs +++ b/crates/git/src/commit.rs @@ -1,3 +1,6 @@ +// +use git_next_config as config; + #[derive(Clone, Debug, PartialEq, Eq, derive_more::Constructor, derive_more::Display)] #[display("{}", sha)] pub struct Commit { @@ -13,19 +16,21 @@ impl Commit { } } +impl From for Commit { + fn from(value: config::webhook::Push) -> Self { + Self::new( + Sha::new(value.sha().to_owned()), + Message::new(value.message().to_owned()), + ) + } +} + #[derive(Clone, Debug, PartialEq, Eq, derive_more::Constructor, derive_more::Display)] pub struct Sha(String); #[derive(Clone, Debug, PartialEq, Eq, derive_more::Constructor, derive_more::Display)] pub struct Message(String); -#[derive(Debug)] -pub enum Status { - Pass, - Fail, - Pending, -} - #[derive(Clone, Debug)] pub struct Histories { pub main: Vec, diff --git a/crates/git/src/forge/commit.rs b/crates/git/src/forge/commit.rs new file mode 100644 index 0000000..dafab24 --- /dev/null +++ b/crates/git/src/forge/commit.rs @@ -0,0 +1,6 @@ +#[derive(Debug)] +pub enum Status { + Pass, + Fail, + Pending, +} diff --git a/crates/git/src/forge/like.rs b/crates/git/src/forge/like.rs new file mode 100644 index 0000000..7ac484b --- /dev/null +++ b/crates/git/src/forge/like.rs @@ -0,0 +1,41 @@ +use crate as git; +use git_next_config as config; + +#[async_trait::async_trait] +pub trait ForgeLike { + fn name(&self) -> String; + + /// Checks that the message has a valid authorisation + fn is_message_authorised( + &self, + message: &config::WebhookMessage, + expected: &config::WebhookAuth, + ) -> bool; + + /// Parses the webhook body into Some(Push) struct if appropriate, or None if not. + fn parse_webhook_body( + &self, + body: &config::webhook::message::Body, + ) -> git::forge::webhook::Result; + + /// Checks the results of any (e.g. CI) status checks for the commit. + async fn commit_status(&self, commit: &git::Commit) -> git::forge::commit::Status; + + // Lists all the webhooks + async fn list_webhooks( + &self, + url: &config::server::WebhookUrl, + ) -> git::forge::webhook::Result>; + + // Unregisters a webhook + async fn unregister_webhook( + &self, + webhook: &config::WebhookId, + ) -> git::forge::webhook::Result<()>; + + // Registers a webhook + async fn register_webhook( + &self, + webhook_url: &config::server::WebhookUrl, + ) -> git::forge::webhook::Result; +} diff --git a/crates/git/src/forge/mod.rs b/crates/git/src/forge/mod.rs new file mode 100644 index 0000000..4fc48f1 --- /dev/null +++ b/crates/git/src/forge/mod.rs @@ -0,0 +1,3 @@ +pub mod commit; +pub(super) mod like; +pub mod webhook; diff --git a/crates/git/src/forge/webhook.rs b/crates/git/src/forge/webhook.rs new file mode 100644 index 0000000..c6f3d2d --- /dev/null +++ b/crates/git/src/forge/webhook.rs @@ -0,0 +1,34 @@ +use derive_more::Display; + +pub type Result = core::result::Result; + +#[derive(Debug, Display)] +pub enum Error { + #[display("network: {}", 0)] + Network(kxio::network::NetworkError), + + FailedToRegister(String), + + NetworkResponseEmpty, + + NoRepoConfig, + + FailedToNotifySelf(String), + + Serde(serde_json::error::Error), + + UnknownBranch(String), +} +impl std::error::Error for Error {} + +impl From for Error { + fn from(value: kxio::network::NetworkError) -> Self { + Self::Network(value) + } +} + +impl From for Error { + fn from(value: serde_json::error::Error) -> Self { + Self::Serde(value) + } +} diff --git a/crates/git/src/forge_like.rs b/crates/git/src/forge_like.rs deleted file mode 100644 index b10b16d..0000000 --- a/crates/git/src/forge_like.rs +++ /dev/null @@ -1,9 +0,0 @@ -use crate as git; - -#[async_trait::async_trait] -pub trait ForgeLike { - fn forge_alias(&self) -> String; - - /// Checks the results of any (e.g. CI) status checks for the commit. - async fn commit_status(&self, commit: &git::Commit) -> git::commit::Status; -} diff --git a/crates/git/src/lib.rs b/crates/git/src/lib.rs index 978256d..739a11e 100644 --- a/crates/git/src/lib.rs +++ b/crates/git/src/lib.rs @@ -4,7 +4,7 @@ pub mod commit; pub mod common; pub mod fetch; pub mod file; -mod forge_like; +pub mod forge; mod generation; mod git_ref; mod git_remote; @@ -17,7 +17,7 @@ pub mod validation; mod tests; pub use commit::Commit; -pub use forge_like::ForgeLike; +pub use forge::like::ForgeLike; pub use generation::Generation; pub use git_ref::GitRef; pub use git_remote::GitRemote; diff --git a/crates/git/src/repository/mod.rs b/crates/git/src/repository/mod.rs index 1a14a21..653ffee 100644 --- a/crates/git/src/repository/mod.rs +++ b/crates/git/src/repository/mod.rs @@ -10,6 +10,7 @@ use git_next_config as config; use git_next_config::GitDir; pub use open::OpenRepository; +use tracing::info; use crate::{repository::mock::MockRepository, validation::repo::validate_repo}; @@ -29,18 +30,20 @@ pub fn mock() -> (Repository, MockRepository) { } /// Opens a repository, cloning if necessary +#[tracing::instrument(skip_all)] pub fn open( repository: &Repository, repo_details: &RepoDetails, gitdir: config::GitDir, ) -> Result { let repository = if !gitdir.exists() { - // info!("Local copy not found - cloning..."); + info!("Local copy not found - cloning..."); repository.git_clone(repo_details)? } else { + info!("Local copy found - opening..."); repository.open(&gitdir)? }; - // info!("Validating..."); + info!("Validating..."); validate_repo(&repository, repo_details).map_err(|e| Error::Validation(e.to_string()))?; Ok(repository) } diff --git a/crates/git/src/repository/open/oreal.rs b/crates/git/src/repository/open/oreal.rs index 2f6a500..c9113b4 100644 --- a/crates/git/src/repository/open/oreal.rs +++ b/crates/git/src/repository/open/oreal.rs @@ -162,15 +162,12 @@ impl super::OpenRepositoryLike for RealOpenRepository { git::commit::Sha::new(id), git::commit::Message::new(message), ); - info!(?commit, "found"); if find_commits.contains(&commit) { - info!("Is in find_commits"); commits.push(commit); break; } commits.push(commit); } - info!("finished walkfing"); Ok(commits) })? } diff --git a/crates/git/src/repository/real.rs b/crates/git/src/repository/real.rs index b693c7a..3284c12 100644 --- a/crates/git/src/repository/real.rs +++ b/crates/git/src/repository/real.rs @@ -15,13 +15,16 @@ impl RepositoryLike for RealRepository { Ok(OpenRepository::real(gix_repo)) } + #[tracing::instrument(skip_all)] fn git_clone(&self, repo_details: &RepoDetails) -> Result { + tracing::info!("creating"); use secrecy::ExposeSecret; let (gix_repo, _outcome) = gix::prepare_clone_bare( repo_details.origin().expose_secret().as_str(), repo_details.gitdir.deref(), )? .fetch_only(gix::progress::Discard, &AtomicBool::new(false))?; + tracing::info!("created"); Ok(OpenRepository::real(gix_repo)) } diff --git a/crates/repo-actor/Cargo.toml b/crates/repo-actor/Cargo.toml index 1738481..3125c9e 100644 --- a/crates/repo-actor/Cargo.toml +++ b/crates/repo-actor/Cargo.toml @@ -4,7 +4,7 @@ version = { workspace = true } edition = { workspace = true } [features] -default = ["forgejo"] +default = ["forgejo", "github"] forgejo = [] github = [] diff --git a/crates/repo-actor/src/lib.rs b/crates/repo-actor/src/lib.rs index b372733..c507ee0 100644 --- a/crates/repo-actor/src/lib.rs +++ b/crates/repo-actor/src/lib.rs @@ -9,6 +9,7 @@ mod tests; use std::time::Duration; use actix::prelude::*; +use config::RegisteredWebhook; use git::validation::positions::{validate_positions, Positions}; use crate as repo_actor; @@ -26,8 +27,8 @@ pub struct RepoActor { message_token: MessageToken, repo_details: git::RepoDetails, webhook: config::server::Webhook, - webhook_id: Option, // INFO: if [None] then no webhook is configured - webhook_auth: Option, // INFO: if [None] then no webhook is configured + webhook_id: Option, // INFO: if [None] then no webhook is configured + webhook_auth: Option, // INFO: if [None] then no webhook is configured last_main_commit: Option, last_next_commit: Option, last_dev_commit: Option, @@ -47,6 +48,7 @@ impl RepoActor { let forge = match details.forge.forge_type() { #[cfg(feature = "forgejo")] config::ForgeType::ForgeJo => forge::Forge::new_forgejo(details.clone(), net.clone()), + config::ForgeType::GitHub => forge::Forge::new_github(details.clone(), net.clone()), config::ForgeType::MockForge => forge::Forge::new_mock(), }; debug!(?forge, "new"); @@ -74,13 +76,16 @@ impl Actor for RepoActor { info!("Checking webhook"); match self.webhook_id.take() { Some(webhook_id) => { - let repo_details = self.repo_details.clone(); - let net = self.net.clone(); info!(%webhook_id, "Unregistring webhook"); - webhook::unregister(webhook_id, repo_details, net) - .in_current_span() - .into_actor(self) - .wait(ctx); + let forge = self.forge.clone(); + async move { + if let Err(err) = forge.unregister_webhook(&webhook_id).await { + warn!("unregistering webhook: {err}"); + } + } + .in_current_span() + .into_actor(self) + .wait(ctx); Running::Continue } None => Running::Stop, @@ -107,7 +112,7 @@ impl Handler for RepoActor { }); } } - Err(err) => warn!("Could not Clone repo: {err}"), + Err(err) => warn!("Could not open repo: {err}"), } } } @@ -171,12 +176,26 @@ impl Handler for RepoActor { } } if self.webhook_id.is_none() { - webhook::register( - self.repo_details.clone(), - self.webhook.clone(), - ctx.address(), - self.net.clone(), - ) + let forge_alias = self.repo_details.forge.forge_alias(); + let repo_alias = &self.repo_details.repo_alias; + let webhook_url = self.webhook.url(forge_alias, repo_alias); + let forge = self.forge.clone(); + let addr = ctx.address(); + async move { + if let Err(err) = + forge + .register_webhook(&webhook_url) + .await + .and_then(|registered_webhook| { + addr.try_send(WebhookRegistered::from(registered_webhook)) + .map_err(|e| { + git::forge::webhook::Error::FailedToNotifySelf(e.to_string()) + }) + }) + { + warn!("registering webhook: {err}"); + } + } .in_current_span() .into_actor(self) .wait(ctx); @@ -264,7 +283,12 @@ impl Handler for RepoActor { #[derive(Message)] #[rtype(result = "()")] -pub struct WebhookRegistered(webhook::WebhookId, webhook::WebhookAuth); +pub struct WebhookRegistered(config::WebhookId, config::WebhookAuth); +impl From for WebhookRegistered { + fn from(value: RegisteredWebhook) -> Self { + Self(value.id().clone(), value.auth().clone()) + } +} impl Handler for RepoActor { type Result = (); #[tracing::instrument(name = "RepoActor::WebhookRegistered", skip_all, fields(repo = %self.repo_details, webhook_id = %msg.0))] diff --git a/crates/repo-actor/src/status.rs b/crates/repo-actor/src/status.rs index a5b06af..b7b4ea4 100644 --- a/crates/repo-actor/src/status.rs +++ b/crates/repo-actor/src/status.rs @@ -19,14 +19,14 @@ pub async fn check_next( let status = forge.commit_status(&next).await; info!(?status, "Checking next branch"); match status { - git::commit::Status::Pass => { + git::forge::commit::Status::Pass => { addr.do_send(AdvanceMainTo(next)); } - git::commit::Status::Pending => { + git::forge::commit::Status::Pending => { tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; addr.do_send(ValidateRepo { message_token }); } - git::commit::Status::Fail => { + git::forge::commit::Status::Fail => { warn!("Checks have failed"); } } diff --git a/crates/repo-actor/src/tests.rs b/crates/repo-actor/src/tests.rs index 463b368..b9c7df4 100644 --- a/crates/repo-actor/src/tests.rs +++ b/crates/repo-actor/src/tests.rs @@ -32,12 +32,3 @@ mod branch { assert_eq!(next_commit, Some(expected), "Found the wrong commit"); } } - -mod webhook { - use super::super::webhook::*; - - #[test] - fn should_split_ref() { - assert_eq!(split_ref("refs/heads/next"), ("refs/heads/", "next")); - } -} diff --git a/crates/repo-actor/src/webhook.rs b/crates/repo-actor/src/webhook.rs index b57ecc4..21c808a 100644 --- a/crates/repo-actor/src/webhook.rs +++ b/crates/repo-actor/src/webhook.rs @@ -1,195 +1,30 @@ +// use actix::prelude::*; -use git_next_config::{ - server::{Webhook, WebhookUrl}, - BranchName, ForgeAlias, RepoAlias, RepoBranches, -}; + +use crate::{RepoActor, ValidateRepo}; +use git_next_config as config; use git_next_git as git; -use kxio::network::{self, json}; + use tracing::{info, warn}; -use ulid::DecodeError; -use std::{collections::HashMap, str::FromStr}; - -use crate::{RepoActor, ValidateRepo, WebhookRegistered}; - -#[derive( - Clone, Debug, PartialEq, Eq, derive_more::Constructor, derive_more::Deref, derive_more::Display, -)] -pub struct WebhookId(String); - -#[derive(Clone, Debug, PartialEq, Eq, derive_more::Deref, derive_more::Display)] -pub struct WebhookAuth(ulid::Ulid); -impl WebhookAuth { - pub fn new(authorisation: &str) -> Result { - let id = ulid::Ulid::from_str(authorisation)?; - info!("Parse auth token: {}", id); - Ok(Self(id)) - } - - fn generate() -> Self { - Self(ulid::Ulid::new()) - } - - fn header_value(&self) -> String { - format!("Basic {}", self) - } -} - -#[tracing::instrument(skip_all, fields(%webhook_id))] -pub async fn unregister( - webhook_id: WebhookId, - repo_details: git::RepoDetails, - net: network::Network, -) { - let hostname = &repo_details.forge.hostname(); - let repo_path = repo_details.repo_path; - use secrecy::ExposeSecret; - let token = repo_details.forge.token().expose_secret(); - let url = network::NetUrl::new(format!( - "https://{hostname}/api/v1/repos/{repo_path}/hooks/{webhook_id}?token={token}" - )); - let request = network::NetRequest::new( - network::RequestMethod::Delete, - url, - network::NetRequestHeaders::new(), - network::RequestBody::None, - network::ResponseType::None, - None, - network::NetRequestLogging::None, - ); - let result = net.delete(request).await; - match result { - Ok(_) => info!("unregistered webhook"), - Err(err) => warn!(?err, "Failed to unregister webhook"), - } -} - -#[tracing::instrument(skip_all)] -pub async fn register( - repo_details: git::RepoDetails, - webhook: Webhook, - addr: actix::prelude::Addr, - net: network::Network, -) { - let Some(repo_config) = repo_details.repo_config.clone() else { - return; - }; - - let forge_alias = repo_details.forge.forge_alias(); - let repo_alias = &repo_details.repo_alias; - let webhook_url = webhook.url(forge_alias, repo_alias); - // remove any lingering webhooks for the same URL - let existing_webhook_ids = find_existing_webhooks(&repo_details, &webhook_url, &net).await; - for webhook_id in existing_webhook_ids { - unregister(webhook_id, repo_details.clone(), net.clone()).await; - } - - let hostname = &repo_details.forge.hostname(); - let repo_path = repo_details.repo_path; - use secrecy::ExposeSecret; - let token = repo_details.forge.token().expose_secret(); - let url = network::NetUrl::new(format!( - "https://{hostname}/api/v1/repos/{repo_path}/hooks?token={token}" - )); - let headers = network::NetRequestHeaders::new().with("Content-Type", "application/json"); - let authorisation = WebhookAuth::generate(); - let body = json!({ - "active": true, - "authorization_header": authorisation.header_value(), - "branch_filter": format!("{{{},{},{}}}", repo_config.branches().main(), repo_config.branches().next(), repo_config.branches().dev()), - "config": { - "content_type": "json", - "url": webhook_url.as_ref(), - }, - "events": [ "push" ], - "type": "forgejo" - }); - let request = network::NetRequest::new( - network::RequestMethod::Post, - url, - headers, - network::RequestBody::Json(body), - network::ResponseType::Json, - None, - network::NetRequestLogging::None, - ); - let result = net.post_json::(request).await; - match result { - Ok(response) => { - if let Some(hook) = response.response_body() { - info!(webhook_id = %hook.id, "Webhook registered"); - addr.do_send(WebhookRegistered(hook.id(), authorisation)); - } - } - Err(_) => warn!("Failed to register webhook"), - } -} - -async fn find_existing_webhooks( - repo_details: &git::RepoDetails, - webhook_url: &WebhookUrl, - net: &network::Network, -) -> Vec { - let mut ids: Vec = vec![]; - let hostname = &repo_details.forge.hostname(); - let repo_path = &repo_details.repo_path; - let mut page = 1; - loop { - use secrecy::ExposeSecret; - let token = &repo_details.forge.token().expose_secret(); - let url = - format!("https://{hostname}/api/v1/repos/{repo_path}/hooks?page={page}&token={token}"); - let net_url = network::NetUrl::new(url); - let request = network::NetRequest::new( - network::RequestMethod::Get, - net_url, - network::NetRequestHeaders::new(), - network::RequestBody::None, - network::ResponseType::Json, - None, - network::NetRequestLogging::None, - ); - let result = net.get::>(request).await; - if let Ok(response) = result { - if let Some(list) = response.response_body() { - if list.is_empty() { - return ids; - } - for hook in list { - if let Some(existing_url) = hook.config.get("url") { - if existing_url == webhook_url.as_ref() { - ids.push(hook.id()); - } - } - } - } - } - page += 1; - } -} - -#[derive(Debug, serde::Deserialize)] -struct Hook { - id: i64, - config: HashMap, -} -impl Hook { - fn id(&self) -> WebhookId { - WebhookId(format!("{}", self.id)) - } -} - -impl Handler for RepoActor { +impl Handler for RepoActor { type Result = (); #[allow(clippy::cognitive_complexity)] // TODO: (#49) reduce complexity #[tracing::instrument(name = "RepoActor::WebhookMessage", skip_all, fields(token = %self.message_token, repo = %self.repo_details))] - fn handle(&mut self, msg: WebhookMessage, ctx: &mut Self::Context) -> Self::Result { + fn handle(&mut self, msg: config::WebhookMessage, ctx: &mut Self::Context) -> Self::Result { let Some(expected_authorization) = &self.webhook_auth else { warn!("Don't know what authorization to expect"); return; }; - if msg.authorisation() != expected_authorization { + let Some(config) = &self.repo_details.repo_config else { + warn!("No repo config"); + return; + }; + if !self + .forge + .is_message_authorised(&msg, expected_authorization) + { warn!( "Invalid authorization - expected {}", expected_authorization @@ -197,146 +32,62 @@ impl Handler for RepoActor { return; } let body = msg.body(); - match serde_json::from_str::(body.as_str()) { - Err(err) => warn!(?err, ?body, "Not a 'push'"), - Ok(push) => { - if let Some(config) = &self.repo_details.repo_config { - match push.branch(config.branches()) { - None => warn!( - ?push, - "Unrecognised branch, we should be filtering to only the ones we want" - ), - Some(branch) => { - match branch { - Branch::Main => { - if self.last_main_commit == Some(push.commit()) { - info!( - branch = %config.branches().main(), - commit = %push.commit(), - "Ignoring - already aware of branch at commit", - ); - return; - } - self.last_main_commit.replace(push.commit()) - } - Branch::Next => { - if self.last_next_commit == Some(push.commit()) { - info!( - branch = %config.branches().next(), - commit = %push.commit(), - "Ignoring - already aware of branch at commit", - ); - return; - } - self.last_next_commit.replace(push.commit()) - } - Branch::Dev => { - if self.last_dev_commit == Some(push.commit()) { - info!( - branch = %config.branches().dev(), - commit = %push.commit(), - "Ignoring - already aware of branch at commit", - ); - return; - } - self.last_dev_commit.replace(push.commit()) - } - }; - let message_token = self.message_token.next(); - info!( - token = %message_token, - ?branch, - commit = %push.commit(), - "New commit" - ); - ctx.address().do_send(ValidateRepo { message_token }); - } - } - } + match self.forge.parse_webhook_body(body) { + Err(err) => { + warn!(?err, "Not a 'push'"); + return; } + Ok(push) => match push.branch(config.branches()) { + None => { + warn!( + ?push, + "Unrecognised branch, we should be filtering to only the ones we want" + ); + return; + } + Some(config::webhook::push::Branch::Main) => { + let commit = git::Commit::from(push); + if self.last_main_commit.as_ref() == Some(&commit) { + info!( + branch = %config.branches().main(), + %commit, + "Ignoring - already aware of branch at commit", + ); + return; + } + self.last_main_commit.replace(commit); + } + Some(config::webhook::push::Branch::Next) => { + let commit = git::Commit::from(push); + if self.last_next_commit.as_ref() == Some(&commit) { + info!( + branch = %config.branches().next(), + %commit, + "Ignoring - already aware of branch at commit", + ); + return; + } + self.last_next_commit.replace(commit); + } + Some(config::webhook::push::Branch::Dev) => { + let commit = git::Commit::from(push); + if self.last_dev_commit.as_ref() == Some(&commit) { + info!( + branch = %config.branches().dev(), + %commit, + "Ignoring - already aware of branch at commit", + ); + return; + } + self.last_dev_commit.replace(commit); + } + }, } - } -} - -pub fn split_ref(reference: &str) -> (&str, &str) { - reference.split_at(11) -} - -#[derive(Debug, serde::Deserialize)] -struct Push { - #[serde(rename = "ref")] - reference: String, - after: String, - head_commit: HeadCommit, -} -impl Push { - pub fn branch(&self, repo_branches: &RepoBranches) -> Option { - if !self.reference.starts_with("refs/heads/") { - warn!(r#ref = self.reference, "Unexpected ref"); - return None; - } - let (_, branch) = split_ref(&self.reference); - let branch = BranchName::new(branch); - if branch == repo_branches.main() { - return Some(Branch::Main); - } - if branch == repo_branches.next() { - return Some(Branch::Next); - } - if branch == repo_branches.dev() { - return Some(Branch::Dev); - } - warn!(%branch, "Unexpected branch"); - None - } - - pub fn commit(&self) -> git::Commit { - git::Commit::new( - git::commit::Sha::new(self.after.clone()), - git::commit::Message::new(self.head_commit.message.clone()), - ) - } -} - -#[derive(Debug)] -pub enum Branch { - Main, - Next, - Dev, -} - -#[derive(Debug, serde::Deserialize)] -struct HeadCommit { - message: String, -} - -#[derive(Message, Debug, Clone, derive_more::Constructor)] -#[rtype(result = "()")] -pub struct WebhookMessage { - forge_alias: ForgeAlias, - repo_alias: RepoAlias, - authorisation: WebhookAuth, - body: Body, -} -impl WebhookMessage { - pub const fn forge_alias(&self) -> &ForgeAlias { - &self.forge_alias - } - pub const fn repo_alias(&self) -> &RepoAlias { - &self.repo_alias - } - pub const fn body(&self) -> &Body { - &self.body - } - pub const fn authorisation(&self) -> &WebhookAuth { - &self.authorisation - } -} - -#[derive(Clone, Debug, derive_more::Constructor)] -pub struct Body(String); -impl Body { - pub fn as_str(&self) -> &str { - self.0.as_str() + let message_token = self.message_token.next(); + info!( + token = %message_token, + "New commit" + ); + ctx.address().do_send(ValidateRepo { message_token }); } } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 4b6049f..0b57586 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -3,11 +3,6 @@ name = "git-next-server" version = { workspace = true } edition = { workspace = true } -[features] -default = ["forgejo"] -forgejo = [] -github = [] - [dependencies] git-next-config = { workspace = true } git-next-git = { workspace = true } diff --git a/crates/server/src/actors/webhook/mod.rs b/crates/server/src/actors/webhook/mod.rs index 885fc75..502535f 100644 --- a/crates/server/src/actors/webhook/mod.rs +++ b/crates/server/src/actors/webhook/mod.rs @@ -1,13 +1,13 @@ // crate::server::actors::webhook +use actix::prelude::*; mod router; mod server; +use git_next_config as config; + use std::net::SocketAddr; -use actix::prelude::*; - -use git_next_repo_actor::webhook::WebhookMessage; pub use router::AddWebhookRecipient; pub use router::WebhookRouter; use tracing::Instrument; @@ -17,10 +17,13 @@ pub struct WebhookActor { socket_addr: SocketAddr, span: tracing::Span, spawn_handle: Option, - message_receiver: Recipient, + message_receiver: Recipient, } impl WebhookActor { - pub fn new(socket_addr: SocketAddr, message_receiver: Recipient) -> Self { + pub fn new( + socket_addr: SocketAddr, + message_receiver: Recipient, + ) -> Self { let span = tracing::info_span!("WebhookActor"); Self { socket_addr, @@ -34,7 +37,7 @@ impl Actor for WebhookActor { type Context = actix::Context; fn started(&mut self, ctx: &mut Self::Context) { let _gaurd = self.span.enter(); - let address: Recipient = self.message_receiver.clone(); + let address: Recipient = self.message_receiver.clone(); let server = server::start(self.socket_addr, address); let spawn_handle = ctx.spawn(server.in_current_span().into_actor(self)); self.spawn_handle.replace(spawn_handle); diff --git a/crates/server/src/actors/webhook/router.rs b/crates/server/src/actors/webhook/router.rs index 7205788..993bd9d 100644 --- a/crates/server/src/actors/webhook/router.rs +++ b/crates/server/src/actors/webhook/router.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; use actix::prelude::*; use derive_more::Constructor; +use git_next_config::WebhookMessage; use git_next_config::{ForgeAlias, RepoAlias}; -use git_next_repo_actor::webhook::WebhookMessage; use tracing::{debug, info}; pub struct WebhookRouter { diff --git a/crates/server/src/actors/webhook/server.rs b/crates/server/src/actors/webhook/server.rs index 57f3117..ce83c55 100644 --- a/crates/server/src/actors/webhook/server.rs +++ b/crates/server/src/actors/webhook/server.rs @@ -1,14 +1,17 @@ // -use std::net::SocketAddr; +use std::{collections::HashMap, net::SocketAddr}; use actix::prelude::*; -use git_next_config::{ForgeAlias, RepoAlias}; -use git_next_repo_actor::webhook::{self, WebhookAuth, WebhookMessage}; -use tracing::{info, warn}; -use warp::reject::Rejection; +use config::{ForgeAlias, RepoAlias}; +use git_next_config as config; -pub async fn start(socket_addr: SocketAddr, address: actix::prelude::Recipient) { +use tracing::{info, warn}; + +pub async fn start( + socket_addr: SocketAddr, + address: actix::prelude::Recipient, +) { // start webhook server use warp::Filter; // Define the Warp route to handle incoming HTTP requests @@ -19,7 +22,7 @@ pub async fn start(socket_addr: SocketAddr, address: actix::prelude::Recipient, + |recipient: Recipient, forge_alias: String, repo_alias: String, // query: String, @@ -29,47 +32,26 @@ pub async fn start(socket_addr: SocketAddr, address: actix::prelude::Recipient { - let message = WebhookMessage::new( - forge_alias, - repo_alias, - authorisation, - body, - ); - recipient - .try_send(message) - .map(|_| { - info!("Message sent ok"); - warp::reply::with_status("OK", warp::http::StatusCode::OK) - }) - .map_err(|e| { - warn!("Unknown error: {:?}", e); - warp::reject() - }) - } - Err(e) => { - warn!(?e, "Failed to decode authorization header"); - Err(warp::reject()) - } - } - }, - ) + let body = config::webhook::message::Body::new( + String::from_utf8_lossy(&bytes).to_string(), + ); + let headers = headers + .into_iter() + .filter_map(|(k, v)| { + k.map(|k| (k.to_string(), v.to_str().unwrap_or_default().to_string())) + }) + .collect::>(); + let message = config::WebhookMessage::new(forge_alias, repo_alias, headers, body); + recipient + .try_send(message) + .map(|_| { + info!("Message sent ok"); + warp::reply::with_status("OK", warp::http::StatusCode::OK) + }) + .map_err(|e| { + warn!("Unknown error: {:?}", e); + warp::reject() + }) }, ); @@ -77,27 +59,3 @@ pub async fn start(socket_addr: SocketAddr, address: actix::prelude::Recipient Result { - WebhookAuth::new( - authorization_header - .to_str() - .map_err(|e| { - warn!("Invalid non-ascii value in authorization: {:?}", e); - warp::reject() - }) // valid characters - .map(|v| { - info!("raw auth header: {}", v); - v - })? - .strip_prefix("Basic ") - .ok_or_else(|| { - warn!("Authorization must be 'Basic'"); - warp::reject() - })?, // must start with "Basic " - ) - .map_err(|e| { - warn!(?e, "decode error"); - warp::reject() - }) -}