This commit is contained in:
Magnus Root 2026-03-24 17:56:59 +03:00
parent 42f08139c8
commit 2438306073
7 changed files with 533 additions and 0 deletions

73
collect.go Normal file
View file

@ -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("Опишите инцидент в 23 предложениях:")
// ── 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
}

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module postmortem
go 1.22.2

97
main.go Normal file
View file

@ -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()
}

64
model.go Normal file
View file

@ -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
}

BIN
postmortem Executable file

Binary file not shown.

126
render.go Normal file
View file

@ -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()
}

170
ui.go Normal file
View file

@ -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
}