diff --git a/internal/incident/incident_postmortem.go b/internal/incident/incident_postmortem.go index 5cec249..bec5786 100644 --- a/internal/incident/incident_postmortem.go +++ b/internal/incident/incident_postmortem.go @@ -104,6 +104,11 @@ func renderPostmortem(id string, pm *incidentv1.Postmortem) error { return output.PrintJSON(toPostmortemJSON(pm)) } if pm == nil { + // Piped output is a postmortem body by contract; never let the hint + // end up in a file that 'pm edit --file' would upload. + if !output.IsTerminal() || output.IsQuiet() { + return fmt.Errorf("no postmortem yet for incident %s. Write one: openstatus inc postmortem edit %s --file postmortem.md", id, id) + } 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 diff --git a/internal/incident/incident_postmortem_test.go b/internal/incident/incident_postmortem_test.go index edc1df5..6b95af5 100644 --- a/internal/incident/incident_postmortem_test.go +++ b/internal/incident/incident_postmortem_test.go @@ -63,15 +63,17 @@ func Test_ShowPostmortem(t *testing.T) { } }) - t.Run("none yet", func(t *testing.T) { + t.Run("none yet when piped fails and keeps stdout empty", func(t *testing.T) { srv := newServer(http.StatusOK, `{}`) + var err error out := captureStdout(t, func() { - if err := incident.ShowPostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42"); err != nil { - t.Errorf("unexpected error: %v", err) - } + err = incident.ShowPostmortemWithHTTPClient(context.Background(), srv.client(), "token", "42") }) - if !strings.Contains(out, "No postmortem yet for incident 42") || !strings.Contains(out, "postmortem edit 42") { - t.Errorf("unexpected output %q", out) + if err == nil || !strings.Contains(err.Error(), "no postmortem yet for incident 42") || !strings.Contains(err.Error(), "postmortem edit 42") { + t.Errorf("unexpected error %v", err) + } + if out != "" { + t.Errorf("expected empty stdout, got %q", out) } }) diff --git a/internal/statusreport/export_test.go b/internal/statusreport/export_test.go new file mode 100644 index 0000000..46a9ff7 --- /dev/null +++ b/internal/statusreport/export_test.go @@ -0,0 +1,3 @@ +package statusreport + +var RenderCreated = renderCreated diff --git a/internal/statusreport/statusreport_create.go b/internal/statusreport/statusreport_create.go index 0a09273..2eff38d 100644 --- a/internal/statusreport/statusreport_create.go +++ b/internal/statusreport/statusreport_create.go @@ -2,6 +2,7 @@ package statusreport import ( "context" + "errors" "fmt" "net/http" "os" @@ -10,6 +11,7 @@ import ( "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_report/v1/status_reportv1connect" status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" + "connectrpc.com/connect" "github.com/urfave/cli/v3" "github.com/openstatusHQ/cli/internal/auth" @@ -68,12 +70,26 @@ func createStatusReport(ctx context.Context, client status_reportv1connect.Statu resp, err := client.CreateStatusReport(ctx, req) if err != nil { - return nil, output.FormatError(err, "status-report", "") + return nil, formatCreateError(err, p.IncidentID) } return resp.GetStatusReport(), nil } +// formatCreateError keeps the server message on NotFound when an incident is +// linked, since it names the missing resource (page or incident); the generic +// mapping would always blame the status report. +func formatCreateError(err error, incidentID string) error { + var connectErr *connect.Error + if incidentID != "" && errors.As(err, &connectErr) && connectErr.Code() == connect.CodeNotFound { + if msg := strings.TrimSuffix(connectErr.Message(), "."); msg != "" { + return errors.New(msg) + } + return fmt.Errorf("incident %s not found. Run 'openstatus incident list' to see available incidents", incidentID) + } + return output.FormatError(err, "status-report", "") +} + func CreateStatusReportWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, p CreateStatusReportParams) (string, error) { client := NewStatusReportClientWithHTTPClient(httpClient, apiKey) return CreateStatusReport(ctx, client, p) @@ -213,18 +229,30 @@ func GetStatusReportCreateCmd() *cli.Command { return cli.Exit(err.Error(), 1) } - id := report.GetId() - fmt.Printf("Status report created successfully (ID: %s)\n", id) - if incidentID := cmd.String("incident-id"); incidentID != "" { - if report.HasIncidentId() { - fmt.Printf("Linked to incident %s\n", report.GetIncidentId()) - } else { - fmt.Fprintf(os.Stderr, "Warning: the report was not linked to incident %s. Link it with: openstatus inc link-report %s %s\n", incidentID, incidentID, id) - } - } - fmt.Printf("To add updates, run: openstatus status-report add-update %s --status identified --message '...'\n", id) - fmt.Println("Tip: pass --impact = to update per-component impact.") - return nil + return renderCreated(report, cmd.String("incident-id")) }, } } + +// renderCreated prints the created report. requestedIncidentID is the +// --incident-id the user passed, so a silently dropped link can be flagged. +func renderCreated(report *status_reportv1.StatusReport, requestedIncidentID string) error { + id := report.GetId() + if requestedIncidentID != "" && !report.HasIncidentId() { + fmt.Fprintf(os.Stderr, "Warning: the report was not linked to incident %s. Link it with: openstatus inc link-report %s %s\n", requestedIncidentID, requestedIncidentID, id) + } + if output.IsJSONOutput() { + return output.PrintJSON(toStatusReportDetail(report, affectedOrder(report), currentImpacts(report.GetUpdates()))) + } + if output.IsQuiet() { + fmt.Println(id) + return nil + } + fmt.Printf("Status report created successfully (ID: %s)\n", id) + if report.HasIncidentId() { + fmt.Printf("Linked to incident %s\n", report.GetIncidentId()) + } + fmt.Printf("To add updates, run: openstatus status-report add-update %s --status identified --message '...'\n", id) + fmt.Println("Tip: pass --impact = to update per-component impact.") + return nil +} diff --git a/internal/statusreport/statusreport_incident_test.go b/internal/statusreport/statusreport_incident_test.go index 974854a..8255003 100644 --- a/internal/statusreport/statusreport_incident_test.go +++ b/internal/statusreport/statusreport_incident_test.go @@ -123,3 +123,79 @@ func Test_StatusReport_IncidentIDInJSON(t *testing.T) { } }) } + +func Test_CreateStatusReport_IncidentNotFound(t *testing.T) { + t.Parallel() + + client := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewReader([]byte(`{"code":"not_found","message":"incident 999 not found"}`))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }, + } + _, err := statusreport.CreateStatusReportWithHTTPClient( + context.Background(), client.GetHTTPClient(), "test-token", + statusreport.CreateStatusReportParams{ + Title: "DB Down", + Status: "investigating", + Message: "Looking", + Date: "2026-03-20T10:00:00Z", + PageID: "page-1", + IncidentID: "999", + }, + ) + if err == nil || err.Error() != "incident 999 not found" { + t.Errorf("unexpected error %v", err) + } +} + +func Test_RenderCreated_JSON(t *testing.T) { + report := &status_reportv1.StatusReport{} + report.SetId("44") + report.SetTitle("DB Down") + report.SetIncidentId("42") + + var err error + out := captureJSONStdout(t, func() { + err = statusreport.RenderCreated(report, "42") + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var got struct { + ID string `json:"id"` + IncidentID string `json:"incident_id"` + } + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out) + } + if got.ID != "44" || got.IncidentID != "42" { + t.Errorf("unexpected output %+v", got) + } +} + +func Test_RenderCreated_Quiet(t *testing.T) { + report := &status_reportv1.StatusReport{} + report.SetId("44") + + output.SetQuietMode(true) + defer output.SetQuietMode(false) + + r, w, _ := os.Pipe() + original := os.Stdout + os.Stdout = w + err := statusreport.RenderCreated(report, "") + w.Close() + os.Stdout = original + out, _ := io.ReadAll(r) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(out) != "44\n" { + t.Errorf("unexpected output %q", out) + } +} diff --git a/internal/statusreport/statusreport_info.go b/internal/statusreport/statusreport_info.go index f345bff..9579291 100644 --- a/internal/statusreport/statusreport_info.go +++ b/internal/statusreport/statusreport_info.go @@ -43,6 +43,39 @@ type componentImpactJSON struct { Impact string `json:"impact"` } +func toStatusReportDetail(report *status_reportv1.StatusReport, affected []string, current map[string]status_reportv1.PageComponentImpact) statusReportDetail { + detail := statusReportDetail{ + ID: report.GetId(), + Title: report.GetTitle(), + Status: statusToString(report.GetStatus()), + Components: report.GetPageComponentIds(), + IncidentID: report.GetIncidentId(), + CreatedAt: report.GetCreatedAt(), + UpdatedAt: report.GetUpdatedAt(), + } + for _, id := range affected { + detail.CurrentImpacts = append(detail.CurrentImpacts, componentImpactJSON{ + ComponentID: id, + Impact: impactToString(current[id]), + }) + } + for _, u := range report.GetUpdates() { + ue := statusReportUpdate{ + Date: u.GetDate(), + Status: statusToString(u.GetStatus()), + Message: u.GetMessage(), + } + for _, ci := range u.GetComponentImpacts() { + ue.Impacts = append(ue.Impacts, componentImpactJSON{ + ComponentID: ci.GetPageComponentId(), + Impact: impactToString(ci.GetImpact()), + }) + } + detail.Updates = append(detail.Updates, ue) + } + return detail +} + func GetStatusReportInfo(ctx context.Context, client status_reportv1connect.StatusReportServiceClient, reportId string, s *output.Spinner) error { if reportId == "" { output.StopSpinner(s) @@ -66,36 +99,7 @@ func GetStatusReportInfo(ctx context.Context, client status_reportv1connect.Stat current := currentImpacts(report.GetUpdates()) if output.IsJSONOutput() { - detail := statusReportDetail{ - ID: report.GetId(), - Title: report.GetTitle(), - Status: statusToString(report.GetStatus()), - Components: report.GetPageComponentIds(), - IncidentID: report.GetIncidentId(), - CreatedAt: report.GetCreatedAt(), - UpdatedAt: report.GetUpdatedAt(), - } - for _, id := range affected { - detail.CurrentImpacts = append(detail.CurrentImpacts, componentImpactJSON{ - ComponentID: id, - Impact: impactToString(current[id]), - }) - } - for _, u := range report.GetUpdates() { - ue := statusReportUpdate{ - Date: u.GetDate(), - Status: statusToString(u.GetStatus()), - Message: u.GetMessage(), - } - for _, ci := range u.GetComponentImpacts() { - ue.Impacts = append(ue.Impacts, componentImpactJSON{ - ComponentID: ci.GetPageComponentId(), - Impact: impactToString(ci.GetImpact()), - }) - } - detail.Updates = append(detail.Updates, ue) - } - return output.PrintJSON(detail) + return output.PrintJSON(toStatusReportDetail(report, affected, current)) } fmt.Println(aurora.Bold("Status Report:"))