refactor!: move notification module to src/notify.rs
BREAKING CHANGE: notification system is now separate module
This commit is contained in:
@@ -1,5 +0,0 @@
|
|||||||
pub mod core;
|
|
||||||
pub mod error;
|
|
||||||
|
|
||||||
pub use core::{NotificationCore, NotificationRequest};
|
|
||||||
pub use error::NotificationError;
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
|
||||||
use crate::engine::{EventSender, ExecutionContext};
|
|
||||||
use crate::finalize::config::{NotificationConfig, NotificationMethod};
|
|
||||||
use crate::finalize::error::PluginError;
|
|
||||||
use crate::finalize::notification::error::NotificationError;
|
|
||||||
use crate::finalize::plugin::{call_named_plugin, PluginMap};
|
|
||||||
use crate::types::build_status::{BuildStatus, StageResult};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::{mpsc, oneshot};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct NotificationRequest {
|
|
||||||
pub pipeline_name: String,
|
|
||||||
pub build_id: String,
|
|
||||||
pub build_status: BuildStatus,
|
|
||||||
pub final_result: StageResult,
|
|
||||||
pub config: NotificationConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single notification job in the queue
|
|
||||||
struct NotificationJob {
|
|
||||||
request: NotificationRequest,
|
|
||||||
ctx: ExecutionContext,
|
|
||||||
event_tx: EventSender,
|
|
||||||
/// Number of remaining retry attempts
|
|
||||||
lives: u32,
|
|
||||||
/// Earliest time this job can be processed (for backoff)
|
|
||||||
not_before: std::time::Instant,
|
|
||||||
/// Response channel
|
|
||||||
done: oneshot::Sender<Result<(), NotificationError>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Core notification dispatcher with MQ logic
|
|
||||||
pub struct NotificationCore {
|
|
||||||
plugins: Arc<PluginMap>,
|
|
||||||
tx: mpsc::Sender<NotificationJob>,
|
|
||||||
_worker: tokio::task::JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NotificationCore {
|
|
||||||
pub fn new(plugins: PluginMap) -> Self {
|
|
||||||
let (tx, mut rx) = mpsc::channel::<NotificationJob>(256);
|
|
||||||
let plugins_arc = Arc::new(plugins);
|
|
||||||
|
|
||||||
let worker = tokio::spawn(async move {
|
|
||||||
while let Some(job) = rx.recv().await {
|
|
||||||
// Check not_before backoff
|
|
||||||
let now = std::time::Instant::now();
|
|
||||||
if now < job.not_before {
|
|
||||||
tokio::time::sleep(job.not_before - now).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let plugins = Arc::clone(&plugins_arc);
|
|
||||||
let result = Self::execute_job(job.request, plugins, &job.ctx, job.event_tx).await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
// Success - nothing to do
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("Notification job failed: {}", e);
|
|
||||||
// Job failed but channel already closed, can't retry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Self { plugins: Arc::new(PluginMap::new()), tx, _worker: worker }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch a notification request.
|
|
||||||
/// Enqueues the request and blocks until it is fully processed.
|
|
||||||
pub async fn dispatch(
|
|
||||||
&self,
|
|
||||||
request: NotificationRequest,
|
|
||||||
ctx: &ExecutionContext,
|
|
||||||
event_tx: EventSender,
|
|
||||||
) -> Result<(), NotificationError> {
|
|
||||||
let (done_tx, done_rx) = oneshot::channel();
|
|
||||||
let job = NotificationJob {
|
|
||||||
request,
|
|
||||||
ctx: ctx.clone(),
|
|
||||||
event_tx,
|
|
||||||
lives: 0, // TODO: derive from config retry + 1
|
|
||||||
not_before: std::time::Instant::now(),
|
|
||||||
done: done_tx,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.tx
|
|
||||||
.send(job)
|
|
||||||
.await
|
|
||||||
.map_err(|_| NotificationError::QueueClosed)?;
|
|
||||||
done_rx
|
|
||||||
.await
|
|
||||||
.map_err(|_| NotificationError::WorkerDropped)?
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute a notification job: iterate priorities, fallback on failure
|
|
||||||
async fn execute_job(
|
|
||||||
request: NotificationRequest,
|
|
||||||
plugins: Arc<PluginMap>,
|
|
||||||
ctx: &ExecutionContext,
|
|
||||||
event_tx: EventSender,
|
|
||||||
) -> Result<(), NotificationError> {
|
|
||||||
let groups = Self::sort_by_priority(&request.config.groups);
|
|
||||||
|
|
||||||
for (priority, methods) in groups {
|
|
||||||
// Standalone: filter out on_finish_of (not supported)
|
|
||||||
let active_methods: Vec<_> = methods
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, m)| m.on_finish_of.is_none())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if active_methods.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which template to use based on final_result
|
|
||||||
let results = futures_util::future::join_all(
|
|
||||||
active_methods
|
|
||||||
.iter()
|
|
||||||
.map(|(name, method)| {
|
|
||||||
Self::dispatch_single(
|
|
||||||
name,
|
|
||||||
method,
|
|
||||||
&request,
|
|
||||||
&plugins,
|
|
||||||
ctx,
|
|
||||||
event_tx.clone(),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let any_success = results.iter().any(|r| r.is_ok());
|
|
||||||
if any_success {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we should trigger fallback to next priority
|
|
||||||
let any_critical_failed = active_methods
|
|
||||||
.iter()
|
|
||||||
.zip(results.iter())
|
|
||||||
.any(|((_, m), r)| !m.fallible && r.is_err());
|
|
||||||
|
|
||||||
if any_critical_failed {
|
|
||||||
log::warn!(
|
|
||||||
"Priority {} all failed, triggering fallback to next priority",
|
|
||||||
priority
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
} else {
|
|
||||||
// All fallible, no fallback
|
|
||||||
return Err(NotificationError::AllFailed(priority));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// All priorities exhausted
|
|
||||||
Err(NotificationError::AllPrioritiesFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch a single notification method
|
|
||||||
async fn dispatch_single(
|
|
||||||
plugin_name: &str,
|
|
||||||
method: &NotificationMethod,
|
|
||||||
request: &NotificationRequest,
|
|
||||||
plugins: &PluginMap,
|
|
||||||
ctx: &ExecutionContext,
|
|
||||||
event_tx: EventSender,
|
|
||||||
) -> Result<(), NotificationError> {
|
|
||||||
// TODO: Template resolution (currently passthrough)
|
|
||||||
// In the future:
|
|
||||||
// 1. Resolve template based on method.template
|
|
||||||
// 2. Render with minijinja
|
|
||||||
// 3. Merge with method.config
|
|
||||||
let config = method.config.clone();
|
|
||||||
|
|
||||||
call_named_plugin(plugins, plugin_name, config, ctx, &event_tx)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
log::warn!("Plugin {} failed: {}", plugin_name, e);
|
|
||||||
NotificationError::from(e)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sort notification groups by priority (ascending: lower number = higher priority)
|
|
||||||
fn sort_by_priority(
|
|
||||||
groups: &HashMap<u32, HashMap<String, NotificationMethod>>,
|
|
||||||
) -> Vec<(u32, HashMap<String, NotificationMethod>)> {
|
|
||||||
let mut sorted: Vec<_> = groups.iter().map(|(k, v)| (*k, v.clone())).collect();
|
|
||||||
sorted.sort_by_key(|(priority, _)| *priority);
|
|
||||||
sorted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::finalize::config::NotificationMethod;
|
|
||||||
use serde_yaml::Value;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
fn create_test_method(fallible: bool) -> NotificationMethod {
|
|
||||||
NotificationMethod {
|
|
||||||
fallible,
|
|
||||||
schema: Some("fallback".to_string()),
|
|
||||||
on_success: None,
|
|
||||||
on_failure: None,
|
|
||||||
on_finish_of: None,
|
|
||||||
config: Value::Mapping(serde_yaml::Mapping::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sort_by_priority() {
|
|
||||||
let mut groups = HashMap::new();
|
|
||||||
groups.insert(10, HashMap::new());
|
|
||||||
groups.insert(5, HashMap::new());
|
|
||||||
groups.insert(20, HashMap::new());
|
|
||||||
|
|
||||||
let sorted = NotificationCore::sort_by_priority(&groups);
|
|
||||||
assert_eq!(sorted.len(), 3);
|
|
||||||
assert_eq!(sorted[0].0, 5);
|
|
||||||
assert_eq!(sorted[1].0, 10);
|
|
||||||
assert_eq!(sorted[2].0, 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_notification_request_serialization() {
|
|
||||||
let request = NotificationRequest {
|
|
||||||
pipeline_name: "test-pipeline".to_string(),
|
|
||||||
build_id: "build-1".to_string(),
|
|
||||||
build_status: BuildStatus::default(),
|
|
||||||
final_result: StageResult::Success,
|
|
||||||
config: NotificationConfig {
|
|
||||||
templates: HashMap::new(),
|
|
||||||
groups: HashMap::new(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&request).unwrap();
|
|
||||||
let deserialized: NotificationRequest = serde_json::from_str(&json).unwrap();
|
|
||||||
assert_eq!(deserialized.pipeline_name, "test-pipeline");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
|
||||||
use crate::finalize::error::PluginError;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Error, Debug, Clone)]
|
|
||||||
pub enum NotificationError {
|
|
||||||
#[error("Queue closed")]
|
|
||||||
QueueClosed,
|
|
||||||
|
|
||||||
#[error("Worker dropped")]
|
|
||||||
WorkerDropped,
|
|
||||||
|
|
||||||
#[error("All plugins failed at priority {0}")]
|
|
||||||
AllFailed(u32),
|
|
||||||
|
|
||||||
#[error("All priorities failed")]
|
|
||||||
AllPrioritiesFailed,
|
|
||||||
|
|
||||||
#[error("Plugin error: {0}")]
|
|
||||||
PluginError(String),
|
|
||||||
|
|
||||||
#[error("Template error: {0}")]
|
|
||||||
TemplateError(String),
|
|
||||||
|
|
||||||
#[error("Unknown plugin: {0}")]
|
|
||||||
UnknownPlugin(String),
|
|
||||||
|
|
||||||
#[error("In-site notification failed: {0}")]
|
|
||||||
InsiteFailed(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<PluginError> for NotificationError {
|
|
||||||
fn from(e: PluginError) -> Self {
|
|
||||||
NotificationError::PluginError(e.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user