2026-04-24 18:01:34 +00:00
|
|
|
//! 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`.
|
|
|
|
|
|
2026-05-13 05:02:52 +00:00
|
|
|
use crate::pipeline_state::Stage;
|
2026-04-24 21:32:39 +00:00
|
|
|
use crate::service::common::item_id::extract_item_number;
|
|
|
|
|
|
2026-05-13 05:02:52 +00:00
|
|
|
/// 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",
|
2026-05-13 22:50:13 +00:00
|
|
|
Stage::Coding { .. } => "Current",
|
2026-05-13 05:02:52 +00:00
|
|
|
Stage::Blocked { .. } => "Blocked",
|
|
|
|
|
Stage::Qa => "QA",
|
|
|
|
|
Stage::Merge { .. } => "Merge",
|
|
|
|
|
Stage::Done { .. } => "Done",
|
|
|
|
|
Stage::Archived { .. } => "Archived",
|
|
|
|
|
Stage::MergeFailure { .. } => "MergeFailure",
|
2026-05-13 06:05:01 +00:00
|
|
|
Stage::MergeFailureFinal { .. } => "MergeFailureFinal",
|
|
|
|
|
Stage::Frozen { .. } => "Frozen",
|
|
|
|
|
Stage::ReviewHold { .. } => "ReviewHold",
|
2026-05-13 16:43:19 +00:00
|
|
|
Stage::Abandoned { .. } => "Abandoned",
|
|
|
|
|
Stage::Superseded { .. } => "Superseded",
|
|
|
|
|
Stage::Rejected { .. } => "Rejected",
|
2026-04-24 18:01:34 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Format a stage transition notification message.
|
|
|
|
|
///
|
|
|
|
|
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
|
|
|
|
|
pub fn format_stage_notification(
|
|
|
|
|
item_id: &str,
|
2026-05-13 14:51:39 +00:00
|
|
|
story_name: &str,
|
2026-05-13 05:02:52 +00:00
|
|
|
from_stage: &Stage,
|
|
|
|
|
to_stage: &Stage,
|
2026-04-24 18:01:34 +00:00
|
|
|
) -> (String, String) {
|
2026-04-24 21:32:39 +00:00
|
|
|
let number = extract_item_number(item_id).unwrap_or(item_id);
|
2026-05-13 14:51:39 +00:00
|
|
|
let effective_name = if story_name.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(story_name)
|
|
|
|
|
};
|
2026-05-12 23:05:50 +00:00
|
|
|
let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default();
|
|
|
|
|
let name_html = effective_name
|
|
|
|
|
.map(|n| format!("<em>{n}</em> "))
|
|
|
|
|
.unwrap_or_default();
|
2026-04-24 18:01:34 +00:00
|
|
|
|
2026-05-13 05:02:52 +00:00
|
|
|
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}");
|
2026-04-24 18:01:34 +00:00
|
|
|
let html = format!(
|
2026-05-13 05:02:52 +00:00
|
|
|
"{prefix}<strong>#{number}</strong> {name_html}\u{2014} {from_display} \u{2192} {to_display}"
|
2026-04-24 18:01:34 +00:00
|
|
|
);
|
|
|
|
|
(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,
|
2026-05-13 14:51:39 +00:00
|
|
|
story_name: &str,
|
2026-04-24 18:01:34 +00:00
|
|
|
reason: &str,
|
|
|
|
|
) -> (String, String) {
|
2026-04-24 21:32:39 +00:00
|
|
|
let number = extract_item_number(item_id).unwrap_or(item_id);
|
2026-05-13 14:51:39 +00:00
|
|
|
let effective_name = if story_name.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(story_name)
|
|
|
|
|
};
|
2026-05-12 23:05:50 +00:00
|
|
|
let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default();
|
|
|
|
|
let name_html = effective_name
|
|
|
|
|
.map(|n| format!("<em>{n}</em> "))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let plain = format!("\u{274c} #{number} {name_plain}\u{2014} {reason}");
|
|
|
|
|
let html = format!("\u{274c} <strong>#{number}</strong> {name_html}\u{2014} {reason}");
|
2026-04-24 18:01:34 +00:00
|
|
|
(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,
|
2026-05-13 14:51:39 +00:00
|
|
|
story_name: &str,
|
2026-04-24 18:01:34 +00:00
|
|
|
reason: &str,
|
|
|
|
|
) -> (String, String) {
|
2026-04-24 21:32:39 +00:00
|
|
|
let number = extract_item_number(item_id).unwrap_or(item_id);
|
2026-05-13 14:51:39 +00:00
|
|
|
let effective_name = if story_name.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(story_name)
|
|
|
|
|
};
|
2026-05-12 23:05:50 +00:00
|
|
|
let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default();
|
|
|
|
|
let name_html = effective_name
|
|
|
|
|
.map(|n| format!("<em>{n}</em> "))
|
|
|
|
|
.unwrap_or_default();
|
2026-04-24 18:01:34 +00:00
|
|
|
|
2026-05-12 23:05:50 +00:00
|
|
|
let plain = format!("\u{1f6ab} #{number} {name_plain}\u{2014} BLOCKED: {reason}");
|
2026-04-24 18:01:34 +00:00
|
|
|
let html =
|
2026-05-12 23:05:50 +00:00
|
|
|
format!("\u{1f6ab} <strong>#{number}</strong> {name_html}\u{2014} BLOCKED: {reason}");
|
2026-04-24 18:01:34 +00:00
|
|
|
(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,
|
2026-05-13 14:51:39 +00:00
|
|
|
story_name: &str,
|
2026-04-24 18:01:34 +00:00
|
|
|
agent_name: &str,
|
|
|
|
|
) -> (String, String) {
|
2026-04-24 21:32:39 +00:00
|
|
|
let number = extract_item_number(item_id).unwrap_or(item_id);
|
2026-05-13 14:51:39 +00:00
|
|
|
let effective_name = if story_name.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(story_name)
|
|
|
|
|
};
|
2026-05-12 23:05:50 +00:00
|
|
|
let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default();
|
|
|
|
|
let name_html = effective_name
|
|
|
|
|
.map(|n| format!("<em>{n}</em> "))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let plain = format!(
|
|
|
|
|
"\u{26a0}\u{fe0f} #{number} {name_plain}\u{2014} {agent_name} hit an API rate limit"
|
|
|
|
|
);
|
2026-04-24 18:01:34 +00:00
|
|
|
let html = format!(
|
2026-05-12 23:05:50 +00:00
|
|
|
"\u{26a0}\u{fe0f} <strong>#{number}</strong> {name_html}\u{2014} \
|
2026-04-24 18:01:34 +00:00
|
|
|
{agent_name} hit an API rate limit"
|
|
|
|
|
);
|
|
|
|
|
(plain, html)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 18:39:35 +00:00
|
|
|
/// 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 <strong>{new_email}</strong>");
|
|
|
|
|
(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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 21:28:41 +00:00
|
|
|
/// 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,
|
2026-05-13 14:51:39 +00:00
|
|
|
story_name: &str,
|
2026-04-29 21:28:41 +00:00
|
|
|
agent_name: &str,
|
|
|
|
|
) -> (String, String) {
|
|
|
|
|
let number = extract_item_number(item_id).unwrap_or(item_id);
|
2026-05-13 14:51:39 +00:00
|
|
|
let effective_name = if story_name.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(story_name)
|
|
|
|
|
};
|
2026-05-12 23:05:50 +00:00
|
|
|
let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default();
|
|
|
|
|
let name_html = effective_name
|
|
|
|
|
.map(|n| format!("<em>{n}</em> "))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let plain = format!("\u{1F916} #{number} {name_plain}\u{2014} {agent_name} started");
|
|
|
|
|
let html =
|
|
|
|
|
format!("\u{1F916} <strong>#{number}</strong> {name_html}\u{2014} {agent_name} started");
|
2026-04-29 21:28:41 +00:00
|
|
|
(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,
|
2026-05-13 14:51:39 +00:00
|
|
|
story_name: &str,
|
2026-04-29 21:28:41 +00:00
|
|
|
agent_name: &str,
|
|
|
|
|
success: bool,
|
|
|
|
|
) -> (String, String) {
|
|
|
|
|
let number = extract_item_number(item_id).unwrap_or(item_id);
|
2026-05-13 14:51:39 +00:00
|
|
|
let effective_name = if story_name.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(story_name)
|
|
|
|
|
};
|
2026-05-12 23:05:50 +00:00
|
|
|
let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default();
|
|
|
|
|
let name_html = effective_name
|
|
|
|
|
.map(|n| format!("<em>{n}</em> "))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
2026-04-29 21:28:41 +00:00
|
|
|
let (emoji, result) = if success {
|
|
|
|
|
("\u{2705}", "completed") // ✅
|
|
|
|
|
} else {
|
|
|
|
|
("\u{274C}", "failed") // ❌
|
|
|
|
|
};
|
2026-05-12 23:05:50 +00:00
|
|
|
let plain = format!("{emoji} #{number} {name_plain}\u{2014} {agent_name} {result}");
|
|
|
|
|
let html =
|
|
|
|
|
format!("{emoji} <strong>#{number}</strong> {name_html}\u{2014} {agent_name} {result}");
|
2026-04-29 21:28:41 +00:00
|
|
|
(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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 18:01:34 +00:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
// ── stage_display_name ────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-13 05:02:52 +00:00
|
|
|
fn done_stage() -> Stage {
|
|
|
|
|
Stage::from_dir("done").unwrap()
|
|
|
|
|
}
|
|
|
|
|
fn merge_stage() -> Stage {
|
|
|
|
|
Stage::from_dir("merge").unwrap()
|
2026-04-24 18:01:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-13 05:02:52 +00:00
|
|
|
fn stage_display_name_maps_all_known_stages() {
|
|
|
|
|
assert_eq!(stage_display_name(&Stage::Backlog), "Backlog");
|
2026-05-13 22:50:13 +00:00
|
|
|
assert_eq!(
|
2026-05-14 08:07:43 +00:00
|
|
|
stage_display_name(&Stage::Coding {
|
|
|
|
|
claim: None,
|
2026-05-14 11:01:06 +00:00
|
|
|
plan: Default::default(),
|
|
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
}),
|
2026-05-13 22:50:13 +00:00
|
|
|
"Current"
|
|
|
|
|
);
|
2026-05-13 05:02:52 +00:00
|
|
|
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");
|
2026-04-24 18:01:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── format_stage_notification ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_notification_done_stage_includes_party_emoji() {
|
2026-05-13 05:02:52 +00:00
|
|
|
let (plain, html) = format_stage_notification(
|
|
|
|
|
"353_story_done",
|
2026-05-13 14:51:39 +00:00
|
|
|
"Done Story",
|
2026-05-13 05:02:52 +00:00
|
|
|
&merge_stage(),
|
|
|
|
|
&done_stage(),
|
|
|
|
|
);
|
2026-04-24 18:01:34 +00:00
|
|
|
assert_eq!(
|
|
|
|
|
plain,
|
|
|
|
|
"\u{1f389} #353 Done Story \u{2014} Merge \u{2192} Done"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
html,
|
|
|
|
|
"\u{1f389} <strong>#353</strong> <em>Done Story</em> \u{2014} Merge \u{2192} Done"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_notification_non_done_stage_has_no_emoji() {
|
2026-05-13 05:02:52 +00:00
|
|
|
let (plain, _html) = format_stage_notification(
|
|
|
|
|
"42_story_thing",
|
2026-05-13 14:51:39 +00:00
|
|
|
"Some Story",
|
2026-05-13 05:02:52 +00:00
|
|
|
&Stage::Backlog,
|
2026-05-14 08:07:43 +00:00
|
|
|
&Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: Default::default(),
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
2026-05-13 05:02:52 +00:00
|
|
|
);
|
2026-04-24 18:01:34 +00:00
|
|
|
assert!(!plain.contains("\u{1f389}"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_notification_with_story_name() {
|
|
|
|
|
let (plain, html) = format_stage_notification(
|
|
|
|
|
"261_story_bot_notifications",
|
2026-05-13 14:51:39 +00:00
|
|
|
"Bot notifications",
|
2026-05-13 05:02:52 +00:00
|
|
|
&Stage::Upcoming,
|
2026-05-14 08:07:43 +00:00
|
|
|
&Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: Default::default(),
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
2026-04-24 18:01:34 +00:00
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
plain,
|
|
|
|
|
"#261 Bot notifications \u{2014} Upcoming \u{2192} Current"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
html,
|
|
|
|
|
"<strong>#261</strong> <em>Bot notifications</em> \u{2014} Upcoming \u{2192} Current"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-12 23:05:50 +00:00
|
|
|
fn format_stage_notification_without_story_name_falls_back_to_number() {
|
2026-05-13 22:50:13 +00:00
|
|
|
let (plain, html) = format_stage_notification(
|
|
|
|
|
"42_bug_fix_thing",
|
|
|
|
|
"",
|
2026-05-14 08:07:43 +00:00
|
|
|
&Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: Default::default(),
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
2026-05-13 22:50:13 +00:00
|
|
|
&Stage::Qa,
|
|
|
|
|
);
|
2026-05-12 23:05:50 +00:00
|
|
|
assert_eq!(plain, "#42 \u{2014} Current \u{2192} QA");
|
|
|
|
|
assert_eq!(html, "<strong>#42</strong> \u{2014} Current \u{2192} QA");
|
2026-04-24 18:01:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_notification_non_numeric_id_uses_full_id() {
|
2026-05-13 14:51:39 +00:00
|
|
|
let (plain, _html) =
|
|
|
|
|
format_stage_notification("abc_story_thing", "Some Story", &Stage::Qa, &merge_stage());
|
2026-04-24 18:01:34 +00:00
|
|
|
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);
|
2026-05-13 22:50:13 +00:00
|
|
|
let (plain, _html) = format_stage_notification(
|
|
|
|
|
"1_story_long",
|
|
|
|
|
&long_name,
|
2026-05-14 08:07:43 +00:00
|
|
|
&Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: Default::default(),
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
2026-05-13 22:50:13 +00:00
|
|
|
&Stage::Qa,
|
|
|
|
|
);
|
2026-04-24 18:01:34 +00:00
|
|
|
assert!(plain.contains(&long_name));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-12 23:05:50 +00:00
|
|
|
fn format_stage_notification_empty_story_name_falls_back_to_number() {
|
2026-05-13 22:50:13 +00:00
|
|
|
let (plain, html) = format_stage_notification(
|
|
|
|
|
"42_story_empty",
|
|
|
|
|
"",
|
2026-05-14 08:07:43 +00:00
|
|
|
&Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: Default::default(),
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
2026-05-13 22:50:13 +00:00
|
|
|
&Stage::Qa,
|
|
|
|
|
);
|
2026-05-12 23:05:50 +00:00
|
|
|
assert_eq!(plain, "#42 \u{2014} Current \u{2192} QA");
|
|
|
|
|
assert_eq!(html, "<strong>#42</strong> \u{2014} Current \u{2192} QA");
|
2026-04-24 18:01:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_stage_notification_unicode_name() {
|
2026-05-13 05:02:52 +00:00
|
|
|
let (plain, html) = format_stage_notification(
|
|
|
|
|
"7_story_i18n",
|
2026-05-13 14:51:39 +00:00
|
|
|
"Ünïcödé Ñämé 🎉",
|
2026-05-13 05:02:52 +00:00
|
|
|
&Stage::Qa,
|
|
|
|
|
&merge_stage(),
|
|
|
|
|
);
|
2026-04-24 18:01:34 +00:00
|
|
|
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",
|
2026-05-13 14:51:39 +00:00
|
|
|
"Bot error notifications",
|
2026-04-24 18:01:34 +00:00
|
|
|
"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} <strong>#262</strong> <em>Bot error notifications</em> \u{2014} merge conflict in src/main.rs"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-12 23:05:50 +00:00
|
|
|
fn format_error_notification_without_story_name_falls_back_to_number() {
|
2026-05-13 14:51:39 +00:00
|
|
|
let (plain, html) = format_error_notification("42_bug_fix_thing", "", "tests failed");
|
2026-05-12 23:05:50 +00:00
|
|
|
assert_eq!(plain, "\u{274c} #42 \u{2014} tests failed");
|
|
|
|
|
assert_eq!(html, "\u{274c} <strong>#42</strong> \u{2014} tests failed");
|
2026-04-24 18:01:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_error_notification_non_numeric_id_uses_full_id() {
|
|
|
|
|
let (plain, _html) =
|
2026-05-13 14:51:39 +00:00
|
|
|
format_error_notification("abc_story_thing", "Some Story", "clippy errors");
|
2026-04-24 18:01:34 +00:00
|
|
|
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);
|
2026-05-13 14:51:39 +00:00
|
|
|
let (plain, _html) = format_error_notification("1_story_foo", "", &long_reason);
|
2026-04-24 18:01:34 +00:00
|
|
|
assert!(plain.contains(&long_reason));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_error_notification_unicode_reason() {
|
2026-05-13 14:51:39 +00:00
|
|
|
let (plain, _html) = format_error_notification("5_story_foo", "Foo", "错误:合并冲突");
|
2026-04-24 18:01:34 +00:00
|
|
|
assert!(plain.contains("错误:合并冲突"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 23:05:50 +00:00
|
|
|
#[test]
|
|
|
|
|
fn format_error_notification_empty_story_name_falls_back_to_number() {
|
2026-05-13 14:51:39 +00:00
|
|
|
let (plain, _html) = format_error_notification("42_bug_fix_thing", "", "tests failed");
|
2026-05-12 23:05:50 +00:00
|
|
|
assert_eq!(plain, "\u{274c} #42 \u{2014} tests failed");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 18:01:34 +00:00
|
|
|
// ── format_blocked_notification ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_blocked_notification_with_story_name() {
|
|
|
|
|
let (plain, html) = format_blocked_notification(
|
|
|
|
|
"425_story_blocking_reason",
|
2026-05-13 14:51:39 +00:00
|
|
|
"Blocking Reason Story",
|
2026-04-24 18:01:34 +00:00
|
|
|
"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} <strong>#425</strong> <em>Blocking Reason Story</em> \u{2014} BLOCKED: Retry limit exceeded (3/3) at coder stage"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-12 23:05:50 +00:00
|
|
|
fn format_blocked_notification_falls_back_to_number() {
|
2026-05-13 14:51:39 +00:00
|
|
|
let (plain, html) = format_blocked_notification("42_story_thing", "", "empty diff");
|
2026-05-12 23:05:50 +00:00
|
|
|
assert_eq!(plain, "\u{1f6ab} #42 \u{2014} BLOCKED: empty diff");
|
2026-04-24 18:01:34 +00:00
|
|
|
assert_eq!(
|
2026-05-12 23:05:50 +00:00
|
|
|
html,
|
|
|
|
|
"\u{1f6ab} <strong>#42</strong> \u{2014} BLOCKED: empty diff"
|
2026-04-24 18:01:34 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 23:05:50 +00:00
|
|
|
#[test]
|
|
|
|
|
fn format_blocked_notification_empty_story_name_falls_back_to_number() {
|
2026-05-13 14:51:39 +00:00
|
|
|
let (plain, _html) = format_blocked_notification("42_story_thing", "", "empty diff");
|
2026-05-12 23:05:50 +00:00
|
|
|
assert_eq!(plain, "\u{1f6ab} #42 \u{2014} BLOCKED: empty diff");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 18:01:34 +00:00
|
|
|
#[test]
|
|
|
|
|
fn format_blocked_notification_unicode_reason() {
|
2026-05-13 14:51:39 +00:00
|
|
|
let (plain, _html) = format_blocked_notification("3_story_x", "X", "理由:空の差分");
|
2026-04-24 18:01:34 +00:00
|
|
|
assert!(plain.contains("BLOCKED: 理由:空の差分"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── format_rate_limit_notification ────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_rate_limit_notification_includes_agent_and_story() {
|
|
|
|
|
let (plain, html) =
|
2026-05-13 14:51:39 +00:00
|
|
|
format_rate_limit_notification("365_story_my_feature", "My Feature", "coder-2");
|
2026-04-24 18:01:34 +00:00
|
|
|
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} <strong>#365</strong> <em>My Feature</em> \u{2014} coder-2 hit an API rate limit"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-12 23:05:50 +00:00
|
|
|
fn format_rate_limit_notification_falls_back_to_number() {
|
2026-05-13 14:51:39 +00:00
|
|
|
let (plain, html) = format_rate_limit_notification("42_story_thing", "", "coder-1");
|
2026-04-24 18:01:34 +00:00
|
|
|
assert_eq!(
|
|
|
|
|
plain,
|
2026-05-12 23:05:50 +00:00
|
|
|
"\u{26a0}\u{fe0f} #42 \u{2014} coder-1 hit an API rate limit"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
html,
|
|
|
|
|
"\u{26a0}\u{fe0f} <strong>#42</strong> \u{2014} coder-1 hit an API rate limit"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_rate_limit_notification_empty_story_name_falls_back_to_number() {
|
2026-05-13 14:51:39 +00:00
|
|
|
let (plain, _html) = format_rate_limit_notification("42_story_thing", "", "coder-1");
|
2026-05-12 23:05:50 +00:00
|
|
|
assert_eq!(
|
|
|
|
|
plain,
|
|
|
|
|
"\u{26a0}\u{fe0f} #42 \u{2014} coder-1 hit an API rate limit"
|
2026-04-24 18:01:34 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_rate_limit_notification_unicode_agent_name() {
|
2026-05-13 14:51:39 +00:00
|
|
|
let (plain, _html) = format_rate_limit_notification("9_story_foo", "Foo", "агент-1");
|
2026-04-24 18:01:34 +00:00
|
|
|
assert!(plain.contains("агент-1"));
|
|
|
|
|
assert!(plain.contains("hit an API rate limit"));
|
|
|
|
|
}
|
2026-05-12 23:05:50 +00:00
|
|
|
|
|
|
|
|
// ── format_agent_started_notification ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_agent_started_notification_with_story_name() {
|
|
|
|
|
let (plain, html) =
|
2026-05-13 14:51:39 +00:00
|
|
|
format_agent_started_notification("42_story_foo", "My Feature", "coder-1");
|
2026-05-12 23:05:50 +00:00
|
|
|
assert_eq!(plain, "\u{1F916} #42 My Feature \u{2014} coder-1 started");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
html,
|
|
|
|
|
"\u{1F916} <strong>#42</strong> <em>My Feature</em> \u{2014} coder-1 started"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_agent_started_notification_falls_back_to_number() {
|
2026-05-13 14:51:39 +00:00
|
|
|
let (plain, html) = format_agent_started_notification("42_story_foo", "", "coder-1");
|
2026-05-12 23:05:50 +00:00
|
|
|
assert_eq!(plain, "\u{1F916} #42 \u{2014} coder-1 started");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
html,
|
|
|
|
|
"\u{1F916} <strong>#42</strong> \u{2014} coder-1 started"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_agent_started_notification_empty_name_falls_back_to_number() {
|
2026-05-13 14:51:39 +00:00
|
|
|
let (plain, _html) = format_agent_started_notification("42_story_foo", "", "coder-1");
|
2026-05-12 23:05:50 +00:00
|
|
|
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() {
|
2026-05-13 14:51:39 +00:00
|
|
|
let (plain, html) =
|
|
|
|
|
format_agent_completed_notification("42_story_foo", "My Feature", "coder-1", true);
|
2026-05-12 23:05:50 +00:00
|
|
|
assert_eq!(plain, "\u{2705} #42 My Feature \u{2014} coder-1 completed");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
html,
|
|
|
|
|
"\u{2705} <strong>#42</strong> <em>My Feature</em> \u{2014} coder-1 completed"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_agent_completed_notification_failure_with_story_name() {
|
2026-05-13 14:51:39 +00:00
|
|
|
let (plain, _html) =
|
|
|
|
|
format_agent_completed_notification("42_story_foo", "My Feature", "coder-1", false);
|
2026-05-12 23:05:50 +00:00
|
|
|
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) =
|
2026-05-13 14:51:39 +00:00
|
|
|
format_agent_completed_notification("42_story_foo", "", "coder-1", true);
|
2026-05-12 23:05:50 +00:00
|
|
|
assert_eq!(plain, "\u{2705} #42 \u{2014} coder-1 completed");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
html,
|
|
|
|
|
"\u{2705} <strong>#42</strong> \u{2014} coder-1 completed"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_agent_completed_notification_empty_name_falls_back_to_number() {
|
|
|
|
|
let (plain, _html) =
|
2026-05-13 14:51:39 +00:00
|
|
|
format_agent_completed_notification("42_story_foo", "", "coder-1", false);
|
2026-05-12 23:05:50 +00:00
|
|
|
assert_eq!(plain, "\u{274C} #42 \u{2014} coder-1 failed");
|
|
|
|
|
}
|
2026-04-24 18:01:34 +00:00
|
|
|
}
|