g1t/apps/status/src/postmortem.ts
| 1 | /** |
| 2 | * A postmortem's starting point: the timeline written out from the |
| 3 | * incident's own, the impact from its parts and times, and the follow-ups |
| 4 | * as action items. Staff edit it before publishing. No Workers imports. |
| 5 | */ |
| 6 | import type { AdminIncident, FollowUp, PostmortemFields, TimelineEntry } from "@g1t/contracts"; |
| 7 | |
| 8 | import { duration } from "./incidents.ts"; |
| 9 | import { IMPACT_WORD } from "./model.ts"; |
| 10 | |
| 11 | const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; |
| 12 | |
| 13 | /** "5 Oct 14:03 UTC". */ |
| 14 | export function stamp(at: string): string { |
| 15 | const d = new Date(at); |
| 16 | const hh = String(d.getUTCHours()).padStart(2, "0"); |
| 17 | const mm = String(d.getUTCMinutes()).padStart(2, "0"); |
| 18 | return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${hh}:${mm} UTC`; |
| 19 | } |
| 20 | |
| 21 | /** Lines not worth a reader's time: acknowledgements, follow-up ticks, the postmortem itself. */ |
| 22 | const SKIP = new Set(["acknowledged", "followup", "postmortem"]); |
| 23 | |
| 24 | /** The internal timeline as a list, one line per entry, public updates marked. */ |
| 25 | export function timelineText(entries: TimelineEntry[]): string { |
| 26 | return entries |
| 27 | .filter((e) => !SKIP.has(e.kind)) |
| 28 | .map((e) => `- ${stamp(e.at)}: ${e.kind === "update" ? "Status page: " : ""}${e.text.replace(/\s*\n+\s*/g, " ")}`) |
| 29 | .join("\n"); |
| 30 | } |
| 31 | |
| 32 | export function postmortemDraft( |
| 33 | incident: Pick<AdminIncident, "started_at" | "resolved_at" | "components" | "durations">, |
| 34 | timeline: TimelineEntry[], |
| 35 | followups: FollowUp[], |
| 36 | names: Map<string, string>, |
| 37 | ): PostmortemFields { |
| 38 | const parts = incident.components |
| 39 | .filter((c) => c.impact !== "operational") |
| 40 | .map((c) => `${names.get(c.key) ?? c.key} (${IMPACT_WORD[c.impact].toLowerCase()})`); |
| 41 | const lasted = incident.durations.to_resolve != null ? ` for ${duration(incident.durations.to_resolve)}` : ""; |
| 42 | const window = `from ${stamp(incident.started_at)}${incident.resolved_at ? ` to ${stamp(incident.resolved_at)}` : ""}`; |
| 43 | return { |
| 44 | summary: "", |
| 45 | impact: `${parts.join(", ") || "No part"} affected${lasted}, ${window}.`, |
| 46 | timeline: timelineText(timeline), |
| 47 | root_cause: "", |
| 48 | went_well: "", |
| 49 | went_badly: "", |
| 50 | action_items: followups.map((f) => `- ${f.title}`).join("\n"), |
| 51 | }; |
| 52 | } |