huskies: merge 1077
This commit is contained in:
@@ -240,21 +240,28 @@ pub fn format_new_item_notification(
|
|||||||
(plain, html)
|
(plain, html)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the first non-empty line from a merge failure reason, truncated to `max_len` chars.
|
/// Maximum number of trailing gate-output lines included in a merge-failure
|
||||||
|
/// chat notification.
|
||||||
///
|
///
|
||||||
/// Used to produce a compact snippet for chat notifications.
|
/// Gate output can be hundreds of lines; only the tail (where errors appear)
|
||||||
pub fn merge_failure_snippet(reason: &str, max_len: usize) -> String {
|
/// is useful at a glance. Full output remains available via `get_merge_status`
|
||||||
let line = reason
|
/// or the web UI — this limit is chat-display-only.
|
||||||
.lines()
|
pub const MERGE_FAILURE_TAIL_LINES: usize = 30;
|
||||||
.find(|l| !l.trim().is_empty())
|
|
||||||
.unwrap_or(reason);
|
/// Truncate `gate_output` to its last `max_lines` lines for chat notifications.
|
||||||
let mut chars = line.chars();
|
///
|
||||||
let truncated: String = chars.by_ref().take(max_len).collect();
|
/// If the output contains more than `max_lines` non-empty lines, a leading
|
||||||
if chars.next().is_some() {
|
/// marker line `[...output truncated, last N lines shown...]` is prepended to
|
||||||
format!("{truncated}\u{2026}") // append …
|
/// the tail so readers know output was cut. If the output fits within the
|
||||||
} else {
|
/// limit it is returned unchanged (no marker added).
|
||||||
truncated
|
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)]
|
#[cfg(test)]
|
||||||
@@ -588,6 +595,64 @@ mod tests {
|
|||||||
assert_eq!(plain, "\u{1F916} #42 \u{2014} coder-1 started");
|
assert_eq!(plain, "\u{1F916} #42 \u{2014} coder-1 started");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── truncate_gate_output ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_gate_output_short_output_returned_unchanged() {
|
||||||
|
let output = "line1\nline2\nline3";
|
||||||
|
assert_eq!(truncate_gate_output(output, 30), output);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_gate_output_exact_limit_returned_unchanged() {
|
||||||
|
let lines: Vec<String> = (1..=30).map(|i| format!("line{i}")).collect();
|
||||||
|
let output = lines.join("\n");
|
||||||
|
assert_eq!(truncate_gate_output(&output, 30), output);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_gate_output_over_limit_prepends_marker() {
|
||||||
|
let lines: Vec<String> = (1..=35).map(|i| format!("line{i}")).collect();
|
||||||
|
let output = lines.join("\n");
|
||||||
|
let result = truncate_gate_output(&output, 30);
|
||||||
|
assert!(
|
||||||
|
result.starts_with("[...output truncated, last 30 lines shown...]"),
|
||||||
|
"must start with truncation marker; got: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_gate_output_over_limit_contains_tail_lines() {
|
||||||
|
let lines: Vec<String> = (1..=35).map(|i| format!("line{i}")).collect();
|
||||||
|
let output = lines.join("\n");
|
||||||
|
let result = truncate_gate_output(&output, 30);
|
||||||
|
// Last 30 lines are line6..line35.
|
||||||
|
assert!(result.contains("line35"), "must contain last line");
|
||||||
|
assert!(result.contains("line6"), "must contain first tail line");
|
||||||
|
assert!(!result.contains("line5"), "must not contain dropped line");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_gate_output_empty_input_returned_unchanged() {
|
||||||
|
assert_eq!(truncate_gate_output("", 30), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_gate_output_single_line_returned_unchanged() {
|
||||||
|
assert_eq!(truncate_gate_output("only one line", 30), "only one line");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_gate_output_marker_contains_configured_limit() {
|
||||||
|
let lines: Vec<String> = (1..=10).map(|i| format!("x{i}")).collect();
|
||||||
|
let output = lines.join("\n");
|
||||||
|
let result = truncate_gate_output(&output, 5);
|
||||||
|
assert!(
|
||||||
|
result.contains("last 5 lines shown"),
|
||||||
|
"marker must state configured limit; got: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── format_agent_completed_notification ───────────────────────────────────
|
// ── format_agent_completed_notification ───────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ use tokio::sync::broadcast;
|
|||||||
use super::super::events::classify;
|
use super::super::events::classify;
|
||||||
use super::super::filter::{AGENT_EVENT_DEBOUNCE, should_send_rate_limit};
|
use super::super::filter::{AGENT_EVENT_DEBOUNCE, should_send_rate_limit};
|
||||||
use super::super::format::{
|
use super::super::format::{
|
||||||
format_agent_completed_notification, format_agent_started_notification,
|
MERGE_FAILURE_TAIL_LINES, format_agent_completed_notification,
|
||||||
format_blocked_notification, format_error_notification, format_new_item_notification,
|
format_agent_started_notification, format_blocked_notification, format_error_notification,
|
||||||
format_oauth_account_swapped, format_oauth_accounts_exhausted, format_rate_limit_notification,
|
format_new_item_notification, format_oauth_account_swapped, format_oauth_accounts_exhausted,
|
||||||
merge_failure_snippet,
|
format_rate_limit_notification, truncate_gate_output,
|
||||||
};
|
};
|
||||||
use super::super::route::rooms_for_notification;
|
use super::super::route::rooms_for_notification;
|
||||||
use super::{find_story_name_any_stage, read_story_name};
|
use super::{find_story_name_any_stage, read_story_name};
|
||||||
@@ -120,9 +120,7 @@ pub fn spawn_notification_listener(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let story_name = read_story_name(&project_root, "4_merge", story_id);
|
let story_name = read_story_name(&project_root, "4_merge", story_id);
|
||||||
// AC3: include only the first non-empty line of the failure,
|
let snippet = truncate_gate_output(reason, MERGE_FAILURE_TAIL_LINES);
|
||||||
// truncated to ~120 chars.
|
|
||||||
let snippet = merge_failure_snippet(reason, 120);
|
|
||||||
let (plain, html) = format_error_notification(story_id, &story_name, &snippet);
|
let (plain, html) = format_error_notification(story_id, &story_name, &snippet);
|
||||||
slog!("[bot] Sending error notification: {plain}");
|
slog!("[bot] Sending error notification: {plain}");
|
||||||
for room_id in &rooms_for_notification(&get_room_ids) {
|
for room_id in &rooms_for_notification(&get_room_ids) {
|
||||||
|
|||||||
@@ -5,6 +5,89 @@ use super::spawn_notification_listener;
|
|||||||
use crate::io::watcher::WatcherEvent;
|
use crate::io::watcher::WatcherEvent;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
// ── spawn_notification_listener: MergeFailure ────────────────────────────────
|
||||||
|
|
||||||
|
/// Long gate output is truncated to the tail and includes the marker line.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn merge_failure_long_output_is_truncated_to_tail() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||||
|
let (transport, calls) = MockTransport::new();
|
||||||
|
|
||||||
|
spawn_notification_listener(
|
||||||
|
transport,
|
||||||
|
|| vec!["!room1:example.org".to_string()],
|
||||||
|
watcher_rx,
|
||||||
|
tmp.path().to_path_buf(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build a reason with 50 lines (more than MERGE_FAILURE_TAIL_LINES = 30).
|
||||||
|
let long_reason: String = (1..=50).map(|i| format!("gate-line-{i}\n")).collect();
|
||||||
|
|
||||||
|
watcher_tx
|
||||||
|
.send(WatcherEvent::MergeFailure {
|
||||||
|
story_id: "1077_story_trunc".to_string(),
|
||||||
|
reason: long_reason,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
let calls = calls.lock().unwrap();
|
||||||
|
assert_eq!(calls.len(), 1, "Expected exactly one notification");
|
||||||
|
let (_, plain, _) = &calls[0];
|
||||||
|
assert!(
|
||||||
|
plain.contains("truncated"),
|
||||||
|
"notification must contain the truncation marker; got: {plain}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plain.contains("gate-line-50"),
|
||||||
|
"notification must contain the last line; got: {plain}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!plain.contains("gate-line-1\n"),
|
||||||
|
"notification must not contain the first (dropped) line; got: {plain}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short gate output (within limit) passes through unchanged, no marker added.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn merge_failure_short_output_passes_through_unchanged() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||||
|
let (transport, calls) = MockTransport::new();
|
||||||
|
|
||||||
|
spawn_notification_listener(
|
||||||
|
transport,
|
||||||
|
|| vec!["!room1:example.org".to_string()],
|
||||||
|
watcher_rx,
|
||||||
|
tmp.path().to_path_buf(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let short_reason = "error: type mismatch on line 42\nexpected i32, found &str".to_string();
|
||||||
|
|
||||||
|
watcher_tx
|
||||||
|
.send(WatcherEvent::MergeFailure {
|
||||||
|
story_id: "1077_story_short".to_string(),
|
||||||
|
reason: short_reason.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
let calls = calls.lock().unwrap();
|
||||||
|
assert_eq!(calls.len(), 1, "Expected exactly one notification");
|
||||||
|
let (_, plain, _) = &calls[0];
|
||||||
|
assert!(
|
||||||
|
!plain.contains("truncated"),
|
||||||
|
"short output must not have a truncation marker; got: {plain}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plain.contains("type mismatch"),
|
||||||
|
"short output must be included verbatim; got: {plain}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── spawn_notification_listener: RateLimitWarning ────────────────────────────
|
// ── spawn_notification_listener: RateLimitWarning ────────────────────────────
|
||||||
|
|
||||||
/// AC2 + AC3: when a RateLimitWarning event arrives, send_message is called
|
/// AC2 + AC3: when a RateLimitWarning event arrives, send_message is called
|
||||||
|
|||||||
Reference in New Issue
Block a user