Started
This commit is contained in:
parent
42f08139c8
commit
2438306073
7 changed files with 533 additions and 0 deletions
73
collect.go
Normal file
73
collect.go
Normal 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("Опишите инцидент в 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
|
||||
}
|
||||
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module postmortem
|
||||
|
||||
go 1.22.2
|
||||
97
main.go
Normal file
97
main.go
Normal 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
64
model.go
Normal 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
BIN
postmortem
Executable file
Binary file not shown.
126
render.go
Normal file
126
render.go
Normal 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
170
ui.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue