diff --git a/collect.go b/collect.go new file mode 100644 index 0000000..ded0c9e --- /dev/null +++ b/collect.go @@ -0,0 +1,73 @@ +package main + +import "time" + +// collect проводит пользователя через все секции и возвращает заполненный PostMortem. +func collect() PostMortem { + var pm PostMortem + + // ── Основная информация ────────────────────────────────────────────────── + section("📋 Основная информация") + pm.Title = ask("Название инцидента:") + pm.Status = choose("Статус документа:", []string{ + "🟡 Draft", + "🔵 In Review", + "🟢 Resolved", + }) + pm.Severity = choose("Severity:", []string{ + "SEV-1 (критический)", + "SEV-2 (высокий)", + "SEV-3 (средний)", + "SEV-4 (низкий)", + }) + pm.IncidentDate = askDefault("Дата инцидента (ДД.ММ.ГГГГ):", time.Now().Format("02.01.2006")) + pm.PostmortemDate = time.Now().Format("02.01.2006") + pm.Author = ask("Автор (@имя):") + pm.Participants = ask("Участники разбора (через запятую):") + + // ── Краткое резюме ─────────────────────────────────────────────────────── + section("📝 Краткое резюме") + pm.Summary = ask("Опишите инцидент в 2–3 предложениях:") + + // ── Impact ─────────────────────────────────────────────────────────────── + section("💥 Влияние (Impact)") + pm.Duration = ask("Длительность (напр. 2ч 33мин):") + pm.AffectedUsers = ask("Затронутые пользователи (напр. ~5000 / 100% трафика):") + pm.AffectedServices = ask("Затронутые сервисы:") + pm.FinancialLoss = askDefault("Финансовые потери:", "не оценивались") + pm.SLABreach = choose("Нарушение SLA:", []string{"Да", "Нет", "Под вопросом"}) + pm.SupportTickets = askDefault("Обращений в поддержку:", "—") + + // ── Хронология ─────────────────────────────────────────────────────────── + section("🕐 Хронология") + pm.Timeline = collectTimeline() + + // ── Корневая причина ───────────────────────────────────────────────────── + section("🔍 Корневая причина") + pm.RootCause = ask("Опишите корневую причину:") + pm.WhyAnalysis = collectWhyAnalysis() + + // ── Сопутствующие факторы ──────────────────────────────────────────────── + section("⚠️ Сопутствующие факторы") + pm.Contributing = collectList("Что усугубило ситуацию?", "Фактор") + + // ── Что сработало / что улучшить ───────────────────────────────────────── + section("✅ Что сработало хорошо") + pm.WentWell = collectList("Что сработало хорошо?", "Пункт") + + section("🔧 Что можно улучшить") + pm.ToImprove = collectList("Что можно улучшить?", "Пункт") + + // ── Action Items ───────────────────────────────────────────────────────── + section("📌 Action Items") + pm.ActionItems = collectActionItems() + + // ── Ссылки ─────────────────────────────────────────────────────────────── + section("🔗 Ссылки") + pm.TicketLink = askDefault("Тикет инцидента (URL):", "[LINK]") + pm.LogsLink = askDefault("Логи / дашборд (URL):", "[LINK]") + pm.PRLink = askDefault("PR / деплой (URL):", "[LINK]") + pm.MeetingLink = askDefault("Запись встречи (URL):", "[LINK]") + + return pm +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dca0fa3 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module postmortem + +go 1.22.2 diff --git a/main.go b/main.go new file mode 100644 index 0000000..46f07b0 --- /dev/null +++ b/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strings" + "time" +) + +func main() { + printBanner() + + // 1. Сбор данных + pm := collect() + + // 2. Сохранение + section("💾 Сохранение") + filename := askFilename(pm.Title) + + content := renderMarkdown(pm) + if err := os.WriteFile(filename, []byte(content), 0644); err != nil { + fmt.Printf(" %s Ошибка записи файла: %v\n", clr(red, "✘"), err) + os.Exit(1) + } + + fmt.Println() + fmt.Println(clr(green+bold, " ✔ Готово!")) + fmt.Printf(" %s Файл сохранён: %s\n", clr(green, "→"), bld(filename)) + fmt.Println() + + // 3. Предложить открыть в редакторе + choice := choose("Открыть файл в редакторе?", []string{"Да", "Нет"}) + if choice == "Да" { + openInEditor(filename) + } + + fmt.Println() + fmt.Println(clr(dim, " Удачного постмортема! 🚀")) + fmt.Println() +} + +func printBanner() { + fmt.Println() + fmt.Println(clr(bold+blue, "╔══════════════════════════════════════════════╗")) + fmt.Println(clr(bold+blue, "║") + clr(bold+white, " 🔥 Post Mortem Generator v1.0 ") + clr(bold+blue, "║")) + fmt.Println(clr(bold+blue, "╚══════════════════════════════════════════════╝")) + fmt.Println(clr(dim, " Blameless post mortem — цель: улучшить систему")) + fmt.Println() +} + +func askFilename(title string) string { + slug := strings.ToLower(title) + slug = strings.ReplaceAll(slug, " ", "_") + for _, ch := range []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|"} { + slug = strings.ReplaceAll(slug, ch, "") + } + if len(slug) > 40 { + slug = slug[:40] + } + defaultName := fmt.Sprintf("postmortem_%s_%s.md", time.Now().Format("2006-01-02"), slug) + + name := askDefault("Имя файла:", defaultName) + if !strings.HasSuffix(name, ".md") { + name += ".md" + } + return name +} + +func openInEditor(path string) { + editor := os.Getenv("EDITOR") + if editor == "" { + switch runtime.GOOS { + case "windows": + editor = "notepad" + case "darwin": + editor = "open" + default: + for _, e := range []string{"nano", "vim", "vi"} { + if _, err := exec.LookPath(e); err == nil { + editor = e + break + } + } + } + } + if editor == "" { + fmt.Printf(" %s Редактор не найден — откройте файл вручную.\n", clr(yellow, "⚠")) + return + } + cmd := exec.Command(editor, path) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..973d931 --- /dev/null +++ b/model.go @@ -0,0 +1,64 @@ +package main + +// ActionItem — одна задача из раздела Action Items +type ActionItem struct { + Task string + Assignee string + Deadline string +} + +// TimelineEvent — одно событие в хронологии инцидента +type TimelineEvent struct { + Time string + Event string +} + +// WhyItem — одна итерация анализа «5 Почему» +type WhyItem struct { + Why string + Answer string +} + +// PostMortem — полная структура документа +type PostMortem struct { + // Заголовок + Title string + Status string + Severity string + IncidentDate string + PostmortemDate string + Author string + Participants string + + // Резюме + Summary string + + // Impact + Duration string + AffectedUsers string + AffectedServices string + FinancialLoss string + SLABreach string + SupportTickets string + + // Хронология + Timeline []TimelineEvent + + // Корневая причина + RootCause string + WhyAnalysis []WhyItem + + // Факторы и выводы + Contributing []string + WentWell []string + ToImprove []string + + // Action items + ActionItems []ActionItem + + // Ссылки + TicketLink string + LogsLink string + PRLink string + MeetingLink string +} diff --git a/postmortem b/postmortem new file mode 100755 index 0000000..6569657 Binary files /dev/null and b/postmortem differ diff --git a/render.go b/render.go new file mode 100644 index 0000000..811303b --- /dev/null +++ b/render.go @@ -0,0 +1,126 @@ +package main + +import ( + "fmt" + "strings" +) + +// renderMarkdown генерирует готовый Markdown-документ из структуры PostMortem. +func renderMarkdown(pm PostMortem) string { + var sb strings.Builder + + line := func(s string) { sb.WriteString(s + "\n") } + linef := func(format string, a ...any) { sb.WriteString(fmt.Sprintf(format, a...) + "\n") } + blank := func() { sb.WriteString("\n") } + hr := func() { line("---"); blank() } + + // ── Заголовок ──────────────────────────────────────────────────────────── + line("# 🔥 Post Mortem: " + pm.Title) + blank() + linef("> **Статус:** %s ", pm.Status) + linef("> **Severity:** %s ", pm.Severity) + linef("> **Дата инцидента:** %s ", pm.IncidentDate) + linef("> **Дата постмортема:** %s ", pm.PostmortemDate) + linef("> **Автор:** %s ", pm.Author) + linef("> **Участники разбора:** %s ", pm.Participants) + blank() + hr() + + // ── Резюме ─────────────────────────────────────────────────────────────── + line("## 📋 Краткое резюме") + blank() + line(pm.Summary) + blank() + hr() + + // ── Impact ─────────────────────────────────────────────────────────────── + line("## 💥 Влияние (Impact)") + blank() + line("| Параметр | Значение |") + line("|----------|----------|") + linef("| Длительность | %s |", pm.Duration) + linef("| Затронутые пользователи | %s |", pm.AffectedUsers) + linef("| Затронутые сервисы | %s |", pm.AffectedServices) + linef("| Финансовые потери | %s |", pm.FinancialLoss) + linef("| Нарушение SLA | %s |", pm.SLABreach) + linef("| Обращений в поддержку | %s |", pm.SupportTickets) + blank() + hr() + + // ── Хронология ─────────────────────────────────────────────────────────── + line("## 🕐 Хронология (Timeline)") + blank() + line("| Время | Событие |") + line("|-------|---------|") + for _, e := range pm.Timeline { + linef("| %s | %s |", e.Time, e.Event) + } + blank() + hr() + + // ── Корневая причина ───────────────────────────────────────────────────── + line("## 🔍 Корневая причина (Root Cause)") + blank() + line(pm.RootCause) + blank() + line("### Анализ «5 Почему»") + blank() + for i, item := range pm.WhyAnalysis { + linef("%d. **Почему** %s → %s", i+1, item.Why, item.Answer) + } + blank() + hr() + + // ── Сопутствующие факторы ──────────────────────────────────────────────── + line("## ⚠️ Сопутствующие факторы") + blank() + for _, f := range pm.Contributing { + linef("- %s", f) + } + blank() + hr() + + // ── Что сработало хорошо ───────────────────────────────────────────────── + line("## ✅ Что сработало хорошо") + blank() + for _, f := range pm.WentWell { + linef("- %s", f) + } + blank() + hr() + + // ── Что улучшить ───────────────────────────────────────────────────────── + line("## 🔧 Что можно улучшить") + blank() + for _, f := range pm.ToImprove { + linef("- %s", f) + } + blank() + hr() + + // ── Action Items ───────────────────────────────────────────────────────── + line("## 📌 Action Items") + blank() + line("| # | Задача | Ответственный | Дедлайн | Статус |") + line("|---|--------|---------------|---------|--------|") + for i, item := range pm.ActionItems { + linef("| %d | %s | %s | %s | 🔲 Открыта |", i+1, item.Task, item.Assignee, item.Deadline) + } + blank() + hr() + + // ── Ссылки ─────────────────────────────────────────────────────────────── + line("## 💬 Дополнительные материалы") + blank() + linef("- Тикет инцидента: %s", pm.TicketLink) + linef("- Логи / дашборд: %s", pm.LogsLink) + linef("- PR / деплой: %s", pm.PRLink) + linef("- Запись встречи: %s", pm.MeetingLink) + blank() + hr() + + // ── Футер ──────────────────────────────────────────────────────────────── + line("*Документ подготовлен в соответствии с процессом blameless post mortem. Цель — улучшение системы, а не поиск виноватых.*") + + return sb.String() +} diff --git a/ui.go b/ui.go new file mode 100644 index 0000000..eafc120 --- /dev/null +++ b/ui.go @@ -0,0 +1,170 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" +) + +// ─── ANSI цвета ─────────────────────────────────────────────────────────────── + +const ( + reset = "\033[0m" + bold = "\033[1m" + dim = "\033[2m" + red = "\033[31m" + green = "\033[32m" + yellow = "\033[33m" + blue = "\033[34m" + magenta = "\033[35m" + cyan = "\033[36m" + white = "\033[37m" +) + +func clr(color, text string) string { return color + text + reset } +func bld(text string) string { return bold + text + reset } + +// ─── Ввод ───────────────────────────────────────────────────────────────────── + +var reader = bufio.NewReader(os.Stdin) + +func readLine() string { + line, _ := reader.ReadString('\n') + return strings.TrimSpace(line) +} + +// ask выводит подсказку и читает строку. Пустая строка → "_не указано_" +func ask(prompt string) string { + fmt.Printf(" %s %s ", clr(cyan, "→"), prompt) + val := readLine() + if val == "" { + return "_не указано_" + } + return val +} + +// askDefault выводит подсказку с дефолтным значением. +func askDefault(prompt, defaultVal string) string { + fmt.Printf(" %s %s %s ", clr(cyan, "→"), prompt, clr(dim, "(Enter = "+defaultVal+")")) + val := readLine() + if val == "" { + return defaultVal + } + return val +} + +// choose выводит нумерованное меню и возвращает выбранный вариант. +func choose(prompt string, options []string) string { + fmt.Printf("\n %s %s\n", clr(magenta, "▸"), bld(prompt)) + for i, opt := range options { + fmt.Printf(" %s %s\n", clr(yellow, fmt.Sprintf("[%d]", i+1)), opt) + } + for { + fmt.Printf(" %s Выберите (1-%d): ", clr(cyan, "→"), len(options)) + input := readLine() + n, err := strconv.Atoi(input) + if err == nil && n >= 1 && n <= len(options) { + fmt.Printf(" %s %s\n", clr(green, "✔"), options[n-1]) + return options[n-1] + } + fmt.Printf(" %s Введите число от 1 до %d\n", clr(red, "✘"), len(options)) + } +} + +// section печатает визуальный разделитель с заголовком. +func section(title string) { + fmt.Printf("\n%s\n%s\n%s\n", + clr(blue, strings.Repeat("─", 60)), + clr(bold+blue, " "+title), + clr(blue, strings.Repeat("─", 60)), + ) +} + +// ─── Коллекторы списков ─────────────────────────────────────────────────────── + +func collectList(prompt, itemLabel string) []string { + fmt.Printf("\n %s %s %s\n", clr(magenta, "▸"), bld(prompt), clr(dim, "(пустая строка = завершить)")) + var items []string + for i := 1; ; i++ { + fmt.Printf(" %s %s %d: ", clr(cyan, "→"), itemLabel, i) + val := readLine() + if val == "" { + break + } + items = append(items, val) + } + if len(items) == 0 { + return []string{"_не указано_"} + } + return items +} + +func collectTimeline() []TimelineEvent { + fmt.Printf("\n %s %s %s\n", clr(magenta, "▸"), bld("Хронология событий"), clr(dim, "(пустое время = завершить)")) + var events []TimelineEvent + for i := 1; ; i++ { + fmt.Printf(" %s Событие %d — время (напр. 14:32): ", clr(cyan, "→"), i) + t := readLine() + if t == "" { + break + } + fmt.Printf(" %s Событие %d — описание: ", clr(cyan, "→"), i) + desc := readLine() + if desc == "" { + desc = "—" + } + events = append(events, TimelineEvent{Time: t, Event: desc}) + } + if len(events) == 0 { + events = append(events, TimelineEvent{Time: "—", Event: "_не указано_"}) + } + return events +} + +func collectWhyAnalysis() []WhyItem { + fmt.Printf("\n %s %s %s\n", clr(magenta, "▸"), bld("Анализ «5 Почему»"), clr(dim, "(пустой ввод = завершить, макс. 5)")) + var items []WhyItem + for i := 1; i <= 5; i++ { + fmt.Printf(" %s Почему #%d: ", clr(cyan, "→"), i) + why := readLine() + if why == "" { + break + } + fmt.Printf(" %s Ответ #%d: ", clr(cyan, "→"), i) + answer := readLine() + items = append(items, WhyItem{Why: why, Answer: answer}) + } + if len(items) == 0 { + items = append(items, WhyItem{Why: "_не указано_", Answer: "—"}) + } + return items +} + +func collectActionItems() []ActionItem { + fmt.Printf("\n %s %s %s\n", clr(magenta, "▸"), bld("Action Items"), clr(dim, "(пустая задача = завершить)")) + var items []ActionItem + for i := 1; ; i++ { + fmt.Printf("\n %s Задача %d (Enter = завершить): ", clr(cyan, "→"), i) + task := readLine() + if task == "" { + break + } + fmt.Printf(" %s Ответственный (@имя): ", clr(cyan, "→")) + assignee := readLine() + if assignee == "" { + assignee = "@tbd" + } + fmt.Printf(" %s Дедлайн (ДД.ММ): ", clr(cyan, "→")) + deadline := readLine() + if deadline == "" { + deadline = "—" + } + items = append(items, ActionItem{Task: task, Assignee: assignee, Deadline: deadline}) + } + if len(items) == 0 { + items = append(items, ActionItem{Task: "_не указано_", Assignee: "@tbd", Deadline: "—"}) + } + return items +}