Compare commits

..

No commits in common. "f3bc16d701292c5e4e4d48a65e32b5fd2572a060" and "421e85cb0b56081d09ca680ee085b75801d18786" have entirely different histories.

18 changed files with 163 additions and 239 deletions

15
Cargo.lock generated
View file

@ -945,6 +945,8 @@ dependencies = [
"actix-rt", "actix-rt",
"anyhow", "anyhow",
"assert2", "assert2",
"async-trait",
"base64 0.22.1",
"bytes", "bytes",
"clap", "clap",
"derive-with", "derive-with",
@ -962,6 +964,7 @@ dependencies = [
"rand", "rand",
"secrecy", "secrecy",
"sendmail", "sendmail",
"serde",
"serde_json", "serde_json",
"standardwebhooks", "standardwebhooks",
"test-log", "test-log",
@ -979,6 +982,7 @@ name = "git-next-core"
version = "0.13.0" version = "0.13.0"
dependencies = [ dependencies = [
"actix", "actix",
"actix-rt",
"assert2", "assert2",
"async-trait", "async-trait",
"derive-with", "derive-with",
@ -995,9 +999,9 @@ dependencies = [
"serde_json", "serde_json",
"test-log", "test-log",
"thiserror", "thiserror",
"time",
"toml", "toml",
"tracing", "tracing",
"tracing-subscriber",
"ulid", "ulid",
] ]
@ -1007,6 +1011,9 @@ version = "0.13.0"
dependencies = [ dependencies = [
"assert2", "assert2",
"async-trait", "async-trait",
"base64 0.22.1",
"bytes",
"derive_more",
"git-next-core", "git-next-core",
"kxio", "kxio",
"rand", "rand",
@ -1014,7 +1021,9 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"toml",
"tracing", "tracing",
"ulid",
] ]
[[package]] [[package]]
@ -1023,6 +1032,8 @@ version = "0.13.0"
dependencies = [ dependencies = [
"assert2", "assert2",
"async-trait", "async-trait",
"base64 0.22.1",
"bytes",
"clap", "clap",
"derive_more", "derive_more",
"git-next-core", "git-next-core",
@ -1035,7 +1046,9 @@ dependencies = [
"serde_json", "serde_json",
"sha2", "sha2",
"tokio", "tokio",
"toml",
"tracing", "tracing",
"ulid",
] ]
[[package]] [[package]]

View file

@ -31,12 +31,18 @@ kxio = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
# git
async-trait = { workspace = true }
# Conventional Commit check # Conventional Commit check
git-conventional = { workspace = true } git-conventional = { workspace = true }
# TOML parsing # TOML parsing
toml = { workspace = true } toml = { workspace = true }
# base64 decoding
base64 = { workspace = true }
# Actors # Actors
actix = { workspace = true } actix = { workspace = true }
actix-rt = { workspace = true } actix-rt = { workspace = true }
@ -48,6 +54,7 @@ anyhow = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
# Webhooks # Webhooks
serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
ulid = { workspace = true } ulid = { workspace = true }
time = { workspace = true } time = { workspace = true }

View file

@ -1,9 +1,8 @@
// //
use crate::alerts::short_message; use crate::alerts::{messages::NotifyUser, short_message};
use git_next_core::git::UserNotification;
pub(super) fn send_desktop_notification(user_notification: &UserNotification) { pub(super) fn send_desktop_notification(msg: &NotifyUser) {
let message = short_message(user_notification); let message = short_message(msg);
if let Err(err) = notifica::notify("git-next", &message) { if let Err(err) = notifica::notify("git-next", &message) {
tracing::warn!(?err, "failed to send desktop notification"); tracing::warn!(?err, "failed to send desktop notification");
} }

View file

@ -1,10 +1,7 @@
// //
use git_next_core::{ use git_next_core::server::{EmailConfig, SmtpConfig};
git::UserNotification,
server::{EmailConfig, SmtpConfig},
};
use crate::alerts::{full_message, short_message}; use crate::alerts::{full_message, messages::NotifyUser, short_message};
#[derive(Debug)] #[derive(Debug)]
struct EmailMessage { struct EmailMessage {
@ -14,12 +11,12 @@ struct EmailMessage {
body: String, body: String,
} }
pub(super) fn send_email(user_notification: &UserNotification, email_config: &EmailConfig) { pub(super) fn send_email(msg: &NotifyUser, email_config: &EmailConfig) {
let email_message = EmailMessage { let email_message = EmailMessage {
from: email_config.from().to_string(), from: email_config.from().to_string(),
to: email_config.to().to_string(), to: email_config.to().to_string(),
subject: short_message(user_notification), subject: short_message(msg),
body: full_message(user_notification), body: full_message(msg),
}; };
match email_config.smtp() { match email_config.smtp() {
Some(smtp) => send_email_smtp(email_message, smtp), Some(smtp) => send_email_smtp(email_message, smtp),

View file

@ -1,6 +1,5 @@
// //
use actix::prelude::*; use actix::prelude::*;
use tracing::{info, instrument, Instrument as _}; use tracing::{info, instrument, Instrument as _};
use crate::alerts::{ use crate::alerts::{
@ -19,21 +18,19 @@ impl Handler<NotifyUser> for AlertsActor {
}; };
let net = self.net.clone(); let net = self.net.clone();
let shout = shout.clone(); let shout = shout.clone();
if let Some(user_notification) = self.history.sendable(msg.unwrap()) { async move {
async move { if let Some(webhook_config) = shout.webhook() {
if let Some(webhook_config) = shout.webhook() { send_webhook(&msg, webhook_config, &net).await;
send_webhook(&user_notification, webhook_config, &net).await; }
} if let Some(email_config) = shout.email() {
if let Some(email_config) = shout.email() { send_email(&msg, email_config);
send_email(&user_notification, email_config); }
} if shout.desktop() {
if shout.desktop() { send_desktop_notification(&msg);
send_desktop_notification(&user_notification);
}
} }
.in_current_span()
.into_actor(self)
.wait(ctx);
} }
.in_current_span()
.into_actor(self)
.wait(ctx);
} }
} }

View file

@ -1,47 +1,2 @@
//
use git_next_core::git::UserNotification;
use std::{
collections::HashMap,
time::{Duration, Instant},
};
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct History { pub struct History {}
/// The maximum age of an item in the history.
///
/// Items older than this will be dropped.
max_age_seconds: Duration,
/// Maps a user notification to when it was last seen.
///
/// The user notification will not be sent until after max_age_seconds from last seen.
///
/// Each time we see a given user notification, the last seen time will be updated.
items: HashMap<UserNotification, Instant>,
}
impl History {
pub fn new(max_age_seconds: Duration) -> Self {
Self {
max_age_seconds,
items: HashMap::default(),
}
}
pub fn sendable(&mut self, user_notification: UserNotification) -> Option<UserNotification> {
let now = Instant::now();
self.prune(&now); // remove expired items first
let contains_key = self.items.contains_key(&user_notification);
self.items.insert(user_notification.clone(), now);
if contains_key {
return None;
}
Some(user_notification)
}
pub fn prune(&mut self, now: &Instant) {
if let Some(threshold) = now.checked_sub(self.max_age_seconds) {
self.items.retain(|_, last_seen| *last_seen > threshold)
};
}
}

View file

@ -1,6 +1,86 @@
// //
use derive_more::Deref as _;
use git_next_core::{git::UserNotification, message, server::Shout}; use git_next_core::{git::UserNotification, message, server::Shout};
use serde_json::json;
message!(UpdateShout: Shout: "Updated Shout configuration"); message!(UpdateShout: Shout: "Updated Shout configuration");
message!(NotifyUser: UserNotification: "Request to send the message payload to the notification webhook"); message!(NotifyUser: UserNotification: "Request to send the message payload to the notification webhook");
impl NotifyUser {
pub fn as_json(&self, timestamp: time::OffsetDateTime) -> serde_json::Value {
let timestamp = timestamp.unix_timestamp().to_string();
match self.deref() {
UserNotification::CICheckFailed {
forge_alias,
repo_alias,
commit,
} => json!({
"type": "cicheck.failed",
"timestamp": timestamp,
"data": {
"forge_alias": forge_alias,
"repo_alias": repo_alias,
"commit": {
"sha": commit.sha(),
"message": commit.message()
}
}
}),
UserNotification::RepoConfigLoadFailure {
forge_alias,
repo_alias,
reason,
} => json!({
"type": "config.load.failed",
"timestamp": timestamp,
"data": {
"forge_alias": forge_alias,
"repo_alias": repo_alias,
"reason": reason
}
}),
UserNotification::WebhookRegistration {
forge_alias,
repo_alias,
reason,
} => json!({
"type": "webhook.registration.failed",
"timestamp": timestamp,
"data": {
"forge_alias": forge_alias,
"repo_alias": repo_alias,
"reason": reason
}
}),
UserNotification::DevNotBasedOnMain {
forge_alias,
repo_alias,
dev_branch,
main_branch,
dev_commit,
main_commit,
} => json!({
"type": "branch.dev.not-on-main",
"timestamp": timestamp,
"data": {
"forge_alias": forge_alias,
"repo_alias": repo_alias,
"branches": {
"dev": dev_branch,
"main": main_branch
},
"commits": {
"dev": {
"sha": dev_commit.sha(),
"message": dev_commit.message()
},
"main": {
"sha": main_commit.sha(),
"message": main_commit.message()
}
}
}
}),
}
}
}

View file

@ -1,3 +1,5 @@
use std::ops::Deref as _;
// //
use actix::prelude::*; use actix::prelude::*;
@ -6,6 +8,7 @@ use derive_more::derive::Constructor;
use git_next_core::{git::UserNotification, server::Shout}; use git_next_core::{git::UserNotification, server::Shout};
pub use history::History; pub use history::History;
use messages::NotifyUser;
mod desktop; mod desktop;
mod email; mod email;
@ -14,13 +17,11 @@ mod history;
pub mod messages; pub mod messages;
mod webhook; mod webhook;
#[cfg(test)]
mod tests;
#[derive(Debug, Constructor)] #[derive(Debug, Constructor)]
pub struct AlertsActor { pub struct AlertsActor {
shout: Option<Shout>, // config for sending alerts to users shout: Option<Shout>, // config for sending alerts to users
history: History, // record of alerts sent recently (e.g. 24 hours) #[allow(dead_code)] // TODO (#128) Prevent duplicate user notifications
history: History, // record of alerts sent recently (e.g. 24 hours)
net: kxio::network::Network, net: kxio::network::Network,
} }
@ -28,8 +29,8 @@ impl Actor for AlertsActor {
type Context = Context<Self>; type Context = Context<Self>;
} }
fn short_message(user_notification: &UserNotification) -> String { fn short_message(msg: &NotifyUser) -> String {
let tail = match user_notification { let tail = match msg.deref() {
UserNotification::CICheckFailed { UserNotification::CICheckFailed {
forge_alias, forge_alias,
repo_alias, repo_alias,
@ -57,8 +58,8 @@ fn short_message(user_notification: &UserNotification) -> String {
format!("[git-next] {tail}") format!("[git-next] {tail}")
} }
fn full_message(user_notification: &UserNotification) -> String { fn full_message(msg: &NotifyUser) -> String {
match user_notification { match msg.deref() {
UserNotification::CICheckFailed { UserNotification::CICheckFailed {
forge_alias, forge_alias,
repo_alias, repo_alias,

View file

@ -1,66 +0,0 @@
use std::time::Duration;
use assert2::let_assert;
use git_next_core::git::UserNotification;
use crate::{alerts::History, repo::tests::given};
#[test]
fn when_history_is_empty_then_message_is_passed() {
let mut history = History::new(Duration::from_millis(1));
let user_notification = UserNotification::RepoConfigLoadFailure {
forge_alias: given::a_forge_alias(),
repo_alias: given::a_repo_alias(),
reason: given::a_name(),
};
let result = history.sendable(user_notification);
assert!(result.is_some());
}
#[test]
fn when_history_has_expired_then_message_is_passed() {
let dur = Duration::from_millis(1);
let mut history = History::new(dur);
let user_notification = UserNotification::RepoConfigLoadFailure {
forge_alias: given::a_forge_alias(),
repo_alias: given::a_repo_alias(),
reason: given::a_name(),
};
// add message to history
let result = history.sendable(user_notification);
let_assert!(Some(user_notification) = result);
std::thread::sleep(dur);
// after dur, message has expired, so is still valid
let result = history.sendable(user_notification);
assert!(result.is_some());
}
#[test]
fn when_history_has_unexpired_then_message_is_blocked() {
let dur = Duration::from_millis(1);
let mut history = History::new(dur);
let user_notification = UserNotification::RepoConfigLoadFailure {
forge_alias: given::a_forge_alias(),
repo_alias: given::a_repo_alias(),
reason: given::a_name(),
};
// add message to history
let result = history.sendable(user_notification);
let_assert!(Some(user_notification) = result);
// no time passed
// std::thread::sleep(dur);
// after dur, message has expired, so is still valid
let result = history.sendable(user_notification);
assert!(result.is_none());
}

View file

@ -1 +0,0 @@
mod history;

View file

@ -1,10 +1,12 @@
// //
use git_next_core::{git::UserNotification, server::OutboundWebhook}; use git_next_core::server::OutboundWebhook;
use secrecy::ExposeSecret as _; use secrecy::ExposeSecret as _;
use standardwebhooks::Webhook; use standardwebhooks::Webhook;
use crate::alerts::messages::NotifyUser;
pub(super) async fn send_webhook( pub(super) async fn send_webhook(
user_notification: &UserNotification, msg: &NotifyUser,
webhook_config: &OutboundWebhook, webhook_config: &OutboundWebhook,
net: &kxio::network::Network, net: &kxio::network::Network,
) { ) {
@ -14,18 +16,18 @@ pub(super) async fn send_webhook(
tracing::warn!("Invalid notification configuration (signer) - can't sent notification"); tracing::warn!("Invalid notification configuration (signer) - can't sent notification");
return; return;
}; };
do_send_webhook(user_notification, webhook, webhook_config, net).await; do_send_webhook(msg, webhook, webhook_config, net).await;
} }
async fn do_send_webhook( async fn do_send_webhook(
user_notification: &UserNotification, msg: &NotifyUser,
webhook: Webhook, webhook: Webhook,
webhook_config: &OutboundWebhook, webhook_config: &OutboundWebhook,
net: &kxio::network::Network, net: &kxio::network::Network,
) { ) {
let message_id = format!("msg_{}", ulid::Ulid::new()); let message_id = format!("msg_{}", ulid::Ulid::new());
let timestamp = time::OffsetDateTime::now_utc(); let timestamp = time::OffsetDateTime::now_utc();
let payload = user_notification.as_json(timestamp); let payload = msg.as_json(timestamp);
let timestamp = timestamp.unix_timestamp(); let timestamp = timestamp.unix_timestamp();
let to_sign = format!("{message_id}.{timestamp}.{payload}"); let to_sign = format!("{message_id}.{timestamp}.{payload}");
tracing::info!(?to_sign, ""); tracing::info!(?to_sign, "");

View file

@ -24,7 +24,7 @@ pub mod messages;
mod notifications; mod notifications;
#[cfg(test)] #[cfg(test)]
pub mod tests; mod tests;
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct RepoActorLog(std::sync::Arc<std::sync::RwLock<Vec<String>>>); pub struct RepoActorLog(std::sync::Arc<std::sync::RwLock<Vec<String>>>);

View file

@ -14,12 +14,14 @@ github = []
[dependencies] [dependencies]
# logging # logging
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true }
# fs/network # fs/network
kxio = { workspace = true } kxio = { workspace = true }
# Actors # Actors
actix = { workspace = true } actix = { workspace = true }
actix-rt = { workspace = true }
# TOML parsing # TOML parsing
serde = { workspace = true } serde = { workspace = true }
@ -35,7 +37,6 @@ async-trait = { workspace = true }
# Webhooks # Webhooks
ulid = { workspace = true } ulid = { workspace = true }
time = { workspace = true }
# boilerplate # boilerplate
derive_more = { workspace = true } derive_more = { workspace = true }

View file

@ -1,4 +1,3 @@
use derive_more::derive::Display;
use serde::Serialize; use serde::Serialize;
crate::newtype!(BranchName: String, Display, Default, Hash, Serialize: "The name of a Git branch"); crate::newtype!(BranchName: String, derive_more::Display, Default, Serialize: "The name of a Git branch");

View file

@ -3,6 +3,6 @@ use serde::Serialize;
use crate::newtype; use crate::newtype;
newtype!(RepoAlias: String, Display, Default, Hash, PartialOrd, Ord, Serialize: r#"The alias of a repo. newtype!(RepoAlias: String, Display, Default, PartialOrd, Ord, Serialize: r#"The alias of a repo.
This is the alias for the repo within `git-next-server.toml`."#); This is the alias for the repo within `git-next-server.toml`."#);

View file

@ -1,8 +1,7 @@
// //
use crate::{git::Commit, BranchName, ForgeAlias, RepoAlias}; use crate::{git::Commit, BranchName, ForgeAlias, RepoAlias};
use serde_json::json;
#[derive(Clone, Debug, Hash, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum UserNotification { pub enum UserNotification {
CICheckFailed { CICheckFailed {
forge_alias: ForgeAlias, forge_alias: ForgeAlias,
@ -28,81 +27,3 @@ pub enum UserNotification {
main_commit: Commit, main_commit: Commit,
}, },
} }
impl UserNotification {
pub fn as_json(&self, timestamp: time::OffsetDateTime) -> serde_json::Value {
let timestamp = timestamp.unix_timestamp().to_string();
match self {
Self::CICheckFailed {
forge_alias,
repo_alias,
commit,
} => json!({
"type": "cicheck.failed",
"timestamp": timestamp,
"data": {
"forge_alias": forge_alias,
"repo_alias": repo_alias,
"commit": {
"sha": commit.sha(),
"message": commit.message()
}
}
}),
Self::RepoConfigLoadFailure {
forge_alias,
repo_alias,
reason,
} => json!({
"type": "config.load.failed",
"timestamp": timestamp,
"data": {
"forge_alias": forge_alias,
"repo_alias": repo_alias,
"reason": reason
}
}),
Self::WebhookRegistration {
forge_alias,
repo_alias,
reason,
} => json!({
"type": "webhook.registration.failed",
"timestamp": timestamp,
"data": {
"forge_alias": forge_alias,
"repo_alias": repo_alias,
"reason": reason
}
}),
Self::DevNotBasedOnMain {
forge_alias,
repo_alias,
dev_branch,
main_branch,
dev_commit,
main_commit,
} => json!({
"type": "branch.dev.not-on-main",
"timestamp": timestamp,
"data": {
"forge_alias": forge_alias,
"repo_alias": repo_alias,
"branches": {
"dev": dev_branch,
"main": main_branch
},
"commits": {
"dev": {
"sha": dev_commit.sha(),
"message": dev_commit.message()
},
"main": {
"sha": main_commit.sha(),
"message": main_commit.message()
}
}
}
}),
}
}
}

View file

@ -12,6 +12,9 @@ git-next-core = { workspace = true }
# logging # logging
tracing = { workspace = true } tracing = { workspace = true }
# base64 decoding
base64 = { workspace = true }
# git # git
async-trait = { workspace = true } async-trait = { workspace = true }
@ -21,10 +24,18 @@ kxio = { workspace = true }
# TOML parsing # TOML parsing
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
toml = { workspace = true }
# Secrets and Password # Secrets and Password
secrecy = { workspace = true } secrecy = { workspace = true }
# Webhooks
bytes = { workspace = true }
ulid = { workspace = true }
# boilerplate
derive_more = { workspace = true }
# # Actors # # Actors
tokio = { workspace = true } tokio = { workspace = true }

View file

@ -20,6 +20,9 @@ hmac = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }
hex = { workspace = true } hex = { workspace = true }
# base64 decoding
base64 = { workspace = true }
# git # git
async-trait = { workspace = true } async-trait = { workspace = true }
@ -29,10 +32,15 @@ kxio = { workspace = true }
# TOML parsing # TOML parsing
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
toml = { workspace = true }
# Secrets and Password # Secrets and Password
secrecy = { workspace = true } secrecy = { workspace = true }
# Webhooks
bytes = { workspace = true }
ulid = { workspace = true }
# boilerplate # boilerplate
derive_more = { workspace = true } derive_more = { workspace = true }