From 898ad1d73a2238721fe6940f3a0dc623f6127493 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Ducasse Date: Fri, 16 Oct 2026 16:03:46 +0200 Subject: [PATCH] feat(incident): add postmortem subcommands Add 'openstatus incident postmortem' (alias pm) with show, edit and approve. show pages the body with its review status in a terminal and prints the raw markdown when piped, so show and edit round-trip through a file. edit takes --file (or - for stdin) or --content; approve can also close the incident. Co-Authored-By: Claude Opus 5.5 --- internal/incident/export_test.go | 44 +-- internal/incident/incident.go | 1 + internal/incident/incident_postmortem.go | 353 ++++++++++++++++++ internal/incident/incident_postmortem_test.go | 270 ++++++++++++++ internal/incident/incident_test.go | 2 +- 5 files changed, 648 insertions(+), 22 deletions(-) create mode 100644 internal/incident/incident_postmortem.go create mode 100644 internal/incident/incident_postmortem_test.go diff --git a/internal/incident/export_test.go b/internal/incident/export_test.go index 76f6c5c..cd45237 100644 --- a/internal/incident/export_test.go +++ b/internal/incident/export_test.go @@ -1,25 +1,27 @@ package incident var ( - SeverityToSDK = severityToSDK - SeverityToString = severityToString - StatusToSDK = statusToSDK - StatusToString = statusToString - ParseStatuses = parseStatuses - EventTypeToString = eventTypeToString - FormatUser = formatUser - ShortUser = shortUser - DisplayStatus = displayStatus - ReadTextArg = readTextArg - NextStepHint = nextStepHint - NeedsConfirmation = needsConfirmation - ListFooter = listFooter - BuildListRequest = buildListRequest - BuildDeclareRequest = buildDeclareRequest - MissingDeclareFlags = missingDeclareFlags - BuildUpdateRequest = buildUpdateRequest - BuildSetStatusRequest = buildSetStatusRequest - BuildNoteRequest = buildNoteRequest - InfoRows = infoRows - StatusDoneWord = statusDoneWord + SeverityToSDK = severityToSDK + SeverityToString = severityToString + StatusToSDK = statusToSDK + StatusToString = statusToString + ParseStatuses = parseStatuses + EventTypeToString = eventTypeToString + FormatUser = formatUser + ShortUser = shortUser + DisplayStatus = displayStatus + ReadTextArg = readTextArg + NextStepHint = nextStepHint + NeedsConfirmation = needsConfirmation + ListFooter = listFooter + BuildListRequest = buildListRequest + BuildDeclareRequest = buildDeclareRequest + MissingDeclareFlags = missingDeclareFlags + BuildUpdateRequest = buildUpdateRequest + BuildSetStatusRequest = buildSetStatusRequest + BuildNoteRequest = buildNoteRequest + InfoRows = infoRows + StatusDoneWord = statusDoneWord + ResolvePostmortemContent = resolvePostmortemContent + WritePostmortem = writePostmortem ) diff --git a/internal/incident/incident.go b/internal/incident/incident.go index 04e19aa..51d2b78 100644 --- a/internal/incident/incident.go +++ b/internal/incident/incident.go @@ -511,6 +511,7 @@ Use 'openstatus status-report' for the public communication on a status page.`, GetIncidentUnlinkReportCmd(), GetIncidentCloseCmd(), GetIncidentDeleteCmd(), + postmortemCmd(), }, } } diff --git a/internal/incident/incident_postmortem.go b/internal/incident/incident_postmortem.go new file mode 100644 index 0000000..cf4737a --- /dev/null +++ b/internal/incident/incident_postmortem.go @@ -0,0 +1,353 @@ +package incident + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "unicode/utf8" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/incident/v1/incidentv1connect" + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + "github.com/urfave/cli/v3" + + output "github.com/openstatusHQ/cli/internal/cli" +) + +const ( + maxPostmortemLen = 100000 + pmShowUsage = "openstatus incident postmortem show " + pmEditUsage = "openstatus incident postmortem edit --file | --content " + pmApproveUsage = "openstatus incident postmortem approve [--close] [-y]" +) + +type postmortemJSON struct { + IncidentID string `json:"incident_id"` + Status string `json:"status"` + Content string `json:"content"` + DraftedBy string `json:"drafted_by"` + ApprovedBy *userJSON `json:"approved_by,omitempty"` + ApprovedAt string `json:"approved_at,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type approveOutput struct { + Postmortem postmortemJSON `json:"postmortem"` + Incident incidentDetail `json:"incident"` +} + +func postmortemStatusToString(s incidentv1.PostmortemStatus) string { + switch s { + case incidentv1.PostmortemStatus_POSTMORTEM_STATUS_DRAFT: + return "draft" + case incidentv1.PostmortemStatus_POSTMORTEM_STATUS_APPROVED: + return "approved" + default: + return "unknown" + } +} + +func postmortemAuthorToString(a incidentv1.PostmortemAuthor) string { + switch a { + case incidentv1.PostmortemAuthor_POSTMORTEM_AUTHOR_AGENT: + return "agent" + case incidentv1.PostmortemAuthor_POSTMORTEM_AUTHOR_USER: + return "user" + default: + return "unknown" + } +} + +func toPostmortemJSON(pm *incidentv1.Postmortem) postmortemJSON { + return postmortemJSON{ + IncidentID: pm.GetIncidentId(), + Status: postmortemStatusToString(pm.GetStatus()), + Content: pm.GetContent(), + DraftedBy: postmortemAuthorToString(pm.GetDraftedBy()), + ApprovedBy: toUserJSON(pm.GetApprovedBy()), + ApprovedAt: pm.GetApprovedAt(), + CreatedAt: pm.GetCreatedAt(), + UpdatedAt: pm.GetUpdatedAt(), + } +} + +func GetPostmortem(ctx context.Context, client incidentv1connect.IncidentServiceClient, id string) (*incidentv1.Postmortem, error) { + resp, err := client.GetPostmortem(ctx, &incidentv1.GetPostmortemRequest{IncidentId: id}) + if err != nil { + return nil, formatIncidentError(err, id, opGeneric) + } + if !resp.HasPostmortem() { + return nil, nil + } + return resp.GetPostmortem(), nil +} + +func writePostmortem(w io.Writer, pm *incidentv1.Postmortem) { + fmt.Fprintf(w, "Status: %s\n", postmortemStatusToString(pm.GetStatus())) + fmt.Fprintf(w, "Drafted by: %s\n", postmortemAuthorToString(pm.GetDraftedBy())) + if pm.HasApprovedBy() { + fmt.Fprintf(w, "Approved: %s by %s\n", output.FormatTimestamp(pm.GetApprovedAt()), formatUser(pm.GetApprovedBy())) + } + fmt.Fprintf(w, "Updated: %s\n\n", output.FormatTimestamp(pm.GetUpdatedAt())) + fmt.Fprintln(w, pm.GetContent()) +} + +func renderPostmortem(id string, pm *incidentv1.Postmortem) error { + if output.IsJSONOutput() { + if pm == nil { + return output.PrintJSON(nil) + } + return output.PrintJSON(toPostmortemJSON(pm)) + } + if pm == nil { + fmt.Printf("No postmortem yet for incident %s\n", id) + fmt.Printf("Write one: openstatus inc postmortem edit %s --file postmortem.md\n", id) + return nil + } + if !output.IsTerminal() { + fmt.Println(pm.GetContent()) + return nil + } + output.WithPager(func(w io.Writer) { + writePostmortem(w, pm) + }) + return nil +} + +func ShowPostmortemWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, id string) error { + pm, err := GetPostmortem(ctx, NewIncidentClientWithHTTPClient(httpClient, apiKey), id) + if err != nil { + return err + } + return renderPostmortem(id, pm) +} + +func resolvePostmortemContent(file, content string, fileSet, contentSet bool, stdin io.Reader) (string, error) { + if fileSet == contentSet { + return "", errors.New("pass exactly one of --file or --content") + } + + text := content + if fileSet { + switch file { + case "": + return "", errors.New("--file cannot be empty") + case "-": + var err error + text, err = readTextArg("-", stdin) + if err != nil { + return "", err + } + default: + data, err := os.ReadFile(file) + if err != nil { + return "", fmt.Errorf("read postmortem file %q: %w", file, err) + } + text = string(data) + } + } + + if strings.TrimSpace(text) == "" { + return "", errors.New("postmortem content cannot be empty") + } + if utf8.RuneCountInString(text) > maxPostmortemLen { + return "", fmt.Errorf("postmortem is too long: maximum %d characters", maxPostmortemLen) + } + return text, nil +} + +func UpdatePostmortem(ctx context.Context, client incidentv1connect.IncidentServiceClient, id, content string) (*incidentv1.Postmortem, error) { + resp, err := client.UpdatePostmortem(ctx, &incidentv1.UpdatePostmortemRequest{IncidentId: id, Content: content}) + if err != nil { + return nil, formatIncidentError(err, id, opGeneric) + } + return resp.GetPostmortem(), nil +} + +func renderPostmortemSaved(id string, pm *incidentv1.Postmortem) error { + if output.IsJSONOutput() { + return output.PrintJSON(toPostmortemJSON(pm)) + } + if output.IsQuiet() { + fmt.Println(id) + return nil + } + status := postmortemStatusToString(pm.GetStatus()) + fmt.Printf("Postmortem saved for incident %s (%s)\n", id, status) + if status == "draft" { + fmt.Printf("Approve: openstatus inc postmortem approve %s [--close]\n", id) + } + return nil +} + +func UpdatePostmortemWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, id, content string) error { + pm, err := UpdatePostmortem(ctx, NewIncidentClientWithHTTPClient(httpClient, apiKey), id, content) + if err != nil { + return err + } + return renderPostmortemSaved(id, pm) +} + +func ApprovePostmortem(ctx context.Context, client incidentv1connect.IncidentServiceClient, id string, closeIncident bool) (*incidentv1.ApprovePostmortemResponse, error) { + req := &incidentv1.ApprovePostmortemRequest{IncidentId: id} + if closeIncident { + req.SetClose(true) + } + resp, err := client.ApprovePostmortem(ctx, req) + if err != nil { + return nil, formatIncidentError(err, id, opApprove) + } + return resp, nil +} + +func renderApproved(id string, resp *incidentv1.ApprovePostmortemResponse) error { + if output.IsJSONOutput() { + return output.PrintJSON(approveOutput{ + Postmortem: toPostmortemJSON(resp.GetPostmortem()), + Incident: toDetailJSON(resp.GetIncident()), + }) + } + if output.IsQuiet() { + fmt.Println(id) + return nil + } + fmt.Printf("Postmortem approved for incident %s\n", id) + if resp.GetIncident().GetClosedAt() != "" { + fmt.Printf("Incident %s closed\n", id) + return nil + } + fmt.Printf("Close the incident: openstatus inc close %s\n", id) + return nil +} + +func ApprovePostmortemWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, id string, closeIncident bool) error { + resp, err := ApprovePostmortem(ctx, NewIncidentClientWithHTTPClient(httpClient, apiKey), id, closeIncident) + if err != nil { + return err + } + return renderApproved(id, resp) +} + +func postmortemCmd() *cli.Command { + return &cli.Command{ + Name: "postmortem", + Aliases: []string{"pm"}, + Usage: "Read, write and approve an incident's postmortem", + Commands: []*cli.Command{ + postmortemShowCmd(), + postmortemEditCmd(), + postmortemApproveCmd(), + }, + } +} + +func postmortemShowCmd() *cli.Command { + return &cli.Command{ + Name: "show", + Usage: "Show the postmortem of an incident", + UsageText: `openstatus incident postmortem show + openstatus inc pm show 42 > postmortem.md`, + Description: `In a terminal, prints the review status and the markdown body through your +pager. When piped, prints only the markdown body, so it can be edited and sent +back with 'openstatus inc pm edit --file postmortem.md'.`, + Flags: []cli.Flag{accessTokenFlag()}, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + id, err := requireID(cmd, pmShowUsage) + if err != nil { + return err + } + s := output.StartSpinner("Fetching postmortem...") + pm, err := GetPostmortem(ctx, client, id) + output.StopSpinner(s) + if err != nil { + return err + } + return renderPostmortem(id, pm) + }), + } +} + +func postmortemEditCmd() *cli.Command { + return &cli.Command{ + Name: "edit", + Usage: "Write or replace the postmortem of a resolved incident", + UsageText: `openstatus incident postmortem edit --file postmortem.md + cat postmortem.md | openstatus inc pm edit 42 --file - + openstatus inc pm edit 42 --content "## Summary ..."`, + Description: "Replaces the whole markdown body. An approved postmortem stays approved.", + Flags: []cli.Flag{ + accessTokenFlag(), + &cli.StringFlag{ + Name: "file", + Usage: "Markdown file to upload; use - to read from stdin", + }, + &cli.StringFlag{ + Name: "content", + Usage: "Markdown body passed inline", + }, + }, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + id, err := requireID(cmd, pmEditUsage) + if err != nil { + return err + } + content, err := resolvePostmortemContent(cmd.String("file"), cmd.String("content"), cmd.IsSet("file"), cmd.IsSet("content"), os.Stdin) + if err != nil { + return err + } + + s := output.StartSpinner("Saving postmortem...") + pm, err := UpdatePostmortem(ctx, client, id, content) + output.StopSpinner(s) + if err != nil { + return err + } + return renderPostmortemSaved(id, pm) + }), + } +} + +func postmortemApproveCmd() *cli.Command { + return &cli.Command{ + Name: "approve", + Usage: "Approve the postmortem, optionally closing the incident", + UsageText: `openstatus incident postmortem approve + openstatus inc pm approve 42 --close -y`, + Flags: []cli.Flag{ + accessTokenFlag(), + &cli.BoolFlag{ + Name: "close", + Usage: "Also close the incident", + }, + autoAcceptFlag(), + }, + Action: withClient(func(ctx context.Context, cmd *cli.Command, client incidentv1connect.IncidentServiceClient) error { + id, err := requireID(cmd, pmApproveUsage) + if err != nil { + return err + } + closeIncident := cmd.Bool("close") + if closeIncident { + confirmed, err := confirm(cmd, fmt.Sprintf("You are about to approve the postmortem and close incident %s. It becomes read-only except for its postmortem. Continue", id)) + if err != nil { + return err + } + if !confirmed { + return nil + } + } + + s := output.StartSpinner("Approving postmortem...") + resp, err := ApprovePostmortem(ctx, client, id, closeIncident) + output.StopSpinner(s) + if err != nil { + return err + } + return renderApproved(id, resp) + }), + } +} diff --git a/internal/incident/incident_postmortem_test.go b/internal/incident/incident_postmortem_test.go new file mode 100644 index 0000000..ec31034 --- /dev/null +++ b/internal/incident/incident_postmortem_test.go @@ -0,0 +1,270 @@ +package incident_test + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + incidentv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/incident/v1" + + "github.com/openstatusHQ/cli/internal/incident" +) + +const postmortemResponse = `{"postmortem":{ + "incidentId": "42", + "status": "POSTMORTEM_STATUS_DRAFT", + "content": "# Summary\n\nBad deploy.", + "draftedBy": "POSTMORTEM_AUTHOR_USER", + "createdAt": "2026-10-16T12:00:00Z", + "updatedAt": "2026-10-16T12:30:00Z" +}}` + +func Test_PostmortemCmd(t *testing.T) { + t.Parallel() + + var pm []string + for _, sub := range incident.IncidentCmd().Commands { + if sub.Name != "postmortem" { + continue + } + if !slices.Contains(sub.Aliases, "pm") { + t.Error("expected alias 'pm'") + } + for _, c := range sub.Commands { + pm = append(pm, c.Name) + } + } + if !slices.Equal(pm, []string{"show", "edit", "approve"}) { + t.Errorf("postmortem subcommands = %v", pm) + } +} + +func Test_ShowPostmortem(t *testing.T) { + t.Run("piped output is the raw body", func(t *testing.T) { + srv := newServer(http.StatusOK, postmortemResponse) + var err error + out := captureStdout(t, func() { + err = incident.ShowPostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42") + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := decodeBody(t, srv.reqBody, &incidentv1.GetPostmortemRequest{}) + if req.GetIncidentId() != "42" { + t.Errorf("unexpected request %v", req) + } + if out != "# Summary\n\nBad deploy.\n" { + t.Errorf("unexpected output %q", out) + } + }) + + t.Run("none yet", func(t *testing.T) { + srv := newServer(http.StatusOK, `{}`) + out := captureStdout(t, func() { + if err := incident.ShowPostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + if !strings.Contains(out, "No postmortem yet for incident 42") || !strings.Contains(out, "postmortem edit 42") { + t.Errorf("unexpected output %q", out) + } + }) + + t.Run("none yet json is null", func(t *testing.T) { + withOutputModes(t, true, false) + srv := newServer(http.StatusOK, `{}`) + out := captureStdout(t, func() { + if err := incident.ShowPostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + if strings.TrimSpace(out) != "null" { + t.Errorf("unexpected output %q", out) + } + }) + + t.Run("json", func(t *testing.T) { + withOutputModes(t, true, false) + srv := newServer(http.StatusOK, postmortemResponse) + out := captureStdout(t, func() { + if err := incident.ShowPostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + var got struct { + IncidentID string `json:"incident_id"` + Status string `json:"status"` + DraftedBy string `json:"drafted_by"` + Content string `json:"content"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if got.IncidentID != "42" || got.Status != "draft" || got.DraftedBy != "user" || !strings.HasPrefix(got.Content, "# Summary") { + t.Errorf("unexpected payload %+v", got) + } + }) +} + +func Test_ResolvePostmortemContent(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "pm.md") + if err := os.WriteFile(path, []byte("# From file\n"), 0o600); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + file string + content string + fileSet bool + contentSet bool + stdin string + want string + wantErr string + }{ + {name: "file", file: path, fileSet: true, want: "# From file\n"}, + {name: "stdin", file: "-", fileSet: true, stdin: "# From stdin\n", want: "# From stdin"}, + {name: "content", content: "# Inline", contentSet: true, want: "# Inline"}, + {name: "neither", wantErr: "exactly one of --file or --content"}, + {name: "both", file: path, fileSet: true, content: "x", contentSet: true, wantErr: "exactly one of --file or --content"}, + {name: "missing file", file: filepath.Join(dir, "nope.md"), fileSet: true, wantErr: "read postmortem file"}, + {name: "empty content", content: " ", contentSet: true, wantErr: "cannot be empty"}, + {name: "empty file flag", file: "", fileSet: true, wantErr: "--file cannot be empty"}, + {name: "too long", content: strings.Repeat("a", 100001), contentSet: true, wantErr: "too long"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := incident.ResolvePostmortemContent(tt.file, tt.content, tt.fileSet, tt.contentSet, strings.NewReader(tt.stdin)) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("got %v, want error containing %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func Test_UpdatePostmortem(t *testing.T) { + srv := newServer(http.StatusOK, postmortemResponse) + var err error + out := captureStdout(t, func() { + err = incident.UpdatePostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42", "# Summary") + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := decodeBody(t, srv.reqBody, &incidentv1.UpdatePostmortemRequest{}) + if req.GetIncidentId() != "42" || req.GetContent() != "# Summary" { + t.Errorf("unexpected request %v", req) + } + if !strings.Contains(out, "Postmortem saved for incident 42 (draft)") || !strings.Contains(out, "postmortem approve 42") { + t.Errorf("unexpected output %q", out) + } +} + +func Test_ApprovePostmortem(t *testing.T) { + approved := `{"postmortem":{"incidentId":"42","status":"POSTMORTEM_STATUS_APPROVED","draftedBy":"POSTMORTEM_AUTHOR_AGENT","approvedBy":{"email":"jane@example.com","name":"Jane"}},"incident":` + closedResponse[len(`{"incident":`):] + + t.Run("close sent only when requested", func(t *testing.T) { + for _, closeIncident := range []bool{false, true} { + srv := newServer(http.StatusOK, approved) + out := captureStdout(t, func() { + if err := incident.ApprovePostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42", closeIncident); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + req := decodeBody(t, srv.reqBody, &incidentv1.ApprovePostmortemRequest{}) + if req.HasClose() != closeIncident || req.GetIncidentId() != "42" { + t.Errorf("close=%v: unexpected request %v", closeIncident, req) + } + if !strings.Contains(out, "Postmortem approved for incident 42") || !strings.Contains(out, "Incident 42 closed") { + t.Errorf("unexpected output %q", out) + } + } + }) + + t.Run("json has postmortem and incident", func(t *testing.T) { + withOutputModes(t, true, false) + srv := newServer(http.StatusOK, approved) + out := captureStdout(t, func() { + if err := incident.ApprovePostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42", true); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + var got struct { + Postmortem struct { + Status string `json:"status"` + DraftedBy string `json:"drafted_by"` + ApprovedBy struct { + Email string `json:"email"` + } `json:"approved_by"` + } `json:"postmortem"` + Incident struct { + ClosedAt string `json:"closed_at"` + } `json:"incident"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if got.Postmortem.Status != "approved" || got.Postmortem.DraftedBy != "agent" || got.Postmortem.ApprovedBy.Email != "jane@example.com" || got.Incident.ClosedAt == "" { + t.Errorf("unexpected payload %+v", got) + } + }) + + t.Run("not found means no postmortem written", func(t *testing.T) { + srv := errorServer(http.StatusNotFound, "not_found", "postmortem not found") + err := incident.ApprovePostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42", false) + if err == nil || !strings.Contains(err.Error(), "no postmortem written yet for incident 42") { + t.Errorf("unexpected error %v", err) + } + }) + + t.Run("permission denied explains roles", func(t *testing.T) { + srv := errorServer(http.StatusForbidden, "permission_denied", "forbidden") + err := incident.ApprovePostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42", false) + if err == nil || !strings.Contains(err.Error(), "or the incident commander") { + t.Errorf("unexpected error %v", err) + } + }) +} + +func Test_WritePostmortemHeader(t *testing.T) { + t.Parallel() + + approvedAt := "2026-10-16T13:00:00Z" + pm := incidentv1.Postmortem_builder{ + IncidentId: "42", + Status: incidentv1.PostmortemStatus_POSTMORTEM_STATUS_APPROVED, + Content: "# Summary", + DraftedBy: incidentv1.PostmortemAuthor_POSTMORTEM_AUTHOR_AGENT, + ApprovedBy: incidentv1.IncidentUser_builder{Name: "Jane", Email: "jane@example.com"}.Build(), + ApprovedAt: &approvedAt, + UpdatedAt: "2026-10-16T13:00:00Z", + }.Build() + + var buf strings.Builder + incident.WritePostmortem(&buf, pm) + out := buf.String() + for _, want := range []string{"Status: approved", "Drafted by: agent", "Approved: 2026-10-16 13:00 UTC by Jane ", "\n\n# Summary\n"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } +} diff --git a/internal/incident/incident_test.go b/internal/incident/incident_test.go index bcea366..1f9acfb 100644 --- a/internal/incident/incident_test.go +++ b/internal/incident/incident_test.go @@ -30,7 +30,7 @@ func Test_IncidentCmd(t *testing.T) { expected := []string{ "list", "info", "declare", "update", "set-status", "mitigate", "resolve", "reopen", "cancel", - "note", "link-report", "unlink-report", "close", "delete", + "note", "link-report", "unlink-report", "close", "delete", "postmortem", } if len(cmd.Commands) != len(expected) { t.Errorf("expected %d subcommands, got %d", len(expected), len(cmd.Commands)) -- knotmirror 0.0.1