From 5fb2e6221647dd981b95ac16a871294bf36f48fc Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 21:14:09 -0700 Subject: [PATCH] feat: add ecc2 webhook notifications --- ecc2/Cargo.lock | 154 ++++++++++++++++++ ecc2/Cargo.toml | 1 + ecc2/src/config/mod.rs | 54 ++++++- ecc2/src/notifications.rs | 307 +++++++++++++++++++++++++++++++++++- ecc2/src/session/manager.rs | 1 + ecc2/src/tui/dashboard.rs | 188 +++++++++++++++++++++- ecc2/src/worktree/mod.rs | 116 ++++++++++++++ 7 files changed, 816 insertions(+), 5 deletions(-) diff --git a/ecc2/Cargo.lock b/ecc2/Cargo.lock index 7a4e60eb..40cd4724 100644 --- a/ecc2/Cargo.lock +++ b/ecc2/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -300,6 +306,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -507,6 +522,7 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "ureq", "uuid", ] @@ -592,6 +608,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1141,6 +1167,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1612,6 +1648,20 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -1661,6 +1711,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1794,6 +1879,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "siphasher" version = "1.0.2" @@ -1855,6 +1946,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -2208,6 +2305,30 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -2374,6 +2495,24 @@ dependencies = [ "semver", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -2527,6 +2666,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -2776,6 +2924,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/ecc2/Cargo.toml b/ecc2/Cargo.toml index 170aacb4..85399a3b 100644 --- a/ecc2/Cargo.toml +++ b/ecc2/Cargo.toml @@ -27,6 +27,7 @@ serde_json = "1" toml = "0.8" regex = "1" sha2 = "0.10" +ureq = { version = "2", features = ["json"] } # CLI clap = { version = "4", features = ["derive"] } diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 2b0fbe6a..f83d7a32 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -3,7 +3,9 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -use crate::notifications::{CompletionSummaryConfig, DesktopNotificationConfig}; +use crate::notifications::{ + CompletionSummaryConfig, DesktopNotificationConfig, WebhookNotificationConfig, +}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -48,6 +50,7 @@ pub struct Config { pub auto_create_worktrees: bool, pub auto_merge_ready_worktrees: bool, pub desktop_notifications: DesktopNotificationConfig, + pub webhook_notifications: WebhookNotificationConfig, pub completion_summary_notifications: CompletionSummaryConfig, pub cost_budget_usd: f64, pub token_budget: u64, @@ -112,6 +115,7 @@ impl Default for Config { auto_create_worktrees: true, auto_merge_ready_worktrees: false, desktop_notifications: DesktopNotificationConfig::default(), + webhook_notifications: WebhookNotificationConfig::default(), completion_summary_notifications: CompletionSummaryConfig::default(), cost_budget_usd: 10.0, token_budget: 500_000, @@ -438,6 +442,7 @@ theme = "Dark" defaults.auto_merge_ready_worktrees ); assert_eq!(config.desktop_notifications, defaults.desktop_notifications); + assert_eq!(config.webhook_notifications, defaults.webhook_notifications); assert_eq!( config.auto_terminate_stale_sessions, defaults.auto_terminate_stale_sessions @@ -636,6 +641,42 @@ delivery = "desktop_and_tui_popup" ); } + #[test] + fn webhook_notifications_deserialize_from_toml() { + let config: Config = toml::from_str( + r#" +[webhook_notifications] +enabled = true +session_started = true +session_completed = true +session_failed = true +budget_alerts = true +approval_requests = false + +[[webhook_notifications.targets]] +provider = "slack" +url = "https://hooks.slack.test/services/abc" + +[[webhook_notifications.targets]] +provider = "discord" +url = "https://discord.test/api/webhooks/123" +"#, + ) + .unwrap(); + + assert!(config.webhook_notifications.enabled); + assert!(config.webhook_notifications.session_started); + assert_eq!(config.webhook_notifications.targets.len(), 2); + assert_eq!( + config.webhook_notifications.targets[0].provider, + crate::notifications::WebhookProvider::Slack + ); + assert_eq!( + config.webhook_notifications.targets[1].provider, + crate::notifications::WebhookProvider::Discord + ); + } + #[test] fn invalid_budget_alert_thresholds_fall_back_to_defaults() { let config: Config = toml::from_str( @@ -663,6 +704,11 @@ critical = 1.10 config.auto_create_worktrees = false; config.auto_merge_ready_worktrees = true; config.desktop_notifications.session_completed = false; + config.webhook_notifications.enabled = true; + config.webhook_notifications.targets = vec![crate::notifications::WebhookTarget { + provider: crate::notifications::WebhookProvider::Slack, + url: "https://hooks.slack.test/services/abc".to_string(), + }]; config.completion_summary_notifications.delivery = crate::notifications::CompletionSummaryDelivery::TuiPopup; config.desktop_notifications.quiet_hours.enabled = true; @@ -688,6 +734,12 @@ critical = 1.10 assert!(!loaded.auto_create_worktrees); assert!(loaded.auto_merge_ready_worktrees); assert!(!loaded.desktop_notifications.session_completed); + assert!(loaded.webhook_notifications.enabled); + assert_eq!(loaded.webhook_notifications.targets.len(), 1); + assert_eq!( + loaded.webhook_notifications.targets[0].provider, + crate::notifications::WebhookProvider::Slack + ); assert_eq!( loaded.completion_summary_notifications.delivery, crate::notifications::CompletionSummaryDelivery::TuiPopup diff --git a/ecc2/src/notifications.rs b/ecc2/src/notifications.rs index e4238627..f7d51883 100644 --- a/ecc2/src/notifications.rs +++ b/ecc2/src/notifications.rs @@ -1,12 +1,14 @@ use anyhow::Result; use chrono::{DateTime, Local, Timelike}; use serde::{Deserialize, Serialize}; +use serde_json::json; #[cfg(not(test))] use anyhow::Context; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NotificationEvent { + SessionStarted, SessionCompleted, SessionFailed, BudgetAlert, @@ -25,6 +27,7 @@ pub struct QuietHoursConfig { #[serde(default)] pub struct DesktopNotificationConfig { pub enabled: bool, + pub session_started: bool, pub session_completed: bool, pub session_failed: bool, pub budget_alerts: bool, @@ -48,11 +51,43 @@ pub struct CompletionSummaryConfig { pub delivery: CompletionSummaryDelivery, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WebhookProvider { + #[default] + Slack, + Discord, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct WebhookTarget { + pub provider: WebhookProvider, + pub url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct WebhookNotificationConfig { + pub enabled: bool, + pub session_started: bool, + pub session_completed: bool, + pub session_failed: bool, + pub budget_alerts: bool, + pub approval_requests: bool, + pub targets: Vec, +} + #[derive(Debug, Clone)] pub struct DesktopNotifier { config: DesktopNotificationConfig, } +#[derive(Debug, Clone)] +pub struct WebhookNotifier { + config: WebhookNotificationConfig, +} + impl Default for QuietHoursConfig { fn default() -> Self { Self { @@ -96,6 +131,7 @@ impl Default for DesktopNotificationConfig { fn default() -> Self { Self { enabled: true, + session_started: false, session_completed: true, session_failed: true, budget_alerts: true, @@ -120,6 +156,7 @@ impl DesktopNotificationConfig { } match event { + NotificationEvent::SessionStarted => config.session_started, NotificationEvent::SessionCompleted => config.session_completed, NotificationEvent::SessionFailed => config.session_failed, NotificationEvent::BudgetAlert => config.budget_alerts, @@ -155,6 +192,68 @@ impl CompletionSummaryConfig { } } +impl Default for WebhookTarget { + fn default() -> Self { + Self { + provider: WebhookProvider::Slack, + url: String::new(), + } + } +} + +impl WebhookTarget { + fn sanitized(self) -> Option { + let url = self.url.trim().to_string(); + if url.starts_with("https://") || url.starts_with("http://") { + Some(Self { url, ..self }) + } else { + None + } + } +} + +impl Default for WebhookNotificationConfig { + fn default() -> Self { + Self { + enabled: false, + session_started: true, + session_completed: true, + session_failed: true, + budget_alerts: true, + approval_requests: false, + targets: Vec::new(), + } + } +} + +impl WebhookNotificationConfig { + pub fn sanitized(self) -> Self { + Self { + targets: self + .targets + .into_iter() + .filter_map(WebhookTarget::sanitized) + .collect(), + ..self + } + } + + pub fn allows(&self, event: NotificationEvent) -> bool { + let config = self.clone().sanitized(); + if !config.enabled || config.targets.is_empty() { + return false; + } + + match event { + NotificationEvent::SessionStarted => config.session_started, + NotificationEvent::SessionCompleted => config.session_completed, + NotificationEvent::SessionFailed => config.session_failed, + NotificationEvent::BudgetAlert => config.budget_alerts, + NotificationEvent::ApprovalRequest => config.approval_requests, + } + } +} + impl DesktopNotifier { pub fn new(config: DesktopNotificationConfig) -> Self { Self { @@ -192,6 +291,57 @@ impl DesktopNotifier { } } +impl WebhookNotifier { + pub fn new(config: WebhookNotificationConfig) -> Self { + Self { + config: config.sanitized(), + } + } + + pub fn notify(&self, event: NotificationEvent, message: &str) -> bool { + match self.try_notify(event, message) { + Ok(sent) => sent, + Err(error) => { + tracing::warn!("Failed to send webhook notification: {error}"); + false + } + } + } + + fn try_notify(&self, event: NotificationEvent, message: &str) -> Result { + self.try_notify_with(event, message, send_webhook_request) + } + + fn try_notify_with( + &self, + event: NotificationEvent, + message: &str, + mut sender: F, + ) -> Result + where + F: FnMut(&WebhookTarget, serde_json::Value) -> Result<()>, + { + if !self.config.allows(event) { + return Ok(false); + } + + let mut delivered = false; + for target in &self.config.targets { + let payload = webhook_payload(target, message); + match sender(target, payload) { + Ok(()) => delivered = true, + Err(error) => tracing::warn!( + "Failed to deliver {:?} webhook notification to {}: {error}", + target.provider, + target.url + ), + } + } + + Ok(delivered) + } +} + fn notification_command(platform: &str, title: &str, body: &str) -> Option<(String, Vec)> { match platform { "macos" => Some(( @@ -218,6 +368,20 @@ fn notification_command(platform: &str, title: &str, body: &str) -> Option<(Stri } } +fn webhook_payload(target: &WebhookTarget, message: &str) -> serde_json::Value { + match target.provider { + WebhookProvider::Slack => json!({ + "text": message, + }), + WebhookProvider::Discord => json!({ + "content": message, + "allowed_mentions": { + "parse": [] + } + }), + } +} + #[cfg(not(test))] fn run_notification_command(program: &str, args: &[String]) -> Result<()> { let status = std::process::Command::new(program) @@ -237,6 +401,29 @@ fn run_notification_command(_program: &str, _args: &[String]) -> Result<()> { Ok(()) } +#[cfg(not(test))] +fn send_webhook_request(target: &WebhookTarget, payload: serde_json::Value) -> Result<()> { + let agent = ureq::AgentBuilder::new() + .timeout_connect(std::time::Duration::from_secs(5)) + .timeout_read(std::time::Duration::from_secs(5)) + .build(); + let response = agent + .post(&target.url) + .send_json(payload) + .with_context(|| format!("POST {}", target.url))?; + + if response.status() >= 200 && response.status() < 300 { + Ok(()) + } else { + anyhow::bail!("{} returned {}", target.url, response.status()); + } +} + +#[cfg(test)] +fn send_webhook_request(_target: &WebhookTarget, _payload: serde_json::Value) -> Result<()> { + Ok(()) +} + fn sanitize_osascript(value: &str) -> String { value .replace('\\', "") @@ -247,10 +434,12 @@ fn sanitize_osascript(value: &str) -> String { #[cfg(test)] mod tests { use super::{ - notification_command, DesktopNotificationConfig, DesktopNotifier, NotificationEvent, - QuietHoursConfig, + notification_command, webhook_payload, CompletionSummaryDelivery, + DesktopNotificationConfig, DesktopNotifier, NotificationEvent, QuietHoursConfig, + WebhookNotificationConfig, WebhookNotifier, WebhookProvider, WebhookTarget, }; use chrono::{Local, TimeZone}; + use serde_json::json; #[test] fn quiet_hours_support_cross_midnight_ranges() { @@ -285,6 +474,7 @@ mod tests { assert!(!config.allows(NotificationEvent::SessionCompleted, now)); assert!(config.allows(NotificationEvent::BudgetAlert, now)); + assert!(!config.allows(NotificationEvent::SessionStarted, now)); } #[test] @@ -329,4 +519,117 @@ mod tests { assert_eq!(args[2], "ECC 2.0: Approval needed"); assert_eq!(args[3], "worker-123"); } + + #[test] + fn webhook_notifications_require_enabled_targets_and_event() { + let mut config = WebhookNotificationConfig::default(); + assert!(!config.allows(NotificationEvent::SessionCompleted)); + + config.enabled = true; + config.targets = vec![WebhookTarget { + provider: WebhookProvider::Slack, + url: "https://hooks.slack.test/services/abc".to_string(), + }]; + + assert!(config.allows(NotificationEvent::SessionCompleted)); + assert!(config.allows(NotificationEvent::SessionStarted)); + assert!(!config.allows(NotificationEvent::ApprovalRequest)); + } + + #[test] + fn webhook_sanitization_filters_invalid_urls() { + let config = WebhookNotificationConfig { + enabled: true, + targets: vec![ + WebhookTarget { + provider: WebhookProvider::Slack, + url: "https://hooks.slack.test/services/abc".to_string(), + }, + WebhookTarget { + provider: WebhookProvider::Discord, + url: "ftp://discord.invalid".to_string(), + }, + ], + ..WebhookNotificationConfig::default() + } + .sanitized(); + + assert_eq!(config.targets.len(), 1); + assert_eq!(config.targets[0].provider, WebhookProvider::Slack); + } + + #[test] + fn slack_webhook_payload_uses_text() { + let payload = webhook_payload( + &WebhookTarget { + provider: WebhookProvider::Slack, + url: "https://hooks.slack.test/services/abc".to_string(), + }, + "*ECC 2.0* hello", + ); + + assert_eq!(payload, json!({ "text": "*ECC 2.0* hello" })); + } + + #[test] + fn discord_webhook_payload_disables_mentions() { + let payload = webhook_payload( + &WebhookTarget { + provider: WebhookProvider::Discord, + url: "https://discord.test/api/webhooks/123".to_string(), + }, + "```text\nsummary\n```", + ); + + assert_eq!( + payload, + json!({ + "content": "```text\nsummary\n```", + "allowed_mentions": { "parse": [] } + }) + ); + } + + #[test] + fn webhook_notifier_sends_to_each_target() { + let notifier = WebhookNotifier::new(WebhookNotificationConfig { + enabled: true, + targets: vec![ + WebhookTarget { + provider: WebhookProvider::Slack, + url: "https://hooks.slack.test/services/abc".to_string(), + }, + WebhookTarget { + provider: WebhookProvider::Discord, + url: "https://discord.test/api/webhooks/123".to_string(), + }, + ], + ..WebhookNotificationConfig::default() + }); + let mut sent = Vec::new(); + + let delivered = notifier + .try_notify_with( + NotificationEvent::SessionCompleted, + "payload text", + |target, payload| { + sent.push((target.provider, payload)); + Ok(()) + }, + ) + .unwrap(); + + assert!(delivered); + assert_eq!(sent.len(), 2); + assert_eq!(sent[0].0, WebhookProvider::Slack); + assert_eq!(sent[1].0, WebhookProvider::Discord); + } + + #[test] + fn completion_summary_delivery_defaults_to_desktop() { + assert_eq!( + CompletionSummaryDelivery::default(), + CompletionSummaryDelivery::Desktop + ); + } } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index edbaa539..64a55e9b 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -2246,6 +2246,7 @@ mod tests { auto_create_worktrees: true, auto_merge_ready_worktrees: false, desktop_notifications: crate::notifications::DesktopNotificationConfig::default(), + webhook_notifications: crate::notifications::WebhookNotificationConfig::default(), completion_summary_notifications: crate::notifications::CompletionSummaryConfig::default(), cost_budget_usd: 10.0, diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index f2f5e8d2..ae1aa796 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -15,7 +15,7 @@ use tokio::sync::broadcast; use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter}; use crate::comms; use crate::config::{Config, PaneLayout, PaneNavigationAction, Theme}; -use crate::notifications::{DesktopNotifier, NotificationEvent}; +use crate::notifications::{DesktopNotifier, NotificationEvent, WebhookNotifier}; use crate::observability::ToolLogEntry; use crate::session::manager; use crate::session::output::{ @@ -81,6 +81,7 @@ pub struct Dashboard { output_store: SessionOutputStore, output_rx: broadcast::Receiver, notifier: DesktopNotifier, + webhook_notifier: WebhookNotifier, sessions: Vec, session_output_cache: HashMap>, unread_message_counts: HashMap, @@ -456,6 +457,7 @@ impl Dashboard { .map(|message| message.id); let output_rx = output_store.subscribe(); let notifier = DesktopNotifier::new(cfg.desktop_notifications.clone()); + let webhook_notifier = WebhookNotifier::new(cfg.webhook_notifications.clone()); let mut session_table_state = TableState::default(); if !sessions.is_empty() { session_table_state.select(Some(0)); @@ -467,6 +469,7 @@ impl Dashboard { output_store, output_rx, notifier, + webhook_notifier, sessions, session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), @@ -3649,21 +3652,40 @@ impl Dashboard { "ECC 2.0: Budget alert", &format!("{summary_suffix} | tokens {token_budget} | cost {cost_budget}"), ); + self.notify_webhook( + NotificationEvent::BudgetAlert, + &budget_alert_webhook_body( + &summary_suffix, + &token_budget, + &cost_budget, + self.active_session_count(), + ), + ); } fn sync_session_state_notifications(&mut self) { let mut next_states = HashMap::new(); let mut completion_summaries = Vec::new(); let mut failed_notifications = Vec::new(); + let mut started_webhooks = Vec::new(); + let mut completion_webhooks = Vec::new(); + let mut failed_webhooks = Vec::new(); for session in &self.sessions { let previous_state = self.last_session_states.get(&session.id); if let Some(previous_state) = previous_state { if previous_state != &session.state { match session.state { + SessionState::Running => { + started_webhooks.push(session_started_webhook_body( + session, + session_compare_url(session).as_deref(), + )); + } SessionState::Completed => { + let summary = self.build_completion_summary(session); if self.cfg.completion_summary_notifications.enabled { - completion_summaries.push(self.build_completion_summary(session)); + completion_summaries.push(summary.clone()); } else if self.cfg.desktop_notifications.session_completed { self.notify_desktop( NotificationEvent::SessionCompleted, @@ -3675,8 +3697,14 @@ impl Dashboard { ), ); } + completion_webhooks.push(completion_summary_webhook_body( + &summary, + session, + session_compare_url(session).as_deref(), + )); } SessionState::Failed => { + let summary = self.build_completion_summary(session); failed_notifications.push(( "ECC 2.0: Session failed".to_string(), format!( @@ -3685,10 +3713,20 @@ impl Dashboard { truncate_for_dashboard(&session.task, 96) ), )); + failed_webhooks.push(completion_summary_webhook_body( + &summary, + session, + session_compare_url(session).as_deref(), + )); } _ => {} } } + } else if session.state == SessionState::Running { + started_webhooks.push(session_started_webhook_body( + session, + session_compare_url(session).as_deref(), + )); } next_states.insert(session.id.clone(), session.state.clone()); @@ -3698,12 +3736,24 @@ impl Dashboard { self.deliver_completion_summary(summary); } + for body in started_webhooks { + self.notify_webhook(NotificationEvent::SessionStarted, &body); + } + if self.cfg.desktop_notifications.session_failed { for (title, body) in failed_notifications { self.notify_desktop(NotificationEvent::SessionFailed, &title, &body); } } + for body in completion_webhooks { + self.notify_webhook(NotificationEvent::SessionCompleted, &body); + } + + for body in failed_webhooks { + self.notify_webhook(NotificationEvent::SessionFailed, &body); + } + self.last_session_states = next_states; } @@ -3740,6 +3790,10 @@ impl Dashboard { preview ), ); + self.notify_webhook( + NotificationEvent::ApprovalRequest, + &approval_request_webhook_body(&message, &preview), + ); } fn deliver_completion_summary(&mut self, summary: SessionCompletionSummary) { @@ -3830,6 +3884,10 @@ impl Dashboard { let _ = self.notifier.notify(event, title, body); } + fn notify_webhook(&self, event: NotificationEvent, body: &str) { + let _ = self.webhook_notifier.notify(event, body); + } + fn sync_selection(&mut self) { if self.sessions.is_empty() { self.selected_session = 0; @@ -7263,6 +7321,129 @@ fn summarize_completion_warnings( warnings } +fn session_started_webhook_body(session: &Session, compare_url: Option<&str>) -> String { + let mut lines = vec![ + "*ECC 2.0: Session started*".to_string(), + format!( + "`{}` {}", + format_session_id(&session.id), + truncate_for_dashboard(&session.task, 96) + ), + format!( + "Project `{}` | Group `{}` | Agent `{}`", + session.project, session.task_group, session.agent_type + ), + ]; + + if let Some(worktree) = session.worktree.as_ref() { + lines.push(format!( + "```text\nbranch: {}\nbase: {}\nworktree: {}\n```", + worktree.branch, + worktree.base_branch, + worktree.path.display() + )); + } + + if let Some(compare_url) = compare_url { + lines.push(format!("PR / compare: {compare_url}")); + } + + lines.join("\n") +} + +fn completion_summary_webhook_body( + summary: &SessionCompletionSummary, + session: &Session, + compare_url: Option<&str>, +) -> String { + let mut lines = vec![ + format!("*{}*", summary.title()), + format!( + "`{}` {}", + format_session_id(&summary.session_id), + truncate_for_dashboard(&summary.task, 96) + ), + format!( + "Project `{}` | Group `{}` | State `{}`", + session.project, session.task_group, session.state + ), + format!( + "Duration `{}` | Files `{}` | Tokens `{}` | Cost `{}`", + format_duration(summary.duration_secs), + summary.files_changed, + format_token_count(summary.tokens_used), + format_currency(summary.cost_usd) + ), + if summary.tests_run > 0 { + format!( + "Tests `{}` run / `{}` passed", + summary.tests_run, summary.tests_passed + ) + } else { + "Tests `not detected`".to_string() + }, + ]; + + if !summary.recent_files.is_empty() { + lines.push(markdown_code_block("Recent files", &summary.recent_files)); + } + + if !summary.key_decisions.is_empty() { + lines.push(markdown_code_block("Key decisions", &summary.key_decisions)); + } + + if !summary.warnings.is_empty() { + lines.push(markdown_code_block("Warnings", &summary.warnings)); + } + + if let Some(compare_url) = compare_url { + lines.push(format!("PR / compare: {compare_url}")); + } + + lines.join("\n") +} + +fn budget_alert_webhook_body( + summary_suffix: &str, + token_budget: &str, + cost_budget: &str, + active_sessions: usize, +) -> String { + [ + "*ECC 2.0: Budget alert*".to_string(), + summary_suffix.to_string(), + format!("Tokens `{token_budget}`"), + format!("Cost `{cost_budget}`"), + format!("Active sessions `{active_sessions}`"), + ] + .join("\n") +} + +fn approval_request_webhook_body(message: &SessionMessage, preview: &str) -> String { + [ + "*ECC 2.0: Approval needed*".to_string(), + format!( + "To `{}` from `{}`", + format_session_id(&message.to_session), + format_session_id(&message.from_session) + ), + format!("Type `{}`", message.msg_type), + markdown_code_block("Request", &[preview.to_string()]), + ] + .join("\n") +} + +fn markdown_code_block(label: &str, lines: &[String]) -> String { + format!("{label}\n```text\n{}\n```", lines.join("\n")) +} + +fn session_compare_url(session: &Session) -> Option { + session + .worktree + .as_ref() + .and_then(|worktree| worktree::github_compare_url(worktree).ok().flatten()) +} + fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str { match action { crate::session::FileActivityAction::Read => "read", @@ -11838,6 +12019,7 @@ diff --git a/src/lib.rs b/src/lib.rs let selected_session = selected_session.min(sessions.len().saturating_sub(1)); let cfg = Config::default(); let notifier = DesktopNotifier::new(cfg.desktop_notifications.clone()); + let webhook_notifier = WebhookNotifier::new(cfg.webhook_notifications.clone()); let last_session_states = sessions .iter() .map(|session| (session.id.clone(), session.state.clone())) @@ -11856,6 +12038,7 @@ diff --git a/src/lib.rs b/src/lib.rs output_store, output_rx, notifier, + webhook_notifier, sessions, session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), @@ -11937,6 +12120,7 @@ diff --git a/src/lib.rs b/src/lib.rs auto_create_worktrees: true, auto_merge_ready_worktrees: false, desktop_notifications: crate::notifications::DesktopNotificationConfig::default(), + webhook_notifications: crate::notifications::WebhookNotificationConfig::default(), completion_summary_notifications: crate::notifications::CompletionSummaryConfig::default(), cost_budget_usd: 10.0, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index caab2466..3173662f 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -373,6 +373,20 @@ pub fn create_draft_pr(worktree: &WorktreeInfo, title: &str, body: &str) -> Resu create_draft_pr_with_gh(worktree, title, body, Path::new("gh")) } +pub fn github_compare_url(worktree: &WorktreeInfo) -> Result> { + let repo_root = base_checkout_path(worktree)?; + let origin = git_remote_origin_url(&repo_root)?; + let Some(repo_url) = github_repo_web_url(&origin) else { + return Ok(None); + }; + + Ok(Some(format!( + "{repo_url}/compare/{}...{}?expand=1", + percent_encode_git_ref(&worktree.base_branch), + percent_encode_git_ref(&worktree.branch) + ))) +} + fn create_draft_pr_with_gh( worktree: &WorktreeInfo, title: &str, @@ -418,6 +432,67 @@ fn create_draft_pr_with_gh( Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +fn git_remote_origin_url(repo_root: &Path) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .args(["remote", "get-url", "origin"]) + .output() + .context("Failed to resolve git origin remote")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git remote get-url origin failed: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn github_repo_web_url(origin: &str) -> Option { + let trimmed = origin.trim().trim_end_matches(".git"); + if trimmed.is_empty() { + return None; + } + + if let Some(rest) = trimmed.strip_prefix("git@") { + let (host, path) = rest.split_once(':')?; + return Some(format!("https://{host}/{}", path.trim_start_matches('/'))); + } + + if let Some(rest) = trimmed.strip_prefix("ssh://") { + return parse_httpish_remote(rest); + } + + if let Some(rest) = trimmed.strip_prefix("https://") { + return parse_httpish_remote(rest); + } + + if let Some(rest) = trimmed.strip_prefix("http://") { + return parse_httpish_remote(rest); + } + + None +} + +fn parse_httpish_remote(rest: &str) -> Option { + let without_user = rest.strip_prefix("git@").unwrap_or(rest); + let (host, path) = without_user.split_once('/')?; + Some(format!("https://{host}/{}", path.trim_start_matches('/'))) +} + +fn percent_encode_git_ref(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + let ch = byte as char; + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~') { + encoded.push(ch); + } else { + encoded.push('%'); + encoded.push_str(&format!("{byte:02X}")); + } + } + encoded +} + pub fn diff_file_preview(worktree: &WorktreeInfo, limit: usize) -> Result> { let mut preview = Vec::new(); let base_ref = format!("{}...HEAD", worktree.base_branch); @@ -1730,6 +1805,47 @@ mod tests { Ok(()) } + #[test] + fn github_compare_url_uses_origin_remote_and_encodes_refs() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-compare-url-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + run_git( + &repo, + &["remote", "add", "origin", "git@github.com:example/ecc.git"], + )?; + + let worktree = WorktreeInfo { + path: repo.clone(), + branch: "ecc/worker-123".to_string(), + base_branch: "main".to_string(), + }; + + let url = github_compare_url(&worktree)?.expect("compare url"); + assert_eq!( + url, + "https://github.com/example/ecc/compare/main...ecc%2Fworker-123?expand=1" + ); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn github_repo_web_url_supports_multiple_remote_formats() { + assert_eq!( + github_repo_web_url("git@github.com:example/ecc.git").as_deref(), + Some("https://github.com/example/ecc") + ); + assert_eq!( + github_repo_web_url("https://github.example.com/org/repo.git").as_deref(), + Some("https://github.example.com/org/repo") + ); + assert_eq!( + github_repo_web_url("ssh://git@github.example.com/org/repo.git").as_deref(), + Some("https://github.example.com/org/repo") + ); + } + #[test] fn create_for_session_links_shared_node_modules_cache() -> Result<()> { let root =