//! 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, /// Post an OAuth account-swap notification naming the new account. OAuthAccountSwapped, /// Post an OAuth accounts-exhausted notification with the earliest reset time. OAuthAccountsExhausted, /// Post an agent-started (running) notification. AgentStarted, /// Post an agent-completed notification with pass/fail result. AgentCompleted { /// `true` if acceptance gates passed. success: bool, }, /// 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 { // Stage-change notifications are now handled by the TransitionFired subscriber // (story 995). WorkItem events are skipped regardless of from_stage. WatcherEvent::WorkItem { .. } => EventAction::Skip, WatcherEvent::MergeFailure { .. } => EventAction::MergeFailure, WatcherEvent::RateLimitWarning { .. } => EventAction::RateLimitWarning, WatcherEvent::StoryBlocked { .. } => EventAction::StoryBlocked, WatcherEvent::RateLimitHardBlock { .. } => EventAction::LogOnly, WatcherEvent::ConfigChanged => EventAction::ReloadConfig, WatcherEvent::OAuthAccountSwapped { .. } => EventAction::OAuthAccountSwapped, WatcherEvent::OAuthAccountsExhausted { .. } => EventAction::OAuthAccountsExhausted, WatcherEvent::AgentStarted { .. } => EventAction::AgentStarted, WatcherEvent::AgentCompleted { success, .. } => { EventAction::AgentCompleted { success: *success } } _ => 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), } } // Stage-change notifications moved to TransitionFired subscriber (story 995). // All WorkItem events are now classified as Skip regardless of from_stage. #[test] fn work_item_with_from_stage_is_skip() { let event = work_item(Some("2_current")); assert_eq!(classify(&event), EventAction::Skip); } #[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 ); } #[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); } #[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 } ); } }