From 6969a9caff914831e7ea831dffb1b613d7b52ecf Mon Sep 17 00:00:00 2001 From: Eric Garcia Date: Sat, 24 Jan 2026 04:31:16 -0500 Subject: [PATCH] feat: Phase 9 - post-mortem and runbook tools Add 4 tools: - blue_postmortem_create: Create post-mortem documents for incident tracking - blue_postmortem_action_to_rfc: Convert post-mortem action items to RFCs - blue_runbook_create: Create runbook documents for operations - blue_runbook_update: Update runbooks with new operations/troubleshooting Also adds DocType::Postmortem and DocType::Runbook to blue-core. Total: 54 tools ported from coherence-mcp Co-Authored-By: Claude Opus 4.5 --- crates/blue-core/src/store.rs | 8 + crates/blue-mcp/src/handlers/mod.rs | 2 + crates/blue-mcp/src/handlers/postmortem.rs | 486 +++++++++++++++++++++ crates/blue-mcp/src/handlers/runbook.rs | 343 +++++++++++++++ crates/blue-mcp/src/server.rs | 163 +++++++ 5 files changed, 1002 insertions(+) create mode 100644 crates/blue-mcp/src/handlers/postmortem.rs create mode 100644 crates/blue-mcp/src/handlers/runbook.rs diff --git a/crates/blue-core/src/store.rs b/crates/blue-core/src/store.rs index 60915ec..30f1805 100644 --- a/crates/blue-core/src/store.rs +++ b/crates/blue-core/src/store.rs @@ -162,6 +162,8 @@ pub enum DocType { Adr, Decision, Prd, + Postmortem, + Runbook, } impl DocType { @@ -172,6 +174,8 @@ impl DocType { DocType::Adr => "adr", DocType::Decision => "decision", DocType::Prd => "prd", + DocType::Postmortem => "postmortem", + DocType::Runbook => "runbook", } } @@ -182,6 +186,8 @@ impl DocType { "adr" => Some(DocType::Adr), "decision" => Some(DocType::Decision), "prd" => Some(DocType::Prd), + "postmortem" => Some(DocType::Postmortem), + "runbook" => Some(DocType::Runbook), _ => None, } } @@ -194,6 +200,8 @@ impl DocType { DocType::Adr => "ADRs", DocType::Decision => "decisions", DocType::Prd => "PRDs", + DocType::Postmortem => "post-mortems", + DocType::Runbook => "runbooks", } } } diff --git a/crates/blue-mcp/src/handlers/mod.rs b/crates/blue-mcp/src/handlers/mod.rs index 1775a9f..708ef16 100644 --- a/crates/blue-mcp/src/handlers/mod.rs +++ b/crates/blue-mcp/src/handlers/mod.rs @@ -11,11 +11,13 @@ pub mod env; pub mod guide; pub mod lint; pub mod playwright; +pub mod postmortem; pub mod pr; pub mod prd; pub mod release; pub mod reminder; pub mod rfc; +pub mod runbook; pub mod session; pub mod spike; pub mod staging; diff --git a/crates/blue-mcp/src/handlers/postmortem.rs b/crates/blue-mcp/src/handlers/postmortem.rs new file mode 100644 index 0000000..99444b6 --- /dev/null +++ b/crates/blue-mcp/src/handlers/postmortem.rs @@ -0,0 +1,486 @@ +//! Post-Mortem tool handlers +//! +//! Handles post-mortem creation and action item tracking. + +use std::fs; +use std::path::PathBuf; + +use blue_core::{DocType, Document, ProjectState, Rfc}; +use serde_json::{json, Value}; + +use crate::error::ServerError; + +/// Severity levels for post-mortems +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Severity { + P1, // Critical - major outage + P2, // High - significant impact + P3, // Medium - moderate impact + P4, // Low - minor impact +} + +impl Severity { + pub fn from_str(s: &str) -> Option { + match s.to_uppercase().as_str() { + "P1" | "CRITICAL" => Some(Severity::P1), + "P2" | "HIGH" => Some(Severity::P2), + "P3" | "MEDIUM" => Some(Severity::P3), + "P4" | "LOW" => Some(Severity::P4), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Severity::P1 => "P1", + Severity::P2 => "P2", + Severity::P3 => "P3", + Severity::P4 => "P4", + } + } +} + +/// Handle blue_postmortem_create +pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result { + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let severity_str = args + .get("severity") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let severity = Severity::from_str(severity_str).ok_or_else(|| { + ServerError::CommandFailed(format!( + "Invalid severity '{}'. Use P1, P2, P3, or P4.", + severity_str + )) + })?; + + let summary = args.get("summary").and_then(|v| v.as_str()); + let root_cause = args.get("root_cause").and_then(|v| v.as_str()); + let duration = args.get("duration").and_then(|v| v.as_str()); + + let impact: Vec = args + .get("impact") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + // Get next postmortem number + let pm_number = state + .store + .next_number(DocType::Postmortem) + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + + // Generate file path with date prefix + let date = chrono::Local::now().format("%Y-%m-%d").to_string(); + let file_name = format!("{}-{}.md", date, to_kebab_case(title)); + let file_path = PathBuf::from("postmortems").join(&file_name); + let docs_path = state.home.docs_path(&state.project); + let pm_path = docs_path.join(&file_path); + + // Generate markdown content + let markdown = generate_postmortem_markdown(title, severity, summary, root_cause, duration, &impact); + + // Create document in SQLite store + let doc = Document { + id: None, + doc_type: DocType::Postmortem, + number: Some(pm_number), + title: title.to_string(), + status: "open".to_string(), + file_path: Some(file_path.to_string_lossy().to_string()), + created_at: None, + updated_at: None, + }; + state + .store + .add_document(&doc) + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + + // Create postmortems directory if it doesn't exist + if let Some(parent) = pm_path.parent() { + fs::create_dir_all(parent).map_err(|e| ServerError::CommandFailed(e.to_string()))?; + } + fs::write(&pm_path, &markdown).map_err(|e| ServerError::CommandFailed(e.to_string()))?; + + let hint = "Post-mortem created. Fill in the timeline and lessons learned sections."; + + Ok(json!({ + "status": "success", + "message": blue_core::voice::info( + &format!("Post-mortem created: {}", title), + Some(hint) + ), + "title": title, + "severity": severity.as_str(), + "file": pm_path.display().to_string(), + "content": markdown, + })) +} + +/// Handle blue_postmortem_action_to_rfc +pub fn handle_action_to_rfc(state: &mut ProjectState, args: &Value) -> Result { + let postmortem_title = args + .get("postmortem_title") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let action = args + .get("action") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let rfc_title_override = args.get("rfc_title").and_then(|v| v.as_str()); + + // Find the post-mortem + let pm_doc = state + .store + .find_document(DocType::Postmortem, postmortem_title) + .map_err(|_| { + ServerError::NotFound(format!("Post-mortem '{}' not found", postmortem_title)) + })?; + + let pm_file_path = pm_doc.file_path.as_ref().ok_or_else(|| { + ServerError::CommandFailed("Post-mortem has no file path".to_string()) + })?; + + let docs_path = state.home.docs_path(&state.project); + let pm_path = docs_path.join(pm_file_path); + let pm_content = fs::read_to_string(&pm_path) + .map_err(|e| ServerError::CommandFailed(format!("Failed to read post-mortem: {}", e)))?; + + // Find the action item + let (action_idx, action_description) = find_action_item(&pm_content, action)?; + + // Generate RFC title from action item if not provided + let rfc_title = rfc_title_override + .map(String::from) + .unwrap_or_else(|| { + action_description + .chars() + .take(50) + .collect::() + .trim() + .to_string() + }); + + // Create RFC with post-mortem reference + let mut rfc = Rfc::new(&rfc_title); + rfc.problem = Some(format!( + "From post-mortem: {}\n\nAction item: {}", + postmortem_title, action_description + )); + + // Get next RFC number + let rfc_number = state + .store + .next_number(DocType::Rfc) + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + + // Generate file path + let rfc_file_name = format!("{:04}-{}.md", rfc_number, to_kebab_case(&rfc_title)); + let rfc_file_path = PathBuf::from("rfcs").join(&rfc_file_name); + let rfc_path = docs_path.join(&rfc_file_path); + + // Generate RFC markdown with post-mortem link + let mut markdown = rfc.to_markdown(rfc_number as u32); + + // Add source post-mortem link + let pm_link = format!( + "| **Source Post-Mortem** | [{}](../postmortems/{}) |", + postmortem_title, + pm_file_path.replace("postmortems/", "") + ); + markdown = markdown.replace( + "| **Status** | Draft |", + &format!("| **Status** | Draft |\n{}", pm_link), + ); + + // Create RFC document in store + let rfc_doc = Document { + id: None, + doc_type: DocType::Rfc, + number: Some(rfc_number), + title: rfc_title.clone(), + status: "draft".to_string(), + file_path: Some(rfc_file_path.to_string_lossy().to_string()), + created_at: None, + updated_at: None, + }; + state + .store + .add_document(&rfc_doc) + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + + // Write RFC file + if let Some(parent) = rfc_path.parent() { + fs::create_dir_all(parent).map_err(|e| ServerError::CommandFailed(e.to_string()))?; + } + fs::write(&rfc_path, &markdown).map_err(|e| ServerError::CommandFailed(e.to_string()))?; + + // Update post-mortem action item with RFC link + let updated_pm_content = update_action_item_with_rfc( + &pm_content, + action_idx, + &format!("RFC {:04}: {}", rfc_number, rfc_title), + ); + fs::write(&pm_path, updated_pm_content) + .map_err(|e| ServerError::CommandFailed(format!("Failed to update post-mortem: {}", e)))?; + + let hint = format!( + "RFC created from post-mortem action item. Review and expand the design: {}", + rfc_path.display() + ); + + Ok(json!({ + "status": "success", + "message": blue_core::voice::info( + &format!("RFC {:04} created from post-mortem", rfc_number), + Some(&hint) + ), + "rfc_title": rfc_title, + "rfc_number": rfc_number, + "rfc_file": rfc_path.display().to_string(), + "source_postmortem": postmortem_title, + "action_item": action_description, + "action_index": action_idx, + })) +} + +/// Find action item by index or substring +fn find_action_item(content: &str, identifier: &str) -> Result<(usize, String), ServerError> { + let actions = parse_all_actions(content); + + // Try to parse as index first + if let Ok(idx) = identifier.parse::() { + if idx > 0 && idx <= actions.len() { + return Ok((idx, actions[idx - 1].clone())); + } + return Err(ServerError::NotFound(format!( + "Action item #{} not found. Found {} action items.", + idx, + actions.len() + ))); + } + + // Try substring match + for (i, action) in actions.iter().enumerate() { + if action.to_lowercase().contains(&identifier.to_lowercase()) { + return Ok((i + 1, action.clone())); + } + } + + Err(ServerError::NotFound(format!( + "No action item matching '{}' found", + identifier + ))) +} + +/// Parse all action items from post-mortem content +fn parse_all_actions(content: &str) -> Vec { + let mut actions = Vec::new(); + let mut in_actions_section = false; + + for line in content.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with('#') && trimmed.to_lowercase().contains("action item") { + in_actions_section = true; + continue; + } + + if in_actions_section && trimmed.starts_with('#') { + break; + } + + if in_actions_section && trimmed.starts_with('|') && !trimmed.contains("---") { + let parts: Vec<&str> = trimmed.split('|').map(|s| s.trim()).collect(); + if parts.len() >= 2 && !parts[1].is_empty() && parts[1] != "Item" { + actions.push(parts[1].to_string()); + } + } + } + + actions +} + +/// Update action item row with RFC link +fn update_action_item_with_rfc(content: &str, action_idx: usize, rfc_ref: &str) -> String { + let mut lines: Vec = content.lines().map(String::from).collect(); + let mut in_actions_section = false; + let mut current_action = 0; + + for line in lines.iter_mut() { + let trimmed = line.trim(); + + if trimmed.starts_with('#') && trimmed.to_lowercase().contains("action item") { + in_actions_section = true; + continue; + } + + if in_actions_section && trimmed.starts_with('#') { + break; + } + + if in_actions_section && trimmed.starts_with('|') && !trimmed.contains("---") { + let parts: Vec<&str> = trimmed.split('|').map(|s| s.trim()).collect(); + if parts.len() >= 2 && !parts[1].is_empty() && parts[1] != "Item" { + current_action += 1; + if current_action == action_idx { + // Update the RFC column (last column) + let mut new_parts: Vec = parts.iter().map(|s| s.to_string()).collect(); + if new_parts.len() > 5 { + new_parts[5] = rfc_ref.to_string(); + } else { + while new_parts.len() <= 5 { + new_parts.push(String::new()); + } + new_parts[5] = rfc_ref.to_string(); + } + *line = format!("| {} |", new_parts[1..].join(" | ")); + } + } + } + } + + lines.join("\n") +} + +/// Generate post-mortem markdown content +fn generate_postmortem_markdown( + title: &str, + severity: Severity, + summary: Option<&str>, + root_cause: Option<&str>, + duration: Option<&str>, + impact: &[String], +) -> String { + let mut md = String::new(); + let date = chrono::Local::now().format("%Y-%m-%d").to_string(); + + // Title + md.push_str(&format!( + "# Post-Mortem: {}\n\n", + to_title_case(title) + )); + + // Metadata table + md.push_str("| | |\n|---|---|\n"); + md.push_str(&format!("| **Date** | {} |\n", date)); + md.push_str(&format!("| **Severity** | {} |\n", severity.as_str())); + if let Some(dur) = duration { + md.push_str(&format!("| **Duration** | {} |\n", dur)); + } + md.push_str("| **Author** | [Name] |\n"); + md.push_str("\n---\n\n"); + + // Summary + md.push_str("## Summary\n\n"); + if let Some(sum) = summary { + md.push_str(sum); + } else { + md.push_str("[One paragraph summary of the incident]"); + } + md.push_str("\n\n"); + + // Timeline + md.push_str("## Timeline\n\n"); + md.push_str("| Time | Event |\n"); + md.push_str("|------|-------|\n"); + md.push_str("| HH:MM | [Event] |\n"); + md.push_str("\n"); + + // Root Cause + md.push_str("## Root Cause\n\n"); + if let Some(rc) = root_cause { + md.push_str(rc); + } else { + md.push_str("[What actually caused the incident]"); + } + md.push_str("\n\n"); + + // Impact + md.push_str("## Impact\n\n"); + if !impact.is_empty() { + for item in impact { + md.push_str(&format!("- {}\n", item)); + } + } else { + md.push_str("- [Impact 1]\n"); + } + md.push_str("\n"); + + // What Went Well + md.push_str("## What Went Well\n\n"); + md.push_str("- [Item 1]\n\n"); + + // What Went Wrong + md.push_str("## What Went Wrong\n\n"); + md.push_str("- [Item 1]\n\n"); + + // Action Items + md.push_str("## Action Items\n\n"); + md.push_str("| Item | Owner | Due | Status | RFC |\n"); + md.push_str("|------|-------|-----|--------|-----|\n"); + md.push_str("| [Action 1] | [Name] | [Date] | Open | |\n"); + md.push_str("\n"); + + // Lessons Learned + md.push_str("## Lessons Learned\n\n"); + md.push_str("[Key takeaways from this incident]\n"); + + md +} + +/// Convert a title to kebab-case for filenames +fn to_kebab_case(s: &str) -> String { + s.to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +/// Convert slug to title case +fn to_title_case(s: &str) -> String { + s.split('-') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(chars).collect(), + } + }) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_severity_from_str() { + assert_eq!(Severity::from_str("P1"), Some(Severity::P1)); + assert_eq!(Severity::from_str("critical"), Some(Severity::P1)); + assert_eq!(Severity::from_str("P4"), Some(Severity::P4)); + assert_eq!(Severity::from_str("invalid"), None); + } + + #[test] + fn test_to_kebab_case() { + assert_eq!(to_kebab_case("Database Outage"), "database-outage"); + assert_eq!(to_kebab_case("API failure"), "api-failure"); + } +} diff --git a/crates/blue-mcp/src/handlers/runbook.rs b/crates/blue-mcp/src/handlers/runbook.rs new file mode 100644 index 0000000..d37a034 --- /dev/null +++ b/crates/blue-mcp/src/handlers/runbook.rs @@ -0,0 +1,343 @@ +//! Runbook tool handlers +//! +//! Handles runbook creation and updates with RFC linking. + +use std::fs; +use std::path::PathBuf; + +use blue_core::{DocType, Document, ProjectState}; +use serde_json::{json, Value}; + +use crate::error::ServerError; + +/// Handle blue_runbook_create +pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result { + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let source_rfc = args.get("source_rfc").and_then(|v| v.as_str()); + let service_name = args.get("service_name").and_then(|v| v.as_str()); + let owner = args.get("owner").and_then(|v| v.as_str()); + + let operations: Vec = args + .get("operations") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + // Validate source RFC exists if provided + let source_rfc_doc = if let Some(rfc_title) = source_rfc { + Some( + state + .store + .find_document(DocType::Rfc, rfc_title) + .map_err(|_| { + ServerError::NotFound(format!("Source RFC '{}' not found", rfc_title)) + })?, + ) + } else { + None + }; + + // Get next runbook number + let runbook_number = state + .store + .next_number(DocType::Runbook) + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + + // Generate file path + let file_name = format!("{}.md", to_kebab_case(title)); + let file_path = PathBuf::from("runbooks").join(&file_name); + let docs_path = state.home.docs_path(&state.project); + let runbook_path = docs_path.join(&file_path); + + // Generate markdown content + let markdown = generate_runbook_markdown(title, &source_rfc_doc, service_name, owner, &operations); + + // Create document in SQLite store + let doc = Document { + id: None, + doc_type: DocType::Runbook, + number: Some(runbook_number), + title: title.to_string(), + status: "active".to_string(), + file_path: Some(file_path.to_string_lossy().to_string()), + created_at: None, + updated_at: None, + }; + state + .store + .add_document(&doc) + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + + // Write the markdown file + if let Some(parent) = runbook_path.parent() { + fs::create_dir_all(parent).map_err(|e| ServerError::CommandFailed(e.to_string()))?; + } + fs::write(&runbook_path, &markdown).map_err(|e| ServerError::CommandFailed(e.to_string()))?; + + // Update source RFC with runbook link if provided + if let Some(ref rfc_doc) = source_rfc_doc { + if let Some(ref rfc_file_path) = rfc_doc.file_path { + let rfc_path = docs_path.join(rfc_file_path); + if rfc_path.exists() { + if let Ok(rfc_content) = fs::read_to_string(&rfc_path) { + let runbook_link = format!( + "| **Runbook** | [{}](../runbooks/{}) |", + title, file_name + ); + + // Insert after Status line if not already present + if !rfc_content.contains("| **Runbook** |") { + let updated_rfc = if rfc_content.contains("| **Status** | Implemented |") { + rfc_content.replace( + "| **Status** | Implemented |", + &format!("| **Status** | Implemented |\n{}", runbook_link), + ) + } else { + rfc_content + }; + let _ = fs::write(&rfc_path, updated_rfc); + } + } + } + } + } + + let hint = "Runbook created. Fill in the operation procedures and troubleshooting sections."; + + Ok(json!({ + "status": "success", + "message": blue_core::voice::info( + &format!("Runbook created: {}", title), + Some(hint) + ), + "title": title, + "file": runbook_path.display().to_string(), + "source_rfc": source_rfc, + "content": markdown, + })) +} + +/// Handle blue_runbook_update +pub fn handle_update(state: &mut ProjectState, args: &Value) -> Result { + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let add_operation = args.get("add_operation").and_then(|v| v.as_str()); + let add_troubleshooting = args.get("add_troubleshooting").and_then(|v| v.as_str()); + + // Find the runbook + let doc = state + .store + .find_document(DocType::Runbook, title) + .map_err(|_| ServerError::NotFound(format!("Runbook '{}' not found", title)))?; + + let runbook_file_path = doc.file_path.as_ref().ok_or_else(|| { + ServerError::CommandFailed("Runbook has no file path".to_string()) + })?; + + let docs_path = state.home.docs_path(&state.project); + let runbook_path = docs_path.join(runbook_file_path); + let mut content = fs::read_to_string(&runbook_path) + .map_err(|e| ServerError::CommandFailed(format!("Failed to read runbook: {}", e)))?; + + let mut changes = Vec::new(); + + // Add new operation if provided + if let Some(operation) = add_operation { + let operation_section = format!( + "\n### Operation: {}\n\n**When to use**: [Describe trigger condition]\n\n**Steps**:\n1. [Step 1]\n\n**Verification**:\n```bash\n# Verify success\n```\n\n**Rollback**:\n```bash\n# Rollback if needed\n```\n", + operation + ); + + // Insert before Troubleshooting section or at end + if content.contains("## Troubleshooting") { + content = content.replace( + "## Troubleshooting", + &format!("{}\n## Troubleshooting", operation_section), + ); + } else { + content.push_str(&operation_section); + } + changes.push(format!("Added operation: {}", operation)); + } + + // Add troubleshooting if provided + if let Some(troubleshooting) = add_troubleshooting { + let troubleshooting_section = format!( + "\n### Symptom: {}\n\n**Possible causes**:\n1. [Cause 1]\n\n**Resolution**:\n1. [Step 1]\n", + troubleshooting + ); + + // Insert into Troubleshooting section or create one + if content.contains("## Troubleshooting") { + if content.contains("## Escalation") { + content = content.replace( + "## Escalation", + &format!("{}\n## Escalation", troubleshooting_section), + ); + } else { + content.push_str(&troubleshooting_section); + } + } else { + content.push_str(&format!( + "\n## Troubleshooting\n{}", + troubleshooting_section + )); + } + changes.push(format!("Added troubleshooting: {}", troubleshooting)); + } + + // Write updated content + fs::write(&runbook_path, &content) + .map_err(|e| ServerError::CommandFailed(format!("Failed to write runbook: {}", e)))?; + + let hint = "Runbook updated. Review the changes and fill in details."; + + Ok(json!({ + "status": "success", + "message": blue_core::voice::info( + &format!("Runbook updated: {}", title), + Some(hint) + ), + "title": title, + "file": runbook_path.display().to_string(), + "changes": changes, + })) +} + +/// Generate runbook markdown content +fn generate_runbook_markdown( + title: &str, + source_rfc: &Option, + service_name: Option<&str>, + owner: Option<&str>, + operations: &[String], +) -> String { + let mut md = String::new(); + + // Title + md.push_str(&format!( + "# Runbook: {}\n\n", + to_title_case(title) + )); + + // Metadata table + md.push_str("| | |\n|---|---|\n"); + md.push_str("| **Status** | Active |\n"); + + if let Some(o) = owner { + md.push_str(&format!("| **Owner** | {} |\n", o)); + } + + let date = chrono::Local::now().format("%Y-%m-%d").to_string(); + md.push_str(&format!("| **Created** | {} |\n", date)); + + if let Some(ref rfc_doc) = source_rfc { + if let Some(ref rfc_file_path) = rfc_doc.file_path { + md.push_str(&format!( + "| **Source RFC** | [{}](../rfcs/{}) |\n", + rfc_doc.title, rfc_file_path.replace("rfcs/", "") + )); + } + } + + md.push_str("\n---\n\n"); + + // Overview + md.push_str("## Overview\n\n"); + if let Some(svc) = service_name { + md.push_str(&format!( + "This runbook covers operational procedures for **{}**.\n\n", + svc + )); + } else { + md.push_str("[Describe what this runbook covers]\n\n"); + } + + // Prerequisites + md.push_str("## Prerequisites\n\n"); + md.push_str("- [ ] Access to [system]\n"); + md.push_str("- [ ] Permissions for [action]\n\n"); + + // Common Operations + md.push_str("## Common Operations\n\n"); + + if !operations.is_empty() { + for op in operations { + md.push_str(&format!( + "### Operation: {}\n\n**When to use**: [Trigger condition]\n\n**Steps**:\n1. [Step 1]\n\n**Verification**:\n```bash\n# Command to verify success\n```\n\n**Rollback**:\n```bash\n# Command to rollback if needed\n```\n\n", + op + )); + } + } else { + md.push_str("### Operation 1: [Name]\n\n**When to use**: [Trigger condition]\n\n**Steps**:\n1. [Step 1]\n\n**Verification**:\n```bash\n# Command to verify success\n```\n\n**Rollback**:\n```bash\n# Command to rollback if needed\n```\n\n"); + } + + // Troubleshooting + md.push_str("## Troubleshooting\n\n"); + md.push_str("### Symptom: [Description]\n\n**Possible causes**:\n1. [Cause 1]\n\n**Resolution**:\n1. [Step 1]\n\n"); + + // Escalation + md.push_str("## Escalation\n\n"); + md.push_str("| Level | Contact | When |\n"); + md.push_str("|-------|---------|------|\n"); + md.push_str("| L1 | [Team] | [Condition] |\n"); + md.push_str("| L2 | [Team] | [Condition] |\n\n"); + + // Related Documents + md.push_str("## Related Documents\n\n"); + if source_rfc.is_some() { + md.push_str("- Source RFC (linked above)\n"); + } + md.push_str("- [Link to architecture]\n"); + md.push_str("- [Link to monitoring dashboard]\n"); + + md +} + +/// Convert a title to kebab-case for filenames +fn to_kebab_case(s: &str) -> String { + s.to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +/// Convert slug to title case +fn to_title_case(s: &str) -> String { + s.split('-') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(chars).collect(), + } + }) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_kebab_case() { + assert_eq!(to_kebab_case("Deploy Service"), "deploy-service"); + assert_eq!(to_kebab_case("API Gateway Runbook"), "api-gateway-runbook"); + } +} diff --git a/crates/blue-mcp/src/server.rs b/crates/blue-mcp/src/server.rs index 5cfa9ec..e0b6b08 100644 --- a/crates/blue-mcp/src/server.rs +++ b/crates/blue-mcp/src/server.rs @@ -46,6 +46,23 @@ impl BlueServer { self.state.as_ref().ok_or(ServerError::BlueNotDetected) } + fn ensure_state_mut(&mut self) -> Result<&mut ProjectState, ServerError> { + if self.state.is_none() { + let cwd = self.cwd.as_ref().ok_or(ServerError::BlueNotDetected)?; + let home = detect_blue(cwd).map_err(|_| ServerError::BlueNotDetected)?; + + // Try to get project name from the current path + let project = home.project_name.clone().unwrap_or_else(|| "default".to_string()); + + let state = ProjectState::load(home, &project) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + self.state = Some(state); + } + + self.state.as_mut().ok_or(ServerError::BlueNotDetected) + } + /// Handle a JSON-RPC request pub fn handle_request(&mut self, request: &str) -> String { let result = self.handle_request_inner(request); @@ -1202,6 +1219,118 @@ impl BlueServer { }, "required": ["task", "base_url"] } + }, + // Phase 9: Post-mortem tools + { + "name": "blue_postmortem_create", + "description": "Create a post-mortem document for incident tracking.", + "inputSchema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Post-mortem title" + }, + "severity": { + "type": "string", + "description": "Severity level (P1, P2, P3, P4)" + }, + "summary": { + "type": "string", + "description": "Brief incident summary" + }, + "root_cause": { + "type": "string", + "description": "Root cause of the incident" + }, + "duration": { + "type": "string", + "description": "Incident duration" + }, + "impact": { + "type": "array", + "items": { "type": "string" }, + "description": "Impact items" + } + }, + "required": ["title", "severity"] + } + }, + { + "name": "blue_postmortem_action_to_rfc", + "description": "Convert a post-mortem action item into an RFC with bidirectional linking.", + "inputSchema": { + "type": "object", + "properties": { + "postmortem_title": { + "type": "string", + "description": "Title of the post-mortem" + }, + "action": { + "type": "string", + "description": "Action item index (1-based) or substring to match" + }, + "rfc_title": { + "type": "string", + "description": "Optional RFC title (defaults to action item text)" + } + }, + "required": ["postmortem_title", "action"] + } + }, + // Phase 9: Runbook tools + { + "name": "blue_runbook_create", + "description": "Create a runbook document for operational procedures.", + "inputSchema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Runbook title" + }, + "source_rfc": { + "type": "string", + "description": "Source RFC title to link" + }, + "service_name": { + "type": "string", + "description": "Service or feature name" + }, + "owner": { + "type": "string", + "description": "Owner team or person" + }, + "operations": { + "type": "array", + "items": { "type": "string" }, + "description": "Initial operations to document" + } + }, + "required": ["title"] + } + }, + { + "name": "blue_runbook_update", + "description": "Update an existing runbook with new operations or troubleshooting.", + "inputSchema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Runbook title" + }, + "add_operation": { + "type": "string", + "description": "New operation to add" + }, + "add_troubleshooting": { + "type": "string", + "description": "Troubleshooting section to add" + } + }, + "required": ["title"] + } } ] })) @@ -1284,6 +1413,12 @@ impl BlueServer { "blue_extract_dialogue" => self.handle_extract_dialogue(&call.arguments), // Phase 8: Playwright handler "blue_playwright_verify" => self.handle_playwright_verify(&call.arguments), + // Phase 9: Post-mortem handlers + "blue_postmortem_create" => self.handle_postmortem_create(&call.arguments), + "blue_postmortem_action_to_rfc" => self.handle_postmortem_action_to_rfc(&call.arguments), + // Phase 9: Runbook handlers + "blue_runbook_create" => self.handle_runbook_create(&call.arguments), + "blue_runbook_update" => self.handle_runbook_update(&call.arguments), _ => Err(ServerError::ToolNotFound(call.name)), }?; @@ -1945,6 +2080,34 @@ impl BlueServer { let args = args.as_ref().ok_or(ServerError::InvalidParams)?; crate::handlers::playwright::handle_verify(args) } + + // Phase 9: Post-mortem handlers + + fn handle_postmortem_create(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let state = self.ensure_state_mut()?; + crate::handlers::postmortem::handle_create(state, args) + } + + fn handle_postmortem_action_to_rfc(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let state = self.ensure_state_mut()?; + crate::handlers::postmortem::handle_action_to_rfc(state, args) + } + + // Phase 9: Runbook handlers + + fn handle_runbook_create(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let state = self.ensure_state_mut()?; + crate::handlers::runbook::handle_create(state, args) + } + + fn handle_runbook_update(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let state = self.ensure_state_mut()?; + crate::handlers::runbook::handle_update(state, args) + } } impl Default for BlueServer {