2026-04-24 18:01:34 +00:00
|
|
|
//! Event-to-notification mapping.
|
|
|
|
|
//!
|
|
|
|
|
//! Pure functions that classify [`WatcherEvent`] variants into notification
|
|
|
|
|
//! actions, deciding which events produce user-visible messages and which
|
|
|
|
|
//! are suppressed or logged server-side only.
|
|
|
|
|
|
|
|
|
|
use crate::io::watcher::WatcherEvent;
|
|
|
|
|
|
|
|
|
|
/// The notification action to take in response to a [`WatcherEvent`].
|
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
|
|
|
pub enum EventAction {
|
|
|
|
|
/// Post a merge-failure error notification.
|
|
|
|
|
MergeFailure,
|
|
|
|
|
/// Post a rate-limit warning (subject to config/debounce suppression).
|
|
|
|
|
RateLimitWarning,
|
|
|
|
|
/// Post a story-blocked notification.
|
|
|
|
|
StoryBlocked,
|
2026-04-27 18:39:35 +00:00
|
|
|
/// Post an OAuth account-swap notification naming the new account.
|
|
|
|
|
OAuthAccountSwapped,
|
|
|
|
|
/// Post an OAuth accounts-exhausted notification with the earliest reset time.
|
|
|
|
|
OAuthAccountsExhausted,
|
2026-04-29 21:28:41 +00:00
|
|
|
/// Post an agent-started (running) notification.
|
|
|
|
|
AgentStarted,
|
|
|
|
|
/// Post an agent-completed notification with pass/fail result.
|
|
|
|
|
AgentCompleted {
|
|
|
|
|
/// `true` if acceptance gates passed.
|
|
|
|
|
success: bool,
|
|
|
|
|
},
|
2026-04-24 18:01:34 +00:00
|
|
|
/// Log server-side only; do not post to chat (e.g. hard rate-limit blocks).
|
|
|
|
|
LogOnly,
|
|
|
|
|
/// Reload the project configuration.
|
|
|
|
|
ReloadConfig,
|
|
|
|
|
/// Skip silently (synthetic events, unknown variants).
|
|
|
|
|
Skip,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Classify a [`WatcherEvent`] into the action the notification listener should take.
|
|
|
|
|
pub fn classify(event: &WatcherEvent) -> EventAction {
|
|
|
|
|
match event {
|
2026-05-14 07:51:16 +00:00
|
|
|
// Stage-change notifications are now handled by the TransitionFired subscriber
|
|
|
|
|
// (story 995). WorkItem events are skipped regardless of from_stage.
|
|
|
|
|
WatcherEvent::WorkItem { .. } => EventAction::Skip,
|
2026-04-24 18:01:34 +00:00
|
|
|
WatcherEvent::MergeFailure { .. } => EventAction::MergeFailure,
|
|
|
|
|
WatcherEvent::RateLimitWarning { .. } => EventAction::RateLimitWarning,
|
|
|
|
|
WatcherEvent::StoryBlocked { .. } => EventAction::StoryBlocked,
|
|
|
|
|
WatcherEvent::RateLimitHardBlock { .. } => EventAction::LogOnly,
|
|
|
|
|
WatcherEvent::ConfigChanged => EventAction::ReloadConfig,
|
2026-04-27 18:39:35 +00:00
|
|
|
WatcherEvent::OAuthAccountSwapped { .. } => EventAction::OAuthAccountSwapped,
|
|
|
|
|
WatcherEvent::OAuthAccountsExhausted { .. } => EventAction::OAuthAccountsExhausted,
|
2026-04-29 21:28:41 +00:00
|
|
|
WatcherEvent::AgentStarted { .. } => EventAction::AgentStarted,
|
|
|
|
|
WatcherEvent::AgentCompleted { success, .. } => {
|
|
|
|
|
EventAction::AgentCompleted { success: *success }
|
|
|
|
|
}
|
2026-04-24 18:01:34 +00:00
|
|
|
_ => EventAction::Skip,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
fn work_item(from_stage: Option<&str>) -> WatcherEvent {
|
|
|
|
|
WatcherEvent::WorkItem {
|
|
|
|
|
stage: "3_qa".to_string(),
|
|
|
|
|
item_id: "1_story_foo".to_string(),
|
|
|
|
|
action: "qa".to_string(),
|
|
|
|
|
commit_msg: String::new(),
|
|
|
|
|
from_stage: from_stage.map(str::to_string),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 07:51:16 +00:00
|
|
|
// Stage-change notifications moved to TransitionFired subscriber (story 995).
|
|
|
|
|
// All WorkItem events are now classified as Skip regardless of from_stage.
|
2026-04-24 18:01:34 +00:00
|
|
|
#[test]
|
2026-05-14 07:51:16 +00:00
|
|
|
fn work_item_with_from_stage_is_skip() {
|
2026-04-24 18:01:34 +00:00
|
|
|
let event = work_item(Some("2_current"));
|
2026-05-14 07:51:16 +00:00
|
|
|
assert_eq!(classify(&event), EventAction::Skip);
|
2026-04-24 18:01:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn work_item_without_from_stage_is_skip() {
|
|
|
|
|
let event = work_item(None);
|
|
|
|
|
assert_eq!(classify(&event), EventAction::Skip);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn merge_failure_is_classified_correctly() {
|
|
|
|
|
let event = WatcherEvent::MergeFailure {
|
|
|
|
|
story_id: "1_story_foo".to_string(),
|
|
|
|
|
reason: "conflict".to_string(),
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(classify(&event), EventAction::MergeFailure);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rate_limit_warning_is_classified_correctly() {
|
|
|
|
|
let event = WatcherEvent::RateLimitWarning {
|
|
|
|
|
story_id: "1_story_foo".to_string(),
|
|
|
|
|
agent_name: "coder-1".to_string(),
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(classify(&event), EventAction::RateLimitWarning);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn story_blocked_is_classified_correctly() {
|
|
|
|
|
let event = WatcherEvent::StoryBlocked {
|
|
|
|
|
story_id: "1_story_foo".to_string(),
|
|
|
|
|
reason: "empty diff".to_string(),
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(classify(&event), EventAction::StoryBlocked);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rate_limit_hard_block_is_log_only() {
|
|
|
|
|
let event = WatcherEvent::RateLimitHardBlock {
|
|
|
|
|
story_id: "1_story_foo".to_string(),
|
|
|
|
|
agent_name: "coder-1".to_string(),
|
|
|
|
|
reset_at: chrono::Utc::now(),
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(classify(&event), EventAction::LogOnly);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn config_changed_triggers_reload() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
classify(&WatcherEvent::ConfigChanged),
|
|
|
|
|
EventAction::ReloadConfig
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-27 18:39:35 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn oauth_account_swapped_is_classified_correctly() {
|
|
|
|
|
let event = WatcherEvent::OAuthAccountSwapped {
|
|
|
|
|
new_email: "new@example.com".to_string(),
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(classify(&event), EventAction::OAuthAccountSwapped);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn oauth_accounts_exhausted_is_classified_correctly() {
|
|
|
|
|
let event = WatcherEvent::OAuthAccountsExhausted {
|
|
|
|
|
earliest_reset_msg: "All accounts rate-limited; earliest reset in 2h".to_string(),
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(classify(&event), EventAction::OAuthAccountsExhausted);
|
|
|
|
|
}
|
2026-04-29 21:28:41 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn agent_started_is_classified_correctly() {
|
|
|
|
|
let event = WatcherEvent::AgentStarted {
|
|
|
|
|
story_id: "1_story_foo".to_string(),
|
|
|
|
|
agent_name: "coder-1".to_string(),
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(classify(&event), EventAction::AgentStarted);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn agent_completed_success_is_classified_correctly() {
|
|
|
|
|
let event = WatcherEvent::AgentCompleted {
|
|
|
|
|
story_id: "1_story_foo".to_string(),
|
|
|
|
|
agent_name: "coder-1".to_string(),
|
|
|
|
|
success: true,
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(
|
|
|
|
|
classify(&event),
|
|
|
|
|
EventAction::AgentCompleted { success: true }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn agent_completed_failure_is_classified_correctly() {
|
|
|
|
|
let event = WatcherEvent::AgentCompleted {
|
|
|
|
|
story_id: "1_story_foo".to_string(),
|
|
|
|
|
agent_name: "coder-1".to_string(),
|
|
|
|
|
success: false,
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(
|
|
|
|
|
classify(&event),
|
|
|
|
|
EventAction::AgentCompleted { success: false }
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-24 18:01:34 +00:00
|
|
|
}
|