Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

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