Add format_event_type() and calculate_duration() helpers to convert raw timeline events into human-readable tables and metrics. RCA now includes an Incident Timeline section and Incident Metrics (event count, duration, time-to-root-cause). Postmortem replaces placeholder timeline rows with real events, calculates impact duration, and auto-populates What Went Well from evidence. 10 new Rust tests covering timeline rendering, duration calculation, and event type formatting.
358 lines
13 KiB
Rust
358 lines
13 KiB
Rust
use crate::db::models::IssueDetail;
|
|
use crate::docs::rca::{calculate_duration, format_event_type};
|
|
|
|
pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String {
|
|
let issue = &detail.issue;
|
|
|
|
let mut md = String::new();
|
|
|
|
md.push_str(&format!(
|
|
"# Blameless Post-Mortem: {title}\n\n",
|
|
title = issue.title
|
|
));
|
|
|
|
// Header metadata
|
|
md.push_str("## Metadata\n\n");
|
|
md.push_str(&format!(
|
|
"- **Date:** {created_at}\n",
|
|
created_at = issue.created_at
|
|
));
|
|
md.push_str(&format!(
|
|
"- **Severity:** {severity}\n",
|
|
severity = issue.severity
|
|
));
|
|
md.push_str(&format!(
|
|
"- **Category:** {category}\n",
|
|
category = issue.category
|
|
));
|
|
md.push_str(&format!("- **Status:** {status}\n", status = issue.status));
|
|
md.push_str(&format!(
|
|
"- **Last Updated:** {updated_at}\n",
|
|
updated_at = issue.updated_at
|
|
));
|
|
md.push_str(&format!(
|
|
"- **Assigned To:** {}\n",
|
|
if issue.assigned_to.is_empty() {
|
|
"_Unassigned_"
|
|
} else {
|
|
&issue.assigned_to
|
|
}
|
|
));
|
|
md.push_str("- **Authors:** _[Add authors]_\n");
|
|
md.push_str("- **Reviewers:** _[Add reviewers]_\n\n");
|
|
|
|
// Executive Summary
|
|
md.push_str("## Executive Summary\n\n");
|
|
if !issue.description.is_empty() {
|
|
md.push_str(&issue.description);
|
|
md.push_str("\n\n");
|
|
} else {
|
|
md.push_str("_Provide a brief executive summary of the incident._\n\n");
|
|
}
|
|
|
|
// Impact
|
|
md.push_str("## Impact\n\n");
|
|
if detail.timeline_events.len() >= 2 {
|
|
let first = &detail.timeline_events[0].created_at;
|
|
let last = &detail.timeline_events[detail.timeline_events.len() - 1].created_at;
|
|
md.push_str(&format!(
|
|
"- **Duration:** {}\n",
|
|
calculate_duration(first, last)
|
|
));
|
|
} else {
|
|
md.push_str("- **Duration:** _[How long did the incident last?]_\n");
|
|
}
|
|
md.push_str("- **Users Affected:** _[Number/percentage of affected users]_\n");
|
|
md.push_str("- **Revenue Impact:** _[Financial impact, if applicable]_\n");
|
|
md.push_str("- **SLA Impact:** _[Were any SLAs breached?]_\n\n");
|
|
|
|
// Timeline
|
|
md.push_str("## Timeline\n\n");
|
|
md.push_str("| Time (UTC) | Event |\n");
|
|
md.push_str("|------------|-------|\n");
|
|
md.push_str(&format!(
|
|
"| {created_at} | Issue created |\n",
|
|
created_at = issue.created_at
|
|
));
|
|
if let Some(ref resolved) = issue.resolved_at {
|
|
md.push_str(&format!("| {resolved} | Issue resolved |\n"));
|
|
}
|
|
if detail.timeline_events.is_empty() {
|
|
md.push_str("| _HH:MM_ | _[Add additional timeline events]_ |\n");
|
|
} else {
|
|
for event in &detail.timeline_events {
|
|
md.push_str(&format!(
|
|
"| {} | {} - {} |\n",
|
|
event.created_at,
|
|
format_event_type(&event.event_type),
|
|
event.description
|
|
));
|
|
}
|
|
}
|
|
md.push('\n');
|
|
|
|
// Root Cause Analysis
|
|
md.push_str("## Root Cause Analysis\n\n");
|
|
if detail.resolution_steps.is_empty() {
|
|
md.push_str("### 5 Whys\n\n");
|
|
md.push_str("1. **Why?** _[First question]_ -> _[Answer]_\n");
|
|
md.push_str("2. **Why?** _[Second question]_ -> _[Answer]_\n");
|
|
md.push_str("3. **Why?** _[Third question]_ -> _[Answer]_\n");
|
|
md.push_str("4. **Why?** _[Fourth question]_ -> _[Answer]_\n");
|
|
md.push_str("5. **Why?** _[Fifth question]_ -> _[Answer]_\n\n");
|
|
} else {
|
|
md.push_str("### 5 Whys\n\n");
|
|
for step in &detail.resolution_steps {
|
|
let answer = if step.answer.is_empty() {
|
|
"_Awaiting answer_"
|
|
} else {
|
|
&step.answer
|
|
};
|
|
md.push_str(&format!(
|
|
"{}. **Why?** {} -> {}\n",
|
|
step.step_order, step.why_question, answer
|
|
));
|
|
}
|
|
md.push('\n');
|
|
|
|
if let Some(last) = detail.resolution_steps.last() {
|
|
if !last.answer.is_empty() {
|
|
md.push_str(&format!(
|
|
"**Root Cause:** {answer}\n\n",
|
|
answer = last.answer
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Contributing Factors
|
|
md.push_str("## Contributing Factors\n\n");
|
|
md.push_str(
|
|
"_This is a blameless post-mortem. Focus on systems and processes, not individuals._\n\n",
|
|
);
|
|
md.push_str("- _[Factor 1: e.g., Insufficient monitoring coverage]_\n");
|
|
md.push_str("- _[Factor 2: e.g., Missing automated failover]_\n");
|
|
md.push_str("- _[Factor 3: e.g., Deployment timing during peak hours]_\n\n");
|
|
|
|
// What Went Well
|
|
md.push_str("## What Went Well\n\n");
|
|
if !detail.resolution_steps.is_empty() {
|
|
md.push_str(&format!(
|
|
"- Systematic 5-whys analysis conducted ({} steps completed)\n",
|
|
detail.resolution_steps.len()
|
|
));
|
|
}
|
|
if detail
|
|
.timeline_events
|
|
.iter()
|
|
.any(|e| e.event_type == "root_cause_identified")
|
|
{
|
|
md.push_str("- Root cause was identified during triage\n");
|
|
}
|
|
md.push_str("- _[e.g., Quick detection through existing alerts]_\n");
|
|
md.push_str("- _[e.g., Effective cross-team collaboration]_\n");
|
|
md.push_str("- _[e.g., Smooth communication with stakeholders]_\n\n");
|
|
|
|
// What Could Be Improved
|
|
md.push_str("## What Could Be Improved\n\n");
|
|
md.push_str("- _[e.g., Faster escalation path]_\n");
|
|
md.push_str("- _[e.g., Better runbook documentation]_\n");
|
|
md.push_str("- _[e.g., More comprehensive testing]_\n\n");
|
|
|
|
// Action Items
|
|
md.push_str("## Action Items\n\n");
|
|
md.push_str("| Priority | Action | Owner | Due Date | Status |\n");
|
|
md.push_str("|----------|--------|-------|----------|--------|\n");
|
|
md.push_str("| P0 | _[Critical fix]_ | _[Owner]_ | _[Date]_ | Open |\n");
|
|
md.push_str("| P1 | _[Prevention measure]_ | _[Owner]_ | _[Date]_ | Open |\n");
|
|
md.push_str("| P2 | _[Monitoring improvement]_ | _[Owner]_ | _[Date]_ | Open |\n\n");
|
|
|
|
// Log Files
|
|
if !detail.log_files.is_empty() {
|
|
md.push_str("## Appendix: Log Files\n\n");
|
|
for lf in &detail.log_files {
|
|
md.push_str(&format!(
|
|
"- `{}` ({} bytes, redacted: {})\n",
|
|
lf.file_name,
|
|
lf.file_size,
|
|
if lf.redacted { "yes" } else { "no" }
|
|
));
|
|
}
|
|
md.push('\n');
|
|
}
|
|
|
|
md.push_str("---\n\n");
|
|
md.push_str(&format!(
|
|
"_Generated by Troubleshooting and RCA Assistant on {}_\n",
|
|
chrono::Utc::now().format("%Y-%m-%d %H:%M UTC")
|
|
));
|
|
|
|
md
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::db::models::{Issue, IssueDetail, ResolutionStep, TimelineEvent};
|
|
|
|
fn make_test_detail() -> IssueDetail {
|
|
IssueDetail {
|
|
issue: Issue {
|
|
id: "pm-456".to_string(),
|
|
title: "Payment processing outage".to_string(),
|
|
description: "Payment gateway returning 503 for all transactions.".to_string(),
|
|
severity: "critical".to_string(),
|
|
status: "resolved".to_string(),
|
|
category: "payments".to_string(),
|
|
source: "monitoring".to_string(),
|
|
created_at: "2025-02-10 08:00:00".to_string(),
|
|
updated_at: "2025-02-10 14:00:00".to_string(),
|
|
resolved_at: Some("2025-02-10 12:30:00".to_string()),
|
|
assigned_to: "payments-team".to_string(),
|
|
tags: "[]".to_string(),
|
|
},
|
|
log_files: vec![],
|
|
image_attachments: vec![],
|
|
resolution_steps: vec![ResolutionStep {
|
|
id: "rs-pm-1".to_string(),
|
|
issue_id: "pm-456".to_string(),
|
|
step_order: 1,
|
|
why_question: "Why did payments fail?".to_string(),
|
|
answer: "Gateway certificate expired.".to_string(),
|
|
evidence: "SSL handshake failure in logs.".to_string(),
|
|
created_at: "2025-02-10 09:00:00".to_string(),
|
|
}],
|
|
conversations: vec![],
|
|
timeline_events: vec![],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_postmortem_contains_blameless_title() {
|
|
let md = generate_postmortem_markdown(&make_test_detail());
|
|
assert!(md.contains("# Blameless Post-Mortem: Payment processing outage"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_postmortem_contains_metadata() {
|
|
let md = generate_postmortem_markdown(&make_test_detail());
|
|
assert!(md.contains("- **Severity:** critical"));
|
|
assert!(md.contains("- **Category:** payments"));
|
|
assert!(md.contains("- **Assigned To:** payments-team"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_postmortem_contains_executive_summary() {
|
|
let md = generate_postmortem_markdown(&make_test_detail());
|
|
assert!(md.contains("Payment gateway returning 503"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_postmortem_contains_timeline_with_resolved() {
|
|
let md = generate_postmortem_markdown(&make_test_detail());
|
|
assert!(md.contains("2025-02-10 08:00:00"));
|
|
assert!(md.contains("2025-02-10 12:30:00"));
|
|
assert!(md.contains("Issue resolved"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_postmortem_contains_five_whys() {
|
|
let md = generate_postmortem_markdown(&make_test_detail());
|
|
assert!(md.contains("Why did payments fail?"));
|
|
assert!(md.contains("Gateway certificate expired."));
|
|
}
|
|
|
|
#[test]
|
|
fn test_postmortem_contains_blameless_reminder() {
|
|
let md = generate_postmortem_markdown(&make_test_detail());
|
|
assert!(md.contains("blameless post-mortem"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_postmortem_empty_description_shows_placeholder() {
|
|
let mut detail = make_test_detail();
|
|
detail.issue.description = String::new();
|
|
let md = generate_postmortem_markdown(&detail);
|
|
assert!(md.contains("Provide a brief executive summary"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_postmortem_action_items_table() {
|
|
let md = generate_postmortem_markdown(&make_test_detail());
|
|
assert!(md.contains("| Priority | Action | Owner | Due Date | Status |"));
|
|
assert!(md.contains("| P0 |"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_postmortem_timeline_with_real_events() {
|
|
let mut detail = make_test_detail();
|
|
detail.timeline_events = vec![
|
|
TimelineEvent {
|
|
id: "te-1".to_string(),
|
|
issue_id: "pm-456".to_string(),
|
|
event_type: "triage_started".to_string(),
|
|
description: "Triage initiated".to_string(),
|
|
metadata: "{}".to_string(),
|
|
created_at: "2025-02-10 08:05:00 UTC".to_string(),
|
|
},
|
|
TimelineEvent {
|
|
id: "te-2".to_string(),
|
|
issue_id: "pm-456".to_string(),
|
|
event_type: "root_cause_identified".to_string(),
|
|
description: "Certificate expiry confirmed".to_string(),
|
|
metadata: "{}".to_string(),
|
|
created_at: "2025-02-10 10:30:00 UTC".to_string(),
|
|
},
|
|
];
|
|
let md = generate_postmortem_markdown(&detail);
|
|
assert!(md.contains("## Timeline"));
|
|
assert!(md.contains("| 2025-02-10 08:05:00 UTC | Triage Started - Triage initiated |"));
|
|
assert!(md.contains(
|
|
"| 2025-02-10 10:30:00 UTC | Root Cause Identified - Certificate expiry confirmed |"
|
|
));
|
|
assert!(!md.contains("_[Add additional timeline events]_"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_postmortem_impact_with_duration() {
|
|
let mut detail = make_test_detail();
|
|
detail.timeline_events = vec![
|
|
TimelineEvent {
|
|
id: "te-1".to_string(),
|
|
issue_id: "pm-456".to_string(),
|
|
event_type: "triage_started".to_string(),
|
|
description: "Triage initiated".to_string(),
|
|
metadata: "{}".to_string(),
|
|
created_at: "2025-02-10 08:00:00 UTC".to_string(),
|
|
},
|
|
TimelineEvent {
|
|
id: "te-2".to_string(),
|
|
issue_id: "pm-456".to_string(),
|
|
event_type: "root_cause_identified".to_string(),
|
|
description: "Found it".to_string(),
|
|
metadata: "{}".to_string(),
|
|
created_at: "2025-02-10 10:30:00 UTC".to_string(),
|
|
},
|
|
];
|
|
let md = generate_postmortem_markdown(&detail);
|
|
assert!(md.contains("**Duration:** 2h 30m"));
|
|
assert!(!md.contains("_[How long did the incident last?]_"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_postmortem_what_went_well_with_steps() {
|
|
let mut detail = make_test_detail();
|
|
detail.timeline_events = vec![TimelineEvent {
|
|
id: "te-1".to_string(),
|
|
issue_id: "pm-456".to_string(),
|
|
event_type: "root_cause_identified".to_string(),
|
|
description: "Root cause found".to_string(),
|
|
metadata: "{}".to_string(),
|
|
created_at: "2025-02-10 10:00:00 UTC".to_string(),
|
|
}];
|
|
let md = generate_postmortem_markdown(&detail);
|
|
assert!(md.contains("Systematic 5-whys analysis conducted (1 steps completed)"));
|
|
assert!(md.contains("Root cause was identified during triage"));
|
|
}
|
|
}
|