//! Stage transition notifications for Matrix rooms. //! //! Subscribes to [`WatcherEvent`] broadcasts and posts a notification to all //! configured Matrix rooms whenever a work item moves between pipeline stages. use crate::io::story_metadata::parse_front_matter; use crate::io::watcher::WatcherEvent; use crate::slog; use crate::transport::ChatTransport; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::broadcast; /// Human-readable display name for a pipeline stage directory. pub fn stage_display_name(stage: &str) -> &'static str { match stage { "1_backlog" => "Backlog", "2_current" => "Current", "3_qa" => "QA", "4_merge" => "Merge", "5_done" => "Done", "6_archived" => "Archived", _ => "Unknown", } } /// Infer the previous pipeline stage for a given destination stage. /// /// Returns `None` for `1_backlog` since items are created there (not /// transitioned from another stage). pub fn inferred_from_stage(to_stage: &str) -> Option<&'static str> { match to_stage { "2_current" => Some("Backlog"), "3_qa" => Some("Current"), "4_merge" => Some("QA"), "5_done" => Some("Merge"), "6_archived" => Some("Done"), _ => None, } } /// Extract the numeric story number from an item ID like `"261_story_slug"`. pub fn extract_story_number(item_id: &str) -> Option<&str> { item_id .split('_') .next() .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) } /// Read the story name from the work item file's YAML front matter. /// /// Returns `None` if the file doesn't exist or has no parseable name. pub fn read_story_name(project_root: &Path, stage: &str, item_id: &str) -> Option { let path = project_root .join(".storkit") .join("work") .join(stage) .join(format!("{item_id}.md")); let contents = std::fs::read_to_string(&path).ok()?; let meta = parse_front_matter(&contents).ok()?; meta.name } /// Format a stage transition notification message. /// /// Returns `(plain_text, html)` suitable for `RoomMessageEventContent::text_html`. pub fn format_stage_notification( item_id: &str, story_name: Option<&str>, from_stage: &str, to_stage: &str, ) -> (String, String) { let number = extract_story_number(item_id).unwrap_or(item_id); let name = story_name.unwrap_or(item_id); let prefix = if to_stage == "Done" { "\u{1f389} " } else { "" }; let plain = format!("{prefix}#{number} {name} \u{2014} {from_stage} \u{2192} {to_stage}"); let html = format!( "{prefix}#{number} {name} \u{2014} {from_stage} \u{2192} {to_stage}" ); (plain, html) } /// Format an error notification message for a story failure. /// /// Returns `(plain_text, html)` suitable for `RoomMessageEventContent::text_html`. pub fn format_error_notification( item_id: &str, story_name: Option<&str>, reason: &str, ) -> (String, String) { let number = extract_story_number(item_id).unwrap_or(item_id); let name = story_name.unwrap_or(item_id); let plain = format!("\u{274c} #{number} {name} \u{2014} {reason}"); let html = format!( "\u{274c} #{number} {name} \u{2014} {reason}" ); (plain, html) } /// Spawn a background task that listens for watcher events and posts /// stage-transition notifications to all configured rooms via the /// [`ChatTransport`] abstraction. pub fn spawn_notification_listener( transport: Arc, room_ids: Vec, watcher_rx: broadcast::Receiver, project_root: PathBuf, ) { tokio::spawn(async move { let mut rx = watcher_rx; loop { match rx.recv().await { Ok(WatcherEvent::WorkItem { ref stage, ref item_id, .. }) => { // Only notify on stage transitions, not creations. let Some(from_display) = inferred_from_stage(stage) else { continue; }; let to_display = stage_display_name(stage); let story_name = read_story_name(&project_root, stage, item_id); let (plain, html) = format_stage_notification( item_id, story_name.as_deref(), from_display, to_display, ); slog!("[matrix-bot] Sending stage notification: {plain}"); for room_id in &room_ids { if let Err(e) = transport.send_message(room_id, &plain, &html).await { slog!( "[matrix-bot] Failed to send notification to {room_id}: {e}" ); } } } Ok(WatcherEvent::MergeFailure { ref story_id, ref reason, }) => { let story_name = read_story_name(&project_root, "4_merge", story_id); let (plain, html) = format_error_notification( story_id, story_name.as_deref(), reason, ); slog!("[matrix-bot] Sending error notification: {plain}"); for room_id in &room_ids { if let Err(e) = transport.send_message(room_id, &plain, &html).await { slog!( "[matrix-bot] Failed to send error notification to {room_id}: {e}" ); } } } Ok(_) => {} // Ignore non-work-item events Err(broadcast::error::RecvError::Lagged(n)) => { slog!( "[matrix-bot] Notification listener lagged, skipped {n} events" ); } Err(broadcast::error::RecvError::Closed) => { slog!( "[matrix-bot] Watcher channel closed, stopping notification listener" ); break; } } } }); } #[cfg(test)] mod tests { use super::*; // ── stage_display_name ────────────────────────────────────────────────── #[test] fn stage_display_name_maps_all_known_stages() { assert_eq!(stage_display_name("1_backlog"), "Backlog"); assert_eq!(stage_display_name("2_current"), "Current"); assert_eq!(stage_display_name("3_qa"), "QA"); assert_eq!(stage_display_name("4_merge"), "Merge"); assert_eq!(stage_display_name("5_done"), "Done"); assert_eq!(stage_display_name("6_archived"), "Archived"); assert_eq!(stage_display_name("unknown"), "Unknown"); } // ── inferred_from_stage ───────────────────────────────────────────────── #[test] fn inferred_from_stage_returns_previous_stage() { assert_eq!(inferred_from_stage("2_current"), Some("Backlog")); assert_eq!(inferred_from_stage("3_qa"), Some("Current")); assert_eq!(inferred_from_stage("4_merge"), Some("QA")); assert_eq!(inferred_from_stage("5_done"), Some("Merge")); assert_eq!(inferred_from_stage("6_archived"), Some("Done")); } #[test] fn inferred_from_stage_returns_none_for_backlog() { assert_eq!(inferred_from_stage("1_backlog"), None); } #[test] fn inferred_from_stage_returns_none_for_unknown() { assert_eq!(inferred_from_stage("9_unknown"), None); } // ── extract_story_number ──────────────────────────────────────────────── #[test] fn extract_story_number_parses_numeric_prefix() { assert_eq!( extract_story_number("261_story_bot_notifications"), Some("261") ); assert_eq!(extract_story_number("42_bug_fix_thing"), Some("42")); assert_eq!(extract_story_number("1_spike_research"), Some("1")); } #[test] fn extract_story_number_returns_none_for_non_numeric() { assert_eq!(extract_story_number("abc_story_thing"), None); assert_eq!(extract_story_number(""), None); } // ── read_story_name ───────────────────────────────────────────────────── #[test] fn read_story_name_reads_from_front_matter() { let tmp = tempfile::tempdir().unwrap(); let stage_dir = tmp .path() .join(".storkit") .join("work") .join("2_current"); std::fs::create_dir_all(&stage_dir).unwrap(); std::fs::write( stage_dir.join("42_story_my_feature.md"), "---\nname: My Cool Feature\n---\n# Story\n", ) .unwrap(); let name = read_story_name(tmp.path(), "2_current", "42_story_my_feature"); assert_eq!(name.as_deref(), Some("My Cool Feature")); } #[test] fn read_story_name_returns_none_for_missing_file() { let tmp = tempfile::tempdir().unwrap(); let name = read_story_name(tmp.path(), "2_current", "99_story_missing"); assert_eq!(name, None); } #[test] fn read_story_name_returns_none_for_missing_name_field() { let tmp = tempfile::tempdir().unwrap(); let stage_dir = tmp .path() .join(".storkit") .join("work") .join("2_current"); std::fs::create_dir_all(&stage_dir).unwrap(); std::fs::write( stage_dir.join("42_story_no_name.md"), "---\ncoverage_baseline: 50%\n---\n# Story\n", ) .unwrap(); let name = read_story_name(tmp.path(), "2_current", "42_story_no_name"); assert_eq!(name, None); } // ── format_error_notification ──────────────────────────────────────────── #[test] fn format_error_notification_with_story_name() { let (plain, html) = format_error_notification("262_story_bot_errors", Some("Bot error notifications"), "merge conflict in src/main.rs"); assert_eq!( plain, "\u{274c} #262 Bot error notifications \u{2014} merge conflict in src/main.rs" ); assert_eq!( html, "\u{274c} #262 Bot error notifications \u{2014} merge conflict in src/main.rs" ); } #[test] fn format_error_notification_without_story_name_falls_back_to_item_id() { let (plain, _html) = format_error_notification("42_bug_fix_thing", None, "tests failed"); assert_eq!( plain, "\u{274c} #42 42_bug_fix_thing \u{2014} tests failed" ); } #[test] fn format_error_notification_non_numeric_id_uses_full_id() { let (plain, _html) = format_error_notification("abc_story_thing", Some("Some Story"), "clippy errors"); assert_eq!( plain, "\u{274c} #abc_story_thing Some Story \u{2014} clippy errors" ); } // ── format_stage_notification ─────────────────────────────────────────── #[test] fn format_notification_done_stage_includes_party_emoji() { let (plain, html) = format_stage_notification( "353_story_done", Some("Done Story"), "Merge", "Done", ); assert_eq!( plain, "\u{1f389} #353 Done Story \u{2014} Merge \u{2192} Done" ); assert_eq!( html, "\u{1f389} #353 Done Story \u{2014} Merge \u{2192} Done" ); } #[test] fn format_notification_non_done_stage_has_no_emoji() { let (plain, _html) = format_stage_notification( "42_story_thing", Some("Some Story"), "Backlog", "Current", ); assert!(!plain.contains("\u{1f389}")); } #[test] fn format_notification_with_story_name() { let (plain, html) = format_stage_notification( "261_story_bot_notifications", Some("Bot notifications"), "Upcoming", "Current", ); assert_eq!( plain, "#261 Bot notifications \u{2014} Upcoming \u{2192} Current" ); assert_eq!( html, "#261 Bot notifications \u{2014} Upcoming \u{2192} Current" ); } #[test] fn format_notification_without_story_name_falls_back_to_item_id() { let (plain, _html) = format_stage_notification( "42_bug_fix_thing", None, "Current", "QA", ); assert_eq!( plain, "#42 42_bug_fix_thing \u{2014} Current \u{2192} QA" ); } #[test] fn format_notification_non_numeric_id_uses_full_id() { let (plain, _html) = format_stage_notification( "abc_story_thing", Some("Some Story"), "QA", "Merge", ); assert_eq!( plain, "#abc_story_thing Some Story \u{2014} QA \u{2192} Merge" ); } }