story-kit: merge 262_story_bot_error_notifications_for_story_failures

This commit is contained in:
Dave
2026-03-17 15:26:15 +00:00
parent bf5d9ff6b1
commit 96779c9caf
4 changed files with 98 additions and 0 deletions

View File

@@ -1788,6 +1788,13 @@ fn tool_report_merge_failure(args: &Value, ctx: &AppContext) -> Result<String, S
slog!("[mergemaster] Merge failure reported for '{story_id}': {reason}"); slog!("[mergemaster] Merge failure reported for '{story_id}': {reason}");
ctx.agents.set_merge_failure_reported(story_id); ctx.agents.set_merge_failure_reported(story_id);
// Broadcast the failure so the Matrix notification listener can post an
// error message to configured rooms without coupling this tool to the bot.
let _ = ctx.watcher_tx.send(crate::io::watcher::WatcherEvent::MergeFailure {
story_id: story_id.to_string(),
reason: reason.to_string(),
});
// Persist the failure reason to the story file's front matter so it // Persist the failure reason to the story file's front matter so it
// survives server restarts and is visible in the web UI. // survives server restarts and is visible in the web UI.
if let Ok(project_root) = ctx.state.get_project_root() { if let Ok(project_root) = ctx.state.get_project_root() {

View File

@@ -150,6 +150,9 @@ impl From<WatcherEvent> for Option<WsResponse> {
}), }),
WatcherEvent::ConfigChanged => Some(WsResponse::AgentConfigChanged), WatcherEvent::ConfigChanged => Some(WsResponse::AgentConfigChanged),
WatcherEvent::AgentStateChanged => Some(WsResponse::AgentStateChanged), WatcherEvent::AgentStateChanged => Some(WsResponse::AgentStateChanged),
// MergeFailure is handled by the Matrix notification listener only;
// no WebSocket message is needed for the frontend.
WatcherEvent::MergeFailure { .. } => None,
} }
} }
} }

View File

@@ -50,6 +50,14 @@ pub enum WatcherEvent {
/// Triggers a pipeline state refresh so the frontend can update agent /// Triggers a pipeline state refresh so the frontend can update agent
/// assignments without waiting for a filesystem event. /// assignments without waiting for a filesystem event.
AgentStateChanged, AgentStateChanged,
/// A story encountered a failure (e.g. merge failure).
/// Triggers an error notification to configured Matrix rooms.
MergeFailure {
/// Work item ID (e.g. `"42_story_my_feature"`).
story_id: String,
/// Human-readable description of the failure.
reason: String,
},
} }
/// Return `true` if `path` is the root-level `.story_kit/project.toml`, i.e. /// Return `true` if `path` is the root-level `.story_kit/project.toml`, i.e.

View File

@@ -81,6 +81,24 @@ pub fn format_stage_notification(
(plain, html) (plain, html)
} }
/// Format an error notification message for a story failure.
///
/// Returns `(plain_text, html)` suitable for `RoomMessageEventContent::text_html`.
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)
}
/// Spawn a background task that listens for watcher events and posts /// Spawn a background task that listens for watcher events and posts
/// stage-transition notifications to all configured Matrix rooms. /// stage-transition notifications to all configured Matrix rooms.
pub fn spawn_notification_listener( pub fn spawn_notification_listener(
@@ -126,6 +144,32 @@ pub fn spawn_notification_listener(
} }
} }
} }
Ok(WatcherEvent::MergeFailure {
ref story_id,
ref reason,
}) => {
let story_name =
read_story_name(&project_root, "4_merge", story_id);
let (plain, html) = format_error_notification(
story_id,
story_name.as_deref(),
reason,
);
slog!("[matrix-bot] Sending error notification: {plain}");
for room_id in &room_ids {
if let Some(room) = client.get_room(room_id) {
let content =
RoomMessageEventContent::text_html(plain.clone(), html.clone());
if let Err(e) = room.send(content).await {
slog!(
"[matrix-bot] Failed to send error notification to {room_id}: {e}"
);
}
}
}
}
Ok(_) => {} // Ignore non-work-item events Ok(_) => {} // Ignore non-work-item events
Err(broadcast::error::RecvError::Lagged(n)) => { Err(broadcast::error::RecvError::Lagged(n)) => {
slog!( slog!(
@@ -246,6 +290,42 @@ mod tests {
assert_eq!(name, None); assert_eq!(name, None);
} }
// ── 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"
);
}
// ── format_stage_notification ─────────────────────────────────────────── // ── format_stage_notification ───────────────────────────────────────────
#[test] #[test]