feat(server/gitforge): replace git abstraction

This commit is contained in:
Paul Campbell 2024-04-16 22:21:55 +01:00
parent 968f9dd73d
commit adb44d18c9
32 changed files with 1066 additions and 661 deletions

View file

@ -6,6 +6,7 @@ edition = "2021"
[features] [features]
default = ["forgejo"] default = ["forgejo"]
forgejo = [] forgejo = []
github = []
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -23,6 +24,7 @@ base64 = "0.22"
# git # git
gix = "0.62" gix = "0.62"
async-trait = "0.1"
# fs/network # fs/network
kxio = { version = "1.0", features = [ kxio = { version = "1.0", features = [
@ -59,7 +61,7 @@ tokio = { version = "1.37", features = ["full"] }
[dev-dependencies] [dev-dependencies]
# Testing # Testing
# assert2 = "0.3" assert2 = "0.3"
test-log = "0.2" test-log = "0.2"
anyhow = "1.0" anyhow = "1.0"

View file

@ -4,8 +4,6 @@ mod server;
use clap::Parser; use clap::Parser;
use kxio::{filesystem, network::Network}; use kxio::{filesystem, network::Network};
use crate::server::git::Git;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[clap(version = clap::crate_version!(), author = clap::crate_authors!(), about = clap::crate_description!())] #[clap(version = clap::crate_version!(), author = clap::crate_authors!(), about = clap::crate_description!())]
struct Commands { struct Commands {
@ -28,7 +26,6 @@ enum Server {
async fn main() { async fn main() {
let fs = filesystem::FileSystem::new_real(None); let fs = filesystem::FileSystem::new_real(None);
let net = Network::new_real(); let net = Network::new_real();
let git = Git::new_real();
let commands = Commands::parse(); let commands = Commands::parse();
match commands.command { match commands.command {
@ -40,7 +37,7 @@ async fn main() {
server::init(fs); server::init(fs);
} }
Server::Start => { Server::Start => {
server::start(fs, net, git).await; server::start(fs, net).await;
} }
}, },
} }

View file

@ -1,147 +1,17 @@
use actix::prelude::*; use tracing::{info, warn};
use kxio::network::{self, Network};
use tracing::{error, info, warn};
use crate::server::{ use crate::server::{
config::{self, RepoConfig, RepoDetails}, config, forge,
forge::{self, CommitHistories}, gitforge::{self, Force, Forge},
git::Git,
types::ResetForce,
}; };
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<RepoActor>,
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::<Vec<_>>();
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<CommitHistories, network::NetworkError> {
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 // advance next to the next commit towards the head of the dev branch
#[tracing::instrument(fields(next), skip_all)] #[tracing::instrument(fields(next), skip_all)]
pub async fn advance_next( pub async fn advance_next(
next: forge::Commit, next: forge::Commit,
dev_commit_history: Vec<forge::Commit>, dev_commit_history: Vec<forge::Commit>,
repo_details: config::RepoDetails,
repo_config: config::RepoConfig, repo_config: config::RepoConfig,
git: Git, forge: Forge,
) { ) {
let next_commit = find_next_commit_on_dev(next, dev_commit_history); let next_commit = find_next_commit_on_dev(next, dev_commit_history);
let Some(commit) = next_commit else { let Some(commit) = next_commit else {
@ -153,14 +23,9 @@ pub async fn advance_next(
return; return;
} }
info!("Advancing next to commit '{}'", commit); info!("Advancing next to commit '{}'", commit);
if let Err(err) = git.reset( if let Err(err) = forge.branch_reset(repo_config.branches().next(), commit.into(), Force::No) {
&repo_config.branches().next(),
commit.into(),
ResetForce::None,
&repo_details,
) {
warn!(?err, "Failed") warn!(?err, "Failed")
}; }
} }
#[tracing::instrument] #[tracing::instrument]
@ -199,17 +64,11 @@ fn find_next_commit_on_dev(
#[tracing::instrument(fields(next), skip_all)] #[tracing::instrument(fields(next), skip_all)]
pub async fn advance_main( pub async fn advance_main(
next: forge::Commit, next: forge::Commit,
repo_details: config::RepoDetails,
repo_config: config::RepoConfig, repo_config: config::RepoConfig,
git: Git, forge: gitforge::Forge,
) { ) {
info!("Advancing main to next"); info!("Advancing main to next");
if let Err(err) = git.reset( if let Err(err) = forge.branch_reset(repo_config.branches().main(), next.into(), Force::No) {
&repo_config.branches().main(),
next.into(),
ResetForce::None,
&repo_details,
) {
warn!(?err, "Failed") warn!(?err, "Failed")
}; };
} }

View file

@ -1,23 +1,22 @@
use actix::prelude::*; use actix::prelude::*;
use kxio::network::Network;
use tracing::error; use tracing::error;
use crate::server::{ use crate::server::{
config::{ForgeType, RepoDetails}, config::{ForgeType, RepoDetails},
forge, forge, gitforge,
}; };
use super::{LoadedConfig, RepoActor}; use super::{LoadedConfig, RepoActor};
pub async fn load(details: RepoDetails, addr: Addr<RepoActor>, net: Network) { pub async fn load(details: RepoDetails, addr: Addr<RepoActor>, forge: gitforge::Forge) {
let config = match details.config { let config = match details.config {
Some(config) => config, Some(config) => config,
None => { None => {
let config = match details.forge.forge_type { let config = match details.forge.forge_type {
#[cfg(feature = "forgejo")] #[cfg(feature = "forgejo")]
ForgeType::ForgeJo => forge::forgejo::config::load(&details, &net).await, ForgeType::ForgeJo => forge::forgejo::config::load(&details, &forge).await,
#[cfg(test)] #[cfg(test)]
ForgeType::MockForge => forge::mock::config::load(&details, &net).await, ForgeType::MockForge => forge::mock::config::load(&details, &forge).await,
}; };
match config { match config {
Ok(config) => config, Ok(config) => config,

View file

@ -10,8 +10,7 @@ use tracing::{info, warn};
use crate::server::{ use crate::server::{
actors::repo::webhook::WebhookAuth, actors::repo::webhook::WebhookAuth,
config::{RepoConfig, RepoDetails, Webhook}, config::{RepoConfig, RepoDetails, Webhook},
forge, forge, gitforge,
git::Git,
}; };
use self::webhook::WebhookId; use self::webhook::WebhookId;
@ -25,15 +24,18 @@ pub struct RepoActor {
last_next_commit: Option<forge::Commit>, last_next_commit: Option<forge::Commit>,
last_dev_commit: Option<forge::Commit>, last_dev_commit: Option<forge::Commit>,
net: Network, net: Network,
git: Git, forge: gitforge::Forge,
} }
impl RepoActor { impl RepoActor {
pub(crate) const fn new( pub(crate) fn new(details: RepoDetails, webhook: Webhook, net: Network) -> Self {
details: RepoDetails, let forge = match details.forge.forge_type {
webhook: Webhook, #[cfg(feature = "forgejo")]
net: Network, crate::server::config::ForgeType::ForgeJo => {
git: Git, gitforge::Forge::new_forgejo(details.clone(), net.clone())
) -> Self { }
#[cfg(test)]
crate::server::config::ForgeType::MockForge => gitforge::Forge::new_mock(),
};
Self { Self {
details, details,
webhook, webhook,
@ -43,7 +45,7 @@ impl RepoActor {
last_next_commit: None, last_next_commit: None,
last_dev_commit: None, last_dev_commit: None,
net, net,
git, forge,
} }
} }
} }
@ -73,8 +75,10 @@ impl Handler<StartRepo> for RepoActor {
info!(%self.details, "Starting Repo"); info!(%self.details, "Starting Repo");
let details = self.details.clone(); let details = self.details.clone();
let addr = ctx.address(); let addr = ctx.address();
let net = self.net.clone(); let forge = self.forge.clone();
config::load(details, addr, net).into_actor(self).wait(ctx); config::load(details, addr, forge)
.into_actor(self)
.wait(ctx);
} }
} }
@ -108,11 +112,9 @@ impl Handler<ValidateRepo> for RepoActor {
type Result = (); type Result = ();
fn handle(&mut self, _msg: ValidateRepo, ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, _msg: ValidateRepo, ctx: &mut Self::Context) -> Self::Result {
if let Some(repo_config) = self.details.config.clone() { 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 addr = ctx.address();
let net = self.net.clone(); async move { forge.branches_validate_positions(repo_config, addr).await }
let git = self.git.clone();
branch::validate_positions(repo_details, repo_config, addr, git, net)
.into_actor(self) .into_actor(self)
.wait(ctx); .wait(ctx);
} }
@ -143,20 +145,14 @@ impl Handler<StartMonitoring> for RepoActor {
let webhook = self.webhook.clone(); let webhook = self.webhook.clone();
let addr = ctx.address(); let addr = ctx.address();
let net = self.net.clone(); let net = self.net.clone();
let git = self.git.clone(); let forge = self.forge.clone();
if next_ahead_of_main { if next_ahead_of_main {
status::check_next(msg.next, repo_details, addr, net) status::check_next(msg.next, addr, forge)
.into_actor(self) .into_actor(self)
.wait(ctx); .wait(ctx);
} else if dev_ahead_of_next { } else if dev_ahead_of_next {
branch::advance_next( branch::advance_next(msg.next, msg.dev_commit_history, repo_config, forge)
msg.next,
msg.dev_commit_history,
repo_details,
repo_config,
git,
)
.into_actor(self) .into_actor(self)
.wait(ctx); .wait(ctx);
} else if self.webhook_id.is_none() { } else if self.webhook_id.is_none() {
@ -184,13 +180,12 @@ pub struct AdvanceMainTo(pub forge::Commit);
impl Handler<AdvanceMainTo> for RepoActor { impl Handler<AdvanceMainTo> for RepoActor {
type Result = (); type Result = ();
fn handle(&mut self, msg: AdvanceMainTo, ctx: &mut Self::Context) -> Self::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 { let Some(repo_config) = self.details.config.clone() else {
warn!("No config loaded"); warn!("No config loaded");
return; return;
}; };
let git = self.git.clone(); let forge = self.forge.clone();
branch::advance_main(msg.0, repo_details, repo_config, git) branch::advance_main(msg.0, repo_config, forge)
.into_actor(self) .into_actor(self)
.wait(ctx); .wait(ctx);
} }

View file

@ -2,49 +2,24 @@ use actix::prelude::*;
use gix::trace::warn; use gix::trace::warn;
use tracing::info; use tracing::info;
use crate::server::{ use crate::server::{actors::repo::ValidateRepo, forge, gitforge};
actors::repo::ValidateRepo,
config::{self, ForgeType},
forge,
};
use super::AdvanceMainTo; use super::AdvanceMainTo;
pub async fn check_next( pub async fn check_next(next: forge::Commit, addr: Addr<super::RepoActor>, forge: gitforge::Forge) {
next: forge::Commit,
repo_details: config::RepoDetails,
addr: Addr<super::RepoActor>,
net: kxio::network::Network,
) {
// get the status - pass, fail, pending (all others map to fail, e.g. error) // get the status - pass, fail, pending (all others map to fail, e.g. error)
let status = match repo_details.forge.forge_type { let status = forge.commit_status(&next).await;
#[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
}
};
info!(?status, "Checking next branch"); info!(?status, "Checking next branch");
match status { match status {
Status::Pass => { gitforge::CommitStatus::Pass => {
addr.do_send(AdvanceMainTo(next)); addr.do_send(AdvanceMainTo(next));
} }
Status::Pending => { gitforge::CommitStatus::Pending => {
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
addr.do_send(ValidateRepo); addr.do_send(ValidateRepo);
} }
Status::Fail => { gitforge::CommitStatus::Fail => {
warn!("Checks have failed"); warn!("Checks have failed");
} }
} }
} }
#[derive(Debug)]
pub enum Status {
Pass,
Fail,
Pending,
}

View file

@ -61,9 +61,13 @@ pub struct RepoConfig {
branches: RepoBranches, branches: RepoBranches,
} }
impl RepoConfig { impl RepoConfig {
#[allow(dead_code)] #[cfg(test)]
pub(crate) fn load(toml: &str) -> Result<Self, OneOf<(toml::de::Error,)>> { pub const fn new(branches: RepoBranches) -> Self {
toml::from_str(toml).map_err(OneOf::new) Self { branches }
}
// #[cfg(test)]
pub fn load(toml: &str) -> Result<Self, toml::de::Error> {
toml::from_str(toml)
} }
pub const fn branches(&self) -> &RepoBranches { pub const fn branches(&self) -> &RepoBranches {
@ -84,6 +88,14 @@ pub struct RepoBranches {
dev: String, dev: String,
} }
impl RepoBranches { impl RepoBranches {
#[cfg(test)]
pub fn new(main: impl Into<String>, next: impl Into<String>, dev: impl Into<String>) -> Self {
Self {
main: main.into(),
next: next.into(),
dev: dev.into(),
}
}
pub fn main(&self) -> BranchName { pub fn main(&self) -> BranchName {
BranchName(self.main.clone()) BranchName(self.main.clone())
} }

View file

@ -1,9 +1,11 @@
use base64::Engine; use kxio::network;
use kxio::network::{self, Network};
use terrors::OneOf; 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)] #[derive(Debug)]
pub struct RepoConfigFileNotFound; pub struct RepoConfigFileNotFound;
@ -16,177 +18,56 @@ pub struct RepoConfigParseError;
#[derive(Debug)] #[derive(Debug)]
pub struct RepoConfigUnknownError(pub network::StatusCode); pub struct RepoConfigUnknownError(pub network::StatusCode);
type RepoConfigLoadErrors = (
RepoConfigFileNotFound,
RepoConfigIsNotFile,
RepoConfigDecodeError,
RepoConfigParseError,
RepoConfigUnknownError,
RepoConfigBranchNotFound,
);
pub async fn load( pub async fn load(
details: &RepoDetails, details: &RepoDetails,
net: &Network, forge: &gitforge::Forge,
) -> Result<RepoConfig, OneOf<RepoConfigLoadErrors>> { ) -> Result<RepoConfig, OneOf<(ForgeFileError, toml::de::Error, RepoConfigValidationErrors)>> {
let hostname = &details.forge.hostname; let contents = forge
let path = &details.repo; .file_contents_get(&details.branch, ".git-next.toml")
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::<ForgeContentsResponse>(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)
.await .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) Ok(config)
} }
fn decode_config(
body: ForgeContentsResponse,
) -> Result<RepoConfig, OneOf<(RepoConfigDecodeError, RepoConfigParseError)>> {
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)] #[derive(Debug)]
pub struct RepoConfigBranchNotFound(pub BranchName); pub enum RepoConfigValidationErrors {
Forge(gitforge::ForgeBranchError),
type RepoConfigValidateErrors = (RepoConfigBranchNotFound, RepoConfigUnknownError); BranchNotFound(BranchName),
}
pub async fn validate( pub async fn validate(
details: &RepoDetails,
config: RepoConfig, config: RepoConfig,
net: &Network, forge: &gitforge::Forge,
) -> Result<RepoConfig, OneOf<RepoConfigValidateErrors>> { ) -> Result<RepoConfig, RepoConfigValidationErrors> {
let hostname = &details.forge.hostname; let branches = forge.branches_get_all().await.map_err(|e| {
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::<BranchList>(request).await;
let response = result.map_err(|e| {
error!(?e, "Failed to list branches"); error!(?e, "Failed to list branches");
OneOf::new(RepoConfigUnknownError( RepoConfigValidationErrors::Forge(e)
network::StatusCode::INTERNAL_SERVER_ERROR,
))
})?; })?;
let branches = response.response_body().unwrap_or_default();
if !branches if !branches
.iter() .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(), config.branches().main(),
))); ));
} }
if !branches if !branches
.iter() .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(), config.branches().next(),
))); ));
} }
if !branches if !branches
.iter() .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(), config.branches().dev(),
))); ));
} }
Ok(config) Ok(config)
} }
type BranchList = Vec<Branch>;
#[derive(Debug, serde::Deserialize)]
struct Branch {
name: String,
}
impl Branch {
fn name(&self) -> BranchName {
BranchName(self.name.clone())
}
}

View file

@ -1,46 +1,10 @@
use kxio::network; use kxio::network;
use secrecy::ExposeSecret; use secrecy::ExposeSecret;
use tracing::{debug, error, warn};
use crate::server; use crate::server::{self, config::BranchName, forge};
use crate::server::{actors::repo::status::Status, config::BranchName, forge};
use super::CommitHistories;
pub mod config; pub mod config;
pub async fn get_commit_histories(
repo_details: &server::config::RepoDetails,
config: &server::config::RepoConfig,
net: &kxio::network::Network,
) -> Result<CommitHistories, network::NetworkError> {
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)] #[tracing::instrument(fields(%branch_name),skip_all)]
async fn get_commit_history( async fn get_commit_history(
repo_details: &server::config::RepoDetails, repo_details: &server::config::RepoDetails,
@ -114,51 +78,6 @@ impl From<Commit> 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::<CombinedStatus>(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)] #[derive(Debug, serde::Deserialize)]
pub struct CombinedStatus { pub struct CombinedStatus {
pub state: CommitStatusState, pub state: CommitStatusState,

View file

@ -1,16 +1,14 @@
use terrors::OneOf;
use crate::server::{
config::RepoConfig,
forge::forgejo::config::RepoConfigValidationErrors,
gitforge::{self, ForgeFileError},
};
pub async fn load( pub async fn load(
_details: &crate::server::config::RepoDetails, _details: &crate::server::config::RepoDetails,
_net: &kxio::network::Network, _forge: &gitforge::Forge,
) -> Result< ) -> Result<RepoConfig, OneOf<(ForgeFileError, toml::de::Error, RepoConfigValidationErrors)>> {
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,
)>,
> {
todo!() todo!()
} }

View file

@ -1,24 +1 @@
use kxio::network::{Network, NetworkError};
use crate::server::{
config::{RepoConfig, RepoDetails},
forge::CommitHistories,
};
pub mod config; pub mod config;
pub async fn get_commit_histories(
_repo_details: &RepoDetails,
_config: &RepoConfig,
_net: &Network,
) -> Result<CommitHistories, NetworkError> {
todo!()
}
pub async fn get_commit_status(
_clone: super::Commit,
_repo_details: &RepoDetails,
_net: &Network,
) -> crate::server::actors::repo::status::Status {
todo!()
}

View file

@ -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)
}
}

View file

@ -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<(), ()>;

View file

@ -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(())
}
}
}
}

View file

@ -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"),
}
}
}

View file

@ -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<Vec<gitforge::Branch>, 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::<Vec<Branch>>(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::<Vec<_>>();
Ok(branches)
}
#[derive(Debug, serde::Deserialize)]
struct Branch {
name: String,
}
impl Branch {
fn name(&self) -> BranchName {
BranchName(self.name.clone())
}
}

View file

@ -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;

View file

@ -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(())
}
}
}

View file

@ -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<RepoActor>,
) {
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::<Vec<_>>();
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<forge::CommitHistories, network::NetworkError> {
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<forge::Commit>,
net: &kxio::network::Network,
) -> Result<Vec<forge::Commit>, 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::<Vec<Commit>>(request).await?;
let commits = response
.response_body()
.unwrap_or_default()
.into_iter()
.map(forge::Commit::from)
.collect::<Vec<_>>();
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<Commit> for forge::Commit {
fn from(value: Commit) -> Self {
Self::new(&value.sha, &value.commit.message)
}
}

View file

@ -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<String, ForgeFileError> {
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::<ForgeContentsResponse>(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<String, ForgeFileError> {
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,
}

View file

@ -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<Vec<super::Branch>, ForgeBranchError> {
branch::get_all(&self.repo_details, &self.net).await
}
async fn file_contents_get(
&self,
branch: &BranchName,
file_path: &str,
) -> Result<String, ForgeFileError> {
file::contents_get(&self.repo_details, &self.net, branch, file_path).await
}
async fn branches_validate_positions(&self, repo_config: RepoConfig, addr: Addr<RepoActor>) {
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::<CombinedStatus>(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,
}

View file

@ -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<super::Branch> {
todo!()
}
}

View file

@ -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<Vec<super::Branch>, ForgeBranchError> {
todo!()
}
async fn file_contents_get(
&self,
_branch: &BranchName,
_file_path: &str,
) -> Result<String, ForgeFileError> {
todo!()
}
async fn branches_validate_positions(
&self,
_repo_config: RepoConfig,
_addr: actix::prelude::Addr<RepoActor>,
) {
todo!()
}
fn branch_reset(
&self,
_branch_name: BranchName,
_to_commit: GitRef,
_force: Force,
) -> BranchResetResult {
todo!()
}
async fn commit_status(&self, _commit: &Commit) -> CommitStatus {
todo!()
}
}

View file

@ -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<Vec<Branch>, ForgeBranchError>;
async fn file_contents_get(
&self,
branch: &super::config::BranchName,
file_path: &str,
) -> Result<String, ForgeFileError>;
async fn branches_validate_positions(
&self,
repo_config: RepoConfig,
addr: actix::prelude::Addr<super::actors::repo::RepoActor>,
);
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;

View file

@ -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<RepoConfig>) -> 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}"),
))
}

View file

@ -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
}
]

View file

@ -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()))]);
}

View file

@ -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");
}

View file

@ -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");
}

View file

@ -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,
}

View file

@ -1,10 +1,11 @@
mod actors; mod actors;
mod config; mod config;
pub mod forge; pub mod forge;
pub mod git; pub mod gitforge;
pub mod types; pub mod types;
use actix::prelude::*; use actix::prelude::*;
use kxio::network::Network; use kxio::network::Network;
use std::path::PathBuf; use std::path::PathBuf;
@ -16,7 +17,6 @@ use crate::{
server::{ server::{
actors::webhook, actors::webhook,
config::{Forge, ForgeName, RepoAlias, 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 { let Ok(_) = init_logging() else {
eprintln!("Failed to initialize logging."); eprintln!("Failed to initialize logging.");
return; return;
@ -58,7 +58,7 @@ pub async fn start(fs: FileSystem, net: Network, git: Git) {
let webhook = server_config.webhook(); let webhook = server_config.webhook();
server_config server_config
.forges() .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(start_actor)
.map(|(alias, addr)| webhook::AddWebhookRecipient(alias, addr.recipient())) .map(|(alias, addr)| webhook::AddWebhookRecipient(alias, addr.recipient()))
.for_each(|msg| webhook_router.do_send(msg)); .for_each(|msg| webhook_router.do_send(msg));
@ -73,7 +73,6 @@ fn create_forge_repos(
forge_name: ForgeName, forge_name: ForgeName,
webhook: &Webhook, webhook: &Webhook,
net: &Network, net: &Network,
git: &Git,
) -> Vec<(ForgeName, RepoAlias, RepoActor)> { ) -> Vec<(ForgeName, RepoAlias, RepoActor)> {
let forge = forge.clone(); let forge = forge.clone();
let span = tracing::info_span!("Forge", %forge_name, %forge); let span = tracing::info_span!("Forge", %forge_name, %forge);
@ -81,7 +80,7 @@ fn create_forge_repos(
info!("Creating Forge"); info!("Creating Forge");
forge forge
.repos() .repos()
.map(create_actor(forge_name, forge.clone(), webhook, net, git)) .map(create_actor(forge_name, forge.clone(), webhook, net))
.collect::<Vec<_>>() .collect::<Vec<_>>()
} }
@ -90,11 +89,9 @@ fn create_actor(
forge: config::Forge, forge: config::Forge,
webhook: &Webhook, webhook: &Webhook,
net: &Network, net: &Network,
git: &Git,
) -> impl Fn((RepoAlias, &Repo)) -> (ForgeName, RepoAlias, RepoActor) { ) -> impl Fn((RepoAlias, &Repo)) -> (ForgeName, RepoAlias, RepoActor) {
let webhook = webhook.clone(); let webhook = webhook.clone();
let net = net.clone(); let net = net.clone();
let git = git.clone();
move |(repo_name, repo)| { move |(repo_name, repo)| {
let span = tracing::info_span!("Repo", %repo_name, %repo); let span = tracing::info_span!("Repo", %repo_name, %repo);
let _guard = span.enter(); let _guard = span.enter();
@ -103,7 +100,6 @@ fn create_actor(
config::RepoDetails::new(&repo_name, repo, &forge_name, &forge), config::RepoDetails::new(&repo_name, repo, &forge_name, &forge),
webhook.clone(), webhook.clone(),
net.clone(), net.clone(),
git.clone(),
); );
info!("Created Repo"); info!("Created Repo");
(forge_name.clone(), repo_name, actor) (forge_name.clone(), repo_name, actor)

View file

@ -19,14 +19,3 @@ impl Display for GitRef {
write!(f, "{}", self.0) 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)
}
}