//! Pure message-formatting functions for pipeline-event notifications. //! //! All functions are pure (no I/O, no side effects) and accept only owned //! or borrowed string data. They return `(plain_text, html)` pairs suitable //! for `ChatTransport::send_message`. use crate::pipeline_state::Stage; use crate::service::common::item_id::extract_item_number; /// Human-readable display name for a typed pipeline [`Stage`]. pub fn stage_display_name(stage: &Stage) -> &'static str { match stage { Stage::Upcoming => "Upcoming", Stage::Backlog => "Backlog", Stage::Coding => "Current", Stage::Blocked { .. } => "Blocked", Stage::Qa => "QA", Stage::Merge { .. } => "Merge", Stage::Done { .. } => "Done", Stage::Archived { .. } => "Archived", Stage::MergeFailure { .. } => "MergeFailure", } } /// Format a stage transition notification message. /// /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_stage_notification( item_id: &str, story_name: Option<&str>, from_stage: &Stage, to_stage: &Stage, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let effective_name = story_name.filter(|n| !n.is_empty()); let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default(); let name_html = effective_name .map(|n| format!("{n} ")) .unwrap_or_default(); let from_display = stage_display_name(from_stage); let to_display = stage_display_name(to_stage); let prefix = if matches!(to_stage, Stage::Done { .. }) { "\u{1f389} " } else { "" }; let plain = format!("{prefix}#{number} {name_plain}\u{2014} {from_display} \u{2192} {to_display}"); let html = format!( "{prefix}#{number} {name_html}\u{2014} {from_display} \u{2192} {to_display}" ); (plain, html) } /// Format an error notification message for a story merge failure. /// /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_error_notification( item_id: &str, story_name: Option<&str>, reason: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let effective_name = story_name.filter(|n| !n.is_empty()); let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default(); let name_html = effective_name .map(|n| format!("{n} ")) .unwrap_or_default(); let plain = format!("\u{274c} #{number} {name_plain}\u{2014} {reason}"); let html = format!("\u{274c} #{number} {name_html}\u{2014} {reason}"); (plain, html) } /// Format a blocked-story notification message. /// /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_blocked_notification( item_id: &str, story_name: Option<&str>, reason: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let effective_name = story_name.filter(|n| !n.is_empty()); let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default(); let name_html = effective_name .map(|n| format!("{n} ")) .unwrap_or_default(); let plain = format!("\u{1f6ab} #{number} {name_plain}\u{2014} BLOCKED: {reason}"); let html = format!("\u{1f6ab} #{number} {name_html}\u{2014} BLOCKED: {reason}"); (plain, html) } /// Format a rate limit warning notification message. /// /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_rate_limit_notification( item_id: &str, story_name: Option<&str>, agent_name: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let effective_name = story_name.filter(|n| !n.is_empty()); let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default(); let name_html = effective_name .map(|n| format!("{n} ")) .unwrap_or_default(); let plain = format!( "\u{26a0}\u{fe0f} #{number} {name_plain}\u{2014} {agent_name} hit an API rate limit" ); let html = format!( "\u{26a0}\u{fe0f} #{number} {name_html}\u{2014} \ {agent_name} hit an API rate limit" ); (plain, html) } /// Format an OAuth account-swap notification message. /// /// Sent when the pool successfully rotates to a new account after a rate-limit. /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_oauth_account_swapped(new_email: &str) -> (String, String) { let plain = format!("\u{1f504} OAuth account rotated \u{2014} now using {new_email}"); let html = format!("\u{1f504} OAuth account rotated \u{2014} now using {new_email}"); (plain, html) } /// Format an OAuth accounts-exhausted notification message. /// /// Sent when all pool accounts are rate-limited and no swap was possible. /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_oauth_accounts_exhausted(earliest_reset_msg: &str) -> (String, String) { let plain = format!("\u{26d4} {earliest_reset_msg}"); let html = format!("\u{26d4} {earliest_reset_msg}"); (plain, html) } /// Format an agent-started notification message. /// /// Sent when an agent transitions to the Running state. /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_agent_started_notification( item_id: &str, story_name: Option<&str>, agent_name: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let effective_name = story_name.filter(|n| !n.is_empty()); let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default(); let name_html = effective_name .map(|n| format!("{n} ")) .unwrap_or_default(); let plain = format!("\u{1F916} #{number} {name_plain}\u{2014} {agent_name} started"); let html = format!("\u{1F916} #{number} {name_html}\u{2014} {agent_name} started"); (plain, html) } /// Format an agent-completed notification message. /// /// Sent when an agent finishes processing a story (gates passed or failed). /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_agent_completed_notification( item_id: &str, story_name: Option<&str>, agent_name: &str, success: bool, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let effective_name = story_name.filter(|n| !n.is_empty()); let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default(); let name_html = effective_name .map(|n| format!("{n} ")) .unwrap_or_default(); let (emoji, result) = if success { ("\u{2705}", "completed") // ✅ } else { ("\u{274C}", "failed") // ❌ }; let plain = format!("{emoji} #{number} {name_plain}\u{2014} {agent_name} {result}"); let html = format!("{emoji} #{number} {name_html}\u{2014} {agent_name} {result}"); (plain, html) } /// Extract the first non-empty line from a merge failure reason, truncated to `max_len` chars. /// /// Used to produce a compact snippet for chat notifications. pub fn merge_failure_snippet(reason: &str, max_len: usize) -> String { let line = reason .lines() .find(|l| !l.trim().is_empty()) .unwrap_or(reason); let mut chars = line.chars(); let truncated: String = chars.by_ref().take(max_len).collect(); if chars.next().is_some() { format!("{truncated}\u{2026}") // append … } else { truncated } } #[cfg(test)] mod tests { use super::*; // ── stage_display_name ──────────────────────────────────────────────────── fn done_stage() -> Stage { Stage::from_dir("done").unwrap() } fn merge_stage() -> Stage { Stage::from_dir("merge").unwrap() } #[test] fn stage_display_name_maps_all_known_stages() { assert_eq!(stage_display_name(&Stage::Backlog), "Backlog"); assert_eq!(stage_display_name(&Stage::Coding), "Current"); assert_eq!(stage_display_name(&Stage::Qa), "QA"); assert_eq!(stage_display_name(&merge_stage()), "Merge"); assert_eq!(stage_display_name(&done_stage()), "Done"); assert_eq!( stage_display_name(&Stage::from_dir("archived").unwrap()), "Archived" ); assert_eq!(stage_display_name(&Stage::Upcoming), "Upcoming"); } // ── 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_stage(), &done_stage(), ); 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"), &Stage::Backlog, &Stage::Coding, ); 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"), &Stage::Upcoming, &Stage::Coding, ); 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_stage_notification_without_story_name_falls_back_to_number() { let (plain, html) = format_stage_notification("42_bug_fix_thing", None, &Stage::Coding, &Stage::Qa); assert_eq!(plain, "#42 \u{2014} Current \u{2192} QA"); assert_eq!(html, "#42 \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"), &Stage::Qa, &merge_stage(), ); assert_eq!( plain, "#abc_story_thing Some Story \u{2014} QA \u{2192} Merge" ); } #[test] fn format_stage_notification_long_name_is_preserved() { let long_name = "A".repeat(300); let (plain, _html) = format_stage_notification("1_story_long", Some(&long_name), &Stage::Coding, &Stage::Qa); assert!(plain.contains(&long_name)); } #[test] fn format_stage_notification_empty_story_name_falls_back_to_number() { let (plain, html) = format_stage_notification("42_story_empty", Some(""), &Stage::Coding, &Stage::Qa); assert_eq!(plain, "#42 \u{2014} Current \u{2192} QA"); assert_eq!(html, "#42 \u{2014} Current \u{2192} QA"); } #[test] fn format_stage_notification_unicode_name() { let (plain, html) = format_stage_notification( "7_story_i18n", Some("Ünïcödé Ñämé 🎉"), &Stage::Qa, &merge_stage(), ); assert!(plain.contains("Ünïcödé Ñämé 🎉")); assert!(html.contains("Ünïcödé Ñämé 🎉")); } // ── 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_number() { let (plain, html) = format_error_notification("42_bug_fix_thing", None, "tests failed"); assert_eq!(plain, "\u{274c} #42 \u{2014} tests failed"); assert_eq!(html, "\u{274c} #42 \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" ); } #[test] fn format_error_notification_long_reason_preserved() { let long_reason = "x".repeat(500); let (plain, _html) = format_error_notification("1_story_foo", None, &long_reason); assert!(plain.contains(&long_reason)); } #[test] fn format_error_notification_unicode_reason() { let (plain, _html) = format_error_notification("5_story_foo", Some("Foo"), "错误:合并冲突"); assert!(plain.contains("错误:合并冲突")); } #[test] fn format_error_notification_empty_story_name_falls_back_to_number() { let (plain, _html) = format_error_notification("42_bug_fix_thing", Some(""), "tests failed"); assert_eq!(plain, "\u{274c} #42 \u{2014} tests failed"); } // ── format_blocked_notification ─────────────────────────────────────────── #[test] fn format_blocked_notification_with_story_name() { let (plain, html) = format_blocked_notification( "425_story_blocking_reason", Some("Blocking Reason Story"), "Retry limit exceeded (3/3) at coder stage", ); assert_eq!( plain, "\u{1f6ab} #425 Blocking Reason Story \u{2014} BLOCKED: Retry limit exceeded (3/3) at coder stage" ); assert_eq!( html, "\u{1f6ab} #425 Blocking Reason Story \u{2014} BLOCKED: Retry limit exceeded (3/3) at coder stage" ); } #[test] fn format_blocked_notification_falls_back_to_number() { let (plain, html) = format_blocked_notification("42_story_thing", None, "empty diff"); assert_eq!(plain, "\u{1f6ab} #42 \u{2014} BLOCKED: empty diff"); assert_eq!( html, "\u{1f6ab} #42 \u{2014} BLOCKED: empty diff" ); } #[test] fn format_blocked_notification_empty_story_name_falls_back_to_number() { let (plain, _html) = format_blocked_notification("42_story_thing", Some(""), "empty diff"); assert_eq!(plain, "\u{1f6ab} #42 \u{2014} BLOCKED: empty diff"); } #[test] fn format_blocked_notification_unicode_reason() { let (plain, _html) = format_blocked_notification("3_story_x", Some("X"), "理由:空の差分"); assert!(plain.contains("BLOCKED: 理由:空の差分")); } // ── format_rate_limit_notification ──────────────────────────────────────── #[test] fn format_rate_limit_notification_includes_agent_and_story() { let (plain, html) = format_rate_limit_notification("365_story_my_feature", Some("My Feature"), "coder-2"); assert_eq!( plain, "\u{26a0}\u{fe0f} #365 My Feature \u{2014} coder-2 hit an API rate limit" ); assert_eq!( html, "\u{26a0}\u{fe0f} #365 My Feature \u{2014} coder-2 hit an API rate limit" ); } #[test] fn format_rate_limit_notification_falls_back_to_number() { let (plain, html) = format_rate_limit_notification("42_story_thing", None, "coder-1"); assert_eq!( plain, "\u{26a0}\u{fe0f} #42 \u{2014} coder-1 hit an API rate limit" ); assert_eq!( html, "\u{26a0}\u{fe0f} #42 \u{2014} coder-1 hit an API rate limit" ); } #[test] fn format_rate_limit_notification_empty_story_name_falls_back_to_number() { let (plain, _html) = format_rate_limit_notification("42_story_thing", Some(""), "coder-1"); assert_eq!( plain, "\u{26a0}\u{fe0f} #42 \u{2014} coder-1 hit an API rate limit" ); } #[test] fn format_rate_limit_notification_unicode_agent_name() { let (plain, _html) = format_rate_limit_notification("9_story_foo", Some("Foo"), "агент-1"); assert!(plain.contains("агент-1")); assert!(plain.contains("hit an API rate limit")); } // ── format_agent_started_notification ───────────────────────────────────── #[test] fn format_agent_started_notification_with_story_name() { let (plain, html) = format_agent_started_notification("42_story_foo", Some("My Feature"), "coder-1"); assert_eq!(plain, "\u{1F916} #42 My Feature \u{2014} coder-1 started"); assert_eq!( html, "\u{1F916} #42 My Feature \u{2014} coder-1 started" ); } #[test] fn format_agent_started_notification_falls_back_to_number() { let (plain, html) = format_agent_started_notification("42_story_foo", None, "coder-1"); assert_eq!(plain, "\u{1F916} #42 \u{2014} coder-1 started"); assert_eq!( html, "\u{1F916} #42 \u{2014} coder-1 started" ); } #[test] fn format_agent_started_notification_empty_name_falls_back_to_number() { let (plain, _html) = format_agent_started_notification("42_story_foo", Some(""), "coder-1"); assert_eq!(plain, "\u{1F916} #42 \u{2014} coder-1 started"); } // ── format_agent_completed_notification ─────────────────────────────────── #[test] fn format_agent_completed_notification_success_with_story_name() { let (plain, html) = format_agent_completed_notification( "42_story_foo", Some("My Feature"), "coder-1", true, ); assert_eq!(plain, "\u{2705} #42 My Feature \u{2014} coder-1 completed"); assert_eq!( html, "\u{2705} #42 My Feature \u{2014} coder-1 completed" ); } #[test] fn format_agent_completed_notification_failure_with_story_name() { let (plain, _html) = format_agent_completed_notification( "42_story_foo", Some("My Feature"), "coder-1", false, ); assert_eq!(plain, "\u{274C} #42 My Feature \u{2014} coder-1 failed"); } #[test] fn format_agent_completed_notification_falls_back_to_number() { let (plain, html) = format_agent_completed_notification("42_story_foo", None, "coder-1", true); assert_eq!(plain, "\u{2705} #42 \u{2014} coder-1 completed"); assert_eq!( html, "\u{2705} #42 \u{2014} coder-1 completed" ); } #[test] fn format_agent_completed_notification_empty_name_falls_back_to_number() { let (plain, _html) = format_agent_completed_notification("42_story_foo", Some(""), "coder-1", false); assert_eq!(plain, "\u{274C} #42 \u{2014} coder-1 failed"); } }