huskies: merge 944
This commit is contained in:
@@ -87,7 +87,10 @@ pub async fn handle_delete(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build the response.
|
// Build the response.
|
||||||
let stage_label = stage_display_name(&stage);
|
let stage_label = match crate::pipeline_state::Stage::from_dir(&stage) {
|
||||||
|
Some(s) => stage_display_label(&s),
|
||||||
|
None => stage.as_str(),
|
||||||
|
};
|
||||||
let mut response = format!("Deleted **{story_name}** from **{stage_label}**.");
|
let mut response = format!("Deleted **{story_name}** from **{stage_label}**.");
|
||||||
if !outcome.agents_stopped.is_empty() {
|
if !outcome.agents_stopped.is_empty() {
|
||||||
let agent_list = outcome.agents_stopped.join(", ");
|
let agent_list = outcome.agents_stopped.join(", ");
|
||||||
@@ -99,20 +102,19 @@ pub async fn handle_delete(
|
|||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Human-readable label for a pipeline stage directory name.
|
/// Human-readable label for a typed pipeline [`Stage`].
|
||||||
fn stage_display_name(stage: &str) -> &str {
|
fn stage_display_label(stage: &crate::pipeline_state::Stage) -> &'static str {
|
||||||
use crate::pipeline_state::Stage;
|
use crate::pipeline_state::Stage;
|
||||||
match Stage::from_dir(stage) {
|
match stage {
|
||||||
Some(Stage::Upcoming) => "upcoming",
|
Stage::Upcoming => "upcoming",
|
||||||
Some(Stage::Backlog) => "backlog",
|
Stage::Backlog => "backlog",
|
||||||
Some(Stage::Coding) => "in-progress",
|
Stage::Coding => "in-progress",
|
||||||
Some(Stage::Blocked { .. }) => "blocked",
|
Stage::Blocked { .. } => "blocked",
|
||||||
Some(Stage::Qa) => "QA",
|
Stage::Qa => "QA",
|
||||||
Some(Stage::Merge { .. }) => "merge",
|
Stage::Merge { .. } => "merge",
|
||||||
Some(Stage::Done { .. }) => "done",
|
Stage::Done { .. } => "done",
|
||||||
Some(Stage::Archived { .. }) => "archived",
|
Stage::Archived { .. } => "archived",
|
||||||
Some(Stage::MergeFailure { .. }) => "merge-failure",
|
Stage::MergeFailure { .. } => "merge-failure",
|
||||||
None => stage,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,9 +92,20 @@ struct WorkItemContentResponse {
|
|||||||
|
|
||||||
impl From<WorkItemContent> for WorkItemContentResponse {
|
impl From<WorkItemContent> for WorkItemContentResponse {
|
||||||
fn from(w: WorkItemContent) -> Self {
|
fn from(w: WorkItemContent) -> Self {
|
||||||
|
use crate::pipeline_state::Stage;
|
||||||
|
// Frozen items report "frozen" so the UI can render them distinctly;
|
||||||
|
// otherwise we emit the canonical clean stage directory name.
|
||||||
|
let stage = if w.frozen {
|
||||||
|
"frozen".to_string()
|
||||||
|
} else {
|
||||||
|
match &w.stage {
|
||||||
|
Stage::Coding => "current".to_string(),
|
||||||
|
other => other.dir_name().to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
Self {
|
Self {
|
||||||
content: w.content,
|
content: w.content,
|
||||||
stage: w.stage,
|
stage,
|
||||||
name: w.name,
|
name: w.name,
|
||||||
agent: w.agent,
|
agent: w.agent,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
|
|||||||
// Find member items.
|
// Find member items.
|
||||||
let all_items = crate::pipeline_state::read_all_typed();
|
let all_items = crate::pipeline_state::read_all_typed();
|
||||||
let mut member_items: Vec<Value> = Vec::new();
|
let mut member_items: Vec<Value> = Vec::new();
|
||||||
|
let mut done = 0usize;
|
||||||
for item in &all_items {
|
for item in &all_items {
|
||||||
let sid = &item.story_id.0;
|
let sid = &item.story_id.0;
|
||||||
let Some(member_view) = crate::crdt_state::read_item(sid) else {
|
let Some(member_view) = crate::crdt_state::read_item(sid) else {
|
||||||
@@ -141,6 +142,9 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
|
|||||||
Stage::Blocked { .. } => "blocked",
|
Stage::Blocked { .. } => "blocked",
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if matches!(item.stage, Stage::Done { .. }) {
|
||||||
|
done += 1;
|
||||||
|
}
|
||||||
member_items.push(json!({
|
member_items.push(json!({
|
||||||
"story_id": sid,
|
"story_id": sid,
|
||||||
"name": item.name,
|
"name": item.name,
|
||||||
@@ -150,7 +154,6 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
|
|||||||
}
|
}
|
||||||
|
|
||||||
let total = member_items.len();
|
let total = member_items.len();
|
||||||
let done = member_items.iter().filter(|i| i["stage"] == "done").count();
|
|
||||||
|
|
||||||
serde_json::to_string_pretty(&json!({
|
serde_json::to_string_pretty(&json!({
|
||||||
"epic_id": epic_id,
|
"epic_id": epic_id,
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ impl std::fmt::Display for Error {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct WorkItemContent {
|
pub struct WorkItemContent {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub stage: String,
|
pub stage: crate::pipeline_state::Stage,
|
||||||
|
/// Whether the item is frozen — orthogonal to [`Self::stage`].
|
||||||
|
pub frozen: bool,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub agent: Option<String>,
|
pub agent: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -171,13 +173,24 @@ pub fn get_work_item_content(
|
|||||||
project_root: &Path,
|
project_root: &Path,
|
||||||
story_id: &str,
|
story_id: &str,
|
||||||
) -> Result<WorkItemContent, Error> {
|
) -> Result<WorkItemContent, Error> {
|
||||||
|
use crate::pipeline_state::Stage;
|
||||||
|
|
||||||
let stages = [
|
let stages = [
|
||||||
("1_backlog", "backlog"),
|
("1_backlog", Stage::Backlog),
|
||||||
("2_current", "current"),
|
("2_current", Stage::Coding),
|
||||||
("3_qa", "qa"),
|
("3_qa", Stage::Qa),
|
||||||
("4_merge", "merge"),
|
(
|
||||||
("5_done", "done"),
|
"4_merge",
|
||||||
("6_archived", "archived"),
|
Stage::from_dir("merge").expect("merge is a valid stage dir"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"5_done",
|
||||||
|
Stage::from_dir("done").expect("done is a valid stage dir"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"6_archived",
|
||||||
|
Stage::from_dir("archived").expect("archived is a valid stage dir"),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let work_dir = project_root.join(".huskies").join("work");
|
let work_dir = project_root.join(".huskies").join("work");
|
||||||
@@ -191,11 +204,12 @@ pub fn get_work_item_content(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|v| v.agent().map(str::to_string));
|
.and_then(|v| v.agent().map(str::to_string));
|
||||||
|
|
||||||
for (stage_dir, stage_name) in &stages {
|
for (stage_dir, stage) in &stages {
|
||||||
if let Some(content) = io::read_work_item_from_stage(&work_dir, stage_dir, &filename)? {
|
if let Some(content) = io::read_work_item_from_stage(&work_dir, stage_dir, &filename)? {
|
||||||
return Ok(WorkItemContent {
|
return Ok(WorkItemContent {
|
||||||
content,
|
content,
|
||||||
stage: stage_name.to_string(),
|
stage: stage.clone(),
|
||||||
|
frozen: false,
|
||||||
name: crdt_name.clone(),
|
name: crdt_name.clone(),
|
||||||
agent: crdt_agent.clone(),
|
agent: crdt_agent.clone(),
|
||||||
});
|
});
|
||||||
@@ -206,31 +220,14 @@ pub fn get_work_item_content(
|
|||||||
if let Some(content) = crate::db::read_content(story_id) {
|
if let Some(content) = crate::db::read_content(story_id) {
|
||||||
let item = crate::pipeline_state::read_typed(story_id)
|
let item = crate::pipeline_state::read_typed(story_id)
|
||||||
.map_err(|e| Error::Io(format!("Pipeline read error: {e}")))?;
|
.map_err(|e| Error::Io(format!("Pipeline read error: {e}")))?;
|
||||||
let stage = item
|
let (stage, frozen) = match item.as_ref() {
|
||||||
.as_ref()
|
Some(i) => (i.stage.clone(), i.is_frozen()),
|
||||||
.map(|i| {
|
None => (Stage::Upcoming, false),
|
||||||
// Frozen is now an orthogonal CRDT flag (story 934, stage 4).
|
};
|
||||||
if i.is_frozen() {
|
|
||||||
"frozen"
|
|
||||||
} else {
|
|
||||||
match &i.stage {
|
|
||||||
crate::pipeline_state::Stage::Upcoming => "upcoming",
|
|
||||||
crate::pipeline_state::Stage::Backlog => "backlog",
|
|
||||||
crate::pipeline_state::Stage::Coding => "current",
|
|
||||||
crate::pipeline_state::Stage::Blocked { .. } => "blocked",
|
|
||||||
crate::pipeline_state::Stage::Qa => "qa",
|
|
||||||
crate::pipeline_state::Stage::Merge { .. } => "merge",
|
|
||||||
crate::pipeline_state::Stage::MergeFailure { .. } => "merge_failure",
|
|
||||||
crate::pipeline_state::Stage::Done { .. } => "done",
|
|
||||||
crate::pipeline_state::Stage::Archived { .. } => "archived",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string();
|
|
||||||
return Ok(WorkItemContent {
|
return Ok(WorkItemContent {
|
||||||
content,
|
content,
|
||||||
stage,
|
stage,
|
||||||
|
frozen,
|
||||||
name: crdt_name,
|
name: crdt_name,
|
||||||
agent: crdt_agent,
|
agent: crdt_agent,
|
||||||
});
|
});
|
||||||
@@ -364,7 +361,8 @@ 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, "backlog");
|
assert_eq!(item.stage, crate::pipeline_state::Stage::Backlog);
|
||||||
|
assert!(!item.frozen);
|
||||||
assert_eq!(item.name, Some("Foo Story".to_string()));
|
assert_eq!(item.name, Some("Foo Story".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
//! with `[project-name]` prefixes. The actual I/O (HTTP polling, spawning
|
//! with `[project-name]` prefixes. The actual I/O (HTTP polling, spawning
|
||||||
//! tasks, sending messages) lives in `io.rs`.
|
//! tasks, sending messages) lives in `io.rs`.
|
||||||
|
|
||||||
|
use crate::pipeline_state::Stage;
|
||||||
use crate::service::events::StoredEvent;
|
use crate::service::events::StoredEvent;
|
||||||
use crate::service::notifications::{
|
use crate::service::notifications::{
|
||||||
format_blocked_notification, format_error_notification, format_stage_notification,
|
format_blocked_notification, format_error_notification, format_stage_notification,
|
||||||
stage_display_name,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Format a [`StoredEvent`] from a project into a gateway notification.
|
/// Format a [`StoredEvent`] from a project into a gateway notification.
|
||||||
@@ -24,9 +24,9 @@ pub fn format_gateway_event(project_name: &str, event: &StoredEvent) -> (String,
|
|||||||
to_stage,
|
to_stage,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let from_display = stage_display_name(from_stage);
|
let from_typed = Stage::from_dir(from_stage).unwrap_or(Stage::Upcoming);
|
||||||
let to_display = stage_display_name(to_stage);
|
let to_typed = Stage::from_dir(to_stage).unwrap_or(Stage::Upcoming);
|
||||||
let (plain, html) = format_stage_notification(story_id, None, from_display, to_display);
|
let (plain, html) = format_stage_notification(story_id, None, &from_typed, &to_typed);
|
||||||
(format!("{prefix}{plain}"), format!("{prefix}{html}"))
|
(format!("{prefix}{plain}"), format!("{prefix}{html}"))
|
||||||
}
|
}
|
||||||
StoredEvent::MergeFailure {
|
StoredEvent::MergeFailure {
|
||||||
|
|||||||
@@ -4,22 +4,21 @@
|
|||||||
//! or borrowed string data. They return `(plain_text, html)` pairs suitable
|
//! or borrowed string data. They return `(plain_text, html)` pairs suitable
|
||||||
//! for `ChatTransport::send_message`.
|
//! for `ChatTransport::send_message`.
|
||||||
|
|
||||||
|
use crate::pipeline_state::Stage;
|
||||||
use crate::service::common::item_id::extract_item_number;
|
use crate::service::common::item_id::extract_item_number;
|
||||||
|
|
||||||
/// Human-readable display name for a pipeline stage directory.
|
/// Human-readable display name for a typed pipeline [`Stage`].
|
||||||
pub fn stage_display_name(stage: &str) -> &'static str {
|
pub fn stage_display_name(stage: &Stage) -> &'static str {
|
||||||
use crate::pipeline_state::Stage;
|
match stage {
|
||||||
match Stage::from_dir(stage) {
|
Stage::Upcoming => "Upcoming",
|
||||||
Some(Stage::Upcoming) => "Upcoming",
|
Stage::Backlog => "Backlog",
|
||||||
Some(Stage::Backlog) => "Backlog",
|
Stage::Coding => "Current",
|
||||||
Some(Stage::Coding) => "Current",
|
Stage::Blocked { .. } => "Blocked",
|
||||||
Some(Stage::Blocked { .. }) => "Blocked",
|
Stage::Qa => "QA",
|
||||||
Some(Stage::Qa) => "QA",
|
Stage::Merge { .. } => "Merge",
|
||||||
Some(Stage::Merge { .. }) => "Merge",
|
Stage::Done { .. } => "Done",
|
||||||
Some(Stage::Done { .. }) => "Done",
|
Stage::Archived { .. } => "Archived",
|
||||||
Some(Stage::Archived { .. }) => "Archived",
|
Stage::MergeFailure { .. } => "MergeFailure",
|
||||||
Some(Stage::MergeFailure { .. }) => "MergeFailure",
|
|
||||||
None => "Unknown",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,8 +28,8 @@ pub fn stage_display_name(stage: &str) -> &'static str {
|
|||||||
pub fn format_stage_notification(
|
pub fn format_stage_notification(
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
story_name: Option<&str>,
|
story_name: Option<&str>,
|
||||||
from_stage: &str,
|
from_stage: &Stage,
|
||||||
to_stage: &str,
|
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 = story_name.filter(|n| !n.is_empty());
|
||||||
@@ -39,10 +38,17 @@ pub fn format_stage_notification(
|
|||||||
.map(|n| format!("<em>{n}</em> "))
|
.map(|n| format!("<em>{n}</em> "))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let prefix = if to_stage == "Done" { "\u{1f389} " } else { "" };
|
let from_display = stage_display_name(from_stage);
|
||||||
let plain = format!("{prefix}#{number} {name_plain}\u{2014} {from_stage} \u{2192} {to_stage}");
|
let to_display = stage_display_name(to_stage);
|
||||||
|
let prefix = if matches!(to_stage, Stage::Done { .. }) {
|
||||||
|
"\u{1f389} "
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let plain =
|
||||||
|
format!("{prefix}#{number} {name_plain}\u{2014} {from_display} \u{2192} {to_display}");
|
||||||
let html = format!(
|
let html = format!(
|
||||||
"{prefix}<strong>#{number}</strong> {name_html}\u{2014} {from_stage} \u{2192} {to_stage}"
|
"{prefix}<strong>#{number}</strong> {name_html}\u{2014} {from_display} \u{2192} {to_display}"
|
||||||
);
|
);
|
||||||
(plain, html)
|
(plain, html)
|
||||||
}
|
}
|
||||||
@@ -207,29 +213,37 @@ mod tests {
|
|||||||
|
|
||||||
// ── stage_display_name ────────────────────────────────────────────────────
|
// ── stage_display_name ────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
fn done_stage() -> Stage {
|
||||||
fn stage_display_name_maps_all_known_stages() {
|
Stage::from_dir("done").unwrap()
|
||||||
assert_eq!(stage_display_name("backlog"), "Backlog");
|
}
|
||||||
assert_eq!(stage_display_name("coding"), "Current");
|
fn merge_stage() -> Stage {
|
||||||
assert_eq!(stage_display_name("qa"), "QA");
|
Stage::from_dir("merge").unwrap()
|
||||||
assert_eq!(stage_display_name("merge"), "Merge");
|
|
||||||
assert_eq!(stage_display_name("done"), "Done");
|
|
||||||
assert_eq!(stage_display_name("archived"), "Archived");
|
|
||||||
assert_eq!(stage_display_name("unknown"), "Unknown");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stage_display_name_unknown_slug_returns_unknown() {
|
fn stage_display_name_maps_all_known_stages() {
|
||||||
assert_eq!(stage_display_name("99_future"), "Unknown");
|
assert_eq!(stage_display_name(&Stage::Backlog), "Backlog");
|
||||||
assert_eq!(stage_display_name(""), "Unknown");
|
assert_eq!(stage_display_name(&Stage::Coding), "Current");
|
||||||
|
assert_eq!(stage_display_name(&Stage::Qa), "QA");
|
||||||
|
assert_eq!(stage_display_name(&merge_stage()), "Merge");
|
||||||
|
assert_eq!(stage_display_name(&done_stage()), "Done");
|
||||||
|
assert_eq!(
|
||||||
|
stage_display_name(&Stage::from_dir("archived").unwrap()),
|
||||||
|
"Archived"
|
||||||
|
);
|
||||||
|
assert_eq!(stage_display_name(&Stage::Upcoming), "Upcoming");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── format_stage_notification ─────────────────────────────────────────────
|
// ── format_stage_notification ─────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_notification_done_stage_includes_party_emoji() {
|
fn format_notification_done_stage_includes_party_emoji() {
|
||||||
let (plain, html) =
|
let (plain, html) = format_stage_notification(
|
||||||
format_stage_notification("353_story_done", Some("Done Story"), "Merge", "Done");
|
"353_story_done",
|
||||||
|
Some("Done Story"),
|
||||||
|
&merge_stage(),
|
||||||
|
&done_stage(),
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plain,
|
plain,
|
||||||
"\u{1f389} #353 Done Story \u{2014} Merge \u{2192} Done"
|
"\u{1f389} #353 Done Story \u{2014} Merge \u{2192} Done"
|
||||||
@@ -242,8 +256,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_notification_non_done_stage_has_no_emoji() {
|
fn format_notification_non_done_stage_has_no_emoji() {
|
||||||
let (plain, _html) =
|
let (plain, _html) = format_stage_notification(
|
||||||
format_stage_notification("42_story_thing", Some("Some Story"), "Backlog", "Current");
|
"42_story_thing",
|
||||||
|
Some("Some Story"),
|
||||||
|
&Stage::Backlog,
|
||||||
|
&Stage::Coding,
|
||||||
|
);
|
||||||
assert!(!plain.contains("\u{1f389}"));
|
assert!(!plain.contains("\u{1f389}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,8 +270,8 @@ mod tests {
|
|||||||
let (plain, html) = format_stage_notification(
|
let (plain, html) = format_stage_notification(
|
||||||
"261_story_bot_notifications",
|
"261_story_bot_notifications",
|
||||||
Some("Bot notifications"),
|
Some("Bot notifications"),
|
||||||
"Upcoming",
|
&Stage::Upcoming,
|
||||||
"Current",
|
&Stage::Coding,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plain,
|
plain,
|
||||||
@@ -267,15 +285,20 @@ 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) = format_stage_notification("42_bug_fix_thing", None, "Current", "QA");
|
let (plain, html) =
|
||||||
|
format_stage_notification("42_bug_fix_thing", None, &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) =
|
let (plain, _html) = format_stage_notification(
|
||||||
format_stage_notification("abc_story_thing", Some("Some Story"), "QA", "Merge");
|
"abc_story_thing",
|
||||||
|
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"
|
||||||
@@ -286,21 +309,26 @@ 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), "Current", "QA");
|
format_stage_notification("1_story_long", Some(&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) = format_stage_notification("42_story_empty", Some(""), "Current", "QA");
|
let (plain, html) =
|
||||||
|
format_stage_notification("42_story_empty", Some(""), &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_stage_notification_unicode_name() {
|
fn format_stage_notification_unicode_name() {
|
||||||
let (plain, html) =
|
let (plain, html) = format_stage_notification(
|
||||||
format_stage_notification("7_story_i18n", Some("Ünïcödé Ñämé 🎉"), "QA", "Merge");
|
"7_story_i18n",
|
||||||
|
Some("Ünïcödé Ñämé 🎉"),
|
||||||
|
&Stage::Qa,
|
||||||
|
&merge_stage(),
|
||||||
|
);
|
||||||
assert!(plain.contains("Ünïcödé Ñämé 🎉"));
|
assert!(plain.contains("Ünïcödé Ñämé 🎉"));
|
||||||
assert!(html.contains("Ünïcödé Ñämé 🎉"));
|
assert!(html.contains("Ünïcödé Ñämé 🎉"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
use crate::chat::ChatTransport;
|
use crate::chat::ChatTransport;
|
||||||
use crate::config::ProjectConfig;
|
use crate::config::ProjectConfig;
|
||||||
use crate::io::watcher::WatcherEvent;
|
use crate::io::watcher::WatcherEvent;
|
||||||
|
use crate::pipeline_state::Stage;
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -19,7 +20,7 @@ use super::super::format::{
|
|||||||
format_agent_completed_notification, format_agent_started_notification,
|
format_agent_completed_notification, format_agent_started_notification,
|
||||||
format_blocked_notification, format_error_notification, format_oauth_account_swapped,
|
format_blocked_notification, format_error_notification, format_oauth_account_swapped,
|
||||||
format_oauth_accounts_exhausted, format_rate_limit_notification, format_stage_notification,
|
format_oauth_accounts_exhausted, format_rate_limit_notification, format_stage_notification,
|
||||||
merge_failure_snippet, stage_display_name,
|
merge_failure_snippet,
|
||||||
};
|
};
|
||||||
use super::super::route::rooms_for_notification;
|
use super::super::route::rooms_for_notification;
|
||||||
use super::{find_story_name_any_stage, read_story_name};
|
use super::{find_story_name_any_stage, read_story_name};
|
||||||
@@ -47,11 +48,11 @@ pub fn spawn_notification_listener(
|
|||||||
let mut rate_limit_last_notified: HashMap<String, Instant> = HashMap::new();
|
let mut rate_limit_last_notified: HashMap<String, Instant> = HashMap::new();
|
||||||
|
|
||||||
// Pending stage-transition notifications, keyed by item_id.
|
// Pending stage-transition notifications, keyed by item_id.
|
||||||
// Value: (from_display, to_stage_key, story_name).
|
// Value: (from_stage, to_stage, story_name).
|
||||||
// Rapid successive transitions for the same item are coalesced: the
|
// Rapid successive transitions for the same item are coalesced: the
|
||||||
// original from_display is kept while to_stage_key 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, (String, String, Option<String>)> =
|
let mut pending_transitions: HashMap<String, (Stage, Stage, Option<String>)> =
|
||||||
HashMap::new();
|
HashMap::new();
|
||||||
let mut flush_deadline: Option<tokio::time::Instant> = None;
|
let mut flush_deadline: Option<tokio::time::Instant> = None;
|
||||||
|
|
||||||
@@ -83,15 +84,13 @@ pub fn spawn_notification_listener(
|
|||||||
let now = tokio::time::Instant::now();
|
let now = tokio::time::Instant::now();
|
||||||
// Flush stage transitions if their deadline has passed.
|
// Flush stage transitions if their deadline has passed.
|
||||||
if flush_deadline.is_some_and(|d| d <= now) {
|
if flush_deadline.is_some_and(|d| d <= now) {
|
||||||
for (item_id, (from_display, to_stage_key, story_name)) in
|
for (item_id, (from_stage, to_stage, story_name)) in pending_transitions.drain()
|
||||||
pending_transitions.drain()
|
|
||||||
{
|
{
|
||||||
let to_display = stage_display_name(&to_stage_key);
|
|
||||||
let (plain, html) = format_stage_notification(
|
let (plain, html) = format_stage_notification(
|
||||||
&item_id,
|
&item_id,
|
||||||
story_name.as_deref(),
|
story_name.as_deref(),
|
||||||
&from_display,
|
&from_stage,
|
||||||
to_display,
|
&to_stage,
|
||||||
);
|
);
|
||||||
slog!("[bot] Sending stage notification: {plain}");
|
slog!("[bot] Sending stage notification: {plain}");
|
||||||
if config.status_push_enabled {
|
if config.status_push_enabled {
|
||||||
@@ -135,15 +134,14 @@ pub fn spawn_notification_listener(
|
|||||||
slog!("[bot] Watcher channel closed, stopping notification listener");
|
slog!("[bot] Watcher channel closed, stopping notification listener");
|
||||||
// Flush any coalesced transitions that haven't fired yet.
|
// Flush any coalesced transitions that haven't fired yet.
|
||||||
if config.status_push_enabled {
|
if config.status_push_enabled {
|
||||||
for (item_id, (from_display, to_stage_key, story_name)) in
|
for (item_id, (from_stage, to_stage, story_name)) in
|
||||||
pending_transitions.drain()
|
pending_transitions.drain()
|
||||||
{
|
{
|
||||||
let to_display = stage_display_name(&to_stage_key);
|
|
||||||
let (plain, html) = format_stage_notification(
|
let (plain, html) = format_stage_notification(
|
||||||
&item_id,
|
&item_id,
|
||||||
story_name.as_deref(),
|
story_name.as_deref(),
|
||||||
&from_display,
|
&from_stage,
|
||||||
to_display,
|
&to_stage,
|
||||||
);
|
);
|
||||||
slog!("[bot] Sending stage notification: {plain}");
|
slog!("[bot] Sending stage notification: {plain}");
|
||||||
for room_id in &rooms_for_notification(&get_room_ids) {
|
for room_id in &rooms_for_notification(&get_room_ids) {
|
||||||
@@ -185,7 +183,11 @@ pub fn spawn_notification_listener(
|
|||||||
else {
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let from_display = stage_display_name(from_stage.as_deref().unwrap_or(""));
|
let from_typed = from_stage
|
||||||
|
.as_deref()
|
||||||
|
.and_then(Stage::from_dir)
|
||||||
|
.unwrap_or(Stage::Upcoming);
|
||||||
|
let to_typed = Stage::from_dir(stage).unwrap_or(Stage::Upcoming);
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -193,17 +195,17 @@ pub fn spawn_notification_listener(
|
|||||||
.or_else(|| find_story_name_any_stage(&project_root, item_id));
|
.or_else(|| find_story_name_any_stage(&project_root, item_id));
|
||||||
|
|
||||||
// Buffer the transition. If this item_id is already pending (rapid
|
// Buffer the transition. If this item_id is already pending (rapid
|
||||||
// succession), update to_stage_key to the latest destination while
|
// succession), update the destination stage to the latest while
|
||||||
// preserving the original from_display.
|
// preserving the original from_stage.
|
||||||
pending_transitions
|
pending_transitions
|
||||||
.entry(item_id.clone())
|
.entry(item_id.clone())
|
||||||
.and_modify(|e| {
|
.and_modify(|e| {
|
||||||
e.1 = stage.clone();
|
e.1 = to_typed.clone();
|
||||||
if story_name.is_some() {
|
if story_name.is_some() {
|
||||||
e.2 = story_name.clone();
|
e.2 = story_name.clone();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.or_insert_with(|| (from_display.to_string(), stage.clone(), story_name));
|
.or_insert_with(|| (from_typed, to_typed, story_name));
|
||||||
|
|
||||||
// Start or extend the debounce window.
|
// Start or extend the debounce window.
|
||||||
flush_deadline = Some(tokio::time::Instant::now() + STAGE_TRANSITION_DEBOUNCE);
|
flush_deadline = Some(tokio::time::Instant::now() + STAGE_TRANSITION_DEBOUNCE);
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ pub(super) mod route;
|
|||||||
|
|
||||||
pub use format::{
|
pub use format::{
|
||||||
format_blocked_notification, format_error_notification, format_stage_notification,
|
format_blocked_notification, format_error_notification, format_stage_notification,
|
||||||
stage_display_name,
|
|
||||||
};
|
};
|
||||||
pub use io::spawn_notification_listener;
|
pub use io::spawn_notification_listener;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! a human-readable string. Adding a new event type means adding one match arm
|
//! a human-readable string. Adding a new event type means adding one match arm
|
||||||
//! here — no per-transport duplication anywhere in the codebase.
|
//! here — no per-transport duplication anywhere in the codebase.
|
||||||
|
|
||||||
|
use crate::pipeline_state::Stage;
|
||||||
use crate::service::common::item_id::extract_item_number;
|
use crate::service::common::item_id::extract_item_number;
|
||||||
use crate::service::notifications::format::stage_display_name;
|
use crate::service::notifications::format::stage_display_name;
|
||||||
use crate::service::status::StatusEvent;
|
use crate::service::status::StatusEvent;
|
||||||
@@ -25,9 +26,15 @@ pub fn format_status_event(event: &StatusEvent) -> String {
|
|||||||
} => {
|
} => {
|
||||||
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 = story_name.as_deref().unwrap_or(story_id.as_str());
|
||||||
let from = stage_display_name(from_stage);
|
let from_typed = Stage::from_dir(from_stage).unwrap_or(Stage::Upcoming);
|
||||||
let to = stage_display_name(to_stage);
|
let to_typed = Stage::from_dir(to_stage).unwrap_or(Stage::Upcoming);
|
||||||
let prefix = if to == "Done" { "\u{1f389} " } else { "" };
|
let from = stage_display_name(&from_typed);
|
||||||
|
let to = stage_display_name(&to_typed);
|
||||||
|
let prefix = if matches!(to_typed, Stage::Done { .. }) {
|
||||||
|
"\u{1f389} "
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
format!("{prefix}#{number} {name} \u{2014} {from} \u{2192} {to}")
|
format!("{prefix}#{number} {name} \u{2014} {from} \u{2192} {to}")
|
||||||
}
|
}
|
||||||
StatusEvent::MergeFailure {
|
StatusEvent::MergeFailure {
|
||||||
|
|||||||
Reference in New Issue
Block a user