Renames the config directory and updates 514 references across 42 Rust source files, plus CLAUDE.md, .gitignore, Makefile, script/release, and .mcp.json files. All 1205 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
399 lines
14 KiB
Rust
399 lines
14 KiB
Rust
//! Stage transition notifications for Matrix rooms.
|
|
//!
|
|
//! Subscribes to [`WatcherEvent`] broadcasts and posts a notification to all
|
|
//! configured Matrix rooms whenever a work item moves between pipeline stages.
|
|
|
|
use crate::io::story_metadata::parse_front_matter;
|
|
use crate::io::watcher::WatcherEvent;
|
|
use crate::slog;
|
|
use crate::transport::ChatTransport;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use tokio::sync::broadcast;
|
|
|
|
/// Human-readable display name for a pipeline stage directory.
|
|
pub fn stage_display_name(stage: &str) -> &'static str {
|
|
match stage {
|
|
"1_backlog" => "Backlog",
|
|
"2_current" => "Current",
|
|
"3_qa" => "QA",
|
|
"4_merge" => "Merge",
|
|
"5_done" => "Done",
|
|
"6_archived" => "Archived",
|
|
_ => "Unknown",
|
|
}
|
|
}
|
|
|
|
/// Infer the previous pipeline stage for a given destination stage.
|
|
///
|
|
/// Returns `None` for `1_backlog` since items are created there (not
|
|
/// transitioned from another stage).
|
|
pub fn inferred_from_stage(to_stage: &str) -> Option<&'static str> {
|
|
match to_stage {
|
|
"2_current" => Some("Backlog"),
|
|
"3_qa" => Some("Current"),
|
|
"4_merge" => Some("QA"),
|
|
"5_done" => Some("Merge"),
|
|
"6_archived" => Some("Done"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Extract the numeric story number from an item ID like `"261_story_slug"`.
|
|
pub fn extract_story_number(item_id: &str) -> Option<&str> {
|
|
item_id
|
|
.split('_')
|
|
.next()
|
|
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
|
}
|
|
|
|
/// Read the story name from the work item file's YAML front matter.
|
|
///
|
|
/// Returns `None` if the file doesn't exist or has no parseable name.
|
|
pub fn read_story_name(project_root: &Path, stage: &str, item_id: &str) -> Option<String> {
|
|
let path = project_root
|
|
.join(".storkit")
|
|
.join("work")
|
|
.join(stage)
|
|
.join(format!("{item_id}.md"));
|
|
let contents = std::fs::read_to_string(&path).ok()?;
|
|
let meta = parse_front_matter(&contents).ok()?;
|
|
meta.name
|
|
}
|
|
|
|
/// Format a stage transition notification message.
|
|
///
|
|
/// Returns `(plain_text, html)` suitable for `RoomMessageEventContent::text_html`.
|
|
pub fn format_stage_notification(
|
|
item_id: &str,
|
|
story_name: Option<&str>,
|
|
from_stage: &str,
|
|
to_stage: &str,
|
|
) -> (String, String) {
|
|
let number = extract_story_number(item_id).unwrap_or(item_id);
|
|
let name = story_name.unwrap_or(item_id);
|
|
|
|
let prefix = if to_stage == "Done" { "\u{1f389} " } else { "" };
|
|
let plain = format!("{prefix}#{number} {name} \u{2014} {from_stage} \u{2192} {to_stage}");
|
|
let html = format!(
|
|
"{prefix}<strong>#{number}</strong> <em>{name}</em> \u{2014} {from_stage} \u{2192} {to_stage}"
|
|
);
|
|
(plain, html)
|
|
}
|
|
|
|
/// Format an error notification message for a story failure.
|
|
///
|
|
/// Returns `(plain_text, html)` suitable for `RoomMessageEventContent::text_html`.
|
|
pub fn format_error_notification(
|
|
item_id: &str,
|
|
story_name: Option<&str>,
|
|
reason: &str,
|
|
) -> (String, String) {
|
|
let number = extract_story_number(item_id).unwrap_or(item_id);
|
|
let name = story_name.unwrap_or(item_id);
|
|
|
|
let plain = format!("\u{274c} #{number} {name} \u{2014} {reason}");
|
|
let html = format!(
|
|
"\u{274c} <strong>#{number}</strong> <em>{name}</em> \u{2014} {reason}"
|
|
);
|
|
(plain, html)
|
|
}
|
|
|
|
/// Spawn a background task that listens for watcher events and posts
|
|
/// stage-transition notifications to all configured rooms via the
|
|
/// [`ChatTransport`] abstraction.
|
|
pub fn spawn_notification_listener(
|
|
transport: Arc<dyn ChatTransport>,
|
|
room_ids: Vec<String>,
|
|
watcher_rx: broadcast::Receiver<WatcherEvent>,
|
|
project_root: PathBuf,
|
|
) {
|
|
tokio::spawn(async move {
|
|
let mut rx = watcher_rx;
|
|
loop {
|
|
match rx.recv().await {
|
|
Ok(WatcherEvent::WorkItem {
|
|
ref stage,
|
|
ref item_id,
|
|
..
|
|
}) => {
|
|
// Only notify on stage transitions, not creations.
|
|
let Some(from_display) = inferred_from_stage(stage) else {
|
|
continue;
|
|
};
|
|
let to_display = stage_display_name(stage);
|
|
|
|
let story_name = read_story_name(&project_root, stage, item_id);
|
|
let (plain, html) = format_stage_notification(
|
|
item_id,
|
|
story_name.as_deref(),
|
|
from_display,
|
|
to_display,
|
|
);
|
|
|
|
slog!("[matrix-bot] Sending stage notification: {plain}");
|
|
|
|
for room_id in &room_ids {
|
|
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
|
slog!(
|
|
"[matrix-bot] Failed to send notification to {room_id}: {e}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Ok(WatcherEvent::MergeFailure {
|
|
ref story_id,
|
|
ref reason,
|
|
}) => {
|
|
let story_name =
|
|
read_story_name(&project_root, "4_merge", story_id);
|
|
let (plain, html) = format_error_notification(
|
|
story_id,
|
|
story_name.as_deref(),
|
|
reason,
|
|
);
|
|
|
|
slog!("[matrix-bot] Sending error notification: {plain}");
|
|
|
|
for room_id in &room_ids {
|
|
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
|
slog!(
|
|
"[matrix-bot] Failed to send error notification to {room_id}: {e}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Ok(_) => {} // Ignore non-work-item events
|
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
|
slog!(
|
|
"[matrix-bot] Notification listener lagged, skipped {n} events"
|
|
);
|
|
}
|
|
Err(broadcast::error::RecvError::Closed) => {
|
|
slog!(
|
|
"[matrix-bot] Watcher channel closed, stopping notification listener"
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// ── stage_display_name ──────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn stage_display_name_maps_all_known_stages() {
|
|
assert_eq!(stage_display_name("1_backlog"), "Backlog");
|
|
assert_eq!(stage_display_name("2_current"), "Current");
|
|
assert_eq!(stage_display_name("3_qa"), "QA");
|
|
assert_eq!(stage_display_name("4_merge"), "Merge");
|
|
assert_eq!(stage_display_name("5_done"), "Done");
|
|
assert_eq!(stage_display_name("6_archived"), "Archived");
|
|
assert_eq!(stage_display_name("unknown"), "Unknown");
|
|
}
|
|
|
|
// ── inferred_from_stage ─────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn inferred_from_stage_returns_previous_stage() {
|
|
assert_eq!(inferred_from_stage("2_current"), Some("Backlog"));
|
|
assert_eq!(inferred_from_stage("3_qa"), Some("Current"));
|
|
assert_eq!(inferred_from_stage("4_merge"), Some("QA"));
|
|
assert_eq!(inferred_from_stage("5_done"), Some("Merge"));
|
|
assert_eq!(inferred_from_stage("6_archived"), Some("Done"));
|
|
}
|
|
|
|
#[test]
|
|
fn inferred_from_stage_returns_none_for_backlog() {
|
|
assert_eq!(inferred_from_stage("1_backlog"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn inferred_from_stage_returns_none_for_unknown() {
|
|
assert_eq!(inferred_from_stage("9_unknown"), None);
|
|
}
|
|
|
|
// ── extract_story_number ────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn extract_story_number_parses_numeric_prefix() {
|
|
assert_eq!(
|
|
extract_story_number("261_story_bot_notifications"),
|
|
Some("261")
|
|
);
|
|
assert_eq!(extract_story_number("42_bug_fix_thing"), Some("42"));
|
|
assert_eq!(extract_story_number("1_spike_research"), Some("1"));
|
|
}
|
|
|
|
#[test]
|
|
fn extract_story_number_returns_none_for_non_numeric() {
|
|
assert_eq!(extract_story_number("abc_story_thing"), None);
|
|
assert_eq!(extract_story_number(""), None);
|
|
}
|
|
|
|
// ── read_story_name ─────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn read_story_name_reads_from_front_matter() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let stage_dir = tmp
|
|
.path()
|
|
.join(".storkit")
|
|
.join("work")
|
|
.join("2_current");
|
|
std::fs::create_dir_all(&stage_dir).unwrap();
|
|
std::fs::write(
|
|
stage_dir.join("42_story_my_feature.md"),
|
|
"---\nname: My Cool Feature\n---\n# Story\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let name = read_story_name(tmp.path(), "2_current", "42_story_my_feature");
|
|
assert_eq!(name.as_deref(), Some("My Cool Feature"));
|
|
}
|
|
|
|
#[test]
|
|
fn read_story_name_returns_none_for_missing_file() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let name = read_story_name(tmp.path(), "2_current", "99_story_missing");
|
|
assert_eq!(name, None);
|
|
}
|
|
|
|
#[test]
|
|
fn read_story_name_returns_none_for_missing_name_field() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let stage_dir = tmp
|
|
.path()
|
|
.join(".storkit")
|
|
.join("work")
|
|
.join("2_current");
|
|
std::fs::create_dir_all(&stage_dir).unwrap();
|
|
std::fs::write(
|
|
stage_dir.join("42_story_no_name.md"),
|
|
"---\ncoverage_baseline: 50%\n---\n# Story\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let name = read_story_name(tmp.path(), "2_current", "42_story_no_name");
|
|
assert_eq!(name, None);
|
|
}
|
|
|
|
// ── format_error_notification ────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn format_error_notification_with_story_name() {
|
|
let (plain, html) =
|
|
format_error_notification("262_story_bot_errors", Some("Bot error notifications"), "merge conflict in src/main.rs");
|
|
assert_eq!(
|
|
plain,
|
|
"\u{274c} #262 Bot error notifications \u{2014} merge conflict in src/main.rs"
|
|
);
|
|
assert_eq!(
|
|
html,
|
|
"\u{274c} <strong>#262</strong> <em>Bot error notifications</em> \u{2014} merge conflict in src/main.rs"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_error_notification_without_story_name_falls_back_to_item_id() {
|
|
let (plain, _html) =
|
|
format_error_notification("42_bug_fix_thing", None, "tests failed");
|
|
assert_eq!(
|
|
plain,
|
|
"\u{274c} #42 42_bug_fix_thing \u{2014} tests failed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_error_notification_non_numeric_id_uses_full_id() {
|
|
let (plain, _html) =
|
|
format_error_notification("abc_story_thing", Some("Some Story"), "clippy errors");
|
|
assert_eq!(
|
|
plain,
|
|
"\u{274c} #abc_story_thing Some Story \u{2014} clippy errors"
|
|
);
|
|
}
|
|
|
|
// ── format_stage_notification ───────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn format_notification_done_stage_includes_party_emoji() {
|
|
let (plain, html) = format_stage_notification(
|
|
"353_story_done",
|
|
Some("Done Story"),
|
|
"Merge",
|
|
"Done",
|
|
);
|
|
assert_eq!(
|
|
plain,
|
|
"\u{1f389} #353 Done Story \u{2014} Merge \u{2192} Done"
|
|
);
|
|
assert_eq!(
|
|
html,
|
|
"\u{1f389} <strong>#353</strong> <em>Done Story</em> \u{2014} Merge \u{2192} Done"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_notification_non_done_stage_has_no_emoji() {
|
|
let (plain, _html) = format_stage_notification(
|
|
"42_story_thing",
|
|
Some("Some Story"),
|
|
"Backlog",
|
|
"Current",
|
|
);
|
|
assert!(!plain.contains("\u{1f389}"));
|
|
}
|
|
|
|
#[test]
|
|
fn format_notification_with_story_name() {
|
|
let (plain, html) = format_stage_notification(
|
|
"261_story_bot_notifications",
|
|
Some("Bot notifications"),
|
|
"Upcoming",
|
|
"Current",
|
|
);
|
|
assert_eq!(
|
|
plain,
|
|
"#261 Bot notifications \u{2014} Upcoming \u{2192} Current"
|
|
);
|
|
assert_eq!(
|
|
html,
|
|
"<strong>#261</strong> <em>Bot notifications</em> \u{2014} Upcoming \u{2192} Current"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_notification_without_story_name_falls_back_to_item_id() {
|
|
let (plain, _html) = format_stage_notification(
|
|
"42_bug_fix_thing",
|
|
None,
|
|
"Current",
|
|
"QA",
|
|
);
|
|
assert_eq!(
|
|
plain,
|
|
"#42 42_bug_fix_thing \u{2014} Current \u{2192} QA"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_notification_non_numeric_id_uses_full_id() {
|
|
let (plain, _html) = format_stage_notification(
|
|
"abc_story_thing",
|
|
Some("Some Story"),
|
|
"QA",
|
|
"Merge",
|
|
);
|
|
assert_eq!(
|
|
plain,
|
|
"#abc_story_thing Some Story \u{2014} QA \u{2192} Merge"
|
|
);
|
|
}
|
|
}
|