//! 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; use std::path::Path; /// 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", Stage::MergeFailureFinal { .. } => "MergeFailureFinal", Stage::Frozen { .. } => "Frozen", Stage::ReviewHold { .. } => "ReviewHold", Stage::Abandoned { .. } => "Abandoned", Stage::Superseded { .. } => "Superseded", Stage::Rejected { .. } => "Rejected", } } /// 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: &str, from_stage: &Stage, to_stage: &Stage, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let effective_name = if story_name.is_empty() { None } else { Some(story_name) }; 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: &str, reason: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let effective_name = if story_name.is_empty() { None } else { Some(story_name) }; 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: &str, reason: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let effective_name = if story_name.is_empty() { None } else { Some(story_name) }; 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: &str, agent_name: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let effective_name = if story_name.is_empty() { None } else { Some(story_name) }; 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: &str, agent_name: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let effective_name = if story_name.is_empty() { None } else { Some(story_name) }; 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: &str, agent_name: &str, success: bool, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let effective_name = if story_name.is_empty() { None } else { Some(story_name) }; 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) } /// Format an agent-crashed notification message (story 1211). /// /// Sent when an agent's PTY child process dies before completing its /// current turn (no `"result"` event observed) — a crash, kill, or /// watchdog termination rather than a clean finish. Includes the exit /// code and the last line emitted by the child when available. /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_agent_crashed_notification( item_id: &str, story_name: &str, agent_name: &str, exit_code: Option, last_error_line: Option<&str>, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let effective_name = if story_name.is_empty() { None } else { Some(story_name) }; 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 exit_code_display = exit_code .map(|c| c.to_string()) .unwrap_or_else(|| "unknown".to_string()); let mut plain = format!( "\u{1f480} #{number} {name_plain}\u{2014} {agent_name} crashed mid-turn \ (exit code: {exit_code_display})" ); let mut html = format!( "\u{1f480} #{number} {name_html}\u{2014} {agent_name} crashed mid-turn \ (exit code: {exit_code_display})" ); if let Some(line) = last_error_line { plain.push_str(&format!("\nLast line: {line}")); html.push_str(&format!("
Last line: {line}")); } (plain, html) } /// Emoji used to badge a work item's kind in creation notifications. fn item_type_emoji(item_type: &str) -> &'static str { match item_type { "bug" => "\u{1f41b}", // 🐛 "refactor" => "\u{1f4dd}", // 📝 "spike" => "\u{1f52c}", // 🔬 _ => "\u{1f4d6}", // 📖 (story, epic, and unknown) } } /// A single newly-created work item, as reported in a creation notification. /// /// `origin_label` is the human-readable `"{kind} {id}"` string produced by /// `build_origin` (story 1201) — e.g. `"user alice"`, `"agent coder-1@story=42"`. pub struct NewItemInfo { /// Work item ID (e.g. `"1075_refactor_split_stage"`). pub item_id: String, /// Human-readable item type (`"story"`, `"bug"`, `"refactor"`, `"spike"`, `"epic"`). pub item_type: String, /// Human-readable item name. pub name: String, /// Readable label for who/what created the item. pub origin_label: String, } /// Format a new-work-item creation notification. /// /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_new_item_notification( item_id: &str, item_type: &str, name: &str, origin_label: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let emoji = item_type_emoji(item_type); let plain = format!("{emoji} New {item_type} #{number} \u{2014} {name} (via {origin_label})"); let html = format!( "{emoji} New {item_type} #{number} \u{2014} {name} \ (via {origin_label})" ); (plain, html) } /// Format a creation notification for a burst of items created within the /// same coalescing window (story 1201, AC3). /// /// A single pending item still produces exactly one line, identical to /// [`format_new_item_notification`] (AC4). Two or more items are combined /// into one message listing every item. /// /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_new_items_notification(items: &[NewItemInfo]) -> (String, String) { if let [only] = items { return format_new_item_notification( &only.item_id, &only.item_type, &only.name, &only.origin_label, ); } let mut plain = format!("\u{1f4da} {} new items created:\n", items.len()); let mut html = format!("\u{1f4da} {} new items created:
", items.len()); for item in items { let number = extract_item_number(&item.item_id).unwrap_or(&item.item_id); let emoji = item_type_emoji(&item.item_type); plain.push_str(&format!( "\u{2022} {emoji} {} #{number} \u{2014} {} (via {})\n", item.item_type, item.name, item.origin_label )); html.push_str(&format!( "\u{2022} {emoji} {} #{number} \u{2014} {} (via {})
", item.item_type, item.name, item.origin_label )); } ( plain.trim_end().to_string(), html.trim_end_matches("
").to_string(), ) } /// Format a merge-auto-retry notification message. /// /// Sent when a `GatesFailed` merge failure is automatically retried after a /// delay (story 1185). Returns `(plain_text, html)` suitable for /// `ChatTransport::send_message`. pub fn format_merge_auto_retry_notification( item_id: &str, story_name: &str, attempt: u32, budget: u32, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let effective_name = if story_name.is_empty() { None } else { Some(story_name) }; 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{1f504} #{number} {name_plain}\u{2014} auto-retrying merge (attempt {attempt}/{budget})" ); let html = format!( "\u{1f504} #{number} {name_html}\u{2014} auto-retrying merge \ (attempt {attempt}/{budget})" ); (plain, html) } /// Format a low-disk-space warning notification message (story 1200 AC3). /// /// Includes free space, `target/` and `.huskies/worktrees/` directory sizes, /// and names the `gc` tool as the first remediation step. /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_disk_warning_notification( level: &str, host_id: &str, free_bytes: u64, target_bytes: u64, worktrees_bytes: u64, ) -> (String, String) { let emoji = if level == "critical" { "\u{1f6a8}" // 🚨 } else { "\u{26a0}\u{fe0f}" // ⚠️ }; let free_gb = bytes_to_gb(free_bytes); let target_gb = bytes_to_gb(target_bytes); let worktrees_gb = bytes_to_gb(worktrees_bytes); let plain = format!( "{emoji} Low disk space on {host_id} ({level}): {free_gb:.1}GB free \ (target/ {target_gb:.1}GB, worktrees/ {worktrees_gb:.1}GB) \ \u{2014} first response: run the `gc` tool to reclaim space" ); let html = format!( "{emoji} Low disk space on {host_id} ({level}): {free_gb:.1}GB free \ (target/ {target_gb:.1}GB, worktrees/ {worktrees_gb:.1}GB) \ \u{2014} first response: run the gc tool to reclaim space" ); (plain, html) } /// Format a disk-space-recovered notification message (story 1200 AC2). /// /// Sent once when free space climbs back above the configured recovery /// margin after a warn/critical warning. /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_disk_recovery_notification(host_id: &str, free_bytes: u64) -> (String, String) { let free_gb = bytes_to_gb(free_bytes); let plain = format!("\u{2705} Disk space recovered on {host_id}: {free_gb:.1}GB free"); let html = format!("\u{2705} Disk space recovered on {host_id}: {free_gb:.1}GB free"); (plain, html) } /// Convert a byte count to gigabytes for display (story 1200). fn bytes_to_gb(bytes: u64) -> f64 { bytes as f64 / 1_000_000_000.0 } /// Derive the human-readable project display name used to prefix creation /// notifications (story 1201, AC2). /// /// Prefers the explicit `gateway_project` config value (the field the /// codebase already treats as "this project's name" for gateway relay); /// falls back to the project root directory's basename, then to the literal /// `"project"` if neither is available. pub fn project_display_name(gateway_project: Option<&str>, project_root: &Path) -> String { gateway_project .map(str::to_string) .or_else(|| { project_root .file_name() .map(|n| n.to_string_lossy().into_owned()) }) .unwrap_or_else(|| "project".to_string()) } /// Maximum number of trailing gate-output lines included in a merge-failure /// chat notification. /// /// Gate output can be hundreds of lines; only the tail (where errors appear) /// is useful at a glance. Full output remains available via `get_merge_status` /// or the web UI — this limit is chat-display-only. pub const MERGE_FAILURE_TAIL_LINES: usize = 30; /// Truncate `gate_output` to its last `max_lines` lines for chat notifications. /// /// If the output contains more than `max_lines` non-empty lines, a leading /// marker line `[...output truncated, last N lines shown...]` is prepended to /// the tail so readers know output was cut. If the output fits within the /// limit it is returned unchanged (no marker added). pub fn truncate_gate_output(gate_output: &str, max_lines: usize) -> String { let lines: Vec<&str> = gate_output.lines().collect(); if lines.len() <= max_lines { return gate_output.to_string(); } let tail = &lines[lines.len() - max_lines..]; let marker = format!("[...output truncated, last {max_lines} lines shown...]"); format!("{marker}\n{}", tail.join("\n")) } #[cfg(test)] mod tests;