crates/dwarves/src/brokkr/notify.rs
//! Notify routing + transport seam + bounded retry.
//!
//! On a run outcome brokkr decides *whether* to notify (from the pipeline's
//! `notify.on` list — `failure` / `success` / `fixed` = red→green) and delivers
//! through the [`Notifier`] seam. The host supplies real email / Slack / PR-status
//! transports (the twin of nornir's `ClaudeTransport`); this leaf owns the pure
//! routing + a bounded-retry loop so a flaky transport is retried a fixed number
//! of times, never infinitely, and the attempt count is observable.
use crate::brokkr::history::RunStatus;
use crate::brokkr::model::Notify;
/// Should this run outcome notify, given the pipeline's `notify.on` list and the
/// previous run's status (needed for `fixed` = a red→green transition)?
pub fn should_notify(cfg: &Notify, status: RunStatus, previous: Option<RunStatus>) -> bool {
cfg.on.iter().any(|on| match on.as_str() {
"failure" => status == RunStatus::Failed,
"success" => status == RunStatus::Passed,
"fixed" => status == RunStatus::Passed && previous == Some(RunStatus::Failed),
_ => false,
})
}
/// One notification to deliver over one transport.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Notification {
/// Transport name (`email` / `slack` / `pr-status`).
pub via: String,
/// Short subject line.
pub subject: String,
/// Body text.
pub body: String,
}
/// The delivery seam. Real transports live host-side; tests use a fake.
pub trait Notifier: Send + Sync {
/// Attempt to deliver `n`. `Err(reason)` on a transient/hard failure.
fn deliver(&self, n: &Notification) -> Result<(), String>;
}
/// Deliver with **bounded** retries (at least one attempt). Returns the number of
/// attempts made and the final result — so a flaky transport is retried a fixed
/// number of times, never forever.
pub fn deliver_with_retries(
notifier: &dyn Notifier,
n: &Notification,
max_attempts: u32,
) -> (u32, Result<(), String>) {
let attempts = max_attempts.max(1);
let mut last = Err("not attempted".to_string());
for attempt in 1..=attempts {
last = notifier.deliver(n);
if last.is_ok() {
return (attempt, Ok(()));
}
}
(attempts, last)
}
/// Build the notifications a run outcome should send (one per configured `via`),
/// or an empty vec when the outcome does not warrant a notification.
pub fn notifications_for(
cfg: &Notify,
pipeline: &str,
status: RunStatus,
previous: Option<RunStatus>,
) -> Vec<Notification> {
if !should_notify(cfg, status, previous) {
return Vec::new();
}
let subject = format!("[brokkr] {pipeline}: {}", status.label());
cfg.via
.iter()
.map(|via| Notification {
via: via.clone(),
subject: subject.clone(),
body: format!("pipeline '{pipeline}' {}", status.label()),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
fn cfg(on: &[&str], via: &[&str]) -> Notify {
Notify {
on: on.iter().map(|s| s.to_string()).collect(),
via: via.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn routes_failure_and_fixed_but_not_a_steady_green() {
let c = cfg(&["failure", "fixed"], &["slack"]);
assert!(
should_notify(&c, RunStatus::Failed, None),
"failure notifies"
);
assert!(
should_notify(&c, RunStatus::Passed, Some(RunStatus::Failed)),
"red→green is a fix"
);
assert!(
!should_notify(&c, RunStatus::Passed, Some(RunStatus::Passed)),
"green→green is quiet"
);
assert_eq!(notifications_for(&c, "p", RunStatus::Failed, None).len(), 1);
}
/// A transport that fails `fail_first` times then succeeds.
struct FlakyNotifier {
fail_first: u32,
calls: AtomicU32,
}
impl Notifier for FlakyNotifier {
fn deliver(&self, _n: &Notification) -> Result<(), String> {
let n = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
if n <= self.fail_first {
Err(format!("transient failure #{n}"))
} else {
Ok(())
}
}
}
#[test]
fn retries_are_bounded_and_the_count_is_observable() {
let n = Notification {
via: "slack".into(),
subject: "s".into(),
body: "b".into(),
};
// Fails twice, succeeds on the third attempt.
let flaky = FlakyNotifier {
fail_first: 2,
calls: AtomicU32::new(0),
};
let (attempts, res) = deliver_with_retries(&flaky, &n, 5);
assert!(res.is_ok());
assert_eq!(attempts, 3, "delivered on the third attempt");
// Always fails: bounded to exactly max_attempts, then gives up (no infinite loop).
let dead = FlakyNotifier {
fail_first: u32::MAX,
calls: AtomicU32::new(0),
};
let (attempts, res) = deliver_with_retries(&dead, &n, 3);
assert!(res.is_err());
assert_eq!(attempts, 3, "gave up after the bound");
assert_eq!(
dead.calls.load(Ordering::SeqCst),
3,
"exactly 3 real attempts"
);
}
}