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). */
|
/** 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}`;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.`,
|
||||||
|
|||||||
@@ -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}"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 ───────────
|
||||||
|
|||||||
@@ -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(),
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user