huskies: merge 964
This commit is contained in:
@@ -53,7 +53,7 @@ export interface AgentAssignment {
|
||||
/** A single item in any pipeline stage (backlog, current, QA, merge, or done). */
|
||||
export interface PipelineStageItem {
|
||||
story_id: string;
|
||||
name: string | null;
|
||||
name: string;
|
||||
error: string | null;
|
||||
merge_failure: string | null;
|
||||
agent: AgentAssignment | null;
|
||||
@@ -142,32 +142,32 @@ export type StatusEvent =
|
||||
| {
|
||||
type: "stage_transition";
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
story_name: string;
|
||||
from_stage: string;
|
||||
to_stage: string;
|
||||
}
|
||||
| {
|
||||
type: "merge_failure";
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
story_name: string;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: "story_blocked";
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
story_name: string;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: "rate_limit_warning";
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
story_name: string;
|
||||
agent_name: string;
|
||||
}
|
||||
| {
|
||||
type: "rate_limit_hard_block";
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
story_name: string;
|
||||
agent_name: string;
|
||||
reset_at: string;
|
||||
};
|
||||
@@ -212,7 +212,7 @@ export interface AnthropicModelInfo {
|
||||
export interface WorkItemContent {
|
||||
content: string;
|
||||
stage: string;
|
||||
name: string | null;
|
||||
name: string;
|
||||
agent: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
||||
* This conversion happens at render time, not at the WebSocket boundary,
|
||||
* so the original StatusEvent structure is preserved in state. */
|
||||
function formatStatusEventMessage(event: StatusEvent): string {
|
||||
const name = event.story_name ?? event.story_id;
|
||||
const name = event.story_name || event.story_id;
|
||||
switch (event.type) {
|
||||
case "stage_transition":
|
||||
return `${name} — ${event.from_stage} → ${event.to_stage}`;
|
||||
|
||||
@@ -113,7 +113,7 @@ describe("StagePanel", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "1_story_bad",
|
||||
name: null,
|
||||
name: "",
|
||||
error: "Missing front matter",
|
||||
merge_failure: null,
|
||||
agent: null,
|
||||
|
||||
@@ -526,7 +526,7 @@ export function StagePanel({
|
||||
${costs.get(item.story_id)?.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{item.name ?? item.story_id}
|
||||
{item.name || item.story_id}
|
||||
</div>
|
||||
{item.error && (
|
||||
<div
|
||||
@@ -616,10 +616,10 @@ export function StagePanel({
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`delete-btn-${item.story_id}`}
|
||||
title={`Delete ${item.name ?? item.story_id}`}
|
||||
title={`Delete ${item.name || item.story_id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const label = item.name ?? item.story_id;
|
||||
const label = item.name || item.story_id;
|
||||
if (
|
||||
window.confirm(
|
||||
`Delete "${label}"? This cannot be undone.`,
|
||||
|
||||
@@ -138,7 +138,7 @@ mod tests {
|
||||
fn stage_transition_event(story_id: &str) -> StatusEvent {
|
||||
StatusEvent::StageTransition {
|
||||
story_id: story_id.to_string(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
from_stage: "1_backlog".to_string(),
|
||||
to_stage: "2_current".to_string(),
|
||||
}
|
||||
@@ -249,7 +249,7 @@ mod tests {
|
||||
for i in 0..5u32 {
|
||||
bc.publish(StatusEvent::MergeFailure {
|
||||
story_id: format!("story_{i}"),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
reason: "test".to_string(),
|
||||
});
|
||||
}
|
||||
@@ -299,7 +299,7 @@ mod tests {
|
||||
for i in 0..n {
|
||||
bc.publish(StatusEvent::MergeFailure {
|
||||
story_id: format!("other-{i}"),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
reason: format!("reason-{i}"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 name = match state.crdt.doc.items[idx].name.view() {
|
||||
JsonValue::String(s) if !s.is_empty() => Some(s),
|
||||
_ => None,
|
||||
JsonValue::String(s) => s.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
emit_event(CrdtEvent {
|
||||
story_id: sid.clone(),
|
||||
|
||||
@@ -15,8 +15,8 @@ pub struct CrdtEvent {
|
||||
pub from_stage: Option<crate::pipeline_state::Stage>,
|
||||
/// The stage the item is now in.
|
||||
pub to_stage: crate::pipeline_state::Stage,
|
||||
/// Human-readable story name from the CRDT document.
|
||||
pub name: Option<String>,
|
||||
/// Human-readable story name from the CRDT document (empty string when unset).
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
// ── CRDT document types ──────────────────────────────────────────────
|
||||
@@ -538,7 +538,7 @@ mod tests {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
from_stage: Some(crate::pipeline_state::Stage::Backlog),
|
||||
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!(matches!(
|
||||
@@ -546,7 +546,7 @@ mod tests {
|
||||
Some(crate::pipeline_state::Stage::Backlog)
|
||||
));
|
||||
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]
|
||||
@@ -555,12 +555,12 @@ mod tests {
|
||||
story_id: "10_story_bar".to_string(),
|
||||
from_stage: None,
|
||||
to_stage: crate::pipeline_state::Stage::Backlog,
|
||||
name: None,
|
||||
name: String::new(),
|
||||
};
|
||||
let cloned = evt.clone();
|
||||
assert_eq!(cloned.story_id, "10_story_bar");
|
||||
assert!(cloned.from_stage.is_none());
|
||||
assert!(cloned.name.is_none());
|
||||
assert!(cloned.name.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -573,7 +573,7 @@ mod tests {
|
||||
story_id: "99_story_noop".to_string(),
|
||||
from_stage: None,
|
||||
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(),
|
||||
from_stage: Some(Stage::Backlog),
|
||||
to_stage: Stage::Coding,
|
||||
name: Some("Broadcast Test".to_string()),
|
||||
name: "Broadcast Test".to_string(),
|
||||
};
|
||||
tx.send(evt).unwrap();
|
||||
|
||||
@@ -703,6 +703,6 @@ mod tests {
|
||||
assert_eq!(received.story_id, "70_story_broadcast");
|
||||
assert!(matches!(received.from_stage, Some(Stage::Backlog)));
|
||||
assert!(matches!(received.to_stage, Stage::Coding));
|
||||
assert_eq!(received.name.as_deref(), Some("Broadcast Test"));
|
||||
assert_eq!(received.name, "Broadcast Test");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,8 +273,8 @@ pub fn write_item(
|
||||
if stage_changed {
|
||||
// Read the current name from the CRDT document for the event.
|
||||
let current_name = match state.crdt.doc.items[idx].name.view() {
|
||||
JsonValue::String(s) if !s.is_empty() => Some(s),
|
||||
_ => None,
|
||||
JsonValue::String(s) if !s.is_empty() => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
// 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));
|
||||
@@ -336,7 +336,7 @@ pub fn write_item(
|
||||
story_id: story_id.to_string(),
|
||||
from_stage: None,
|
||||
to_stage: stage.clone(),
|
||||
name: name.map(String::from),
|
||||
name: name.unwrap_or("").to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ mod tests {
|
||||
fn status_to_stored_stage_transition() {
|
||||
let ev = StatusEvent::StageTransition {
|
||||
story_id: "42".into(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
from_stage: "1_backlog".into(),
|
||||
to_stage: "2_current".into(),
|
||||
};
|
||||
@@ -217,7 +217,7 @@ mod tests {
|
||||
fn status_to_stored_merge_failure() {
|
||||
let ev = StatusEvent::MergeFailure {
|
||||
story_id: "7".into(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
reason: "conflict".into(),
|
||||
};
|
||||
let stored = status_to_stored(ev).unwrap();
|
||||
@@ -228,7 +228,7 @@ mod tests {
|
||||
fn status_to_stored_story_blocked() {
|
||||
let ev = StatusEvent::StoryBlocked {
|
||||
story_id: "3".into(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
reason: "retry limit".into(),
|
||||
};
|
||||
let stored = status_to_stored(ev).unwrap();
|
||||
@@ -239,7 +239,7 @@ mod tests {
|
||||
fn status_to_stored_rate_limit_warning_is_none() {
|
||||
let ev = StatusEvent::RateLimitWarning {
|
||||
story_id: "1".into(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
agent_name: "coder".into(),
|
||||
};
|
||||
assert!(status_to_stored(ev).is_none());
|
||||
@@ -249,7 +249,7 @@ mod tests {
|
||||
fn status_to_stored_rate_limit_hard_block_is_none() {
|
||||
let ev = StatusEvent::RateLimitHardBlock {
|
||||
story_id: "2".into(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
agent_name: "coder".into(),
|
||||
reset_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
.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);
|
||||
|
||||
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
|
||||
// unknown key is rejected loudly rather than silently flushed to disk.
|
||||
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()) {
|
||||
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" => {
|
||||
let s = value.as_str().filter(|s| !s.is_empty());
|
||||
crate::crdt_state::set_name(story_id, s);
|
||||
let s = value.as_str().filter(|s| !s.trim().is_empty());
|
||||
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" => {
|
||||
let parsed = value
|
||||
|
||||
@@ -18,7 +18,7 @@ pub struct AgentAssignment {
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct UpcomingStory {
|
||||
pub story_id: String,
|
||||
pub name: Option<String>,
|
||||
pub name: String,
|
||||
pub error: Option<String>,
|
||||
/// Merge failure reason persisted to front matter by the mergemaster agent.
|
||||
pub merge_failure: Option<String>,
|
||||
@@ -123,11 +123,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
||||
|
||||
let story = UpcomingStory {
|
||||
story_id: sid.clone(),
|
||||
name: if item.name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(item.name.clone())
|
||||
},
|
||||
name: item.name.clone(),
|
||||
error: None,
|
||||
merge_failure,
|
||||
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());
|
||||
UpcomingStory {
|
||||
story_id: item.story_id.0.clone(),
|
||||
name: if item.name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(item.name)
|
||||
},
|
||||
name: item.name,
|
||||
error: None,
|
||||
merge_failure: None,
|
||||
agent: None,
|
||||
@@ -546,12 +538,12 @@ mod tests {
|
||||
.iter()
|
||||
.find(|s| s.story_id == "9870_story_view_upcoming")
|
||||
.unwrap();
|
||||
assert_eq!(s1.name.as_deref(), Some("View Upcoming"));
|
||||
assert_eq!(s1.name, "View Upcoming");
|
||||
let s2 = stories
|
||||
.iter()
|
||||
.find(|s| s.story_id == "9871_story_worktree")
|
||||
.unwrap();
|
||||
assert_eq!(s2.name.as_deref(), Some("Worktree Orchestration"));
|
||||
assert_eq!(s2.name, "Worktree Orchestration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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.
|
||||
ctx.services.status.publish(StatusEvent::StageTransition {
|
||||
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(),
|
||||
to_stage: "2_current".to_string(),
|
||||
});
|
||||
@@ -396,7 +396,7 @@ async fn ws_handler_multi_project_status_isolation() {
|
||||
let needle = "ProjAIsolation7734";
|
||||
ctx_a.services.status.publish(StatusEvent::MergeFailure {
|
||||
story_id: "10_story_proj_a_isolation".to_string(),
|
||||
story_name: Some(needle.to_string()),
|
||||
story_name: needle.to_string(),
|
||||
reason: "conflict".to_string(),
|
||||
});
|
||||
|
||||
@@ -453,7 +453,7 @@ async fn ws_handler_status_consumer_disabled_via_config() {
|
||||
let needle = "DisabledConsumer9182";
|
||||
ctx.services.status.publish(StatusEvent::StoryBlocked {
|
||||
story_id: "55_story_disabled_consumer".to_string(),
|
||||
story_name: Some(needle.to_string()),
|
||||
story_name: needle.to_string(),
|
||||
reason: "test".to_string(),
|
||||
});
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ impl std::fmt::Display for Error {
|
||||
pub struct WorkItemContent {
|
||||
pub content: String,
|
||||
pub stage: crate::pipeline_state::Stage,
|
||||
pub name: Option<String>,
|
||||
pub name: String,
|
||||
pub agent: Option<crate::config::AgentName>,
|
||||
}
|
||||
|
||||
@@ -162,7 +162,10 @@ pub fn get_work_item_content(
|
||||
let filename = format!("{story_id}.md");
|
||||
|
||||
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());
|
||||
|
||||
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();
|
||||
assert!(item.content.contains("Some content."));
|
||||
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]
|
||||
|
||||
@@ -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 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}"))
|
||||
}
|
||||
StoredEvent::MergeFailure {
|
||||
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}"))
|
||||
}
|
||||
StoredEvent::StoryBlocked {
|
||||
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}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,16 @@ pub fn stage_display_name(stage: &Stage) -> &'static str {
|
||||
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
|
||||
pub fn format_stage_notification(
|
||||
item_id: &str,
|
||||
story_name: Option<&str>,
|
||||
story_name: &str,
|
||||
from_stage: &Stage,
|
||||
to_stage: &Stage,
|
||||
) -> (String, String) {
|
||||
let number = extract_item_number(item_id).unwrap_or(item_id);
|
||||
let effective_name = 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_html = effective_name
|
||||
.map(|n| format!("<em>{n}</em> "))
|
||||
@@ -61,11 +65,15 @@ pub fn format_stage_notification(
|
||||
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
|
||||
pub fn format_error_notification(
|
||||
item_id: &str,
|
||||
story_name: Option<&str>,
|
||||
story_name: &str,
|
||||
reason: &str,
|
||||
) -> (String, String) {
|
||||
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_html = effective_name
|
||||
.map(|n| format!("<em>{n}</em> "))
|
||||
@@ -81,11 +89,15 @@ pub fn format_error_notification(
|
||||
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
|
||||
pub fn format_blocked_notification(
|
||||
item_id: &str,
|
||||
story_name: Option<&str>,
|
||||
story_name: &str,
|
||||
reason: &str,
|
||||
) -> (String, String) {
|
||||
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_html = effective_name
|
||||
.map(|n| format!("<em>{n}</em> "))
|
||||
@@ -102,11 +114,15 @@ pub fn format_blocked_notification(
|
||||
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
|
||||
pub fn format_rate_limit_notification(
|
||||
item_id: &str,
|
||||
story_name: Option<&str>,
|
||||
story_name: &str,
|
||||
agent_name: &str,
|
||||
) -> (String, String) {
|
||||
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_html = effective_name
|
||||
.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`.
|
||||
pub fn format_agent_started_notification(
|
||||
item_id: &str,
|
||||
story_name: Option<&str>,
|
||||
story_name: &str,
|
||||
agent_name: &str,
|
||||
) -> (String, String) {
|
||||
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_html = effective_name
|
||||
.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`.
|
||||
pub fn format_agent_completed_notification(
|
||||
item_id: &str,
|
||||
story_name: Option<&str>,
|
||||
story_name: &str,
|
||||
agent_name: &str,
|
||||
success: bool,
|
||||
) -> (String, String) {
|
||||
let number = extract_item_number(item_id).unwrap_or(item_id);
|
||||
let effective_name = 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_html = effective_name
|
||||
.map(|n| format!("<em>{n}</em> "))
|
||||
@@ -243,7 +267,7 @@ mod tests {
|
||||
fn format_notification_done_stage_includes_party_emoji() {
|
||||
let (plain, html) = format_stage_notification(
|
||||
"353_story_done",
|
||||
Some("Done Story"),
|
||||
"Done Story",
|
||||
&merge_stage(),
|
||||
&done_stage(),
|
||||
);
|
||||
@@ -261,7 +285,7 @@ mod tests {
|
||||
fn format_notification_non_done_stage_has_no_emoji() {
|
||||
let (plain, _html) = format_stage_notification(
|
||||
"42_story_thing",
|
||||
Some("Some Story"),
|
||||
"Some Story",
|
||||
&Stage::Backlog,
|
||||
&Stage::Coding,
|
||||
);
|
||||
@@ -272,7 +296,7 @@ mod tests {
|
||||
fn format_notification_with_story_name() {
|
||||
let (plain, html) = format_stage_notification(
|
||||
"261_story_bot_notifications",
|
||||
Some("Bot notifications"),
|
||||
"Bot notifications",
|
||||
&Stage::Upcoming,
|
||||
&Stage::Coding,
|
||||
);
|
||||
@@ -289,19 +313,15 @@ mod tests {
|
||||
#[test]
|
||||
fn format_stage_notification_without_story_name_falls_back_to_number() {
|
||||
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!(html, "<strong>#42</strong> \u{2014} Current \u{2192} QA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_notification_non_numeric_id_uses_full_id() {
|
||||
let (plain, _html) = format_stage_notification(
|
||||
"abc_story_thing",
|
||||
Some("Some Story"),
|
||||
&Stage::Qa,
|
||||
&merge_stage(),
|
||||
);
|
||||
let (plain, _html) =
|
||||
format_stage_notification("abc_story_thing", "Some Story", &Stage::Qa, &merge_stage());
|
||||
assert_eq!(
|
||||
plain,
|
||||
"#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() {
|
||||
let long_name = "A".repeat(300);
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_stage_notification_empty_story_name_falls_back_to_number() {
|
||||
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!(html, "<strong>#42</strong> \u{2014} Current \u{2192} QA");
|
||||
}
|
||||
@@ -328,7 +348,7 @@ mod tests {
|
||||
fn format_stage_notification_unicode_name() {
|
||||
let (plain, html) = format_stage_notification(
|
||||
"7_story_i18n",
|
||||
Some("Ünïcödé Ñämé 🎉"),
|
||||
"Ünïcödé Ñämé 🎉",
|
||||
&Stage::Qa,
|
||||
&merge_stage(),
|
||||
);
|
||||
@@ -342,7 +362,7 @@ mod tests {
|
||||
fn format_error_notification_with_story_name() {
|
||||
let (plain, html) = format_error_notification(
|
||||
"262_story_bot_errors",
|
||||
Some("Bot error notifications"),
|
||||
"Bot error notifications",
|
||||
"merge conflict in src/main.rs",
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -357,7 +377,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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!(html, "\u{274c} <strong>#42</strong> \u{2014} tests failed");
|
||||
}
|
||||
@@ -365,7 +385,7 @@ mod tests {
|
||||
#[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");
|
||||
format_error_notification("abc_story_thing", "Some Story", "clippy errors");
|
||||
assert_eq!(
|
||||
plain,
|
||||
"\u{274c} #abc_story_thing Some Story \u{2014} clippy errors"
|
||||
@@ -375,21 +395,19 @@ mod tests {
|
||||
#[test]
|
||||
fn format_error_notification_long_reason_preserved() {
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_error_notification_unicode_reason() {
|
||||
let (plain, _html) =
|
||||
format_error_notification("5_story_foo", Some("Foo"), "错误:合并冲突");
|
||||
let (plain, _html) = format_error_notification("5_story_foo", "Foo", "错误:合并冲突");
|
||||
assert!(plain.contains("错误:合并冲突"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_error_notification_empty_story_name_falls_back_to_number() {
|
||||
let (plain, _html) =
|
||||
format_error_notification("42_bug_fix_thing", Some(""), "tests failed");
|
||||
let (plain, _html) = format_error_notification("42_bug_fix_thing", "", "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() {
|
||||
let (plain, html) = format_blocked_notification(
|
||||
"425_story_blocking_reason",
|
||||
Some("Blocking Reason Story"),
|
||||
"Blocking Reason Story",
|
||||
"Retry limit exceeded (3/3) at coder stage",
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -414,7 +432,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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!(
|
||||
html,
|
||||
@@ -424,13 +442,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
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: 理由:空の差分"));
|
||||
}
|
||||
|
||||
@@ -439,7 +457,7 @@ mod tests {
|
||||
#[test]
|
||||
fn format_rate_limit_notification_includes_agent_and_story() {
|
||||
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!(
|
||||
plain,
|
||||
"\u{26a0}\u{fe0f} #365 My Feature \u{2014} coder-2 hit an API rate limit"
|
||||
@@ -452,7 +470,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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!(
|
||||
plain,
|
||||
"\u{26a0}\u{fe0f} #42 \u{2014} coder-1 hit an API rate limit"
|
||||
@@ -465,7 +483,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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!(
|
||||
plain,
|
||||
"\u{26a0}\u{fe0f} #42 \u{2014} coder-1 hit an API rate limit"
|
||||
@@ -474,7 +492,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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("hit an API rate limit"));
|
||||
}
|
||||
@@ -484,7 +502,7 @@ mod tests {
|
||||
#[test]
|
||||
fn format_agent_started_notification_with_story_name() {
|
||||
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!(
|
||||
html,
|
||||
@@ -494,7 +512,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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!(
|
||||
html,
|
||||
@@ -504,7 +522,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -512,12 +530,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_agent_completed_notification_success_with_story_name() {
|
||||
let (plain, html) = format_agent_completed_notification(
|
||||
"42_story_foo",
|
||||
Some("My Feature"),
|
||||
"coder-1",
|
||||
true,
|
||||
);
|
||||
let (plain, html) =
|
||||
format_agent_completed_notification("42_story_foo", "My Feature", "coder-1", true);
|
||||
assert_eq!(plain, "\u{2705} #42 My Feature \u{2014} coder-1 completed");
|
||||
assert_eq!(
|
||||
html,
|
||||
@@ -527,19 +541,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_agent_completed_notification_failure_with_story_name() {
|
||||
let (plain, _html) = format_agent_completed_notification(
|
||||
"42_story_foo",
|
||||
Some("My Feature"),
|
||||
"coder-1",
|
||||
false,
|
||||
);
|
||||
let (plain, _html) =
|
||||
format_agent_completed_notification("42_story_foo", "My Feature", "coder-1", false);
|
||||
assert_eq!(plain, "\u{274C} #42 My Feature \u{2014} coder-1 failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_agent_completed_notification_falls_back_to_number() {
|
||||
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!(
|
||||
html,
|
||||
@@ -550,7 +560,7 @@ mod tests {
|
||||
#[test]
|
||||
fn format_agent_completed_notification_empty_name_falls_back_to_number() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,8 +52,7 @@ pub fn spawn_notification_listener(
|
||||
// Rapid successive transitions for the same item are coalesced: the
|
||||
// original `from_stage` is kept while `to_stage` is updated to the
|
||||
// latest destination, so only one notification fires for the final stage.
|
||||
let mut pending_transitions: HashMap<String, (Stage, Stage, Option<String>)> =
|
||||
HashMap::new();
|
||||
let mut pending_transitions: HashMap<String, (Stage, Stage, String)> = HashMap::new();
|
||||
let mut flush_deadline: Option<tokio::time::Instant> = None;
|
||||
|
||||
// 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(
|
||||
&item_id,
|
||||
story_name.as_deref(),
|
||||
&story_name,
|
||||
&from_stage,
|
||||
&to_stage,
|
||||
);
|
||||
@@ -139,7 +138,7 @@ pub fn spawn_notification_listener(
|
||||
{
|
||||
let (plain, html) = format_stage_notification(
|
||||
&item_id,
|
||||
story_name.as_deref(),
|
||||
&story_name,
|
||||
&from_stage,
|
||||
&to_stage,
|
||||
);
|
||||
@@ -191,8 +190,14 @@ pub fn spawn_notification_listener(
|
||||
|
||||
// Look up the story name in the expected stage directory; fall
|
||||
// back to a full search so stale events still show the name.
|
||||
let story_name = read_story_name(&project_root, stage, item_id)
|
||||
.or_else(|| find_story_name_any_stage(&project_root, item_id));
|
||||
let story_name = {
|
||||
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
|
||||
// succession), update the destination stage to the latest while
|
||||
@@ -201,7 +206,7 @@ pub fn spawn_notification_listener(
|
||||
.entry(item_id.clone())
|
||||
.and_modify(|e| {
|
||||
e.1 = to_typed.clone();
|
||||
if story_name.is_some() {
|
||||
if !story_name.is_empty() {
|
||||
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,
|
||||
// truncated to ~120 chars.
|
||||
let snippet = merge_failure_snippet(reason, 120);
|
||||
let (plain, html) =
|
||||
format_error_notification(story_id, story_name.as_deref(), &snippet);
|
||||
let (plain, html) = format_error_notification(story_id, &story_name, &snippet);
|
||||
slog!("[bot] Sending error notification: {plain}");
|
||||
for room_id in &rooms_for_notification(&get_room_ids) {
|
||||
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);
|
||||
let story_name = find_story_name_any_stage(&project_root, story_id);
|
||||
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}");
|
||||
for room_id in &rooms_for_notification(&get_room_ids) {
|
||||
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||
@@ -290,8 +294,7 @@ pub fn spawn_notification_listener(
|
||||
continue;
|
||||
};
|
||||
let story_name = find_story_name_any_stage(&project_root, story_id);
|
||||
let (plain, html) =
|
||||
format_blocked_notification(story_id, story_name.as_deref(), reason);
|
||||
let (plain, html) = format_blocked_notification(story_id, &story_name, reason);
|
||||
slog!("[bot] Sending blocked notification: {plain}");
|
||||
for room_id in &rooms_for_notification(&get_room_ids) {
|
||||
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||
@@ -350,11 +353,8 @@ pub fn spawn_notification_listener(
|
||||
continue;
|
||||
};
|
||||
let story_name = find_story_name_any_stage(&project_root, story_id);
|
||||
let (plain, html) = format_agent_started_notification(
|
||||
story_id,
|
||||
story_name.as_deref(),
|
||||
agent_name,
|
||||
);
|
||||
let (plain, html) =
|
||||
format_agent_started_notification(story_id, &story_name, agent_name);
|
||||
// Buffer with 5s debounce; later arrivals overwrite earlier ones.
|
||||
let key = format!("{story_id}:started");
|
||||
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 (plain, html) = format_agent_completed_notification(
|
||||
story_id,
|
||||
story_name.as_deref(),
|
||||
&story_name,
|
||||
agent_name,
|
||||
success,
|
||||
);
|
||||
|
||||
@@ -16,16 +16,20 @@ mod tests_notifications;
|
||||
#[cfg(test)]
|
||||
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.
|
||||
pub fn read_story_name(_project_root: &Path, _stage: &str, item_id: &str) -> Option<String> {
|
||||
crate::crdt_state::read_item(item_id).map(|v| v.name().to_string())
|
||||
/// Returns the name as a `String`, or an empty string if the item is not in
|
||||
/// the CRDT or has no name set. Callers that display the name unconditionally
|
||||
/// 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.
|
||||
///
|
||||
/// 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)
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ fn read_story_name_reads_from_front_matter() {
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
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]
|
||||
@@ -124,7 +124,7 @@ fn read_story_name_returns_none_for_missing_file() {
|
||||
crate::db::ensure_content_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let name = read_story_name(tmp.path(), "2_current", "99_story_missing_notif_test");
|
||||
assert_eq!(name, None);
|
||||
assert!(name.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -139,7 +139,7 @@ fn read_story_name_returns_none_for_missing_name_field() {
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
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 ───────────
|
||||
|
||||
@@ -204,7 +204,7 @@ mod tests {
|
||||
fn make_event(id: &str) -> StatusEvent {
|
||||
StatusEvent::MergeFailure {
|
||||
story_id: id.to_string(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
reason: "test".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -398,12 +398,12 @@ mod tests {
|
||||
let items = vec![
|
||||
BufferedItem::Event(StatusEvent::MergeFailure {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
story_name: Some("Foo".to_string()),
|
||||
story_name: "Foo".to_string(),
|
||||
reason: "conflict".to_string(),
|
||||
}),
|
||||
BufferedItem::Event(StatusEvent::StoryBlocked {
|
||||
story_id: "7_story_bar".to_string(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
reason: "retry limit".to_string(),
|
||||
}),
|
||||
];
|
||||
@@ -426,7 +426,7 @@ mod tests {
|
||||
BufferedItem::Truncated(3),
|
||||
BufferedItem::Event(StatusEvent::MergeFailure {
|
||||
story_id: "1_story_x".to_string(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
reason: "test".to_string(),
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -25,7 +25,11 @@ pub fn format_status_event(event: &StatusEvent) -> String {
|
||||
to_stage,
|
||||
} => {
|
||||
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 to_typed = Stage::from_dir(to_stage).unwrap_or(Stage::Upcoming);
|
||||
let from = stage_display_name(&from_typed);
|
||||
@@ -43,7 +47,11 @@ pub fn format_status_event(event: &StatusEvent) -> String {
|
||||
reason,
|
||||
} => {
|
||||
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}")
|
||||
}
|
||||
StatusEvent::StoryBlocked {
|
||||
@@ -52,7 +60,11 @@ pub fn format_status_event(event: &StatusEvent) -> String {
|
||||
reason,
|
||||
} => {
|
||||
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}")
|
||||
}
|
||||
StatusEvent::RateLimitWarning {
|
||||
@@ -61,7 +73,11 @@ pub fn format_status_event(event: &StatusEvent) -> String {
|
||||
agent_name,
|
||||
} => {
|
||||
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")
|
||||
}
|
||||
StatusEvent::RateLimitHardBlock {
|
||||
@@ -71,7 +87,11 @@ pub fn format_status_event(event: &StatusEvent) -> String {
|
||||
reset_at,
|
||||
} => {
|
||||
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();
|
||||
format!(
|
||||
"\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() {
|
||||
let event = StatusEvent::StageTransition {
|
||||
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(),
|
||||
to_stage: "done".to_string(),
|
||||
};
|
||||
@@ -107,7 +127,7 @@ mod tests {
|
||||
fn formats_stage_transition_no_emoji_for_non_done() {
|
||||
let event = StatusEvent::StageTransition {
|
||||
story_id: "10_story_bar".to_string(),
|
||||
story_name: Some("Bar".to_string()),
|
||||
story_name: "Bar".to_string(),
|
||||
from_stage: "backlog".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() {
|
||||
let event = StatusEvent::StageTransition {
|
||||
story_id: "5_story_x".to_string(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
from_stage: "coding".to_string(),
|
||||
to_stage: "qa".to_string(),
|
||||
};
|
||||
@@ -132,7 +152,7 @@ mod tests {
|
||||
fn formats_merge_failure() {
|
||||
let event = StatusEvent::MergeFailure {
|
||||
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(),
|
||||
};
|
||||
let s = format_status_event(&event);
|
||||
@@ -145,7 +165,7 @@ mod tests {
|
||||
fn formats_story_blocked() {
|
||||
let event = StatusEvent::StoryBlocked {
|
||||
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(),
|
||||
};
|
||||
let s = format_status_event(&event);
|
||||
@@ -157,7 +177,7 @@ mod tests {
|
||||
fn formats_rate_limit_warning() {
|
||||
let event = StatusEvent::RateLimitWarning {
|
||||
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(),
|
||||
};
|
||||
let s = format_status_event(&event);
|
||||
@@ -171,7 +191,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let event = StatusEvent::RateLimitHardBlock {
|
||||
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(),
|
||||
reset_at: reset,
|
||||
};
|
||||
@@ -188,18 +208,18 @@ mod tests {
|
||||
let events: Vec<StatusEvent> = vec![
|
||||
StatusEvent::StageTransition {
|
||||
story_id: "1_story_a".to_string(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
from_stage: "backlog".to_string(),
|
||||
to_stage: "coding".to_string(),
|
||||
},
|
||||
StatusEvent::MergeFailure {
|
||||
story_id: "2_story_b".to_string(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
reason: "test".to_string(),
|
||||
},
|
||||
StatusEvent::StoryBlocked {
|
||||
story_id: "3_story_c".to_string(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
reason: "limit".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -55,8 +55,8 @@ pub enum StatusEvent {
|
||||
StageTransition {
|
||||
/// Work item ID (e.g. `"42_story_my_feature"`).
|
||||
story_id: String,
|
||||
/// Human-readable story name, if available.
|
||||
story_name: Option<String>,
|
||||
/// Human-readable story name (empty string when unset).
|
||||
story_name: String,
|
||||
/// Pipeline stage directory the item moved FROM (e.g. `"2_current"`).
|
||||
from_stage: String,
|
||||
/// Pipeline stage directory the item moved TO (e.g. `"3_qa"`).
|
||||
@@ -66,8 +66,8 @@ pub enum StatusEvent {
|
||||
MergeFailure {
|
||||
/// Work item ID (e.g. `"42_story_my_feature"`).
|
||||
story_id: String,
|
||||
/// Human-readable story name, if available.
|
||||
story_name: Option<String>,
|
||||
/// Human-readable story name (empty string when unset).
|
||||
story_name: String,
|
||||
/// Human-readable description of the failure.
|
||||
reason: String,
|
||||
},
|
||||
@@ -75,8 +75,8 @@ pub enum StatusEvent {
|
||||
StoryBlocked {
|
||||
/// Work item ID (e.g. `"42_story_my_feature"`).
|
||||
story_id: String,
|
||||
/// Human-readable story name, if available.
|
||||
story_name: Option<String>,
|
||||
/// Human-readable story name (empty string when unset).
|
||||
story_name: String,
|
||||
/// Human-readable reason the story was blocked.
|
||||
reason: String,
|
||||
},
|
||||
@@ -84,8 +84,8 @@ pub enum StatusEvent {
|
||||
RateLimitWarning {
|
||||
/// Work item ID the agent was working on.
|
||||
story_id: String,
|
||||
/// Human-readable story name, if available.
|
||||
story_name: Option<String>,
|
||||
/// Human-readable story name (empty string when unset).
|
||||
story_name: String,
|
||||
/// Name of the agent that hit the limit.
|
||||
agent_name: String,
|
||||
},
|
||||
@@ -93,8 +93,8 @@ pub enum StatusEvent {
|
||||
RateLimitHardBlock {
|
||||
/// Work item ID the agent was working on.
|
||||
story_id: String,
|
||||
/// Human-readable story name, if available.
|
||||
story_name: Option<String>,
|
||||
/// Human-readable story name (empty string when unset).
|
||||
story_name: String,
|
||||
/// Name of the agent that hit the hard limit.
|
||||
agent_name: String,
|
||||
/// UTC instant at which the rate limit resets.
|
||||
@@ -237,7 +237,7 @@ mod tests {
|
||||
|
||||
broadcaster.publish(StatusEvent::MergeFailure {
|
||||
story_id: "1_story_a".to_string(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
reason: "conflict".to_string(),
|
||||
});
|
||||
|
||||
@@ -253,7 +253,7 @@ mod tests {
|
||||
|
||||
broadcaster.publish(StatusEvent::StoryBlocked {
|
||||
story_id: "2_story_b".to_string(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
reason: "retry limit".to_string(),
|
||||
});
|
||||
|
||||
@@ -273,7 +273,7 @@ mod tests {
|
||||
// Publish an event while disabled.
|
||||
broadcaster.publish(StatusEvent::StoryBlocked {
|
||||
story_id: "3_story_c".to_string(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
reason: "empty diff".to_string(),
|
||||
});
|
||||
|
||||
@@ -290,7 +290,7 @@ mod tests {
|
||||
sub.enable();
|
||||
broadcaster.publish(StatusEvent::MergeFailure {
|
||||
story_id: "4_story_d".to_string(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
reason: "gate failed".to_string(),
|
||||
});
|
||||
|
||||
@@ -329,7 +329,7 @@ mod tests {
|
||||
// Publish an event only to project A.
|
||||
broadcaster_a.publish(StatusEvent::StageTransition {
|
||||
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(),
|
||||
to_stage: "2_current".to_string(),
|
||||
});
|
||||
@@ -337,7 +337,7 @@ mod tests {
|
||||
// Publish a different event only to project B.
|
||||
broadcaster_b.publish(StatusEvent::MergeFailure {
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -378,19 +378,19 @@ mod tests {
|
||||
// Project A fires two events.
|
||||
ba.publish(StatusEvent::StoryBlocked {
|
||||
story_id: "1_story_a_blocked".to_string(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
reason: "retry".to_string(),
|
||||
});
|
||||
ba.publish(StatusEvent::MergeFailure {
|
||||
story_id: "2_story_a_fail".to_string(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
reason: "conflict".to_string(),
|
||||
});
|
||||
|
||||
// Project B fires one event.
|
||||
bb.publish(StatusEvent::StageTransition {
|
||||
story_id: "3_story_b_move".to_string(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
from_stage: "1_backlog".to_string(),
|
||||
to_stage: "2_current".to_string(),
|
||||
});
|
||||
@@ -447,7 +447,7 @@ mod tests {
|
||||
// No subscriber — send is silently dropped.
|
||||
broadcaster.publish(StatusEvent::MergeFailure {
|
||||
story_id: "99_story_nobody".to_string(),
|
||||
story_name: None,
|
||||
story_name: String::new(),
|
||||
reason: "no one listening".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ mod tests {
|
||||
let state = PipelineState {
|
||||
backlog: vec![UpcomingStory {
|
||||
story_id: "1_story_a".to_string(),
|
||||
name: Some("Story A".to_string()),
|
||||
name: "Story A".to_string(),
|
||||
error: None,
|
||||
merge_failure: None,
|
||||
agent: None,
|
||||
@@ -220,7 +220,7 @@ mod tests {
|
||||
}],
|
||||
current: vec![UpcomingStory {
|
||||
story_id: "2_story_b".to_string(),
|
||||
name: Some("Story B".to_string()),
|
||||
name: "Story B".to_string(),
|
||||
error: None,
|
||||
merge_failure: None,
|
||||
agent: None,
|
||||
@@ -236,7 +236,7 @@ mod tests {
|
||||
merge: vec![],
|
||||
done: vec![UpcomingStory {
|
||||
story_id: "50_story_done".to_string(),
|
||||
name: Some("Done Story".to_string()),
|
||||
name: "Done Story".to_string(),
|
||||
error: None,
|
||||
merge_failure: None,
|
||||
agent: None,
|
||||
@@ -289,7 +289,7 @@ mod tests {
|
||||
backlog: vec![],
|
||||
current: vec![UpcomingStory {
|
||||
story_id: "10_story_x".to_string(),
|
||||
name: Some("Story X".to_string()),
|
||||
name: "Story X".to_string(),
|
||||
error: None,
|
||||
merge_failure: None,
|
||||
agent: Some(crate::http::workflow::pipeline::AgentAssignment {
|
||||
|
||||
@@ -201,7 +201,7 @@ mod tests {
|
||||
fn serialize_pipeline_state_response() {
|
||||
let story = UpcomingStory {
|
||||
story_id: "10_story_test".to_string(),
|
||||
name: Some("Test".to_string()),
|
||||
name: "Test".to_string(),
|
||||
error: None,
|
||||
merge_failure: None,
|
||||
agent: None,
|
||||
|
||||
Reference in New Issue
Block a user