huskies: merge 1073

This commit is contained in:
dave
2026-05-15 00:30:07 +00:00
parent d4db96f709
commit f9b140add9
9 changed files with 161 additions and 2 deletions
+8
View File
@@ -38,6 +38,14 @@ pub(crate) fn tool_create_bug(args: &Value, ctx: &AppContext) -> Result<String,
depends_on.as_deref(), depends_on.as_deref(),
)?; )?;
let _ = ctx
.watcher_tx
.send(crate::io::watcher::WatcherEvent::NewItemCreated {
item_id: bug_id.clone(),
item_type: "bug".to_string(),
name: req.name.as_ref().to_string(),
});
Ok(format!("Created bug: {bug_id}")) Ok(format!("Created bug: {bug_id}"))
} }
@@ -36,6 +36,14 @@ pub(crate) fn tool_create_refactor(args: &Value, ctx: &AppContext) -> Result<Str
depends_on.as_deref(), depends_on.as_deref(),
)?; )?;
let _ = ctx
.watcher_tx
.send(crate::io::watcher::WatcherEvent::NewItemCreated {
item_id: refactor_id.clone(),
item_type: "refactor".to_string(),
name: req.name.as_ref().to_string(),
});
Ok(format!("Created refactor: {refactor_id}")) Ok(format!("Created refactor: {refactor_id}"))
} }
+8
View File
@@ -36,6 +36,14 @@ pub(crate) fn tool_create_spike(args: &Value, ctx: &AppContext) -> Result<String
depends_on.as_deref(), depends_on.as_deref(),
)?; )?;
let _ = ctx
.watcher_tx
.send(crate::io::watcher::WatcherEvent::NewItemCreated {
item_id: spike_id.clone(),
item_type: "spike".to_string(),
name: req.name.as_ref().to_string(),
});
Ok(format!("Created spike: {spike_id}")) Ok(format!("Created spike: {spike_id}"))
} }
@@ -31,6 +31,14 @@ pub(crate) fn tool_create_story(args: &Value, ctx: &AppContext) -> Result<String
false, false,
)?; )?;
let _ = ctx
.watcher_tx
.send(crate::io::watcher::WatcherEvent::NewItemCreated {
item_id: story_id.clone(),
item_type: "story".to_string(),
name: req.name.as_ref().to_string(),
});
// Bug 503: warn at creation time if any depends_on points at an already-archived story. // Bug 503: warn at creation time if any depends_on points at an already-archived story.
let archived_deps: Vec<u32> = depends_on_ids let archived_deps: Vec<u32> = depends_on_ids
.as_deref() .as_deref()
+10
View File
@@ -90,4 +90,14 @@ pub enum WatcherEvent {
/// `true` if acceptance gates passed; `false` if they failed. /// `true` if acceptance gates passed; `false` if they failed.
success: bool, success: bool,
}, },
/// A new work item was successfully created and added to the backlog.
/// Triggers a creation notification to configured chat rooms.
NewItemCreated {
/// Work item ID (e.g. `"1075_refactor_split_stage_enum"`).
item_id: String,
/// Human-readable item type (`"story"`, `"bug"`, `"refactor"`, `"spike"`).
item_type: String,
/// Human-readable item name.
name: String,
},
} }
@@ -26,6 +26,8 @@ pub enum EventAction {
/// `true` if acceptance gates passed. /// `true` if acceptance gates passed.
success: bool, success: bool,
}, },
/// Post a new-item-created notification.
NewItemCreated,
/// Log server-side only; do not post to chat (e.g. hard rate-limit blocks). /// Log server-side only; do not post to chat (e.g. hard rate-limit blocks).
LogOnly, LogOnly,
/// Reload the project configuration. /// Reload the project configuration.
@@ -51,6 +53,7 @@ pub fn classify(event: &WatcherEvent) -> EventAction {
WatcherEvent::AgentCompleted { success, .. } => { WatcherEvent::AgentCompleted { success, .. } => {
EventAction::AgentCompleted { success: *success } EventAction::AgentCompleted { success: *success }
} }
WatcherEvent::NewItemCreated { .. } => EventAction::NewItemCreated,
_ => EventAction::Skip, _ => EventAction::Skip,
} }
} }
@@ -178,4 +181,14 @@ mod tests {
EventAction::AgentCompleted { success: false } EventAction::AgentCompleted { success: false }
); );
} }
#[test]
fn new_item_created_is_classified_correctly() {
let event = WatcherEvent::NewItemCreated {
item_id: "1075_refactor_split_stage".to_string(),
item_type: "refactor".to_string(),
name: "Split Stage enum".to_string(),
};
assert_eq!(classify(&event), EventAction::NewItemCreated);
}
} }
@@ -220,6 +220,26 @@ pub fn format_agent_completed_notification(
(plain, html) (plain, html)
} }
/// 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,
) -> (String, String) {
let number = extract_item_number(item_id).unwrap_or(item_id);
let emoji = match item_type {
"bug" => "\u{1f41b}", // 🐛
"refactor" => "\u{1f4dd}", // 📝
"spike" => "\u{1f52c}", // 🔬
_ => "\u{1f4d6}", // 📖 (story and unknown)
};
let plain = format!("{emoji} New {item_type} #{number} \u{2014} {name}");
let html = format!("{emoji} New {item_type} <strong>#{number}</strong> \u{2014} {name}");
(plain, html)
}
/// Extract the first non-empty line from a merge failure reason, truncated to `max_len` chars. /// 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. /// Used to produce a compact snippet for chat notifications.
@@ -599,6 +619,67 @@ mod tests {
); );
} }
// ── format_new_item_notification ──────────────────────────────────────────
#[test]
fn format_new_item_notification_story() {
let (plain, html) =
format_new_item_notification("42_story_my_feature", "story", "My Feature");
assert_eq!(plain, "\u{1f4d6} New story #42 \u{2014} My Feature");
assert_eq!(
html,
"\u{1f4d6} New story <strong>#42</strong> \u{2014} My Feature"
);
}
#[test]
fn format_new_item_notification_bug() {
let (plain, html) =
format_new_item_notification("99_bug_login_crash", "bug", "Login Crash");
assert_eq!(plain, "\u{1f41b} New bug #99 \u{2014} Login Crash");
assert_eq!(
html,
"\u{1f41b} New bug <strong>#99</strong> \u{2014} Login Crash"
);
}
#[test]
fn format_new_item_notification_refactor() {
let (plain, html) = format_new_item_notification(
"1075_refactor_split_stage",
"refactor",
"Split Stage enum into Pipeline + Status",
);
assert_eq!(
plain,
"\u{1f4dd} New refactor #1075 \u{2014} Split Stage enum into Pipeline + Status"
);
assert_eq!(
html,
"\u{1f4dd} New refactor <strong>#1075</strong> \u{2014} Split Stage enum into Pipeline + Status"
);
}
#[test]
fn format_new_item_notification_spike() {
let (plain, html) =
format_new_item_notification("7_spike_encoder_comparison", "spike", "Compare Encoders");
assert_eq!(plain, "\u{1f52c} New spike #7 \u{2014} Compare Encoders");
assert_eq!(
html,
"\u{1f52c} New spike <strong>#7</strong> \u{2014} Compare Encoders"
);
}
#[test]
fn format_new_item_notification_non_numeric_id_uses_full_id() {
let (plain, _html) = format_new_item_notification("abc_story_thing", "story", "Some Story");
assert_eq!(
plain,
"\u{1f4d6} New story #abc_story_thing \u{2014} Some Story"
);
}
#[test] #[test]
fn format_agent_completed_notification_empty_name_falls_back_to_number() { fn format_agent_completed_notification_empty_name_falls_back_to_number() {
let (plain, _html) = let (plain, _html) =
@@ -15,8 +15,9 @@ 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, format_agent_completed_notification, format_agent_started_notification,
format_blocked_notification, format_error_notification, format_oauth_account_swapped, format_blocked_notification, format_error_notification, format_new_item_notification,
format_oauth_accounts_exhausted, format_rate_limit_notification, merge_failure_snippet, format_oauth_account_swapped, format_oauth_accounts_exhausted, format_rate_limit_notification,
merge_failure_snippet,
}; };
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};
@@ -276,6 +277,26 @@ pub fn spawn_notification_listener(
pending_agent_events.insert(key, (plain, html)); pending_agent_events.insert(key, (plain, html));
agent_flush_deadline = Some(tokio::time::Instant::now() + AGENT_EVENT_DEBOUNCE); agent_flush_deadline = Some(tokio::time::Instant::now() + AGENT_EVENT_DEBOUNCE);
} }
EventAction::NewItemCreated => {
if !config.status_push_enabled {
continue;
}
let WatcherEvent::NewItemCreated {
ref item_id,
ref item_type,
ref name,
} = event
else {
continue;
};
let (plain, html) = format_new_item_notification(item_id, item_type, name);
slog!("[bot] Sending new-item notification: {plain}");
for room_id in &rooms_for_notification(&get_room_ids) {
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
slog!("[bot] Failed to send new-item notification to {room_id}: {e}");
}
}
}
EventAction::LogOnly => { EventAction::LogOnly => {
// Hard-block: log server-side for debugging; do NOT post to chat. // Hard-block: log server-side for debugging; do NOT post to chat.
// Hard-block auto-resume is normal operation — the status command // Hard-block auto-resume is normal operation — the status command
+2
View File
@@ -37,6 +37,8 @@ pub fn watcher_event_to_response(e: WatcherEvent) -> Option<WsResponse> {
// Agent lifecycle events are forwarded to chat transports only; no WebSocket message. // Agent lifecycle events are forwarded to chat transports only; no WebSocket message.
WatcherEvent::AgentStarted { .. } => None, WatcherEvent::AgentStarted { .. } => None,
WatcherEvent::AgentCompleted { .. } => None, WatcherEvent::AgentCompleted { .. } => None,
// Creation notifications are forwarded to chat transports only; no WebSocket message.
WatcherEvent::NewItemCreated { .. } => None,
} }
} }