204 lines
7.3 KiB
Rust
204 lines
7.3 KiB
Rust
|
|
//! Pure status-event message formatter.
|
||
|
|
//!
|
||
|
|
//! A single [`format_status_event`] function converts any [`StatusEvent`] into
|
||
|
|
//! a human-readable string. Adding a new event type means adding one match arm
|
||
|
|
//! here — no per-transport duplication anywhere in the codebase.
|
||
|
|
|
||
|
|
use crate::service::common::item_id::extract_item_number;
|
||
|
|
use crate::service::notifications::format::stage_display_name;
|
||
|
|
use crate::service::status::StatusEvent;
|
||
|
|
|
||
|
|
/// Render a [`StatusEvent`] into a human-readable plain-text string.
|
||
|
|
#[allow(dead_code)]
|
||
|
|
///
|
||
|
|
/// This is the single formatter for all status event types. Every transport
|
||
|
|
/// (chat, Web UI, agent context) calls this function rather than duplicating
|
||
|
|
/// formatting logic.
|
||
|
|
pub fn format_status_event(event: &StatusEvent) -> String {
|
||
|
|
match event {
|
||
|
|
StatusEvent::StageTransition {
|
||
|
|
story_id,
|
||
|
|
story_name,
|
||
|
|
from_stage,
|
||
|
|
to_stage,
|
||
|
|
} => {
|
||
|
|
let number = extract_item_number(story_id).unwrap_or(story_id.as_str());
|
||
|
|
let name = story_name.as_deref().unwrap_or(story_id.as_str());
|
||
|
|
let from = stage_display_name(from_stage);
|
||
|
|
let to = stage_display_name(to_stage);
|
||
|
|
let prefix = if to == "Done" { "\u{1f389} " } else { "" };
|
||
|
|
format!("{prefix}#{number} {name} \u{2014} {from} \u{2192} {to}")
|
||
|
|
}
|
||
|
|
StatusEvent::MergeFailure {
|
||
|
|
story_id,
|
||
|
|
story_name,
|
||
|
|
reason,
|
||
|
|
} => {
|
||
|
|
let number = extract_item_number(story_id).unwrap_or(story_id.as_str());
|
||
|
|
let name = story_name.as_deref().unwrap_or(story_id.as_str());
|
||
|
|
format!("\u{274c} #{number} {name} \u{2014} {reason}")
|
||
|
|
}
|
||
|
|
StatusEvent::StoryBlocked {
|
||
|
|
story_id,
|
||
|
|
story_name,
|
||
|
|
reason,
|
||
|
|
} => {
|
||
|
|
let number = extract_item_number(story_id).unwrap_or(story_id.as_str());
|
||
|
|
let name = story_name.as_deref().unwrap_or(story_id.as_str());
|
||
|
|
format!("\u{1f6ab} #{number} {name} \u{2014} BLOCKED: {reason}")
|
||
|
|
}
|
||
|
|
StatusEvent::RateLimitWarning {
|
||
|
|
story_id,
|
||
|
|
story_name,
|
||
|
|
agent_name,
|
||
|
|
} => {
|
||
|
|
let number = extract_item_number(story_id).unwrap_or(story_id.as_str());
|
||
|
|
let name = story_name.as_deref().unwrap_or(story_id.as_str());
|
||
|
|
format!("\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit")
|
||
|
|
}
|
||
|
|
StatusEvent::RateLimitHardBlock {
|
||
|
|
story_id,
|
||
|
|
story_name,
|
||
|
|
agent_name,
|
||
|
|
reset_at,
|
||
|
|
} => {
|
||
|
|
let number = extract_item_number(story_id).unwrap_or(story_id.as_str());
|
||
|
|
let name = story_name.as_deref().unwrap_or(story_id.as_str());
|
||
|
|
let reset = reset_at.format("%H:%M UTC").to_string();
|
||
|
|
format!(
|
||
|
|
"\u{26d4} #{number} {name} \u{2014} {agent_name} hard rate-limited until {reset}"
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
use chrono::TimeZone;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn formats_stage_transition_to_done_with_emoji() {
|
||
|
|
let event = StatusEvent::StageTransition {
|
||
|
|
story_id: "42_story_foo".to_string(),
|
||
|
|
story_name: Some("Foo Story".to_string()),
|
||
|
|
from_stage: "4_merge".to_string(),
|
||
|
|
to_stage: "5_done".to_string(),
|
||
|
|
};
|
||
|
|
let s = format_status_event(&event);
|
||
|
|
assert!(
|
||
|
|
s.contains("\u{1f389}"),
|
||
|
|
"done transition should include party emoji"
|
||
|
|
);
|
||
|
|
assert!(s.contains("#42"));
|
||
|
|
assert!(s.contains("Foo Story"));
|
||
|
|
assert!(s.contains("Merge \u{2192} Done"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn formats_stage_transition_no_emoji_for_non_done() {
|
||
|
|
let event = StatusEvent::StageTransition {
|
||
|
|
story_id: "10_story_bar".to_string(),
|
||
|
|
story_name: Some("Bar".to_string()),
|
||
|
|
from_stage: "1_backlog".to_string(),
|
||
|
|
to_stage: "2_current".to_string(),
|
||
|
|
};
|
||
|
|
let s = format_status_event(&event);
|
||
|
|
assert!(!s.contains("\u{1f389}"));
|
||
|
|
assert!(s.contains("Backlog \u{2192} Current"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn formats_stage_transition_falls_back_to_story_id_when_no_name() {
|
||
|
|
let event = StatusEvent::StageTransition {
|
||
|
|
story_id: "5_story_x".to_string(),
|
||
|
|
story_name: None,
|
||
|
|
from_stage: "2_current".to_string(),
|
||
|
|
to_stage: "3_qa".to_string(),
|
||
|
|
};
|
||
|
|
let s = format_status_event(&event);
|
||
|
|
assert!(s.contains("5_story_x"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn formats_merge_failure() {
|
||
|
|
let event = StatusEvent::MergeFailure {
|
||
|
|
story_id: "7_story_fail".to_string(),
|
||
|
|
story_name: Some("Failing Story".to_string()),
|
||
|
|
reason: "conflicts detected".to_string(),
|
||
|
|
};
|
||
|
|
let s = format_status_event(&event);
|
||
|
|
assert!(s.contains("\u{274c}"));
|
||
|
|
assert!(s.contains("#7"));
|
||
|
|
assert!(s.contains("conflicts detected"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn formats_story_blocked() {
|
||
|
|
let event = StatusEvent::StoryBlocked {
|
||
|
|
story_id: "8_story_blk".to_string(),
|
||
|
|
story_name: Some("Blocked Story".to_string()),
|
||
|
|
reason: "retry limit exceeded".to_string(),
|
||
|
|
};
|
||
|
|
let s = format_status_event(&event);
|
||
|
|
assert!(s.contains("\u{1f6ab}"));
|
||
|
|
assert!(s.contains("BLOCKED: retry limit exceeded"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn formats_rate_limit_warning() {
|
||
|
|
let event = StatusEvent::RateLimitWarning {
|
||
|
|
story_id: "9_story_rl".to_string(),
|
||
|
|
story_name: Some("RL Story".to_string()),
|
||
|
|
agent_name: "coder-1".to_string(),
|
||
|
|
};
|
||
|
|
let s = format_status_event(&event);
|
||
|
|
assert!(s.contains("coder-1 hit an API rate limit"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn formats_rate_limit_hard_block() {
|
||
|
|
let reset = chrono::Utc
|
||
|
|
.with_ymd_and_hms(2026, 4, 26, 15, 30, 0)
|
||
|
|
.unwrap();
|
||
|
|
let event = StatusEvent::RateLimitHardBlock {
|
||
|
|
story_id: "3_story_hb".to_string(),
|
||
|
|
story_name: Some("HB Story".to_string()),
|
||
|
|
agent_name: "coder-2".to_string(),
|
||
|
|
reset_at: reset,
|
||
|
|
};
|
||
|
|
let s = format_status_event(&event);
|
||
|
|
assert!(s.contains("\u{26d4}"));
|
||
|
|
assert!(s.contains("coder-2"));
|
||
|
|
assert!(s.contains("hard rate-limited"));
|
||
|
|
assert!(s.contains("15:30 UTC"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn single_formatter_no_per_transport_duplication() {
|
||
|
|
// Each event type produces output through a single call — no per-transport variants.
|
||
|
|
let events: Vec<StatusEvent> = vec![
|
||
|
|
StatusEvent::StageTransition {
|
||
|
|
story_id: "1_story_a".to_string(),
|
||
|
|
story_name: None,
|
||
|
|
from_stage: "1_backlog".to_string(),
|
||
|
|
to_stage: "2_current".to_string(),
|
||
|
|
},
|
||
|
|
StatusEvent::MergeFailure {
|
||
|
|
story_id: "2_story_b".to_string(),
|
||
|
|
story_name: None,
|
||
|
|
reason: "test".to_string(),
|
||
|
|
},
|
||
|
|
StatusEvent::StoryBlocked {
|
||
|
|
story_id: "3_story_c".to_string(),
|
||
|
|
story_name: None,
|
||
|
|
reason: "limit".to_string(),
|
||
|
|
},
|
||
|
|
];
|
||
|
|
for e in &events {
|
||
|
|
let s = format_status_event(e);
|
||
|
|
assert!(!s.is_empty());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|