2026-07-17 19:36:17 +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`.
|
|
|
|
|
|
|
|
|
|
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!("<em>{n}</em> "))
|
|
|
|
|
.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}<strong>#{number}</strong> {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!("<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}");
|
|
|
|
|
(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!("<em>{n}</em> "))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let plain = format!("\u{1f6ab} #{number} {name_plain}\u{2014} BLOCKED: {reason}");
|
|
|
|
|
let html =
|
|
|
|
|
format!("\u{1f6ab} <strong>#{number}</strong> {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!("<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"
|
|
|
|
|
);
|
|
|
|
|
let html = format!(
|
|
|
|
|
"\u{26a0}\u{fe0f} <strong>#{number}</strong> {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 <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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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!("<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");
|
|
|
|
|
(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!("<em>{n}</em> "))
|
|
|
|
|
.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} <strong>#{number}</strong> {name_html}\u{2014} {agent_name} {result}");
|
|
|
|
|
(plain, html)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 10:50:41 +00:00
|
|
|
/// 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<u32>,
|
|
|
|
|
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!("<em>{n}</em> "))
|
|
|
|
|
.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} <strong>#{number}</strong> {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!("<br>Last line: <code>{line}</code>"));
|
|
|
|
|
}
|
|
|
|
|
(plain, html)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 19:36:17 +00:00
|
|
|
/// 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} <strong>#{number}</strong> \u{2014} {name} \
|
|
|
|
|
(via <em>{origin_label}</em>)"
|
|
|
|
|
);
|
|
|
|
|
(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:<br>", 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} {} <strong>#{number}</strong> \u{2014} {} (via <em>{}</em>)<br>",
|
|
|
|
|
item.item_type, item.name, item.origin_label
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
(
|
|
|
|
|
plain.trim_end().to_string(),
|
|
|
|
|
html.trim_end_matches("<br>").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!("<em>{n}</em> "))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let plain = format!(
|
|
|
|
|
"\u{1f504} #{number} {name_plain}\u{2014} auto-retrying merge (attempt {attempt}/{budget})"
|
|
|
|
|
);
|
|
|
|
|
let html = format!(
|
|
|
|
|
"\u{1f504} <strong>#{number}</strong> {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 <strong>{host_id}</strong> ({level}): {free_gb:.1}GB free \
|
|
|
|
|
(target/ {target_gb:.1}GB, worktrees/ {worktrees_gb:.1}GB) \
|
|
|
|
|
\u{2014} first response: run the <code>gc</code> 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 <strong>{host_id}</strong>: {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;
|