huskies: merge 964

This commit is contained in:
dave
2026-05-13 14:51:39 +00:00
parent c811672e18
commit dcb43c465a
24 changed files with 234 additions and 188 deletions
+7 -7
View File
@@ -53,7 +53,7 @@ export interface AgentAssignment {
/** A single item in any pipeline stage (backlog, current, QA, merge, or done). */ /** A single item in any pipeline stage (backlog, current, QA, merge, or done). */
export interface PipelineStageItem { export interface PipelineStageItem {
story_id: string; story_id: string;
name: string | null; name: string;
error: string | null; error: string | null;
merge_failure: string | null; merge_failure: string | null;
agent: AgentAssignment | null; agent: AgentAssignment | null;
@@ -142,32 +142,32 @@ export type StatusEvent =
| { | {
type: "stage_transition"; type: "stage_transition";
story_id: string; story_id: string;
story_name: string | null; story_name: string;
from_stage: string; from_stage: string;
to_stage: string; to_stage: string;
} }
| { | {
type: "merge_failure"; type: "merge_failure";
story_id: string; story_id: string;
story_name: string | null; story_name: string;
reason: string; reason: string;
} }
| { | {
type: "story_blocked"; type: "story_blocked";
story_id: string; story_id: string;
story_name: string | null; story_name: string;
reason: string; reason: string;
} }
| { | {
type: "rate_limit_warning"; type: "rate_limit_warning";
story_id: string; story_id: string;
story_name: string | null; story_name: string;
agent_name: string; agent_name: string;
} }
| { | {
type: "rate_limit_hard_block"; type: "rate_limit_hard_block";
story_id: string; story_id: string;
story_name: string | null; story_name: string;
agent_name: string; agent_name: string;
reset_at: string; reset_at: string;
}; };
@@ -212,7 +212,7 @@ export interface AnthropicModelInfo {
export interface WorkItemContent { export interface WorkItemContent {
content: string; content: string;
stage: string; stage: string;
name: string | null; name: string;
agent: string | null; agent: string | null;
} }
@@ -15,7 +15,7 @@ import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
* This conversion happens at render time, not at the WebSocket boundary, * This conversion happens at render time, not at the WebSocket boundary,
* so the original StatusEvent structure is preserved in state. */ * so the original StatusEvent structure is preserved in state. */
function formatStatusEventMessage(event: StatusEvent): string { function formatStatusEventMessage(event: StatusEvent): string {
const name = event.story_name ?? event.story_id; const name = event.story_name || event.story_id;
switch (event.type) { switch (event.type) {
case "stage_transition": case "stage_transition":
return `${name}${event.from_stage}${event.to_stage}`; return `${name}${event.from_stage}${event.to_stage}`;
+1 -1
View File
@@ -113,7 +113,7 @@ describe("StagePanel", () => {
const items: PipelineStageItem[] = [ const items: PipelineStageItem[] = [
{ {
story_id: "1_story_bad", story_id: "1_story_bad",
name: null, name: "",
error: "Missing front matter", error: "Missing front matter",
merge_failure: null, merge_failure: null,
agent: null, agent: null,
+3 -3
View File
@@ -526,7 +526,7 @@ export function StagePanel({
${costs.get(item.story_id)?.toFixed(2)} ${costs.get(item.story_id)?.toFixed(2)}
</span> </span>
)} )}
{item.name ?? item.story_id} {item.name || item.story_id}
</div> </div>
{item.error && ( {item.error && (
<div <div
@@ -616,10 +616,10 @@ export function StagePanel({
<button <button
type="button" type="button"
data-testid={`delete-btn-${item.story_id}`} data-testid={`delete-btn-${item.story_id}`}
title={`Delete ${item.name ?? item.story_id}`} title={`Delete ${item.name || item.story_id}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
const label = item.name ?? item.story_id; const label = item.name || item.story_id;
if ( if (
window.confirm( window.confirm(
`Delete "${label}"? This cannot be undone.`, `Delete "${label}"? This cannot be undone.`,
+3 -3
View File
@@ -138,7 +138,7 @@ mod tests {
fn stage_transition_event(story_id: &str) -> StatusEvent { fn stage_transition_event(story_id: &str) -> StatusEvent {
StatusEvent::StageTransition { StatusEvent::StageTransition {
story_id: story_id.to_string(), story_id: story_id.to_string(),
story_name: None, story_name: String::new(),
from_stage: "1_backlog".to_string(), from_stage: "1_backlog".to_string(),
to_stage: "2_current".to_string(), to_stage: "2_current".to_string(),
} }
@@ -249,7 +249,7 @@ mod tests {
for i in 0..5u32 { for i in 0..5u32 {
bc.publish(StatusEvent::MergeFailure { bc.publish(StatusEvent::MergeFailure {
story_id: format!("story_{i}"), story_id: format!("story_{i}"),
story_name: None, story_name: String::new(),
reason: "test".to_string(), reason: "test".to_string(),
}); });
} }
@@ -299,7 +299,7 @@ mod tests {
for i in 0..n { for i in 0..n {
bc.publish(StatusEvent::MergeFailure { bc.publish(StatusEvent::MergeFailure {
story_id: format!("other-{i}"), story_id: format!("other-{i}"),
story_name: None, story_name: String::new(),
reason: format!("reason-{i}"), reason: format!("reason-{i}"),
}); });
} }
+2 -2
View File
@@ -176,8 +176,8 @@ pub fn apply_remote_op(op: SignedOp) -> bool {
}; };
let from_stage = old_stage_str.and_then(|s| crate::pipeline_state::Stage::from_dir(&s)); let from_stage = old_stage_str.and_then(|s| crate::pipeline_state::Stage::from_dir(&s));
let name = match state.crdt.doc.items[idx].name.view() { let name = match state.crdt.doc.items[idx].name.view() {
JsonValue::String(s) if !s.is_empty() => Some(s), JsonValue::String(s) => s.clone(),
_ => None, _ => String::new(),
}; };
emit_event(CrdtEvent { emit_event(CrdtEvent {
story_id: sid.clone(), story_id: sid.clone(),
+9 -9
View File
@@ -15,8 +15,8 @@ pub struct CrdtEvent {
pub from_stage: Option<crate::pipeline_state::Stage>, pub from_stage: Option<crate::pipeline_state::Stage>,
/// The stage the item is now in. /// The stage the item is now in.
pub to_stage: crate::pipeline_state::Stage, pub to_stage: crate::pipeline_state::Stage,
/// Human-readable story name from the CRDT document. /// Human-readable story name from the CRDT document (empty string when unset).
pub name: Option<String>, pub name: String,
} }
// ── CRDT document types ────────────────────────────────────────────── // ── CRDT document types ──────────────────────────────────────────────
@@ -538,7 +538,7 @@ mod tests {
story_id: "42_story_foo".to_string(), story_id: "42_story_foo".to_string(),
from_stage: Some(crate::pipeline_state::Stage::Backlog), from_stage: Some(crate::pipeline_state::Stage::Backlog),
to_stage: crate::pipeline_state::Stage::Coding, to_stage: crate::pipeline_state::Stage::Coding,
name: Some("Foo Feature".to_string()), name: "Foo Feature".to_string(),
}; };
assert_eq!(evt.story_id, "42_story_foo"); assert_eq!(evt.story_id, "42_story_foo");
assert!(matches!( assert!(matches!(
@@ -546,7 +546,7 @@ mod tests {
Some(crate::pipeline_state::Stage::Backlog) Some(crate::pipeline_state::Stage::Backlog)
)); ));
assert!(matches!(evt.to_stage, crate::pipeline_state::Stage::Coding)); assert!(matches!(evt.to_stage, crate::pipeline_state::Stage::Coding));
assert_eq!(evt.name.as_deref(), Some("Foo Feature")); assert_eq!(evt.name, "Foo Feature");
} }
#[test] #[test]
@@ -555,12 +555,12 @@ mod tests {
story_id: "10_story_bar".to_string(), story_id: "10_story_bar".to_string(),
from_stage: None, from_stage: None,
to_stage: crate::pipeline_state::Stage::Backlog, to_stage: crate::pipeline_state::Stage::Backlog,
name: None, name: String::new(),
}; };
let cloned = evt.clone(); let cloned = evt.clone();
assert_eq!(cloned.story_id, "10_story_bar"); assert_eq!(cloned.story_id, "10_story_bar");
assert!(cloned.from_stage.is_none()); assert!(cloned.from_stage.is_none());
assert!(cloned.name.is_none()); assert!(cloned.name.is_empty());
} }
#[test] #[test]
@@ -573,7 +573,7 @@ mod tests {
story_id: "99_story_noop".to_string(), story_id: "99_story_noop".to_string(),
from_stage: None, from_stage: None,
to_stage: crate::pipeline_state::Stage::Backlog, to_stage: crate::pipeline_state::Stage::Backlog,
name: None, name: String::new(),
}); });
} }
@@ -695,7 +695,7 @@ mod tests {
story_id: "70_story_broadcast".to_string(), story_id: "70_story_broadcast".to_string(),
from_stage: Some(Stage::Backlog), from_stage: Some(Stage::Backlog),
to_stage: Stage::Coding, to_stage: Stage::Coding,
name: Some("Broadcast Test".to_string()), name: "Broadcast Test".to_string(),
}; };
tx.send(evt).unwrap(); tx.send(evt).unwrap();
@@ -703,6 +703,6 @@ mod tests {
assert_eq!(received.story_id, "70_story_broadcast"); assert_eq!(received.story_id, "70_story_broadcast");
assert!(matches!(received.from_stage, Some(Stage::Backlog))); assert!(matches!(received.from_stage, Some(Stage::Backlog)));
assert!(matches!(received.to_stage, Stage::Coding)); assert!(matches!(received.to_stage, Stage::Coding));
assert_eq!(received.name.as_deref(), Some("Broadcast Test")); assert_eq!(received.name, "Broadcast Test");
} }
} }
+3 -3
View File
@@ -273,8 +273,8 @@ pub fn write_item(
if stage_changed { if stage_changed {
// Read the current name from the CRDT document for the event. // Read the current name from the CRDT document for the event.
let current_name = match state.crdt.doc.items[idx].name.view() { let current_name = match state.crdt.doc.items[idx].name.view() {
JsonValue::String(s) if !s.is_empty() => Some(s), JsonValue::String(s) if !s.is_empty() => s,
_ => None, _ => String::new(),
}; };
// Storage seam: convert the old raw CRDT stage string to a typed Stage. // Storage seam: convert the old raw CRDT stage string to a typed Stage.
let from_stage = old_stage.and_then(|s| Stage::from_dir(&s)); let from_stage = old_stage.and_then(|s| Stage::from_dir(&s));
@@ -336,7 +336,7 @@ pub fn write_item(
story_id: story_id.to_string(), story_id: story_id.to_string(),
from_stage: None, from_stage: None,
to_stage: stage.clone(), to_stage: stage.clone(),
name: name.map(String::from), name: name.unwrap_or("").to_string(),
}); });
} }
} }
+5 -5
View File
@@ -203,7 +203,7 @@ mod tests {
fn status_to_stored_stage_transition() { fn status_to_stored_stage_transition() {
let ev = StatusEvent::StageTransition { let ev = StatusEvent::StageTransition {
story_id: "42".into(), story_id: "42".into(),
story_name: None, story_name: String::new(),
from_stage: "1_backlog".into(), from_stage: "1_backlog".into(),
to_stage: "2_current".into(), to_stage: "2_current".into(),
}; };
@@ -217,7 +217,7 @@ mod tests {
fn status_to_stored_merge_failure() { fn status_to_stored_merge_failure() {
let ev = StatusEvent::MergeFailure { let ev = StatusEvent::MergeFailure {
story_id: "7".into(), story_id: "7".into(),
story_name: None, story_name: String::new(),
reason: "conflict".into(), reason: "conflict".into(),
}; };
let stored = status_to_stored(ev).unwrap(); let stored = status_to_stored(ev).unwrap();
@@ -228,7 +228,7 @@ mod tests {
fn status_to_stored_story_blocked() { fn status_to_stored_story_blocked() {
let ev = StatusEvent::StoryBlocked { let ev = StatusEvent::StoryBlocked {
story_id: "3".into(), story_id: "3".into(),
story_name: None, story_name: String::new(),
reason: "retry limit".into(), reason: "retry limit".into(),
}; };
let stored = status_to_stored(ev).unwrap(); let stored = status_to_stored(ev).unwrap();
@@ -239,7 +239,7 @@ mod tests {
fn status_to_stored_rate_limit_warning_is_none() { fn status_to_stored_rate_limit_warning_is_none() {
let ev = StatusEvent::RateLimitWarning { let ev = StatusEvent::RateLimitWarning {
story_id: "1".into(), story_id: "1".into(),
story_name: None, story_name: String::new(),
agent_name: "coder".into(), agent_name: "coder".into(),
}; };
assert!(status_to_stored(ev).is_none()); assert!(status_to_stored(ev).is_none());
@@ -249,7 +249,7 @@ mod tests {
fn status_to_stored_rate_limit_hard_block_is_none() { fn status_to_stored_rate_limit_hard_block_is_none() {
let ev = StatusEvent::RateLimitHardBlock { let ev = StatusEvent::RateLimitHardBlock {
story_id: "2".into(), story_id: "2".into(),
story_name: None, story_name: String::new(),
agent_name: "coder".into(), agent_name: "coder".into(),
reset_at: chrono::Utc::now(), reset_at: chrono::Utc::now(),
}; };
+3 -1
View File
@@ -37,7 +37,9 @@ pub(crate) fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result<Str
let contents = crate::http::workflow::read_story_content(&root, story_id) let contents = crate::http::workflow::read_story_content(&root, story_id)
.map_err(|_| format!("Story file not found: {story_id}.md"))?; .map_err(|_| format!("Story file not found: {story_id}.md"))?;
let story_name = crate::crdt_state::read_item(story_id).map(|v| v.name().to_string()); let story_name = crate::crdt_state::read_item(story_id)
.map(|v| v.name().to_string())
.unwrap_or_default();
let todos = parse_unchecked_todos(&contents); let todos = parse_unchecked_todos(&contents);
serde_json::to_string_pretty(&json!({ serde_json::to_string_pretty(&json!({
@@ -18,7 +18,15 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
// escape hatch; every known key is recognised and routed below, and any // escape hatch; every known key is recognised and routed below, and any
// unknown key is rejected loudly rather than silently flushed to disk. // unknown key is rejected loudly rather than silently flushed to disk.
if let Some(name) = args.get("name").and_then(|v| v.as_str()) { if let Some(name) = args.get("name").and_then(|v| v.as_str()) {
crate::crdt_state::set_name(story_id, Some(name)); if name.trim().is_empty() {
return Err("name must not be empty".to_string());
}
if !crate::crdt_state::set_name(story_id, Some(name)) {
return Err(format!(
"Story '{story_id}' not found in CRDT — name was not updated. \
The story may not exist or may not yet be registered."
));
}
} }
if let Some(agent) = args.get("agent").and_then(|v| v.as_str()) { if let Some(agent) = args.get("agent").and_then(|v| v.as_str()) {
crate::crdt_state::set_agent(story_id, agent.parse::<crate::config::AgentName>().ok()); crate::crdt_state::set_agent(story_id, agent.parse::<crate::config::AgentName>().ok());
@@ -38,8 +46,15 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
); );
} }
"name" => { "name" => {
let s = value.as_str().filter(|s| !s.is_empty()); let s = value.as_str().filter(|s| !s.trim().is_empty());
crate::crdt_state::set_name(story_id, s); if s.is_none() {
return Err("name must not be empty".to_string());
}
if !crate::crdt_state::set_name(story_id, s) {
return Err(format!(
"Story '{story_id}' not found in CRDT — name was not updated."
));
}
} }
"agent" => { "agent" => {
let parsed = value let parsed = value
+5 -13
View File
@@ -18,7 +18,7 @@ pub struct AgentAssignment {
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct UpcomingStory { pub struct UpcomingStory {
pub story_id: String, pub story_id: String,
pub name: Option<String>, pub name: String,
pub error: Option<String>, pub error: Option<String>,
/// Merge failure reason persisted to front matter by the mergemaster agent. /// Merge failure reason persisted to front matter by the mergemaster agent.
pub merge_failure: Option<String>, pub merge_failure: Option<String>,
@@ -123,11 +123,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
let story = UpcomingStory { let story = UpcomingStory {
story_id: sid.clone(), story_id: sid.clone(),
name: if item.name.is_empty() { name: item.name.clone(),
None
} else {
Some(item.name.clone())
},
error: None, error: None,
merge_failure, merge_failure,
agent, agent,
@@ -248,11 +244,7 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result<Vec<UpcomingStory>, St
let epic_id = crate::crdt_state::read_item(sid).and_then(|v| v.epic()); let epic_id = crate::crdt_state::read_item(sid).and_then(|v| v.epic());
UpcomingStory { UpcomingStory {
story_id: item.story_id.0.clone(), story_id: item.story_id.0.clone(),
name: if item.name.is_empty() { name: item.name,
None
} else {
Some(item.name)
},
error: None, error: None,
merge_failure: None, merge_failure: None,
agent: None, agent: None,
@@ -546,12 +538,12 @@ mod tests {
.iter() .iter()
.find(|s| s.story_id == "9870_story_view_upcoming") .find(|s| s.story_id == "9870_story_view_upcoming")
.unwrap(); .unwrap();
assert_eq!(s1.name.as_deref(), Some("View Upcoming")); assert_eq!(s1.name, "View Upcoming");
let s2 = stories let s2 = stories
.iter() .iter()
.find(|s| s.story_id == "9871_story_worktree") .find(|s| s.story_id == "9871_story_worktree")
.unwrap(); .unwrap();
assert_eq!(s2.name.as_deref(), Some("Worktree Orchestration")); assert_eq!(s2.name, "Worktree Orchestration");
} }
#[test] #[test]
+3 -3
View File
@@ -363,7 +363,7 @@ async fn ws_handler_forwards_status_events_as_status_update() {
// Use a story ID unique enough that genuine server logs won't match it. // Use a story ID unique enough that genuine server logs won't match it.
ctx.services.status.publish(StatusEvent::StageTransition { ctx.services.status.publish(StatusEvent::StageTransition {
story_id: "77_story_status_fwd_test".to_string(), story_id: "77_story_status_fwd_test".to_string(),
story_name: Some("StatusFwdTest".to_string()), story_name: "StatusFwdTest".to_string(),
from_stage: "1_backlog".to_string(), from_stage: "1_backlog".to_string(),
to_stage: "2_current".to_string(), to_stage: "2_current".to_string(),
}); });
@@ -396,7 +396,7 @@ async fn ws_handler_multi_project_status_isolation() {
let needle = "ProjAIsolation7734"; let needle = "ProjAIsolation7734";
ctx_a.services.status.publish(StatusEvent::MergeFailure { ctx_a.services.status.publish(StatusEvent::MergeFailure {
story_id: "10_story_proj_a_isolation".to_string(), story_id: "10_story_proj_a_isolation".to_string(),
story_name: Some(needle.to_string()), story_name: needle.to_string(),
reason: "conflict".to_string(), reason: "conflict".to_string(),
}); });
@@ -453,7 +453,7 @@ async fn ws_handler_status_consumer_disabled_via_config() {
let needle = "DisabledConsumer9182"; let needle = "DisabledConsumer9182";
ctx.services.status.publish(StatusEvent::StoryBlocked { ctx.services.status.publish(StatusEvent::StoryBlocked {
story_id: "55_story_disabled_consumer".to_string(), story_id: "55_story_disabled_consumer".to_string(),
story_name: Some(needle.to_string()), story_name: needle.to_string(),
reason: "test".to_string(), reason: "test".to_string(),
}); });
+6 -3
View File
@@ -58,7 +58,7 @@ impl std::fmt::Display for Error {
pub struct WorkItemContent { pub struct WorkItemContent {
pub content: String, pub content: String,
pub stage: crate::pipeline_state::Stage, pub stage: crate::pipeline_state::Stage,
pub name: Option<String>, pub name: String,
pub agent: Option<crate::config::AgentName>, pub agent: Option<crate::config::AgentName>,
} }
@@ -162,7 +162,10 @@ pub fn get_work_item_content(
let filename = format!("{story_id}.md"); let filename = format!("{story_id}.md");
let crdt_view = crate::crdt_state::read_item(story_id); let crdt_view = crate::crdt_state::read_item(story_id);
let crdt_name = crdt_view.as_ref().map(|v| v.name().to_string()); let crdt_name = crdt_view
.as_ref()
.map(|v| v.name().to_string())
.unwrap_or_default();
let crdt_agent = crdt_view.as_ref().and_then(|v| v.agent()); let crdt_agent = crdt_view.as_ref().and_then(|v| v.agent());
for (stage_dir, stage) in &stages { for (stage_dir, stage) in &stages {
@@ -320,7 +323,7 @@ max_budget_usd = 5.0
let item = get_work_item_content(tmp.path(), "42_story_foo").unwrap(); let item = get_work_item_content(tmp.path(), "42_story_foo").unwrap();
assert!(item.content.contains("Some content.")); assert!(item.content.contains("Some content."));
assert_eq!(item.stage, crate::pipeline_state::Stage::Backlog); assert_eq!(item.stage, crate::pipeline_state::Stage::Backlog);
assert_eq!(item.name, Some("Foo Story".to_string())); assert_eq!(item.name, "Foo Story");
} }
#[test] #[test]
+3 -3
View File
@@ -26,19 +26,19 @@ pub fn format_gateway_event(project_name: &str, event: &StoredEvent) -> (String,
} => { } => {
let from_typed = Stage::from_dir(from_stage).unwrap_or(Stage::Upcoming); let from_typed = Stage::from_dir(from_stage).unwrap_or(Stage::Upcoming);
let to_typed = Stage::from_dir(to_stage).unwrap_or(Stage::Upcoming); let to_typed = Stage::from_dir(to_stage).unwrap_or(Stage::Upcoming);
let (plain, html) = format_stage_notification(story_id, None, &from_typed, &to_typed); let (plain, html) = format_stage_notification(story_id, "", &from_typed, &to_typed);
(format!("{prefix}{plain}"), format!("{prefix}{html}")) (format!("{prefix}{plain}"), format!("{prefix}{html}"))
} }
StoredEvent::MergeFailure { StoredEvent::MergeFailure {
story_id, reason, .. story_id, reason, ..
} => { } => {
let (plain, html) = format_error_notification(story_id, None, reason); let (plain, html) = format_error_notification(story_id, "", reason);
(format!("{prefix}{plain}"), format!("{prefix}{html}")) (format!("{prefix}{plain}"), format!("{prefix}{html}"))
} }
StoredEvent::StoryBlocked { StoredEvent::StoryBlocked {
story_id, reason, .. story_id, reason, ..
} => { } => {
let (plain, html) = format_blocked_notification(story_id, None, reason); let (plain, html) = format_blocked_notification(story_id, "", reason);
(format!("{prefix}{plain}"), format!("{prefix}{html}")) (format!("{prefix}{plain}"), format!("{prefix}{html}"))
} }
} }
+68 -58
View File
@@ -30,12 +30,16 @@ pub fn stage_display_name(stage: &Stage) -> &'static str {
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
pub fn format_stage_notification( pub fn format_stage_notification(
item_id: &str, item_id: &str,
story_name: Option<&str>, story_name: &str,
from_stage: &Stage, from_stage: &Stage,
to_stage: &Stage, to_stage: &Stage,
) -> (String, String) { ) -> (String, String) {
let number = extract_item_number(item_id).unwrap_or(item_id); let number = extract_item_number(item_id).unwrap_or(item_id);
let effective_name = story_name.filter(|n| !n.is_empty()); 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_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default();
let name_html = effective_name let name_html = effective_name
.map(|n| format!("<em>{n}</em> ")) .map(|n| format!("<em>{n}</em> "))
@@ -61,11 +65,15 @@ pub fn format_stage_notification(
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
pub fn format_error_notification( pub fn format_error_notification(
item_id: &str, item_id: &str,
story_name: Option<&str>, story_name: &str,
reason: &str, reason: &str,
) -> (String, String) { ) -> (String, String) {
let number = extract_item_number(item_id).unwrap_or(item_id); let number = extract_item_number(item_id).unwrap_or(item_id);
let effective_name = story_name.filter(|n| !n.is_empty()); 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_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default();
let name_html = effective_name let name_html = effective_name
.map(|n| format!("<em>{n}</em> ")) .map(|n| format!("<em>{n}</em> "))
@@ -81,11 +89,15 @@ pub fn format_error_notification(
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
pub fn format_blocked_notification( pub fn format_blocked_notification(
item_id: &str, item_id: &str,
story_name: Option<&str>, story_name: &str,
reason: &str, reason: &str,
) -> (String, String) { ) -> (String, String) {
let number = extract_item_number(item_id).unwrap_or(item_id); let number = extract_item_number(item_id).unwrap_or(item_id);
let effective_name = story_name.filter(|n| !n.is_empty()); 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_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default();
let name_html = effective_name let name_html = effective_name
.map(|n| format!("<em>{n}</em> ")) .map(|n| format!("<em>{n}</em> "))
@@ -102,11 +114,15 @@ pub fn format_blocked_notification(
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
pub fn format_rate_limit_notification( pub fn format_rate_limit_notification(
item_id: &str, item_id: &str,
story_name: Option<&str>, story_name: &str,
agent_name: &str, agent_name: &str,
) -> (String, String) { ) -> (String, String) {
let number = extract_item_number(item_id).unwrap_or(item_id); let number = extract_item_number(item_id).unwrap_or(item_id);
let effective_name = story_name.filter(|n| !n.is_empty()); 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_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default();
let name_html = effective_name let name_html = effective_name
.map(|n| format!("<em>{n}</em> ")) .map(|n| format!("<em>{n}</em> "))
@@ -149,11 +165,15 @@ pub fn format_oauth_accounts_exhausted(earliest_reset_msg: &str) -> (String, Str
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
pub fn format_agent_started_notification( pub fn format_agent_started_notification(
item_id: &str, item_id: &str,
story_name: Option<&str>, story_name: &str,
agent_name: &str, agent_name: &str,
) -> (String, String) { ) -> (String, String) {
let number = extract_item_number(item_id).unwrap_or(item_id); let number = extract_item_number(item_id).unwrap_or(item_id);
let effective_name = story_name.filter(|n| !n.is_empty()); 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_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default();
let name_html = effective_name let name_html = effective_name
.map(|n| format!("<em>{n}</em> ")) .map(|n| format!("<em>{n}</em> "))
@@ -171,12 +191,16 @@ pub fn format_agent_started_notification(
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
pub fn format_agent_completed_notification( pub fn format_agent_completed_notification(
item_id: &str, item_id: &str,
story_name: Option<&str>, story_name: &str,
agent_name: &str, agent_name: &str,
success: bool, success: bool,
) -> (String, String) { ) -> (String, String) {
let number = extract_item_number(item_id).unwrap_or(item_id); let number = extract_item_number(item_id).unwrap_or(item_id);
let effective_name = story_name.filter(|n| !n.is_empty()); 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_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default();
let name_html = effective_name let name_html = effective_name
.map(|n| format!("<em>{n}</em> ")) .map(|n| format!("<em>{n}</em> "))
@@ -243,7 +267,7 @@ mod tests {
fn format_notification_done_stage_includes_party_emoji() { fn format_notification_done_stage_includes_party_emoji() {
let (plain, html) = format_stage_notification( let (plain, html) = format_stage_notification(
"353_story_done", "353_story_done",
Some("Done Story"), "Done Story",
&merge_stage(), &merge_stage(),
&done_stage(), &done_stage(),
); );
@@ -261,7 +285,7 @@ mod tests {
fn format_notification_non_done_stage_has_no_emoji() { fn format_notification_non_done_stage_has_no_emoji() {
let (plain, _html) = format_stage_notification( let (plain, _html) = format_stage_notification(
"42_story_thing", "42_story_thing",
Some("Some Story"), "Some Story",
&Stage::Backlog, &Stage::Backlog,
&Stage::Coding, &Stage::Coding,
); );
@@ -272,7 +296,7 @@ mod tests {
fn format_notification_with_story_name() { fn format_notification_with_story_name() {
let (plain, html) = format_stage_notification( let (plain, html) = format_stage_notification(
"261_story_bot_notifications", "261_story_bot_notifications",
Some("Bot notifications"), "Bot notifications",
&Stage::Upcoming, &Stage::Upcoming,
&Stage::Coding, &Stage::Coding,
); );
@@ -289,19 +313,15 @@ mod tests {
#[test] #[test]
fn format_stage_notification_without_story_name_falls_back_to_number() { fn format_stage_notification_without_story_name_falls_back_to_number() {
let (plain, html) = let (plain, html) =
format_stage_notification("42_bug_fix_thing", None, &Stage::Coding, &Stage::Qa); format_stage_notification("42_bug_fix_thing", "", &Stage::Coding, &Stage::Qa);
assert_eq!(plain, "#42 \u{2014} Current \u{2192} QA"); assert_eq!(plain, "#42 \u{2014} Current \u{2192} QA");
assert_eq!(html, "<strong>#42</strong> \u{2014} Current \u{2192} QA"); assert_eq!(html, "<strong>#42</strong> \u{2014} Current \u{2192} QA");
} }
#[test] #[test]
fn format_notification_non_numeric_id_uses_full_id() { fn format_notification_non_numeric_id_uses_full_id() {
let (plain, _html) = format_stage_notification( let (plain, _html) =
"abc_story_thing", format_stage_notification("abc_story_thing", "Some Story", &Stage::Qa, &merge_stage());
Some("Some Story"),
&Stage::Qa,
&merge_stage(),
);
assert_eq!( assert_eq!(
plain, plain,
"#abc_story_thing Some Story \u{2014} QA \u{2192} Merge" "#abc_story_thing Some Story \u{2014} QA \u{2192} Merge"
@@ -312,14 +332,14 @@ mod tests {
fn format_stage_notification_long_name_is_preserved() { fn format_stage_notification_long_name_is_preserved() {
let long_name = "A".repeat(300); let long_name = "A".repeat(300);
let (plain, _html) = let (plain, _html) =
format_stage_notification("1_story_long", Some(&long_name), &Stage::Coding, &Stage::Qa); format_stage_notification("1_story_long", &long_name, &Stage::Coding, &Stage::Qa);
assert!(plain.contains(&long_name)); assert!(plain.contains(&long_name));
} }
#[test] #[test]
fn format_stage_notification_empty_story_name_falls_back_to_number() { fn format_stage_notification_empty_story_name_falls_back_to_number() {
let (plain, html) = let (plain, html) =
format_stage_notification("42_story_empty", Some(""), &Stage::Coding, &Stage::Qa); format_stage_notification("42_story_empty", "", &Stage::Coding, &Stage::Qa);
assert_eq!(plain, "#42 \u{2014} Current \u{2192} QA"); assert_eq!(plain, "#42 \u{2014} Current \u{2192} QA");
assert_eq!(html, "<strong>#42</strong> \u{2014} Current \u{2192} QA"); assert_eq!(html, "<strong>#42</strong> \u{2014} Current \u{2192} QA");
} }
@@ -328,7 +348,7 @@ mod tests {
fn format_stage_notification_unicode_name() { fn format_stage_notification_unicode_name() {
let (plain, html) = format_stage_notification( let (plain, html) = format_stage_notification(
"7_story_i18n", "7_story_i18n",
Some("Ünïcödé Ñämé 🎉"), "Ünïcödé Ñämé 🎉",
&Stage::Qa, &Stage::Qa,
&merge_stage(), &merge_stage(),
); );
@@ -342,7 +362,7 @@ mod tests {
fn format_error_notification_with_story_name() { fn format_error_notification_with_story_name() {
let (plain, html) = format_error_notification( let (plain, html) = format_error_notification(
"262_story_bot_errors", "262_story_bot_errors",
Some("Bot error notifications"), "Bot error notifications",
"merge conflict in src/main.rs", "merge conflict in src/main.rs",
); );
assert_eq!( assert_eq!(
@@ -357,7 +377,7 @@ mod tests {
#[test] #[test]
fn format_error_notification_without_story_name_falls_back_to_number() { fn format_error_notification_without_story_name_falls_back_to_number() {
let (plain, html) = format_error_notification("42_bug_fix_thing", None, "tests failed"); let (plain, html) = format_error_notification("42_bug_fix_thing", "", "tests failed");
assert_eq!(plain, "\u{274c} #42 \u{2014} tests failed"); assert_eq!(plain, "\u{274c} #42 \u{2014} tests failed");
assert_eq!(html, "\u{274c} <strong>#42</strong> \u{2014} tests failed"); assert_eq!(html, "\u{274c} <strong>#42</strong> \u{2014} tests failed");
} }
@@ -365,7 +385,7 @@ mod tests {
#[test] #[test]
fn format_error_notification_non_numeric_id_uses_full_id() { fn format_error_notification_non_numeric_id_uses_full_id() {
let (plain, _html) = let (plain, _html) =
format_error_notification("abc_story_thing", Some("Some Story"), "clippy errors"); format_error_notification("abc_story_thing", "Some Story", "clippy errors");
assert_eq!( assert_eq!(
plain, plain,
"\u{274c} #abc_story_thing Some Story \u{2014} clippy errors" "\u{274c} #abc_story_thing Some Story \u{2014} clippy errors"
@@ -375,21 +395,19 @@ mod tests {
#[test] #[test]
fn format_error_notification_long_reason_preserved() { fn format_error_notification_long_reason_preserved() {
let long_reason = "x".repeat(500); let long_reason = "x".repeat(500);
let (plain, _html) = format_error_notification("1_story_foo", None, &long_reason); let (plain, _html) = format_error_notification("1_story_foo", "", &long_reason);
assert!(plain.contains(&long_reason)); assert!(plain.contains(&long_reason));
} }
#[test] #[test]
fn format_error_notification_unicode_reason() { fn format_error_notification_unicode_reason() {
let (plain, _html) = let (plain, _html) = format_error_notification("5_story_foo", "Foo", "错误:合并冲突");
format_error_notification("5_story_foo", Some("Foo"), "错误:合并冲突");
assert!(plain.contains("错误:合并冲突")); assert!(plain.contains("错误:合并冲突"));
} }
#[test] #[test]
fn format_error_notification_empty_story_name_falls_back_to_number() { fn format_error_notification_empty_story_name_falls_back_to_number() {
let (plain, _html) = let (plain, _html) = format_error_notification("42_bug_fix_thing", "", "tests failed");
format_error_notification("42_bug_fix_thing", Some(""), "tests failed");
assert_eq!(plain, "\u{274c} #42 \u{2014} tests failed"); assert_eq!(plain, "\u{274c} #42 \u{2014} tests failed");
} }
@@ -399,7 +417,7 @@ mod tests {
fn format_blocked_notification_with_story_name() { fn format_blocked_notification_with_story_name() {
let (plain, html) = format_blocked_notification( let (plain, html) = format_blocked_notification(
"425_story_blocking_reason", "425_story_blocking_reason",
Some("Blocking Reason Story"), "Blocking Reason Story",
"Retry limit exceeded (3/3) at coder stage", "Retry limit exceeded (3/3) at coder stage",
); );
assert_eq!( assert_eq!(
@@ -414,7 +432,7 @@ mod tests {
#[test] #[test]
fn format_blocked_notification_falls_back_to_number() { fn format_blocked_notification_falls_back_to_number() {
let (plain, html) = format_blocked_notification("42_story_thing", None, "empty diff"); let (plain, html) = format_blocked_notification("42_story_thing", "", "empty diff");
assert_eq!(plain, "\u{1f6ab} #42 \u{2014} BLOCKED: empty diff"); assert_eq!(plain, "\u{1f6ab} #42 \u{2014} BLOCKED: empty diff");
assert_eq!( assert_eq!(
html, html,
@@ -424,13 +442,13 @@ mod tests {
#[test] #[test]
fn format_blocked_notification_empty_story_name_falls_back_to_number() { fn format_blocked_notification_empty_story_name_falls_back_to_number() {
let (plain, _html) = format_blocked_notification("42_story_thing", Some(""), "empty diff"); let (plain, _html) = format_blocked_notification("42_story_thing", "", "empty diff");
assert_eq!(plain, "\u{1f6ab} #42 \u{2014} BLOCKED: empty diff"); assert_eq!(plain, "\u{1f6ab} #42 \u{2014} BLOCKED: empty diff");
} }
#[test] #[test]
fn format_blocked_notification_unicode_reason() { fn format_blocked_notification_unicode_reason() {
let (plain, _html) = format_blocked_notification("3_story_x", Some("X"), "理由:空の差分"); let (plain, _html) = format_blocked_notification("3_story_x", "X", "理由:空の差分");
assert!(plain.contains("BLOCKED: 理由:空の差分")); assert!(plain.contains("BLOCKED: 理由:空の差分"));
} }
@@ -439,7 +457,7 @@ mod tests {
#[test] #[test]
fn format_rate_limit_notification_includes_agent_and_story() { fn format_rate_limit_notification_includes_agent_and_story() {
let (plain, html) = let (plain, html) =
format_rate_limit_notification("365_story_my_feature", Some("My Feature"), "coder-2"); format_rate_limit_notification("365_story_my_feature", "My Feature", "coder-2");
assert_eq!( assert_eq!(
plain, plain,
"\u{26a0}\u{fe0f} #365 My Feature \u{2014} coder-2 hit an API rate limit" "\u{26a0}\u{fe0f} #365 My Feature \u{2014} coder-2 hit an API rate limit"
@@ -452,7 +470,7 @@ mod tests {
#[test] #[test]
fn format_rate_limit_notification_falls_back_to_number() { fn format_rate_limit_notification_falls_back_to_number() {
let (plain, html) = format_rate_limit_notification("42_story_thing", None, "coder-1"); let (plain, html) = format_rate_limit_notification("42_story_thing", "", "coder-1");
assert_eq!( assert_eq!(
plain, plain,
"\u{26a0}\u{fe0f} #42 \u{2014} coder-1 hit an API rate limit" "\u{26a0}\u{fe0f} #42 \u{2014} coder-1 hit an API rate limit"
@@ -465,7 +483,7 @@ mod tests {
#[test] #[test]
fn format_rate_limit_notification_empty_story_name_falls_back_to_number() { fn format_rate_limit_notification_empty_story_name_falls_back_to_number() {
let (plain, _html) = format_rate_limit_notification("42_story_thing", Some(""), "coder-1"); let (plain, _html) = format_rate_limit_notification("42_story_thing", "", "coder-1");
assert_eq!( assert_eq!(
plain, plain,
"\u{26a0}\u{fe0f} #42 \u{2014} coder-1 hit an API rate limit" "\u{26a0}\u{fe0f} #42 \u{2014} coder-1 hit an API rate limit"
@@ -474,7 +492,7 @@ mod tests {
#[test] #[test]
fn format_rate_limit_notification_unicode_agent_name() { fn format_rate_limit_notification_unicode_agent_name() {
let (plain, _html) = format_rate_limit_notification("9_story_foo", Some("Foo"), "агент-1"); let (plain, _html) = format_rate_limit_notification("9_story_foo", "Foo", "агент-1");
assert!(plain.contains("агент-1")); assert!(plain.contains("агент-1"));
assert!(plain.contains("hit an API rate limit")); assert!(plain.contains("hit an API rate limit"));
} }
@@ -484,7 +502,7 @@ mod tests {
#[test] #[test]
fn format_agent_started_notification_with_story_name() { fn format_agent_started_notification_with_story_name() {
let (plain, html) = let (plain, html) =
format_agent_started_notification("42_story_foo", Some("My Feature"), "coder-1"); format_agent_started_notification("42_story_foo", "My Feature", "coder-1");
assert_eq!(plain, "\u{1F916} #42 My Feature \u{2014} coder-1 started"); assert_eq!(plain, "\u{1F916} #42 My Feature \u{2014} coder-1 started");
assert_eq!( assert_eq!(
html, html,
@@ -494,7 +512,7 @@ mod tests {
#[test] #[test]
fn format_agent_started_notification_falls_back_to_number() { fn format_agent_started_notification_falls_back_to_number() {
let (plain, html) = format_agent_started_notification("42_story_foo", None, "coder-1"); let (plain, html) = format_agent_started_notification("42_story_foo", "", "coder-1");
assert_eq!(plain, "\u{1F916} #42 \u{2014} coder-1 started"); assert_eq!(plain, "\u{1F916} #42 \u{2014} coder-1 started");
assert_eq!( assert_eq!(
html, html,
@@ -504,7 +522,7 @@ mod tests {
#[test] #[test]
fn format_agent_started_notification_empty_name_falls_back_to_number() { fn format_agent_started_notification_empty_name_falls_back_to_number() {
let (plain, _html) = format_agent_started_notification("42_story_foo", Some(""), "coder-1"); let (plain, _html) = format_agent_started_notification("42_story_foo", "", "coder-1");
assert_eq!(plain, "\u{1F916} #42 \u{2014} coder-1 started"); assert_eq!(plain, "\u{1F916} #42 \u{2014} coder-1 started");
} }
@@ -512,12 +530,8 @@ mod tests {
#[test] #[test]
fn format_agent_completed_notification_success_with_story_name() { fn format_agent_completed_notification_success_with_story_name() {
let (plain, html) = format_agent_completed_notification( let (plain, html) =
"42_story_foo", format_agent_completed_notification("42_story_foo", "My Feature", "coder-1", true);
Some("My Feature"),
"coder-1",
true,
);
assert_eq!(plain, "\u{2705} #42 My Feature \u{2014} coder-1 completed"); assert_eq!(plain, "\u{2705} #42 My Feature \u{2014} coder-1 completed");
assert_eq!( assert_eq!(
html, html,
@@ -527,19 +541,15 @@ mod tests {
#[test] #[test]
fn format_agent_completed_notification_failure_with_story_name() { fn format_agent_completed_notification_failure_with_story_name() {
let (plain, _html) = format_agent_completed_notification( let (plain, _html) =
"42_story_foo", format_agent_completed_notification("42_story_foo", "My Feature", "coder-1", false);
Some("My Feature"),
"coder-1",
false,
);
assert_eq!(plain, "\u{274C} #42 My Feature \u{2014} coder-1 failed"); assert_eq!(plain, "\u{274C} #42 My Feature \u{2014} coder-1 failed");
} }
#[test] #[test]
fn format_agent_completed_notification_falls_back_to_number() { fn format_agent_completed_notification_falls_back_to_number() {
let (plain, html) = let (plain, html) =
format_agent_completed_notification("42_story_foo", None, "coder-1", true); format_agent_completed_notification("42_story_foo", "", "coder-1", true);
assert_eq!(plain, "\u{2705} #42 \u{2014} coder-1 completed"); assert_eq!(plain, "\u{2705} #42 \u{2014} coder-1 completed");
assert_eq!( assert_eq!(
html, html,
@@ -550,7 +560,7 @@ mod tests {
#[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) =
format_agent_completed_notification("42_story_foo", Some(""), "coder-1", false); format_agent_completed_notification("42_story_foo", "", "coder-1", false);
assert_eq!(plain, "\u{274C} #42 \u{2014} coder-1 failed"); assert_eq!(plain, "\u{274C} #42 \u{2014} coder-1 failed");
} }
} }
+18 -18
View File
@@ -52,8 +52,7 @@ pub fn spawn_notification_listener(
// Rapid successive transitions for the same item are coalesced: the // Rapid successive transitions for the same item are coalesced: the
// original `from_stage` is kept while `to_stage` is updated to the // original `from_stage` is kept while `to_stage` is updated to the
// latest destination, so only one notification fires for the final stage. // latest destination, so only one notification fires for the final stage.
let mut pending_transitions: HashMap<String, (Stage, Stage, Option<String>)> = let mut pending_transitions: HashMap<String, (Stage, Stage, String)> = HashMap::new();
HashMap::new();
let mut flush_deadline: Option<tokio::time::Instant> = None; let mut flush_deadline: Option<tokio::time::Instant> = None;
// Pending agent-status notifications, keyed by "{story_id}:{event_kind}". // Pending agent-status notifications, keyed by "{story_id}:{event_kind}".
@@ -88,7 +87,7 @@ pub fn spawn_notification_listener(
{ {
let (plain, html) = format_stage_notification( let (plain, html) = format_stage_notification(
&item_id, &item_id,
story_name.as_deref(), &story_name,
&from_stage, &from_stage,
&to_stage, &to_stage,
); );
@@ -139,7 +138,7 @@ pub fn spawn_notification_listener(
{ {
let (plain, html) = format_stage_notification( let (plain, html) = format_stage_notification(
&item_id, &item_id,
story_name.as_deref(), &story_name,
&from_stage, &from_stage,
&to_stage, &to_stage,
); );
@@ -191,8 +190,14 @@ pub fn spawn_notification_listener(
// Look up the story name in the expected stage directory; fall // Look up the story name in the expected stage directory; fall
// back to a full search so stale events still show the name. // back to a full search so stale events still show the name.
let story_name = read_story_name(&project_root, stage, item_id) let story_name = {
.or_else(|| find_story_name_any_stage(&project_root, item_id)); let n = read_story_name(&project_root, stage, item_id);
if n.is_empty() {
find_story_name_any_stage(&project_root, item_id)
} else {
n
}
};
// Buffer the transition. If this item_id is already pending (rapid // Buffer the transition. If this item_id is already pending (rapid
// succession), update the destination stage to the latest while // succession), update the destination stage to the latest while
@@ -201,7 +206,7 @@ pub fn spawn_notification_listener(
.entry(item_id.clone()) .entry(item_id.clone())
.and_modify(|e| { .and_modify(|e| {
e.1 = to_typed.clone(); e.1 = to_typed.clone();
if story_name.is_some() { if !story_name.is_empty() {
e.2 = story_name.clone(); e.2 = story_name.clone();
} }
}) })
@@ -225,8 +230,7 @@ pub fn spawn_notification_listener(
// AC3: include only the first non-empty line of the failure, // AC3: include only the first non-empty line of the failure,
// truncated to ~120 chars. // truncated to ~120 chars.
let snippet = merge_failure_snippet(reason, 120); let snippet = merge_failure_snippet(reason, 120);
let (plain, html) = let (plain, html) = format_error_notification(story_id, &story_name, &snippet);
format_error_notification(story_id, story_name.as_deref(), &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) {
if let Err(e) = transport.send_message(room_id, &plain, &html).await { if let Err(e) = transport.send_message(room_id, &plain, &html).await {
@@ -267,7 +271,7 @@ pub fn spawn_notification_listener(
rate_limit_last_notified.insert(debounce_key, now); rate_limit_last_notified.insert(debounce_key, now);
let story_name = find_story_name_any_stage(&project_root, story_id); let story_name = find_story_name_any_stage(&project_root, story_id);
let (plain, html) = let (plain, html) =
format_rate_limit_notification(story_id, story_name.as_deref(), agent_name); format_rate_limit_notification(story_id, &story_name, agent_name);
slog!("[bot] Sending rate-limit notification: {plain}"); slog!("[bot] Sending rate-limit notification: {plain}");
for room_id in &rooms_for_notification(&get_room_ids) { for room_id in &rooms_for_notification(&get_room_ids) {
if let Err(e) = transport.send_message(room_id, &plain, &html).await { if let Err(e) = transport.send_message(room_id, &plain, &html).await {
@@ -290,8 +294,7 @@ pub fn spawn_notification_listener(
continue; continue;
}; };
let story_name = find_story_name_any_stage(&project_root, story_id); let story_name = find_story_name_any_stage(&project_root, story_id);
let (plain, html) = let (plain, html) = format_blocked_notification(story_id, &story_name, reason);
format_blocked_notification(story_id, story_name.as_deref(), reason);
slog!("[bot] Sending blocked notification: {plain}"); slog!("[bot] Sending blocked notification: {plain}");
for room_id in &rooms_for_notification(&get_room_ids) { for room_id in &rooms_for_notification(&get_room_ids) {
if let Err(e) = transport.send_message(room_id, &plain, &html).await { if let Err(e) = transport.send_message(room_id, &plain, &html).await {
@@ -350,11 +353,8 @@ pub fn spawn_notification_listener(
continue; continue;
}; };
let story_name = find_story_name_any_stage(&project_root, story_id); let story_name = find_story_name_any_stage(&project_root, story_id);
let (plain, html) = format_agent_started_notification( let (plain, html) =
story_id, format_agent_started_notification(story_id, &story_name, agent_name);
story_name.as_deref(),
agent_name,
);
// Buffer with 5s debounce; later arrivals overwrite earlier ones. // Buffer with 5s debounce; later arrivals overwrite earlier ones.
let key = format!("{story_id}:started"); let key = format!("{story_id}:started");
pending_agent_events.insert(key, (plain, html)); pending_agent_events.insert(key, (plain, html));
@@ -375,7 +375,7 @@ pub fn spawn_notification_listener(
let story_name = find_story_name_any_stage(&project_root, story_id); let story_name = find_story_name_any_stage(&project_root, story_id);
let (plain, html) = format_agent_completed_notification( let (plain, html) = format_agent_completed_notification(
story_id, story_id,
story_name.as_deref(), &story_name,
agent_name, agent_name,
success, success,
); );
+9 -5
View File
@@ -16,16 +16,20 @@ mod tests_notifications;
#[cfg(test)] #[cfg(test)]
mod tests_stage; mod tests_stage;
/// Read the story name from the typed CRDT register (story 929). /// Read the story name from the typed CRDT register.
/// ///
/// Returns `None` if the item is not in the CRDT or has no name set. /// Returns the name as a `String`, or an empty string if the item is not in
pub fn read_story_name(_project_root: &Path, _stage: &str, item_id: &str) -> Option<String> { /// the CRDT or has no name set. Callers that display the name unconditionally
crate::crdt_state::read_item(item_id).map(|v| v.name().to_string()) /// should pass this directly to the format_* notification helpers.
pub fn read_story_name(_project_root: &Path, _stage: &str, item_id: &str) -> String {
crate::crdt_state::read_item(item_id)
.map(|v| v.name().to_string())
.unwrap_or_default()
} }
/// Look up a story name from the CRDT content store regardless of stage. /// Look up a story name from the CRDT content store regardless of stage.
/// ///
/// Used for events (like rate-limit warnings) that arrive without a known stage. /// Used for events (like rate-limit warnings) that arrive without a known stage.
fn find_story_name_any_stage(project_root: &Path, item_id: &str) -> Option<String> { fn find_story_name_any_stage(project_root: &Path, item_id: &str) -> String {
read_story_name(project_root, "", item_id) read_story_name(project_root, "", item_id)
} }
@@ -116,7 +116,7 @@ fn read_story_name_reads_from_front_matter() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let name = read_story_name(tmp.path(), "2_current", "9942_story_my_feature"); let name = read_story_name(tmp.path(), "2_current", "9942_story_my_feature");
assert_eq!(name.as_deref(), Some("My Cool Feature")); assert_eq!(name, "My Cool Feature");
} }
#[test] #[test]
@@ -124,7 +124,7 @@ fn read_story_name_returns_none_for_missing_file() {
crate::db::ensure_content_store(); crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let name = read_story_name(tmp.path(), "2_current", "99_story_missing_notif_test"); let name = read_story_name(tmp.path(), "2_current", "99_story_missing_notif_test");
assert_eq!(name, None); assert!(name.is_empty());
} }
#[test] #[test]
@@ -139,7 +139,7 @@ fn read_story_name_returns_none_for_missing_name_field() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let name = read_story_name(tmp.path(), "2_current", "9943_story_no_name"); let name = read_story_name(tmp.path(), "2_current", "9943_story_no_name");
assert_eq!(name, None); assert!(name.is_empty());
} }
// ── Bug 549: synthetic events with from_stage=None must not notify ─────────── // ── Bug 549: synthetic events with from_stage=None must not notify ───────────
+4 -4
View File
@@ -204,7 +204,7 @@ mod tests {
fn make_event(id: &str) -> StatusEvent { fn make_event(id: &str) -> StatusEvent {
StatusEvent::MergeFailure { StatusEvent::MergeFailure {
story_id: id.to_string(), story_id: id.to_string(),
story_name: None, story_name: String::new(),
reason: "test".to_string(), reason: "test".to_string(),
} }
} }
@@ -398,12 +398,12 @@ mod tests {
let items = vec![ let items = vec![
BufferedItem::Event(StatusEvent::MergeFailure { BufferedItem::Event(StatusEvent::MergeFailure {
story_id: "42_story_foo".to_string(), story_id: "42_story_foo".to_string(),
story_name: Some("Foo".to_string()), story_name: "Foo".to_string(),
reason: "conflict".to_string(), reason: "conflict".to_string(),
}), }),
BufferedItem::Event(StatusEvent::StoryBlocked { BufferedItem::Event(StatusEvent::StoryBlocked {
story_id: "7_story_bar".to_string(), story_id: "7_story_bar".to_string(),
story_name: None, story_name: String::new(),
reason: "retry limit".to_string(), reason: "retry limit".to_string(),
}), }),
]; ];
@@ -426,7 +426,7 @@ mod tests {
BufferedItem::Truncated(3), BufferedItem::Truncated(3),
BufferedItem::Event(StatusEvent::MergeFailure { BufferedItem::Event(StatusEvent::MergeFailure {
story_id: "1_story_x".to_string(), story_id: "1_story_x".to_string(),
story_name: None, story_name: String::new(),
reason: "test".to_string(), reason: "test".to_string(),
}), }),
]; ];
+35 -15
View File
@@ -25,7 +25,11 @@ pub fn format_status_event(event: &StatusEvent) -> String {
to_stage, to_stage,
} => { } => {
let number = extract_item_number(story_id).unwrap_or(story_id.as_str()); let number = extract_item_number(story_id).unwrap_or(story_id.as_str());
let name = story_name.as_deref().unwrap_or(story_id.as_str()); let name = if story_name.is_empty() {
story_id.as_str()
} else {
story_name.as_str()
};
let from_typed = Stage::from_dir(from_stage).unwrap_or(Stage::Upcoming); let from_typed = Stage::from_dir(from_stage).unwrap_or(Stage::Upcoming);
let to_typed = Stage::from_dir(to_stage).unwrap_or(Stage::Upcoming); let to_typed = Stage::from_dir(to_stage).unwrap_or(Stage::Upcoming);
let from = stage_display_name(&from_typed); let from = stage_display_name(&from_typed);
@@ -43,7 +47,11 @@ pub fn format_status_event(event: &StatusEvent) -> String {
reason, reason,
} => { } => {
let number = extract_item_number(story_id).unwrap_or(story_id.as_str()); let number = extract_item_number(story_id).unwrap_or(story_id.as_str());
let name = story_name.as_deref().unwrap_or(story_id.as_str()); let name = if story_name.is_empty() {
story_id.as_str()
} else {
story_name.as_str()
};
format!("\u{274c} #{number} {name} \u{2014} {reason}") format!("\u{274c} #{number} {name} \u{2014} {reason}")
} }
StatusEvent::StoryBlocked { StatusEvent::StoryBlocked {
@@ -52,7 +60,11 @@ pub fn format_status_event(event: &StatusEvent) -> String {
reason, reason,
} => { } => {
let number = extract_item_number(story_id).unwrap_or(story_id.as_str()); let number = extract_item_number(story_id).unwrap_or(story_id.as_str());
let name = story_name.as_deref().unwrap_or(story_id.as_str()); let name = if story_name.is_empty() {
story_id.as_str()
} else {
story_name.as_str()
};
format!("\u{1f6ab} #{number} {name} \u{2014} BLOCKED: {reason}") format!("\u{1f6ab} #{number} {name} \u{2014} BLOCKED: {reason}")
} }
StatusEvent::RateLimitWarning { StatusEvent::RateLimitWarning {
@@ -61,7 +73,11 @@ pub fn format_status_event(event: &StatusEvent) -> String {
agent_name, agent_name,
} => { } => {
let number = extract_item_number(story_id).unwrap_or(story_id.as_str()); let number = extract_item_number(story_id).unwrap_or(story_id.as_str());
let name = story_name.as_deref().unwrap_or(story_id.as_str()); let name = if story_name.is_empty() {
story_id.as_str()
} else {
story_name.as_str()
};
format!("\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit") format!("\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit")
} }
StatusEvent::RateLimitHardBlock { StatusEvent::RateLimitHardBlock {
@@ -71,7 +87,11 @@ pub fn format_status_event(event: &StatusEvent) -> String {
reset_at, reset_at,
} => { } => {
let number = extract_item_number(story_id).unwrap_or(story_id.as_str()); let number = extract_item_number(story_id).unwrap_or(story_id.as_str());
let name = story_name.as_deref().unwrap_or(story_id.as_str()); let name = if story_name.is_empty() {
story_id.as_str()
} else {
story_name.as_str()
};
let reset = reset_at.format("%H:%M UTC").to_string(); let reset = reset_at.format("%H:%M UTC").to_string();
format!( format!(
"\u{26d4} #{number} {name} \u{2014} {agent_name} hard rate-limited until {reset}" "\u{26d4} #{number} {name} \u{2014} {agent_name} hard rate-limited until {reset}"
@@ -89,7 +109,7 @@ mod tests {
fn formats_stage_transition_to_done_with_emoji() { fn formats_stage_transition_to_done_with_emoji() {
let event = StatusEvent::StageTransition { let event = StatusEvent::StageTransition {
story_id: "42_story_foo".to_string(), story_id: "42_story_foo".to_string(),
story_name: Some("Foo Story".to_string()), story_name: "Foo Story".to_string(),
from_stage: "merge".to_string(), from_stage: "merge".to_string(),
to_stage: "done".to_string(), to_stage: "done".to_string(),
}; };
@@ -107,7 +127,7 @@ mod tests {
fn formats_stage_transition_no_emoji_for_non_done() { fn formats_stage_transition_no_emoji_for_non_done() {
let event = StatusEvent::StageTransition { let event = StatusEvent::StageTransition {
story_id: "10_story_bar".to_string(), story_id: "10_story_bar".to_string(),
story_name: Some("Bar".to_string()), story_name: "Bar".to_string(),
from_stage: "backlog".to_string(), from_stage: "backlog".to_string(),
to_stage: "coding".to_string(), to_stage: "coding".to_string(),
}; };
@@ -120,7 +140,7 @@ mod tests {
fn formats_stage_transition_falls_back_to_story_id_when_no_name() { fn formats_stage_transition_falls_back_to_story_id_when_no_name() {
let event = StatusEvent::StageTransition { let event = StatusEvent::StageTransition {
story_id: "5_story_x".to_string(), story_id: "5_story_x".to_string(),
story_name: None, story_name: String::new(),
from_stage: "coding".to_string(), from_stage: "coding".to_string(),
to_stage: "qa".to_string(), to_stage: "qa".to_string(),
}; };
@@ -132,7 +152,7 @@ mod tests {
fn formats_merge_failure() { fn formats_merge_failure() {
let event = StatusEvent::MergeFailure { let event = StatusEvent::MergeFailure {
story_id: "7_story_fail".to_string(), story_id: "7_story_fail".to_string(),
story_name: Some("Failing Story".to_string()), story_name: "Failing Story".to_string(),
reason: "conflicts detected".to_string(), reason: "conflicts detected".to_string(),
}; };
let s = format_status_event(&event); let s = format_status_event(&event);
@@ -145,7 +165,7 @@ mod tests {
fn formats_story_blocked() { fn formats_story_blocked() {
let event = StatusEvent::StoryBlocked { let event = StatusEvent::StoryBlocked {
story_id: "8_story_blk".to_string(), story_id: "8_story_blk".to_string(),
story_name: Some("Blocked Story".to_string()), story_name: "Blocked Story".to_string(),
reason: "retry limit exceeded".to_string(), reason: "retry limit exceeded".to_string(),
}; };
let s = format_status_event(&event); let s = format_status_event(&event);
@@ -157,7 +177,7 @@ mod tests {
fn formats_rate_limit_warning() { fn formats_rate_limit_warning() {
let event = StatusEvent::RateLimitWarning { let event = StatusEvent::RateLimitWarning {
story_id: "9_story_rl".to_string(), story_id: "9_story_rl".to_string(),
story_name: Some("RL Story".to_string()), story_name: "RL Story".to_string(),
agent_name: "coder-1".to_string(), agent_name: "coder-1".to_string(),
}; };
let s = format_status_event(&event); let s = format_status_event(&event);
@@ -171,7 +191,7 @@ mod tests {
.unwrap(); .unwrap();
let event = StatusEvent::RateLimitHardBlock { let event = StatusEvent::RateLimitHardBlock {
story_id: "3_story_hb".to_string(), story_id: "3_story_hb".to_string(),
story_name: Some("HB Story".to_string()), story_name: "HB Story".to_string(),
agent_name: "coder-2".to_string(), agent_name: "coder-2".to_string(),
reset_at: reset, reset_at: reset,
}; };
@@ -188,18 +208,18 @@ mod tests {
let events: Vec<StatusEvent> = vec![ let events: Vec<StatusEvent> = vec![
StatusEvent::StageTransition { StatusEvent::StageTransition {
story_id: "1_story_a".to_string(), story_id: "1_story_a".to_string(),
story_name: None, story_name: String::new(),
from_stage: "backlog".to_string(), from_stage: "backlog".to_string(),
to_stage: "coding".to_string(), to_stage: "coding".to_string(),
}, },
StatusEvent::MergeFailure { StatusEvent::MergeFailure {
story_id: "2_story_b".to_string(), story_id: "2_story_b".to_string(),
story_name: None, story_name: String::new(),
reason: "test".to_string(), reason: "test".to_string(),
}, },
StatusEvent::StoryBlocked { StatusEvent::StoryBlocked {
story_id: "3_story_c".to_string(), story_id: "3_story_c".to_string(),
story_name: None, story_name: String::new(),
reason: "limit".to_string(), reason: "limit".to_string(),
}, },
]; ];
+20 -20
View File
@@ -55,8 +55,8 @@ pub enum StatusEvent {
StageTransition { StageTransition {
/// Work item ID (e.g. `"42_story_my_feature"`). /// Work item ID (e.g. `"42_story_my_feature"`).
story_id: String, story_id: String,
/// Human-readable story name, if available. /// Human-readable story name (empty string when unset).
story_name: Option<String>, story_name: String,
/// Pipeline stage directory the item moved FROM (e.g. `"2_current"`). /// Pipeline stage directory the item moved FROM (e.g. `"2_current"`).
from_stage: String, from_stage: String,
/// Pipeline stage directory the item moved TO (e.g. `"3_qa"`). /// Pipeline stage directory the item moved TO (e.g. `"3_qa"`).
@@ -66,8 +66,8 @@ pub enum StatusEvent {
MergeFailure { MergeFailure {
/// Work item ID (e.g. `"42_story_my_feature"`). /// Work item ID (e.g. `"42_story_my_feature"`).
story_id: String, story_id: String,
/// Human-readable story name, if available. /// Human-readable story name (empty string when unset).
story_name: Option<String>, story_name: String,
/// Human-readable description of the failure. /// Human-readable description of the failure.
reason: String, reason: String,
}, },
@@ -75,8 +75,8 @@ pub enum StatusEvent {
StoryBlocked { StoryBlocked {
/// Work item ID (e.g. `"42_story_my_feature"`). /// Work item ID (e.g. `"42_story_my_feature"`).
story_id: String, story_id: String,
/// Human-readable story name, if available. /// Human-readable story name (empty string when unset).
story_name: Option<String>, story_name: String,
/// Human-readable reason the story was blocked. /// Human-readable reason the story was blocked.
reason: String, reason: String,
}, },
@@ -84,8 +84,8 @@ pub enum StatusEvent {
RateLimitWarning { RateLimitWarning {
/// Work item ID the agent was working on. /// Work item ID the agent was working on.
story_id: String, story_id: String,
/// Human-readable story name, if available. /// Human-readable story name (empty string when unset).
story_name: Option<String>, story_name: String,
/// Name of the agent that hit the limit. /// Name of the agent that hit the limit.
agent_name: String, agent_name: String,
}, },
@@ -93,8 +93,8 @@ pub enum StatusEvent {
RateLimitHardBlock { RateLimitHardBlock {
/// Work item ID the agent was working on. /// Work item ID the agent was working on.
story_id: String, story_id: String,
/// Human-readable story name, if available. /// Human-readable story name (empty string when unset).
story_name: Option<String>, story_name: String,
/// Name of the agent that hit the hard limit. /// Name of the agent that hit the hard limit.
agent_name: String, agent_name: String,
/// UTC instant at which the rate limit resets. /// UTC instant at which the rate limit resets.
@@ -237,7 +237,7 @@ mod tests {
broadcaster.publish(StatusEvent::MergeFailure { broadcaster.publish(StatusEvent::MergeFailure {
story_id: "1_story_a".to_string(), story_id: "1_story_a".to_string(),
story_name: None, story_name: String::new(),
reason: "conflict".to_string(), reason: "conflict".to_string(),
}); });
@@ -253,7 +253,7 @@ mod tests {
broadcaster.publish(StatusEvent::StoryBlocked { broadcaster.publish(StatusEvent::StoryBlocked {
story_id: "2_story_b".to_string(), story_id: "2_story_b".to_string(),
story_name: None, story_name: String::new(),
reason: "retry limit".to_string(), reason: "retry limit".to_string(),
}); });
@@ -273,7 +273,7 @@ mod tests {
// Publish an event while disabled. // Publish an event while disabled.
broadcaster.publish(StatusEvent::StoryBlocked { broadcaster.publish(StatusEvent::StoryBlocked {
story_id: "3_story_c".to_string(), story_id: "3_story_c".to_string(),
story_name: None, story_name: String::new(),
reason: "empty diff".to_string(), reason: "empty diff".to_string(),
}); });
@@ -290,7 +290,7 @@ mod tests {
sub.enable(); sub.enable();
broadcaster.publish(StatusEvent::MergeFailure { broadcaster.publish(StatusEvent::MergeFailure {
story_id: "4_story_d".to_string(), story_id: "4_story_d".to_string(),
story_name: None, story_name: String::new(),
reason: "gate failed".to_string(), reason: "gate failed".to_string(),
}); });
@@ -329,7 +329,7 @@ mod tests {
// Publish an event only to project A. // Publish an event only to project A.
broadcaster_a.publish(StatusEvent::StageTransition { broadcaster_a.publish(StatusEvent::StageTransition {
story_id: "10_story_project_a".to_string(), story_id: "10_story_project_a".to_string(),
story_name: Some("Project A Story".to_string()), story_name: "Project A Story".to_string(),
from_stage: "1_backlog".to_string(), from_stage: "1_backlog".to_string(),
to_stage: "2_current".to_string(), to_stage: "2_current".to_string(),
}); });
@@ -337,7 +337,7 @@ mod tests {
// Publish a different event only to project B. // Publish a different event only to project B.
broadcaster_b.publish(StatusEvent::MergeFailure { broadcaster_b.publish(StatusEvent::MergeFailure {
story_id: "20_story_project_b".to_string(), story_id: "20_story_project_b".to_string(),
story_name: Some("Project B Story".to_string()), story_name: "Project B Story".to_string(),
reason: "b conflict".to_string(), reason: "b conflict".to_string(),
}); });
@@ -378,19 +378,19 @@ mod tests {
// Project A fires two events. // Project A fires two events.
ba.publish(StatusEvent::StoryBlocked { ba.publish(StatusEvent::StoryBlocked {
story_id: "1_story_a_blocked".to_string(), story_id: "1_story_a_blocked".to_string(),
story_name: None, story_name: String::new(),
reason: "retry".to_string(), reason: "retry".to_string(),
}); });
ba.publish(StatusEvent::MergeFailure { ba.publish(StatusEvent::MergeFailure {
story_id: "2_story_a_fail".to_string(), story_id: "2_story_a_fail".to_string(),
story_name: None, story_name: String::new(),
reason: "conflict".to_string(), reason: "conflict".to_string(),
}); });
// Project B fires one event. // Project B fires one event.
bb.publish(StatusEvent::StageTransition { bb.publish(StatusEvent::StageTransition {
story_id: "3_story_b_move".to_string(), story_id: "3_story_b_move".to_string(),
story_name: None, story_name: String::new(),
from_stage: "1_backlog".to_string(), from_stage: "1_backlog".to_string(),
to_stage: "2_current".to_string(), to_stage: "2_current".to_string(),
}); });
@@ -447,7 +447,7 @@ mod tests {
// No subscriber — send is silently dropped. // No subscriber — send is silently dropped.
broadcaster.publish(StatusEvent::MergeFailure { broadcaster.publish(StatusEvent::MergeFailure {
story_id: "99_story_nobody".to_string(), story_id: "99_story_nobody".to_string(),
story_name: None, story_name: String::new(),
reason: "no one listening".to_string(), reason: "no one listening".to_string(),
}); });
} }
+4 -4
View File
@@ -206,7 +206,7 @@ mod tests {
let state = PipelineState { let state = PipelineState {
backlog: vec![UpcomingStory { backlog: vec![UpcomingStory {
story_id: "1_story_a".to_string(), story_id: "1_story_a".to_string(),
name: Some("Story A".to_string()), name: "Story A".to_string(),
error: None, error: None,
merge_failure: None, merge_failure: None,
agent: None, agent: None,
@@ -220,7 +220,7 @@ mod tests {
}], }],
current: vec![UpcomingStory { current: vec![UpcomingStory {
story_id: "2_story_b".to_string(), story_id: "2_story_b".to_string(),
name: Some("Story B".to_string()), name: "Story B".to_string(),
error: None, error: None,
merge_failure: None, merge_failure: None,
agent: None, agent: None,
@@ -236,7 +236,7 @@ mod tests {
merge: vec![], merge: vec![],
done: vec![UpcomingStory { done: vec![UpcomingStory {
story_id: "50_story_done".to_string(), story_id: "50_story_done".to_string(),
name: Some("Done Story".to_string()), name: "Done Story".to_string(),
error: None, error: None,
merge_failure: None, merge_failure: None,
agent: None, agent: None,
@@ -289,7 +289,7 @@ mod tests {
backlog: vec![], backlog: vec![],
current: vec![UpcomingStory { current: vec![UpcomingStory {
story_id: "10_story_x".to_string(), story_id: "10_story_x".to_string(),
name: Some("Story X".to_string()), name: "Story X".to_string(),
error: None, error: None,
merge_failure: None, merge_failure: None,
agent: Some(crate::http::workflow::pipeline::AgentAssignment { agent: Some(crate::http::workflow::pipeline::AgentAssignment {
+1 -1
View File
@@ -201,7 +201,7 @@ mod tests {
fn serialize_pipeline_state_response() { fn serialize_pipeline_state_response() {
let story = UpcomingStory { let story = UpcomingStory {
story_id: "10_story_test".to_string(), story_id: "10_story_test".to_string(),
name: Some("Test".to_string()), name: "Test".to_string(),
error: None, error: None,
merge_failure: None, merge_failure: None,
agent: None, agent: None,