From adb44d18c9209d8e73a4e5ae2c12f0fc8d927909 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Tue, 16 Apr 2024 22:21:55 +0100 Subject: [PATCH] feat(server/gitforge): replace git abstraction --- Cargo.toml | 4 +- src/main.rs | 5 +- src/server/actors/repo/branch.rs | 157 +----------- src/server/actors/repo/config.rs | 9 +- src/server/actors/repo/mod.rs | 55 ++--- src/server/actors/repo/status.rs | 37 +-- src/server/config.rs | 18 +- src/server/forge/forgejo/config.rs | 179 +++----------- src/server/forge/forgejo/mod.rs | 83 +------ src/server/forge/mock/config.rs | 22 +- src/server/forge/mock/mod.rs | 23 -- src/server/git/mock.rs | 47 ---- src/server/git/mod.rs | 48 ---- src/server/git/real.rs | 57 ----- src/server/gitforge/errors.rs | 41 ++++ src/server/gitforge/forgejo/branch/get_all.rs | 54 +++++ src/server/gitforge/forgejo/branch/mod.rs | 7 + src/server/gitforge/forgejo/branch/reset.rs | 48 ++++ .../forgejo/branch/validate_positions.rs | 229 ++++++++++++++++++ src/server/gitforge/forgejo/file/mod.rs | 88 +++++++ src/server/gitforge/forgejo/mod.rs | 121 +++++++++ src/server/gitforge/github.rs | 21 ++ src/server/gitforge/mock_forge.rs | 55 +++++ src/server/gitforge/mod.rs | 85 +++++++ src/server/gitforge/tests/common.rs | 59 +++++ .../tests/data-forgejo-branches-get.json | 50 ++++ src/server/gitforge/tests/forgejo.rs | 48 ++++ src/server/gitforge/tests/github.rs | 8 + src/server/gitforge/tests/mod.rs | 15 ++ src/server/gitforge/types.rs | 29 +++ src/server/mod.rs | 14 +- src/server/types.rs | 11 - 32 files changed, 1066 insertions(+), 661 deletions(-) delete mode 100644 src/server/git/mock.rs delete mode 100644 src/server/git/mod.rs delete mode 100644 src/server/git/real.rs create mode 100644 src/server/gitforge/errors.rs create mode 100644 src/server/gitforge/forgejo/branch/get_all.rs create mode 100644 src/server/gitforge/forgejo/branch/mod.rs create mode 100644 src/server/gitforge/forgejo/branch/reset.rs create mode 100644 src/server/gitforge/forgejo/branch/validate_positions.rs create mode 100644 src/server/gitforge/forgejo/file/mod.rs create mode 100644 src/server/gitforge/forgejo/mod.rs create mode 100644 src/server/gitforge/github.rs create mode 100644 src/server/gitforge/mock_forge.rs create mode 100644 src/server/gitforge/mod.rs create mode 100644 src/server/gitforge/tests/common.rs create mode 100644 src/server/gitforge/tests/data-forgejo-branches-get.json create mode 100644 src/server/gitforge/tests/forgejo.rs create mode 100644 src/server/gitforge/tests/github.rs create mode 100644 src/server/gitforge/tests/mod.rs create mode 100644 src/server/gitforge/types.rs diff --git a/Cargo.toml b/Cargo.toml index ac4c705..330578e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [features] default = ["forgejo"] forgejo = [] +github = [] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -23,6 +24,7 @@ base64 = "0.22" # git gix = "0.62" +async-trait = "0.1" # fs/network kxio = { version = "1.0", features = [ @@ -59,7 +61,7 @@ tokio = { version = "1.37", features = ["full"] } [dev-dependencies] # Testing -# assert2 = "0.3" +assert2 = "0.3" test-log = "0.2" anyhow = "1.0" diff --git a/src/main.rs b/src/main.rs index 4845e95..794dd5a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,8 +4,6 @@ mod server; use clap::Parser; use kxio::{filesystem, network::Network}; -use crate::server::git::Git; - #[derive(Parser, Debug)] #[clap(version = clap::crate_version!(), author = clap::crate_authors!(), about = clap::crate_description!())] struct Commands { @@ -28,7 +26,6 @@ enum Server { async fn main() { let fs = filesystem::FileSystem::new_real(None); let net = Network::new_real(); - let git = Git::new_real(); let commands = Commands::parse(); match commands.command { @@ -40,7 +37,7 @@ async fn main() { server::init(fs); } Server::Start => { - server::start(fs, net, git).await; + server::start(fs, net).await; } }, } diff --git a/src/server/actors/repo/branch.rs b/src/server/actors/repo/branch.rs index 2e1a43b..f24e171 100644 --- a/src/server/actors/repo/branch.rs +++ b/src/server/actors/repo/branch.rs @@ -1,147 +1,17 @@ -use actix::prelude::*; - -use kxio::network::{self, Network}; -use tracing::{error, info, warn}; +use tracing::{info, warn}; use crate::server::{ - config::{self, RepoConfig, RepoDetails}, - forge::{self, CommitHistories}, - git::Git, - types::ResetForce, + config, forge, + gitforge::{self, Force, Forge}, }; -use super::{RepoActor, StartMonitoring}; - -#[tracing::instrument(fields(forge_name = %repo_details.forge.name, repo_name = %repo_details.name), skip_all)] -pub async fn validate_positions( - repo_details: config::RepoDetails, - config: config::RepoConfig, - addr: Addr, - git: Git, - net: network::Network, -) { - // Collect Commit Histories for `main`, `next` and `dev` branches - let commit_histories = get_commit_histories(&repo_details, &config, &net).await; - let commit_histories = match commit_histories { - Ok(commit_histories) => commit_histories, - Err(err) => { - error!(?err, "Failed to get commit histories"); - return; - } - }; - - // Validations - let Some(main) = commit_histories.main.first().cloned() else { - warn!("No commits on main branch '{}'", config.branches().main()); - return; - }; - // verify that next is an ancestor of dev, and force update to it main if it isn't - let Some(next) = commit_histories.next.first().cloned() else { - warn!("No commits on next branch '{}", config.branches().next()); - return; - }; - let next_is_ancestor_of_dev = commit_histories.dev.iter().any(|dev| dev == &next); - if !next_is_ancestor_of_dev { - info!("Next is not an ancestor of dev - resetting next to main"); - reset_next_to_main(next, main, &repo_details, &config, &git); - return; - } - - let next_commits = commit_histories - .next - .into_iter() - .take(2) - .collect::>(); - if !next_commits.contains(&main) { - warn!( - "Main branch '{}' is not on the same commit as next branch '{}', or it's parent - resetting next to main", - config.branches().main(), - config.branches().next() - ); - reset_next_to_main(next, main, &repo_details, &config, &git); - return; - } - let Some(next) = next_commits.first().cloned() else { - warn!("No commits on next branch '{}'", config.branches().next()); - return; - }; - let dev_has_next = commit_histories - .dev - .iter() - .any(|commit| commit == &next_commits[0]); - if !dev_has_next { - warn!( - "Dev branch '{}' is not based on next branch '{}' - next branch will be updated shortly", - config.branches().dev(), - config.branches().next() - ); - return; // dev is not based on next - } - let dev_has_main = commit_histories.dev.iter().any(|commit| commit == &main); - if !dev_has_main { - warn!( - "Dev branch '{}' is not based on main branch '{}' - user should rebase onto main branch '{}'", - config.branches().dev(), - config.branches().main(), - config.branches().main(), - ); - return; - } - let Some(dev) = commit_histories.dev.first().cloned() else { - warn!("No commits on dev branch '{}'", config.branches().dev()); - return; - }; - addr.do_send(StartMonitoring { - main, - next, - dev, - dev_commit_history: commit_histories.dev, - }); -} - -async fn get_commit_histories( - repo_details: &RepoDetails, - config: &RepoConfig, - net: &Network, -) -> Result { - match repo_details.forge.forge_type { - #[cfg(feature = "forgejo")] - config::ForgeType::ForgeJo => { - forge::forgejo::get_commit_histories(repo_details, config, net).await - } - #[cfg(test)] - config::ForgeType::MockForge => { - forge::mock::get_commit_histories(repo_details, config, net).await - } - } -} - -fn reset_next_to_main( - next: forge::Commit, - main: forge::Commit, - repo_details: &RepoDetails, - repo_config: &RepoConfig, - git: &Git, -) { - let reset = git.reset( - &repo_config.branches().next(), - main.into(), - ResetForce::Force(next.into()), - repo_details, - ); - if let Err(err) = reset { - warn!(?err, "Failed"); - } -} - // advance next to the next commit towards the head of the dev branch #[tracing::instrument(fields(next), skip_all)] pub async fn advance_next( next: forge::Commit, dev_commit_history: Vec, - repo_details: config::RepoDetails, repo_config: config::RepoConfig, - git: Git, + forge: Forge, ) { let next_commit = find_next_commit_on_dev(next, dev_commit_history); let Some(commit) = next_commit else { @@ -153,14 +23,9 @@ pub async fn advance_next( return; } info!("Advancing next to commit '{}'", commit); - if let Err(err) = git.reset( - &repo_config.branches().next(), - commit.into(), - ResetForce::None, - &repo_details, - ) { + if let Err(err) = forge.branch_reset(repo_config.branches().next(), commit.into(), Force::No) { warn!(?err, "Failed") - }; + } } #[tracing::instrument] @@ -199,17 +64,11 @@ fn find_next_commit_on_dev( #[tracing::instrument(fields(next), skip_all)] pub async fn advance_main( next: forge::Commit, - repo_details: config::RepoDetails, repo_config: config::RepoConfig, - git: Git, + forge: gitforge::Forge, ) { info!("Advancing main to next"); - if let Err(err) = git.reset( - &repo_config.branches().main(), - next.into(), - ResetForce::None, - &repo_details, - ) { + if let Err(err) = forge.branch_reset(repo_config.branches().main(), next.into(), Force::No) { warn!(?err, "Failed") }; } diff --git a/src/server/actors/repo/config.rs b/src/server/actors/repo/config.rs index 74a52ce..32ef412 100644 --- a/src/server/actors/repo/config.rs +++ b/src/server/actors/repo/config.rs @@ -1,23 +1,22 @@ use actix::prelude::*; -use kxio::network::Network; use tracing::error; use crate::server::{ config::{ForgeType, RepoDetails}, - forge, + forge, gitforge, }; use super::{LoadedConfig, RepoActor}; -pub async fn load(details: RepoDetails, addr: Addr, net: Network) { +pub async fn load(details: RepoDetails, addr: Addr, forge: gitforge::Forge) { let config = match details.config { Some(config) => config, None => { let config = match details.forge.forge_type { #[cfg(feature = "forgejo")] - ForgeType::ForgeJo => forge::forgejo::config::load(&details, &net).await, + ForgeType::ForgeJo => forge::forgejo::config::load(&details, &forge).await, #[cfg(test)] - ForgeType::MockForge => forge::mock::config::load(&details, &net).await, + ForgeType::MockForge => forge::mock::config::load(&details, &forge).await, }; match config { Ok(config) => config, diff --git a/src/server/actors/repo/mod.rs b/src/server/actors/repo/mod.rs index a634f1b..44316fe 100644 --- a/src/server/actors/repo/mod.rs +++ b/src/server/actors/repo/mod.rs @@ -10,8 +10,7 @@ use tracing::{info, warn}; use crate::server::{ actors::repo::webhook::WebhookAuth, config::{RepoConfig, RepoDetails, Webhook}, - forge, - git::Git, + forge, gitforge, }; use self::webhook::WebhookId; @@ -25,15 +24,18 @@ pub struct RepoActor { last_next_commit: Option, last_dev_commit: Option, net: Network, - git: Git, + forge: gitforge::Forge, } impl RepoActor { - pub(crate) const fn new( - details: RepoDetails, - webhook: Webhook, - net: Network, - git: Git, - ) -> Self { + pub(crate) fn new(details: RepoDetails, webhook: Webhook, net: Network) -> Self { + let forge = match details.forge.forge_type { + #[cfg(feature = "forgejo")] + crate::server::config::ForgeType::ForgeJo => { + gitforge::Forge::new_forgejo(details.clone(), net.clone()) + } + #[cfg(test)] + crate::server::config::ForgeType::MockForge => gitforge::Forge::new_mock(), + }; Self { details, webhook, @@ -43,7 +45,7 @@ impl RepoActor { last_next_commit: None, last_dev_commit: None, net, - git, + forge, } } } @@ -73,8 +75,10 @@ impl Handler for RepoActor { info!(%self.details, "Starting Repo"); let details = self.details.clone(); let addr = ctx.address(); - let net = self.net.clone(); - config::load(details, addr, net).into_actor(self).wait(ctx); + let forge = self.forge.clone(); + config::load(details, addr, forge) + .into_actor(self) + .wait(ctx); } } @@ -108,11 +112,9 @@ impl Handler for RepoActor { type Result = (); fn handle(&mut self, _msg: ValidateRepo, ctx: &mut Self::Context) -> Self::Result { if let Some(repo_config) = self.details.config.clone() { - let repo_details = self.details.clone(); + let forge = self.forge.clone(); let addr = ctx.address(); - let net = self.net.clone(); - let git = self.git.clone(); - branch::validate_positions(repo_details, repo_config, addr, git, net) + async move { forge.branches_validate_positions(repo_config, addr).await } .into_actor(self) .wait(ctx); } @@ -143,22 +145,16 @@ impl Handler for RepoActor { let webhook = self.webhook.clone(); let addr = ctx.address(); let net = self.net.clone(); - let git = self.git.clone(); + let forge = self.forge.clone(); if next_ahead_of_main { - status::check_next(msg.next, repo_details, addr, net) + status::check_next(msg.next, addr, forge) .into_actor(self) .wait(ctx); } else if dev_ahead_of_next { - branch::advance_next( - msg.next, - msg.dev_commit_history, - repo_details, - repo_config, - git, - ) - .into_actor(self) - .wait(ctx); + branch::advance_next(msg.next, msg.dev_commit_history, repo_config, forge) + .into_actor(self) + .wait(ctx); } else if self.webhook_id.is_none() { webhook::register(repo_details, webhook, addr, net) .into_actor(self) @@ -184,13 +180,12 @@ pub struct AdvanceMainTo(pub forge::Commit); impl Handler for RepoActor { type Result = (); fn handle(&mut self, msg: AdvanceMainTo, ctx: &mut Self::Context) -> Self::Result { - let repo_details = self.details.clone(); let Some(repo_config) = self.details.config.clone() else { warn!("No config loaded"); return; }; - let git = self.git.clone(); - branch::advance_main(msg.0, repo_details, repo_config, git) + let forge = self.forge.clone(); + branch::advance_main(msg.0, repo_config, forge) .into_actor(self) .wait(ctx); } diff --git a/src/server/actors/repo/status.rs b/src/server/actors/repo/status.rs index 916c099..f26dbc5 100644 --- a/src/server/actors/repo/status.rs +++ b/src/server/actors/repo/status.rs @@ -2,49 +2,24 @@ use actix::prelude::*; use gix::trace::warn; use tracing::info; -use crate::server::{ - actors::repo::ValidateRepo, - config::{self, ForgeType}, - forge, -}; +use crate::server::{actors::repo::ValidateRepo, forge, gitforge}; use super::AdvanceMainTo; -pub async fn check_next( - next: forge::Commit, - repo_details: config::RepoDetails, - addr: Addr, - net: kxio::network::Network, -) { +pub async fn check_next(next: forge::Commit, addr: Addr, forge: gitforge::Forge) { // get the status - pass, fail, pending (all others map to fail, e.g. error) - let status = match repo_details.forge.forge_type { - #[cfg(feature = "forgejo")] - ForgeType::ForgeJo => { - forge::forgejo::get_commit_status(next.clone(), &repo_details, net).await - } - #[cfg(test)] - ForgeType::MockForge => { - forge::mock::get_commit_status(next.clone(), &repo_details, &net).await - } - }; + let status = forge.commit_status(&next).await; info!(?status, "Checking next branch"); match status { - Status::Pass => { + gitforge::CommitStatus::Pass => { addr.do_send(AdvanceMainTo(next)); } - Status::Pending => { + gitforge::CommitStatus::Pending => { tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; addr.do_send(ValidateRepo); } - Status::Fail => { + gitforge::CommitStatus::Fail => { warn!("Checks have failed"); } } } - -#[derive(Debug)] -pub enum Status { - Pass, - Fail, - Pending, -} diff --git a/src/server/config.rs b/src/server/config.rs index a357d8e..0afd9eb 100644 --- a/src/server/config.rs +++ b/src/server/config.rs @@ -61,9 +61,13 @@ pub struct RepoConfig { branches: RepoBranches, } impl RepoConfig { - #[allow(dead_code)] - pub(crate) fn load(toml: &str) -> Result> { - toml::from_str(toml).map_err(OneOf::new) + #[cfg(test)] + pub const fn new(branches: RepoBranches) -> Self { + Self { branches } + } + // #[cfg(test)] + pub fn load(toml: &str) -> Result { + toml::from_str(toml) } pub const fn branches(&self) -> &RepoBranches { @@ -84,6 +88,14 @@ pub struct RepoBranches { dev: String, } impl RepoBranches { + #[cfg(test)] + pub fn new(main: impl Into, next: impl Into, dev: impl Into) -> Self { + Self { + main: main.into(), + next: next.into(), + dev: dev.into(), + } + } pub fn main(&self) -> BranchName { BranchName(self.main.clone()) } diff --git a/src/server/forge/forgejo/config.rs b/src/server/forge/forgejo/config.rs index 3d329e7..3b47c03 100644 --- a/src/server/forge/forgejo/config.rs +++ b/src/server/forge/forgejo/config.rs @@ -1,9 +1,11 @@ -use base64::Engine; -use kxio::network::{self, Network}; +use kxio::network; use terrors::OneOf; -use tracing::{error, info}; +use tracing::error; -use crate::server::config::{BranchName, RepoConfig, RepoDetails}; +use crate::server::{ + config::{BranchName, RepoConfig, RepoDetails}, + gitforge::{self, ForgeFileError}, +}; #[derive(Debug)] pub struct RepoConfigFileNotFound; @@ -16,177 +18,56 @@ pub struct RepoConfigParseError; #[derive(Debug)] pub struct RepoConfigUnknownError(pub network::StatusCode); -type RepoConfigLoadErrors = ( - RepoConfigFileNotFound, - RepoConfigIsNotFile, - RepoConfigDecodeError, - RepoConfigParseError, - RepoConfigUnknownError, - RepoConfigBranchNotFound, -); - pub async fn load( details: &RepoDetails, - net: &Network, -) -> Result> { - let hostname = &details.forge.hostname; - let path = &details.repo; - let filepath = ".git-next.toml"; - let branch = &details.branch; - use secrecy::ExposeSecret; - let token = details.forge.token.expose_secret(); - let url = network::NetUrl::new(format!( - "https://{hostname}/api/v1/repos/{path}/contents/{filepath}?ref={branch}&token={token}" - )); - - info!(%url, "Loading config"); - let request = network::NetRequest::new( - network::RequestMethod::Get, - url, - network::NetRequestHeaders::new(), - network::RequestBody::None, - network::ResponseType::Json, - None, - network::NetRequestLogging::None, - ); - let result = net.get::(request).await; - let response = result.map_err(|e| match e { - network::NetworkError::RequestFailed(_, status_code, _) - | network::NetworkError::RequestError(_, status_code, _) => match status_code { - network::StatusCode::NOT_FOUND => OneOf::new(RepoConfigFileNotFound), - _ => OneOf::new(RepoConfigUnknownError(status_code)), - }, - _ => OneOf::new(RepoConfigUnknownError( - network::StatusCode::INTERNAL_SERVER_ERROR, - )), - })?; - let status = response.status_code(); - let config = match response.response_body() { - Some(body) => { - // we need to decode (see encoding field) the value of 'content' from the response - match body.content_type { - ForgeContentsType::File => decode_config(body).map_err(OneOf::broaden), - _ => Err(OneOf::new(RepoConfigIsNotFile)), - } - } - None => { - error!(%status, "Failed to fetch repo config file"); - Err(OneOf::new(RepoConfigUnknownError(status))) - } - }?; - let config = validate(details, config, net) + forge: &gitforge::Forge, +) -> Result> { + let contents = forge + .file_contents_get(&details.branch, ".git-next.toml") .await - .map_err(OneOf::broaden)?; + .map_err(OneOf::new)?; + let config = RepoConfig::load(&contents).map_err(OneOf::new)?; + let config = validate(config, forge).await.map_err(OneOf::new)?; Ok(config) } -fn decode_config( - body: ForgeContentsResponse, -) -> Result> { - match body.encoding.as_str() { - "base64" => { - let decoded = base64::engine::general_purpose::STANDARD - .decode(body.content) - .map_err(|_| OneOf::new(RepoConfigDecodeError))?; - let decoded = - String::from_utf8(decoded).map_err(|_| OneOf::new(RepoConfigDecodeError))?; - let config = toml::from_str(&decoded).map_err(|_| OneOf::new(RepoConfigParseError))?; - Ok(config) - } - _ => Err(OneOf::new(RepoConfigDecodeError)), - } -} - -#[derive(Clone, Debug, serde::Deserialize)] -struct ForgeContentsResponse { - #[serde(rename = "type")] - pub content_type: ForgeContentsType, - pub content: String, - pub encoding: String, -} -#[derive(Clone, Debug, serde::Deserialize)] -enum ForgeContentsType { - #[serde(rename = "file")] - File, - #[serde(rename = "dir")] - Dir, - #[serde(rename = "symlink")] - Symlink, - #[serde(rename = "submodule")] - Submodule, -} - #[derive(Debug)] -pub struct RepoConfigBranchNotFound(pub BranchName); - -type RepoConfigValidateErrors = (RepoConfigBranchNotFound, RepoConfigUnknownError); +pub enum RepoConfigValidationErrors { + Forge(gitforge::ForgeBranchError), + BranchNotFound(BranchName), +} pub async fn validate( - details: &RepoDetails, config: RepoConfig, - net: &Network, -) -> Result> { - let hostname = &details.forge.hostname; - let path = &details.repo; - use secrecy::ExposeSecret; - let token = details.forge.token.expose_secret(); - let url = network::NetUrl::new(format!( - "https://{hostname}/api/v1/repos/{path}/branches?token={token}" - )); - - info!(%url, "Listing branches"); - let request = network::NetRequest::new( - network::RequestMethod::Get, - url, - network::NetRequestHeaders::new(), - network::RequestBody::None, - network::ResponseType::Json, - None, - network::NetRequestLogging::None, - ); - let result = net.get::(request).await; - let response = result.map_err(|e| { + forge: &gitforge::Forge, +) -> Result { + let branches = forge.branches_get_all().await.map_err(|e| { error!(?e, "Failed to list branches"); - OneOf::new(RepoConfigUnknownError( - network::StatusCode::INTERNAL_SERVER_ERROR, - )) + RepoConfigValidationErrors::Forge(e) })?; - let branches = response.response_body().unwrap_or_default(); if !branches .iter() - .any(|branch| branch.name() == config.branches().main()) + .any(|branch| branch.name() == &config.branches().main()) { - return Err(OneOf::new(RepoConfigBranchNotFound( + return Err(RepoConfigValidationErrors::BranchNotFound( config.branches().main(), - ))); + )); } if !branches .iter() - .any(|branch| branch.name() == config.branches().next()) + .any(|branch| branch.name() == &config.branches().next()) { - return Err(OneOf::new(RepoConfigBranchNotFound( + return Err(RepoConfigValidationErrors::BranchNotFound( config.branches().next(), - ))); + )); } if !branches .iter() - .any(|branch| branch.name() == config.branches().dev()) + .any(|branch| branch.name() == &config.branches().dev()) { - return Err(OneOf::new(RepoConfigBranchNotFound( + return Err(RepoConfigValidationErrors::BranchNotFound( config.branches().dev(), - ))); + )); } Ok(config) } - -type BranchList = Vec; - -#[derive(Debug, serde::Deserialize)] -struct Branch { - name: String, -} -impl Branch { - fn name(&self) -> BranchName { - BranchName(self.name.clone()) - } -} diff --git a/src/server/forge/forgejo/mod.rs b/src/server/forge/forgejo/mod.rs index f2174a3..79e4948 100644 --- a/src/server/forge/forgejo/mod.rs +++ b/src/server/forge/forgejo/mod.rs @@ -1,46 +1,10 @@ use kxio::network; use secrecy::ExposeSecret; -use tracing::{debug, error, warn}; -use crate::server; -use crate::server::{actors::repo::status::Status, config::BranchName, forge}; - -use super::CommitHistories; +use crate::server::{self, config::BranchName, forge}; pub mod config; -pub async fn get_commit_histories( - repo_details: &server::config::RepoDetails, - config: &server::config::RepoConfig, - net: &kxio::network::Network, -) -> Result { - let main = (get_commit_history(repo_details, &config.branches().main(), vec![], net).await)?; - let main_head = main[0].clone(); - let next = (get_commit_history( - repo_details, - &config.branches().next(), - vec![main_head.clone()], - net, - ) - .await)?; - let next_head = next[0].clone(); - let dev = (get_commit_history( - repo_details, - &config.branches().dev(), - vec![next_head, main_head], - net, - ) - .await)?; - debug!( - main = main.len(), - next = next.len(), - dev = dev.len(), - "Commit histories" - ); - let histories = CommitHistories { main, next, dev }; - Ok(histories) -} - #[tracing::instrument(fields(%branch_name),skip_all)] async fn get_commit_history( repo_details: &server::config::RepoDetails, @@ -114,51 +78,6 @@ impl From for forge::Commit { } } -pub async fn get_commit_status( - next: forge::Commit, - repo_details: &crate::server::config::RepoDetails, - net: network::Network, -) -> Status { - let hostname = &repo_details.forge.hostname; - let path = &repo_details.repo; - let token = repo_details.forge.token.expose_secret(); - let url = network::NetUrl::new(format!( - "https://{hostname}/api/v1/repos/{path}/commits/{next}/status?token={token}" - )); - - let request = network::NetRequest::new( - network::RequestMethod::Get, - url, - network::NetRequestHeaders::new(), - network::RequestBody::None, - network::ResponseType::Json, - None, - network::NetRequestLogging::None, - ); - let result = net.get::(request).await; - match result { - Ok(response) => { - match response.response_body() { - Some(status) => match status.state { - CommitStatusState::Success => Status::Pass, - CommitStatusState::Pending => Status::Pending, - CommitStatusState::Failure => Status::Fail, - CommitStatusState::Error => Status::Fail, - CommitStatusState::Blank => Status::Pending, - }, - None => { - warn!("No status found for commit"); - Status::Pending // assume issue is transient and allow retry - } - } - } - Err(e) => { - error!(?e, "Failed to get commit status"); - Status::Pending // assume issue is transient and allow retry - } - } -} - #[derive(Debug, serde::Deserialize)] pub struct CombinedStatus { pub state: CommitStatusState, diff --git a/src/server/forge/mock/config.rs b/src/server/forge/mock/config.rs index 377a17e..6202bd2 100644 --- a/src/server/forge/mock/config.rs +++ b/src/server/forge/mock/config.rs @@ -1,16 +1,14 @@ +use terrors::OneOf; + +use crate::server::{ + config::RepoConfig, + forge::forgejo::config::RepoConfigValidationErrors, + gitforge::{self, ForgeFileError}, +}; + pub async fn load( _details: &crate::server::config::RepoDetails, - _net: &kxio::network::Network, -) -> Result< - crate::server::config::RepoConfig, - terrors::OneOf<( - crate::server::forge::forgejo::config::RepoConfigFileNotFound, - crate::server::forge::forgejo::config::RepoConfigIsNotFile, - crate::server::forge::forgejo::config::RepoConfigDecodeError, - crate::server::forge::forgejo::config::RepoConfigParseError, - crate::server::forge::forgejo::config::RepoConfigUnknownError, - crate::server::forge::forgejo::config::RepoConfigBranchNotFound, - )>, -> { + _forge: &gitforge::Forge, +) -> Result> { todo!() } diff --git a/src/server/forge/mock/mod.rs b/src/server/forge/mock/mod.rs index 620a238..ef68c36 100644 --- a/src/server/forge/mock/mod.rs +++ b/src/server/forge/mock/mod.rs @@ -1,24 +1 @@ -use kxio::network::{Network, NetworkError}; - -use crate::server::{ - config::{RepoConfig, RepoDetails}, - forge::CommitHistories, -}; - pub mod config; - -pub async fn get_commit_histories( - _repo_details: &RepoDetails, - _config: &RepoConfig, - _net: &Network, -) -> Result { - todo!() -} - -pub async fn get_commit_status( - _clone: super::Commit, - _repo_details: &RepoDetails, - _net: &Network, -) -> crate::server::actors::repo::status::Status { - todo!() -} diff --git a/src/server/git/mock.rs b/src/server/git/mock.rs deleted file mode 100644 index 4aa6809..0000000 --- a/src/server/git/mock.rs +++ /dev/null @@ -1,47 +0,0 @@ -use std::collections::HashMap; - -use crate::server::{ - config::{BranchName, RepoDetails}, - git::GitResetResult, - types::{GitRef, ResetForce}, -}; - -#[derive(Clone)] -pub struct MockGit { - reset: HashMap<(BranchName, GitRef), GitResetResult>, -} -impl MockGit { - pub(super) fn new() -> Self { - Self { - reset: HashMap::new(), - } - } - #[allow(dead_code)] // TODO: (#38) Add tests - pub const fn into_git(self) -> super::Git { - super::Git::Mock(self) - } - - #[allow(dead_code)] // TODO: (#38) Add tests - pub fn expect_reset_result( - &mut self, - branch_name: &BranchName, - gitref: &GitRef, - result: GitResetResult, - ) { - self.reset - .insert((branch_name.clone(), gitref.clone()), result); - } -} -impl super::GitLike for MockGit { - fn reset( - &self, - branch: &BranchName, - gitref: GitRef, - _reset_force: ResetForce, - _repo_details: &RepoDetails, - ) -> GitResetResult { - self.reset - .get(&(branch.clone(), gitref.clone())) - .map_or_else(|| panic!("unexpected: {} to {}", branch, gitref), |r| *r) - } -} diff --git a/src/server/git/mod.rs b/src/server/git/mod.rs deleted file mode 100644 index 5cafbe0..0000000 --- a/src/server/git/mod.rs +++ /dev/null @@ -1,48 +0,0 @@ -use std::ops::Deref; - -use crate::server::{ - config::{BranchName, RepoDetails}, - git::{mock::MockGit, real::RealGit}, - types::{GitRef, ResetForce}, -}; - -mod mock; -mod real; - -#[derive(Clone)] -pub enum Git { - Real(RealGit), - #[allow(dead_code)] // TODO: (#38) Add tests - Mock(MockGit), -} -impl Git { - pub const fn new_real() -> Self { - Self::Real(RealGit::new()) - } - #[allow(dead_code)] // TODO: (#38) Add tests - pub fn new_mock() -> MockGit { - MockGit::new() - } -} -impl Deref for Git { - type Target = dyn GitLike; - - fn deref(&self) -> &Self::Target { - match self { - Self::Real(git) => git, - Self::Mock(git) => git, - } - } -} - -pub trait GitLike: Sync + Send { - fn reset( - &self, - branch: &BranchName, - gitref: GitRef, - reset_force: ResetForce, - repo_details: &RepoDetails, - ) -> GitResetResult; -} - -pub type GitResetResult = Result<(), ()>; diff --git a/src/server/git/real.rs b/src/server/git/real.rs deleted file mode 100644 index f5cf78d..0000000 --- a/src/server/git/real.rs +++ /dev/null @@ -1,57 +0,0 @@ -use secrecy::ExposeSecret; -use tracing::{info, warn}; - -use crate::server::{ - config::{BranchName, RepoDetails}, - git::GitResetResult, - types::{GitRef, ResetForce}, -}; - -#[derive(Clone)] -pub struct RealGit; -impl RealGit { - pub(super) const fn new() -> Self { - Self - } -} -impl super::GitLike for RealGit { - fn reset( - &self, - branch: &BranchName, - gitref: GitRef, - reset_force: ResetForce, - repo_details: &RepoDetails, - ) -> GitResetResult { - let user = &repo_details.forge.user; - let hostname = &repo_details.forge.hostname; - let path = &repo_details.repo; - let token = &repo_details.forge.token.expose_secret(); - let origin = format!("https://{user}:{token}@{hostname}/{path}.git"); - let force = match reset_force { - ResetForce::None => "".to_string(), - ResetForce::Force(old_ref) => format!("--force-with-lease={branch}:{old_ref}"), - }; - // INFO: never log the command as it contains the API token - let command = format!("/usr/bin/git push {origin} {gitref}:{branch} {force}"); - drop(origin); - info!("Resetting {branch} to {gitref}"); - match gix::command::prepare(command) - .with_shell_allow_argument_splitting() - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - { - Ok(mut child) => match child.wait() { - Ok(_) => Ok(()), - Err(err) => { - warn!(?err, "Failed (wait)"); - Err(()) - } - }, - Err(err) => { - warn!(?err, "Failed (spawn)"); - Err(()) - } - } - } -} diff --git a/src/server/gitforge/errors.rs b/src/server/gitforge/errors.rs new file mode 100644 index 0000000..60c1d9b --- /dev/null +++ b/src/server/gitforge/errors.rs @@ -0,0 +1,41 @@ +use crate::server::config::BranchName; + +#[derive(Debug)] +pub enum ForgeFileError { + NotFound(String), + ParseContent, + DecodeFromBase64, + DecodeFromUtf8, + UnknownEncoding(String), + NotFile(String), + Unknown(String), +} +impl std::error::Error for ForgeFileError {} +impl std::fmt::Display for ForgeFileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotFound(file_path) => write!(f, "File not found: {file_path}"), + Self::NotFile(file_path) => write!(f, "Not a file: {file_path}"), + Self::DecodeFromBase64 => write!(f, "Unable to decode from base64"), + Self::DecodeFromUtf8 => write!(f, "Unable to decode from UTF-8"), + Self::UnknownEncoding(encoding) => write!(f, "Unknown file encoding: {encoding}"), + Self::ParseContent => write!(f, "Unable to parse file contents"), + Self::Unknown(status) => write!(f, "Unknown error (status: {status})"), + } + } +} + +#[derive(Debug)] +pub enum ForgeBranchError { + NotFound(BranchName), + NoneFound, +} +impl std::error::Error for ForgeBranchError {} +impl std::fmt::Display for ForgeBranchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotFound(branch_name) => write!(f, "Branch not found: {branch_name}"), + Self::NoneFound => write!(f, "Unable to find any branches"), + } + } +} diff --git a/src/server/gitforge/forgejo/branch/get_all.rs b/src/server/gitforge/forgejo/branch/get_all.rs new file mode 100644 index 0000000..0b69c1e --- /dev/null +++ b/src/server/gitforge/forgejo/branch/get_all.rs @@ -0,0 +1,54 @@ +use kxio::network::{self, Network}; +use tracing::{error, info}; + +use crate::server::{ + config::{BranchName, RepoDetails}, + gitforge::{self, ForgeBranchError}, +}; + +pub async fn get_all( + repo_details: &RepoDetails, + net: &Network, +) -> Result, ForgeBranchError> { + let hostname = &repo_details.forge.hostname; + let path = &repo_details.repo; + use secrecy::ExposeSecret; + let token = repo_details.forge.token.expose_secret(); + let url = network::NetUrl::new(format!( + "https://{hostname}/api/v1/repos/{path}/branches?token={token}" + )); + + info!(%url, "Listing branches"); + let request = network::NetRequest::new( + network::RequestMethod::Get, + url, + network::NetRequestHeaders::new(), + network::RequestBody::None, + network::ResponseType::Json, + None, + network::NetRequestLogging::None, + ); + let result = net.get::>(request).await; + let response = result.map_err(|e| { + error!(?e, "Failed to list branches"); + ForgeBranchError::NoneFound // BranchListNotAvailable + })?; + let branches = response + .response_body() + .unwrap_or_default() + .into_iter() + .map(|b| b.name()) + .map(gitforge::Branch) + .collect::>(); + Ok(branches) +} + +#[derive(Debug, serde::Deserialize)] +struct Branch { + name: String, +} +impl Branch { + fn name(&self) -> BranchName { + BranchName(self.name.clone()) + } +} diff --git a/src/server/gitforge/forgejo/branch/mod.rs b/src/server/gitforge/forgejo/branch/mod.rs new file mode 100644 index 0000000..cc3a766 --- /dev/null +++ b/src/server/gitforge/forgejo/branch/mod.rs @@ -0,0 +1,7 @@ +mod get_all; +mod reset; +mod validate_positions; + +pub use get_all::get_all; +pub use reset::reset; +pub use validate_positions::validate_positions; diff --git a/src/server/gitforge/forgejo/branch/reset.rs b/src/server/gitforge/forgejo/branch/reset.rs new file mode 100644 index 0000000..829b93d --- /dev/null +++ b/src/server/gitforge/forgejo/branch/reset.rs @@ -0,0 +1,48 @@ +use tracing::{info, warn}; + +use crate::server::{ + config::{BranchName, RepoDetails}, + gitforge::{BranchResetResult, Force}, + types::GitRef, +}; + +pub fn reset( + repo_details: &RepoDetails, + branch_name: BranchName, + to_commit: GitRef, + force: Force, +) -> BranchResetResult { + let user = &repo_details.forge.user; + let hostname = &repo_details.forge.hostname; + let path = &repo_details.repo; + use secrecy::ExposeSecret; + let expose_secret = &repo_details.forge.token; + let token = expose_secret.expose_secret(); + let origin = format!("https://{user}:{token}@{hostname}/{path}.git"); + let force = match force { + Force::No => "".to_string(), + Force::From(old_ref) => format!("--force-with-lease={branch_name}:{old_ref}"), + }; + // INFO: never log the command as it contains the API token within the 'origin' + let command = format!("/usr/bin/git push {origin} {to_commit}:{branch_name} {force}"); + drop(origin); + info!("Resetting {branch_name} to {to_commit}"); + match gix::command::prepare(command) + .with_shell_allow_argument_splitting() + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(mut child) => match child.wait() { + Ok(_) => Ok(()), + Err(err) => { + warn!(?err, "Failed (wait)"); + Err(()) + } + }, + Err(err) => { + warn!(?err, "Failed (spawn)"); + Err(()) + } + } +} diff --git a/src/server/gitforge/forgejo/branch/validate_positions.rs b/src/server/gitforge/forgejo/branch/validate_positions.rs new file mode 100644 index 0000000..023bd58 --- /dev/null +++ b/src/server/gitforge/forgejo/branch/validate_positions.rs @@ -0,0 +1,229 @@ +use actix::prelude::*; + +use kxio::network; +use tracing::{debug, error, info, warn}; + +use crate::server::{ + self, + actors::repo::{RepoActor, StartMonitoring}, + config::{BranchName, RepoConfig, RepoDetails}, + forge, + gitforge::{forgejo::ForgeJoEnv, Force, ForgeLike}, +}; + +pub async fn validate_positions( + forge: &ForgeJoEnv, + repo_config: RepoConfig, + addr: Addr, +) { + let repo_details = &forge.repo_details; + // Collect Commit Histories for `main`, `next` and `dev` branches + let commit_histories = get_commit_histories(repo_details, &repo_config, &forge.net).await; + let commit_histories = match commit_histories { + Ok(commit_histories) => commit_histories, + Err(err) => { + error!(?err, "Failed to get commit histories"); + return; + } + }; + + // Validations + let Some(main) = commit_histories.main.first().cloned() else { + warn!( + "No commits on main branch '{}'", + repo_config.branches().main() + ); + return; + }; + // verify that next is an ancestor of dev, and force update to it main if it isn't + let Some(next) = commit_histories.next.first().cloned() else { + warn!( + "No commits on next branch '{}", + repo_config.branches().next() + ); + return; + }; + let next_is_ancestor_of_dev = commit_histories.dev.iter().any(|dev| dev == &next); + if !next_is_ancestor_of_dev { + info!("Next is not an ancestor of dev - resetting next to main"); + if let Err(err) = forge.branch_reset( + repo_config.branches().next(), + main.into(), + Force::From(next.into()), + ) { + warn!(?err, "Failed to reset next to main"); + } + return; + } + + let next_commits = commit_histories + .next + .into_iter() + .take(2) + .collect::>(); + if !next_commits.contains(&main) { + warn!( + "Main branch '{}' is not on the same commit as next branch '{}', or it's parent - resetting next to main", + repo_config.branches().main(), + repo_config.branches().next() + ); + if let Err(err) = forge.branch_reset( + repo_config.branches().next(), + main.into(), + Force::From(next.into()), + ) { + warn!(?err, "Failed to reset next to main"); + } + return; + } + let Some(next) = next_commits.first().cloned() else { + warn!( + "No commits on next branch '{}'", + repo_config.branches().next() + ); + return; + }; + let dev_has_next = commit_histories + .dev + .iter() + .any(|commit| commit == &next_commits[0]); + if !dev_has_next { + warn!( + "Dev branch '{}' is not based on next branch '{}' - next branch will be updated shortly", + repo_config.branches().dev(), + repo_config.branches().next() + ); + return; // dev is not based on next + } + let dev_has_main = commit_histories.dev.iter().any(|commit| commit == &main); + if !dev_has_main { + warn!( + "Dev branch '{}' is not based on main branch '{}' - user should rebase onto main branch '{}'", + repo_config.branches().dev(), + repo_config.branches().main(), + repo_config.branches().main(), + ); + return; + } + let Some(dev) = commit_histories.dev.first().cloned() else { + warn!( + "No commits on dev branch '{}'", + repo_config.branches().dev() + ); + return; + }; + addr.do_send(StartMonitoring { + main, + next, + dev, + dev_commit_history: commit_histories.dev, + }); +} + +async fn get_commit_histories( + repo_details: &RepoDetails, + repo_config: &RepoConfig, + net: &network::Network, +) -> Result { + let main = + (get_commit_history(repo_details, &repo_config.branches().main(), vec![], net).await)?; + let main_head = main[0].clone(); + let next = (get_commit_history( + repo_details, + &repo_config.branches().next(), + vec![main_head.clone()], + net, + ) + .await)?; + let next_head = next[0].clone(); + let dev = (get_commit_history( + repo_details, + &repo_config.branches().dev(), + vec![next_head, main_head], + net, + ) + .await)?; + debug!( + main = main.len(), + next = next.len(), + dev = dev.len(), + "Commit histories" + ); + let histories = forge::CommitHistories { main, next, dev }; + Ok(histories) +} + +#[tracing::instrument(fields(%branch_name),skip_all)] +async fn get_commit_history( + repo_details: &server::config::RepoDetails, + branch_name: &BranchName, + find_commits: Vec, + net: &kxio::network::Network, +) -> Result, network::NetworkError> { + let hostname = &repo_details.forge.hostname; + let path = &repo_details.repo; + + let mut page = 1; + let limit = match find_commits.is_empty() { + true => 1, + false => 50, + }; + let options = "stat=false&verification=false&files=false"; + let mut all_commits = Vec::new(); + loop { + let api_token = &repo_details.forge.token; + use secrecy::ExposeSecret; + let token = api_token.expose_secret(); + let url = network::NetUrl::new(format!( + "https://{hostname}/api/v1/repos/{path}/commits?sha={branch_name}&{options}&token={token}&page={page}&limit={limit}" + )); + + let request = network::NetRequest::new( + network::RequestMethod::Get, + url, + network::NetRequestHeaders::new(), + network::RequestBody::None, + network::ResponseType::Json, + None, + network::NetRequestLogging::None, + ); + let response = net.get::>(request).await?; + let commits = response + .response_body() + .unwrap_or_default() + .into_iter() + .map(forge::Commit::from) + .collect::>(); + + let found = find_commits.is_empty() + || find_commits + .clone() + .into_iter() + .any(|find_commit| commits.iter().any(|commit| commit == &find_commit)); + let at_end = commits.len() < limit; + all_commits.extend(commits); + if found || at_end { + break; + } + page += 1; + } + + Ok(all_commits) +} + +#[allow(dead_code)] +#[derive(Debug, Default, serde::Deserialize)] +struct Commit { + sha: String, + commit: RepoCommit, +} +#[allow(dead_code)] +#[derive(Debug, Default, serde::Deserialize)] +struct RepoCommit { + message: String, +} +impl From for forge::Commit { + fn from(value: Commit) -> Self { + Self::new(&value.sha, &value.commit.message) + } +} diff --git a/src/server/gitforge/forgejo/file/mod.rs b/src/server/gitforge/forgejo/file/mod.rs new file mode 100644 index 0000000..42c0759 --- /dev/null +++ b/src/server/gitforge/forgejo/file/mod.rs @@ -0,0 +1,88 @@ +use kxio::network::{self, Network}; +use tracing::{error, info, warn}; + +use crate::server::{ + config::{BranchName, RepoDetails}, + gitforge::ForgeFileError, +}; + +pub(super) async fn contents_get( + repo_details: &RepoDetails, + net: &Network, + branch: &BranchName, + file_path: &str, +) -> Result { + let hostname = &repo_details.forge.hostname; + let path = &repo_details.repo; + let api_token = &repo_details.forge.token; + use secrecy::ExposeSecret; + let token = api_token.expose_secret(); + let url = network::NetUrl::new(format!( + "https://{hostname}/api/v1/repos/{path}/contents/{file_path}?ref={branch}&token={token}" + )); + + info!(%url, "Loading config"); + let request = network::NetRequest::new( + network::RequestMethod::Get, + url, + network::NetRequestHeaders::new(), + network::RequestBody::None, + network::ResponseType::Json, + None, + network::NetRequestLogging::None, + ); + let result = net.get::(request).await; + let response = result.map_err(|e| { + warn!(?e, ""); + ForgeFileError::NotFound(file_path.to_string()) + })?; + let status = response.status_code(); + let contents = match response.response_body() { + Some(body) => { + // we need to decode (see encoding field) the value of 'content' from the response + match body.content_type { + ForgeContentsType::File => decode_config(body), + _ => Err(ForgeFileError::NotFile(file_path.to_string())), + } + } + None => { + error!(%status, "Failed to fetch repo config file"); + Err(ForgeFileError::Unknown(status.to_string())) + } + }?; + Ok(contents) +} + +fn decode_config(body: ForgeContentsResponse) -> Result { + use base64::Engine; + match body.encoding.as_str() { + "base64" => { + let decoded = base64::engine::general_purpose::STANDARD + .decode(body.content) + .map_err(|_| ForgeFileError::DecodeFromBase64)?; + let decoded = String::from_utf8(decoded).map_err(|_| ForgeFileError::DecodeFromUtf8)?; + + Ok(decoded) + } + encoding => Err(ForgeFileError::UnknownEncoding(encoding.to_string())), + } +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct ForgeContentsResponse { + #[serde(rename = "type")] + pub content_type: ForgeContentsType, + pub content: String, + pub encoding: String, +} +#[derive(Clone, Debug, serde::Deserialize)] +enum ForgeContentsType { + #[serde(rename = "file")] + File, + #[serde(rename = "dir")] + Dir, + #[serde(rename = "symlink")] + Symlink, + #[serde(rename = "submodule")] + Submodule, +} diff --git a/src/server/gitforge/forgejo/mod.rs b/src/server/gitforge/forgejo/mod.rs new file mode 100644 index 0000000..cbff8ce --- /dev/null +++ b/src/server/gitforge/forgejo/mod.rs @@ -0,0 +1,121 @@ +mod branch; +mod file; + +use actix::prelude::*; + +use kxio::network::{self, Network}; +use tracing::{error, warn}; + +use crate::server::{ + actors::repo::RepoActor, + config::{BranchName, RepoConfig, RepoDetails}, + forge::Commit, + gitforge::{BranchResetResult, CommitStatus, Force, ForgeBranchError, ForgeFileError}, + types::GitRef, +}; + +struct ForgeJo; +#[derive(Clone)] +pub struct ForgeJoEnv { + repo_details: RepoDetails, + net: Network, +} +impl ForgeJoEnv { + pub(super) const fn new(repo_details: RepoDetails, net: Network) -> Self { + Self { repo_details, net } + } +} +#[async_trait::async_trait] +impl super::ForgeLike for ForgeJoEnv { + fn name(&self) -> String { + "forgejo".to_string() + } + + async fn branches_get_all(&self) -> Result, ForgeBranchError> { + branch::get_all(&self.repo_details, &self.net).await + } + + async fn file_contents_get( + &self, + branch: &BranchName, + file_path: &str, + ) -> Result { + file::contents_get(&self.repo_details, &self.net, branch, file_path).await + } + + async fn branches_validate_positions(&self, repo_config: RepoConfig, addr: Addr) { + branch::validate_positions(self, repo_config, addr).await + } + + fn branch_reset( + &self, + branch_name: BranchName, + to_commit: GitRef, + force: Force, + ) -> BranchResetResult { + branch::reset(&self.repo_details, branch_name, to_commit, force) + } + + async fn commit_status(&self, commit: &Commit) -> CommitStatus { + let repo_details = &self.repo_details; + let hostname = &repo_details.forge.hostname; + let path = &repo_details.repo; + let api_token = &repo_details.forge.token; + use secrecy::ExposeSecret; + let token = api_token.expose_secret(); + let url = network::NetUrl::new(format!( + "https://{hostname}/api/v1/repos/{path}/commits/{commit}/status?token={token}" + )); + + let request = network::NetRequest::new( + network::RequestMethod::Get, + url, + network::NetRequestHeaders::new(), + network::RequestBody::None, + network::ResponseType::Json, + None, + network::NetRequestLogging::None, + ); + let result = self.net.get::(request).await; + match result { + Ok(response) => { + match response.response_body() { + Some(status) => match status.state { + CommitStatusState::Success => CommitStatus::Pass, + CommitStatusState::Pending => CommitStatus::Pending, + CommitStatusState::Failure => CommitStatus::Fail, + CommitStatusState::Error => CommitStatus::Fail, + CommitStatusState::Blank => CommitStatus::Pending, + }, + None => { + warn!("No status found for commit"); + CommitStatus::Pending // assume issue is transient and allow retry + } + } + } + Err(e) => { + error!(?e, "Failed to get commit status"); + CommitStatus::Pending // assume issue is transient and allow retry + } + } + } +} + +#[derive(Debug, serde::Deserialize)] +pub struct CombinedStatus { + pub state: CommitStatusState, +} + +#[derive(Debug, serde::Deserialize)] +pub enum CommitStatusState { + #[serde(rename = "success")] + Success, + #[serde(rename = "pending")] + Pending, + #[serde(rename = "failure")] + Failure, + #[serde(rename = "error")] + Error, + #[serde(rename = "")] + Blank, +} diff --git a/src/server/gitforge/github.rs b/src/server/gitforge/github.rs new file mode 100644 index 0000000..7398d5a --- /dev/null +++ b/src/server/gitforge/github.rs @@ -0,0 +1,21 @@ +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/src/server/gitforge/mock_forge.rs b/src/server/gitforge/mock_forge.rs new file mode 100644 index 0000000..07b0939 --- /dev/null +++ b/src/server/gitforge/mock_forge.rs @@ -0,0 +1,55 @@ +use crate::server::{ + actors::repo::RepoActor, + config::{BranchName, RepoConfig}, + forge::Commit, + gitforge::{BranchResetResult, CommitStatus, Force, ForgeBranchError, ForgeFileError}, + types::GitRef, +}; + +struct MockForge; +#[derive(Clone)] +pub struct MockForgeEnv; +impl MockForgeEnv { + pub(crate) const fn new() -> Self { + Self + } +} +#[async_trait::async_trait] +impl super::ForgeLike for MockForgeEnv { + fn name(&self) -> String { + "mock".to_string() + } + + async fn branches_get_all(&self) -> Result, ForgeBranchError> { + todo!() + } + + async fn file_contents_get( + &self, + _branch: &BranchName, + _file_path: &str, + ) -> Result { + todo!() + } + + async fn branches_validate_positions( + &self, + _repo_config: RepoConfig, + _addr: actix::prelude::Addr, + ) { + todo!() + } + + fn branch_reset( + &self, + _branch_name: BranchName, + _to_commit: GitRef, + _force: Force, + ) -> BranchResetResult { + todo!() + } + + async fn commit_status(&self, _commit: &Commit) -> CommitStatus { + todo!() + } +} diff --git a/src/server/gitforge/mod.rs b/src/server/gitforge/mod.rs new file mode 100644 index 0000000..ada2413 --- /dev/null +++ b/src/server/gitforge/mod.rs @@ -0,0 +1,85 @@ +#![allow(dead_code)] + +use kxio::network::Network; + +#[cfg(feature = "forgejo")] +mod forgejo; + +#[cfg(feature = "github")] +mod github; + +mod mock_forge; + +mod types; +pub use types::*; + +mod errors; +pub use errors::*; + +use crate::server::{ + config::{BranchName, RepoConfig, RepoDetails}, + forge::Commit, + types::GitRef, +}; + +#[async_trait::async_trait] +pub trait ForgeLike { + fn name(&self) -> String; + async fn branches_get_all(&self) -> Result, ForgeBranchError>; + async fn file_contents_get( + &self, + branch: &super::config::BranchName, + file_path: &str, + ) -> Result; + async fn branches_validate_positions( + &self, + repo_config: RepoConfig, + addr: actix::prelude::Addr, + ); + fn branch_reset( + &self, + branch_name: BranchName, + to_commit: GitRef, + force: Force, + ) -> BranchResetResult; + + async fn commit_status(&self, commit: &Commit) -> CommitStatus; +} + +#[derive(Clone)] +pub enum Forge { + Mock(mock_forge::MockForgeEnv), + #[allow(clippy::enum_variant_names)] + #[cfg(feature = "forgejo")] + ForgeJo(forgejo::ForgeJoEnv), + #[cfg(feature = "github")] + Github(github::GithubEnv), +} +impl Forge { + pub const fn new_mock() -> Self { + Self::Mock(mock_forge::MockForgeEnv::new()) + } + #[cfg(feature = "forgejo")] + pub const fn new_forgejo(repo_details: RepoDetails, net: Network) -> Self { + Self::ForgeJo(forgejo::ForgeJoEnv::new(repo_details, net)) + } + #[cfg(feature = "github")] + pub const fn new_github(net: Network) -> Self { + Self::Github(github::GithubEnv::new(net)) + } +} +impl std::ops::Deref for Forge { + type Target = dyn ForgeLike; + fn deref(&self) -> &Self::Target { + match self { + Self::Mock(env) => env, + #[cfg(feature = "forgejo")] + Self::ForgeJo(env) => env, + #[cfg(feature = "github")] + Forge::Github(env) => env, + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/server/gitforge/tests/common.rs b/src/server/gitforge/tests/common.rs new file mode 100644 index 0000000..374216a --- /dev/null +++ b/src/server/gitforge/tests/common.rs @@ -0,0 +1,59 @@ +use crate::server::config::{ + ApiToken, BranchName, ForgeDetails, ForgeName, ForgeType, Hostname, RepoAlias, RepoBranches, + RepoConfig, RepoDetails, RepoPath, User, +}; + +pub fn forge_details(n: u32, forge_type: ForgeType) -> ForgeDetails { + ForgeDetails { + name: forge_name(n), + forge_type, + hostname: hostname(n), + user: user(n), + token: api_token(n), + } +} + +pub fn api_token(n: u32) -> ApiToken { + ApiToken::from(format!("api-{}", n)) +} + +pub fn user(n: u32) -> User { + User(format!("user-{}", n)) +} + +pub fn hostname(n: u32) -> Hostname { + Hostname(format!("hostname-{}", n)) +} + +pub fn forge_name(n: u32) -> ForgeName { + ForgeName(format!("forge-name-{}", n)) +} +pub fn repo_details(n: u32, forge: ForgeDetails, config: Option) -> RepoDetails { + RepoDetails { + name: repo_alias(n), + repo: repo_path(n), + branch: branch_name(n), + forge, + config, + } +} + +pub fn branch_name(n: u32) -> BranchName { + BranchName(format!("branch-name-{}", n)) +} + +pub fn repo_path(n: u32) -> RepoPath { + RepoPath(format!("repo-path-{}", n)) +} + +pub fn repo_alias(n: u32) -> RepoAlias { + RepoAlias(format!("repo-alias-{}", n)) +} + +pub fn config(n: u32) -> RepoConfig { + RepoConfig::new(RepoBranches::new( + format!("main-{n}"), + format!("next-{n}"), + format!("dev-{n}"), + )) +} diff --git a/src/server/gitforge/tests/data-forgejo-branches-get.json b/src/server/gitforge/tests/data-forgejo-branches-get.json new file mode 100644 index 0000000..bc1e2ae --- /dev/null +++ b/src/server/gitforge/tests/data-forgejo-branches-get.json @@ -0,0 +1,50 @@ +[ + { + "commit": { + "added": [ + "string" + ], + "author": { + "email": "user@example.com", + "name": "string", + "username": "string" + }, + "committer": { + "email": "user@example.com", + "name": "string", + "username": "string" + }, + "id": "string", + "message": "string", + "modified": [ + "string" + ], + "removed": [ + "string" + ], + "timestamp": "2024-04-16T21:35:56.331Z", + "url": "string", + "verification": { + "payload": "string", + "reason": "string", + "signature": "string", + "signer": { + "email": "user@example.com", + "name": "string", + "username": "string" + }, + "verified": true + } + }, + "effective_branch_protection_name": "string", + "enable_status_check": true, + "name": "string", + "protected": true, + "required_approvals": 0, + "status_check_contexts": [ + "string" + ], + "user_can_merge": true, + "user_can_push": true + } +] diff --git a/src/server/gitforge/tests/forgejo.rs b/src/server/gitforge/tests/forgejo.rs new file mode 100644 index 0000000..0e1d4f8 --- /dev/null +++ b/src/server/gitforge/tests/forgejo.rs @@ -0,0 +1,48 @@ +use assert2::let_assert; + +use kxio::network::{MockNetwork, StatusCode}; + +use crate::server::config::{BranchName, ForgeType}; + +use super::*; + +#[test] +fn test_name() { + let net = Network::new_mock(); + let repo_details = common::repo_details( + 1, + common::forge_details(1, ForgeType::MockForge), + Some(common::config(1)), + ); + let forge = Forge::new_forgejo(repo_details, net); + assert_eq!(forge.name(), "forgejo"); +} + +#[test_log::test(tokio::test)] +async fn test_branches_get() { + let mut net = MockNetwork::new(); + let hostname = common::hostname(1); + let path = common::repo_path(1); + let api_token = common::api_token(1); + use secrecy::ExposeSecret; + let token = api_token.expose_secret(); + let url = format!("https://{hostname}/api/v1/repos/{path}/branches?token={token}"); + let body = include_str!("./data-forgejo-branches-get.json"); + net.add_get_response(&url, StatusCode::OK, body); + let net = Network::from(net); + + let repo_details = common::repo_details( + 1, + common::forge_details(1, ForgeType::MockForge), + Some(common::config(1)), + ); + + let forge = Forge::new_forgejo(repo_details, net.clone()); + + let_assert!(Ok(branches) = forge.branches_get_all().await); + + let_assert!(Some(requests) = net.mocked_requests()); + assert_eq!(requests.len(), 1); + + assert_eq!(branches, vec![Branch(BranchName("string".into()))]); +} diff --git a/src/server/gitforge/tests/github.rs b/src/server/gitforge/tests/github.rs new file mode 100644 index 0000000..6d355c9 --- /dev/null +++ b/src/server/gitforge/tests/github.rs @@ -0,0 +1,8 @@ +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/src/server/gitforge/tests/mod.rs b/src/server/gitforge/tests/mod.rs new file mode 100644 index 0000000..71c268b --- /dev/null +++ b/src/server/gitforge/tests/mod.rs @@ -0,0 +1,15 @@ +use super::*; + +mod common; + +#[cfg(feature = "forgejo")] +mod forgejo; + +#[cfg(feature = "github")] +mod github; + +#[test] +fn test_mock_name() { + let forge = Forge::new_mock(); + assert_eq!(forge.name(), "mock"); +} diff --git a/src/server/gitforge/types.rs b/src/server/gitforge/types.rs new file mode 100644 index 0000000..f579175 --- /dev/null +++ b/src/server/gitforge/types.rs @@ -0,0 +1,29 @@ +use crate::server::config::BranchName; + +#[derive(Debug, PartialEq, Eq)] +pub struct Branch(pub BranchName); +impl Branch { + pub const fn name(&self) -> &BranchName { + &self.0 + } +} + +#[derive(Debug)] +pub enum Force { + No, + From(crate::server::types::GitRef), +} +impl std::fmt::Display for Force { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +pub type BranchResetResult = Result<(), ()>; + +#[derive(Debug)] +pub enum CommitStatus { + Pass, + Fail, + Pending, +} diff --git a/src/server/mod.rs b/src/server/mod.rs index 3d01a4a..ea60455 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,10 +1,11 @@ mod actors; mod config; pub mod forge; -pub mod git; +pub mod gitforge; pub mod types; use actix::prelude::*; + use kxio::network::Network; use std::path::PathBuf; @@ -16,7 +17,6 @@ use crate::{ server::{ actors::webhook, config::{Forge, ForgeName, RepoAlias, Webhook}, - git::Git, }, }; @@ -40,7 +40,7 @@ pub fn init(fs: FileSystem) { } } -pub async fn start(fs: FileSystem, net: Network, git: Git) { +pub async fn start(fs: FileSystem, net: Network) { let Ok(_) = init_logging() else { eprintln!("Failed to initialize logging."); return; @@ -58,7 +58,7 @@ pub async fn start(fs: FileSystem, net: Network, git: Git) { let webhook = server_config.webhook(); server_config .forges() - .flat_map(|(forge_name, forge)| create_forge_repos(forge, forge_name, webhook, &net, &git)) + .flat_map(|(forge_name, forge)| create_forge_repos(forge, forge_name, webhook, &net)) .map(start_actor) .map(|(alias, addr)| webhook::AddWebhookRecipient(alias, addr.recipient())) .for_each(|msg| webhook_router.do_send(msg)); @@ -73,7 +73,6 @@ fn create_forge_repos( forge_name: ForgeName, webhook: &Webhook, net: &Network, - git: &Git, ) -> Vec<(ForgeName, RepoAlias, RepoActor)> { let forge = forge.clone(); let span = tracing::info_span!("Forge", %forge_name, %forge); @@ -81,7 +80,7 @@ fn create_forge_repos( info!("Creating Forge"); forge .repos() - .map(create_actor(forge_name, forge.clone(), webhook, net, git)) + .map(create_actor(forge_name, forge.clone(), webhook, net)) .collect::>() } @@ -90,11 +89,9 @@ fn create_actor( forge: config::Forge, webhook: &Webhook, net: &Network, - git: &Git, ) -> impl Fn((RepoAlias, &Repo)) -> (ForgeName, RepoAlias, RepoActor) { let webhook = webhook.clone(); let net = net.clone(); - let git = git.clone(); move |(repo_name, repo)| { let span = tracing::info_span!("Repo", %repo_name, %repo); let _guard = span.enter(); @@ -103,7 +100,6 @@ fn create_actor( config::RepoDetails::new(&repo_name, repo, &forge_name, &forge), webhook.clone(), net.clone(), - git.clone(), ); info!("Created Repo"); (forge_name.clone(), repo_name, actor) diff --git a/src/server/types.rs b/src/server/types.rs index 57a9cd3..a91d04f 100644 --- a/src/server/types.rs +++ b/src/server/types.rs @@ -19,14 +19,3 @@ impl Display for GitRef { write!(f, "{}", self.0) } } - -#[derive(Debug)] -pub enum ResetForce { - None, - Force(GitRef), -} -impl Display for ResetForce { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self) - } -}