huskies: merge 616_story_extract_notifications_service
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
//! 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`.
|
||||
|
||||
/// 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",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()))
|
||||
}
|
||||
|
||||
/// 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: &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}<strong>#{number}</strong> <em>{name}</em> \u{2014} {from_stage} \u{2192} {to_stage}"
|
||||
);
|
||||
(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_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} <strong>#{number}</strong> <em>{name}</em> \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_story_number(item_id).unwrap_or(item_id);
|
||||
let name = story_name.unwrap_or(item_id);
|
||||
|
||||
let plain = format!("\u{1f6ab} #{number} {name} \u{2014} BLOCKED: {reason}");
|
||||
let html =
|
||||
format!("\u{1f6ab} <strong>#{number}</strong> <em>{name}</em> \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_story_number(item_id).unwrap_or(item_id);
|
||||
let name = story_name.unwrap_or(item_id);
|
||||
|
||||
let plain =
|
||||
format!("\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit");
|
||||
let html = format!(
|
||||
"\u{26a0}\u{fe0f} <strong>#{number}</strong> <em>{name}</em> \u{2014} \
|
||||
{agent_name} hit an API rate limit"
|
||||
);
|
||||
(plain, html)
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_display_name_unknown_slug_returns_unknown() {
|
||||
assert_eq!(stage_display_name("99_future"), "Unknown");
|
||||
assert_eq!(stage_display_name(""), "Unknown");
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_story_number_returns_none_for_empty_first_segment() {
|
||||
// Leading underscore: first segment is ""
|
||||
assert_eq!(extract_story_number("_story_thing"), None);
|
||||
}
|
||||
|
||||
// ── 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} <strong>#353</strong> <em>Done Story</em> \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,
|
||||
"<strong>#261</strong> <em>Bot notifications</em> \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"
|
||||
);
|
||||
}
|
||||
|
||||
#[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), "Current", "QA");
|
||||
assert!(plain.contains(&long_name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_stage_notification_empty_story_name_falls_back_to_id() {
|
||||
// Some("") is a valid Some but empty — treat as missing? Currently we use it as-is.
|
||||
let (plain, _html) = format_stage_notification("42_story_empty", Some(""), "Current", "QA");
|
||||
// The name slot is empty but the structure is still correct.
|
||||
assert!(plain.contains("#42"));
|
||||
assert!(plain.contains("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é 🎉"), "QA", "Merge");
|
||||
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} <strong>#262</strong> <em>Bot error notifications</em> \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"
|
||||
);
|
||||
}
|
||||
|
||||
#[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("错误:合并冲突"));
|
||||
}
|
||||
|
||||
// ── 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} <strong>#425</strong> <em>Blocking Reason Story</em> \u{2014} BLOCKED: Retry limit exceeded (3/3) at coder stage"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_blocked_notification_falls_back_to_item_id() {
|
||||
let (plain, _html) = format_blocked_notification("42_story_thing", None, "empty diff");
|
||||
assert_eq!(
|
||||
plain,
|
||||
"\u{1f6ab} #42 42_story_thing \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} <strong>#365</strong> <em>My Feature</em> \u{2014} coder-2 hit an API rate limit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_rate_limit_notification_falls_back_to_item_id() {
|
||||
let (plain, _html) = format_rate_limit_notification("42_story_thing", None, "coder-1");
|
||||
assert_eq!(
|
||||
plain,
|
||||
"\u{26a0}\u{fe0f} #42 42_story_thing \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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user