diff --git a/README.md b/README.md index f7830ef..8a6fa59 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,14 @@ openstatus monitors trigger 123 # Report an incident openstatus status-report create --title "API degradation" --status investigating --page-id 1 +# Report an incident with per-component impact +openstatus sr create --title "API down" --status investigating --message "Investigating" \ + --page-id 1 --impact comp_api=major_outage --impact comp_db=degraded + +# Update an incident and change impact +openstatus sr add-update sr_42 --status monitoring --message "Recovering" \ + --impact comp_api=degraded,comp_db=operational + # Run synthetic tests from config openstatus run ``` diff --git a/docs/openstatus-docs.md b/docs/openstatus-docs.md index dbcb768..4d33e98 100644 --- a/docs/openstatus-docs.md +++ b/docs/openstatus-docs.md @@ -328,16 +328,17 @@ $ openstatus [GLOBAL FLAGS] status-report create [COMMAND FLAGS] [ARGUMENTS...] The following flags are supported: -| Name | Description | Type | Default value | Environment variables | -|-----------------------------|------------------------------------------------------------------|--------|:-------------:|:----------------------:| -| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | -| `--title="…"` | Title of the status report | string | | *none* | -| `--status="…"` | Initial status (investigating, identified, monitoring, resolved) | string | | *none* | -| `--message="…"` | Initial message describing the incident | string | | *none* | -| `--page-id="…"` | Status page ID to associate with this report | string | | *none* | -| `--component-ids="…"` | Comma-separated page component IDs | string | | *none* | -| `--notify` | Notify subscribers about this status report | bool | `false` | *none* | -| `--date="…"` | Date when the event occurred (RFC 3339 format, defaults to now) | string | | *none* | +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--title="…"` | Title of the status report | string | | *none* | +| `--status="…"` | Initial status (investigating, identified, monitoring, resolved) | string | | *none* | +| `--message="…"` | Initial message describing the incident | string | | *none* | +| `--page-id="…"` | Status page ID to associate with this report | string | | *none* | +| `--component-ids="…"` | Comma-separated page component IDs (legacy; mutually exclusive with --impact) | string | | *none* | +| `--impact="…"` | Per-component impact, repeatable: --impact = (level: operational, degraded, partial_outage, major_outage). Mutually exclusive with --component-ids | string | | *none* | +| `--notify` | Notify subscribers about this status report | bool | `false` | *none* | +| `--date="…"` | Date when the event occurred (RFC 3339 format, defaults to now) | string | | *none* | ### `status-report update` subcommand @@ -393,13 +394,14 @@ $ openstatus [GLOBAL FLAGS] status-report add-update [COMMAND FLAGS] [ARGUMENTS. The following flags are supported: -| Name | Description | Type | Default value | Environment variables | -|-----------------------------|--------------------------------------------------------------|--------|:-------------:|:----------------------:| -| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | -| `--status="…"` | New status (investigating, identified, monitoring, resolved) | string | | *none* | -| `--message="…"` | Message describing what changed | string | | *none* | -| `--date="…"` | Date for the update (RFC 3339 format, defaults to now) | string | | *none* | -| `--notify` | Notify subscribers about this update | bool | `false` | *none* | +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--status="…"` | New status (investigating, identified, monitoring, resolved) | string | | *none* | +| `--message="…"` | Message describing what changed | string | | *none* | +| `--date="…"` | Date for the update (RFC 3339 format, defaults to now) | string | | *none* | +| `--impact="…"` | Per-component impact, repeatable: --impact = (level: operational, degraded, partial_outage, major_outage). Can also add new affected components | string | | *none* | +| `--notify` | Notify subscribers about this update | bool | `false` | *none* | ### `maintenance` command (aliases: `mt`) diff --git a/docs/openstatus.1 b/docs/openstatus.1 index f38a1ae..f17515b 100644 --- a/docs/openstatus.1 +++ b/docs/openstatus.1 @@ -860,7 +860,7 @@ The following flags are supported: .PP .TS tab(@); -lw(14.3n) lw(32.5n) lw(3.9n) cw(7.4n) cw(11.8n). +lw(8.3n) lw(48.3n) lw(2.3n) cw(4.3n) cw(6.9n). T{ Name T}@T{ @@ -926,7 +926,20 @@ T} T{ \f[CR]\-\-component\-ids=\(dq\&...\(dq\f[R] T}@T{ -Comma\-separated page component IDs +Comma\-separated page component IDs (legacy; mutually exclusive with +\(enimpact) +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-impact=\(dq\&...\(dq\f[R] +T}@T{ +Per\-component impact, repeatable: \(enimpact = (level: operational, +degraded, partial_outage, major_outage). +Mutually exclusive with \(encomponent\-ids T}@T{ string T}@T{ @@ -1088,7 +1101,7 @@ The following flags are supported: .PP .TS tab(@); -lw(14.7n) lw(31.4n) lw(4.1n) cw(7.6n) cw(12.2n). +lw(8.4n) lw(48.0n) lw(2.3n) cw(4.3n) cw(6.9n). T{ Name T}@T{ @@ -1142,6 +1155,18 @@ T}@T{ \f[I]none\f[R] T} T{ +\f[CR]\-\-impact=\(dq\&...\(dq\f[R] +T}@T{ +Per\-component impact, repeatable: \(enimpact = (level: operational, +degraded, partial_outage, major_outage). +Can also add new affected components +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ \f[CR]\-\-notify\f[R] T}@T{ Notify subscribers about this update diff --git a/go.mod b/go.mod index 9430146..3918cdf 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ require github.com/urfave/cli/v3 v3.9.0 // direct require ( buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.2-20260512200453-7d7b7047611f.1 - buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260512200453-7d7b7047611f.1 - connectrpc.com/connect v1.19.2 + buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260611194648-df8af73df8e6.1 + connectrpc.com/connect v1.20.0 github.com/briandowns/spinner v1.23.2 github.com/charmbracelet/huh v1.0.0 github.com/fatih/color v1.19.0 diff --git a/go.sum b/go.sum index feceae7..e8d07fd 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,10 @@ buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.11-20230414000709-087b buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.11-20230414000709-087bc8072ce4.1/go.mod h1:/t9AeRQQp2iNkiGHDLfHLW3SzNpYpNPGRZ+Ih8+SOUs= buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.2-20260512200453-7d7b7047611f.1 h1:NCdo1Xp+waRtzNvXDE9Io5gqzvvMIn3zItVFsxxmBc0= buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.2-20260512200453-7d7b7047611f.1/go.mod h1:pUXTguUtnl60N+cwJnHkPmsdnt0WniHDO9V37KslI4U= -buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260512200453-7d7b7047611f.1 h1:l4scOgYz5A41nIYE/WglmiGdwlZNWruvuyahqhfAKSs= -buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260512200453-7d7b7047611f.1/go.mod h1:GRsD/In1AV3/RC92zWVAjBpj57hJIQm7Rph0rLOYYPg= -connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo= -connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260611194648-df8af73df8e6.1 h1:CCLwLRzDx0s4FfXfbVUy5SBB5m4nGfV9SYTrScUvHqU= +buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260611194648-df8af73df8e6.1/go.mod h1:GRsD/In1AV3/RC92zWVAjBpj57hJIQm7Rph0rLOYYPg= +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= diff --git a/internal/cmd/app.go b/internal/cmd/app.go index ae8ce4e..5009fe4 100644 --- a/internal/cmd/app.go +++ b/internal/cmd/app.go @@ -42,7 +42,7 @@ Get started: openstatus run Run synthetic tests https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new`, - Version: "v1.2.0", + Version: "v1.2.1", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "json", diff --git a/internal/cmd/app_test.go b/internal/cmd/app_test.go index 377664c..b40b4d4 100644 --- a/internal/cmd/app_test.go +++ b/internal/cmd/app_test.go @@ -20,8 +20,8 @@ func Test_NewApp(t *testing.T) { t.Errorf("Expected app name 'openstatus', got %s", app.Name) } - if app.Version != "v1.2.0" { - t.Errorf("Expected version 'v1.2.0', got %s", app.Version) + if app.Version != "v1.2.1" { + t.Errorf("Expected version 'v1.2.1', got %s", app.Version) } if !app.Suggest { diff --git a/internal/statusreport/impact_flag.go b/internal/statusreport/impact_flag.go new file mode 100644 index 0000000..c7ec291 --- /dev/null +++ b/internal/statusreport/impact_flag.go @@ -0,0 +1,54 @@ +package statusreport + +import ( + "fmt" + "strings" + + status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" +) + +func parseImpactsFlag(raw []string) ([]*status_reportv1.ComponentImpact, error) { + if len(raw) == 0 { + return nil, nil + } + + var out []*status_reportv1.ComponentImpact + seen := make(map[string]struct{}) + + for _, entry := range raw { + entry = strings.TrimSpace(entry) + if entry == "" { + return nil, fmt.Errorf("invalid --impact value: empty entry") + } + + key, value, ok := strings.Cut(entry, "=") + if !ok { + return nil, fmt.Errorf("invalid --impact value %q: expected =", entry) + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" { + return nil, fmt.Errorf("invalid --impact value %q: empty component id", entry) + } + if value == "" { + return nil, fmt.Errorf("invalid --impact value %q: empty impact level", entry) + } + + level, err := impactToSDK(value) + if err != nil { + return nil, fmt.Errorf("invalid --impact value %q: %w", entry, err) + } + + if _, dup := seen[key]; dup { + return nil, fmt.Errorf("invalid --impact: component %q specified more than once", key) + } + seen[key] = struct{}{} + + ci := &status_reportv1.ComponentImpact{} + ci.SetPageComponentId(key) + ci.SetImpact(level) + out = append(out, ci) + } + + return out, nil +} diff --git a/internal/statusreport/statusreport.go b/internal/statusreport/statusreport.go index b4d806c..6ae91b7 100644 --- a/internal/statusreport/statusreport.go +++ b/internal/statusreport/statusreport.go @@ -77,6 +77,91 @@ func statusColor(s string) string { } } +func impactToSDK(s string) (status_reportv1.PageComponentImpact, error) { + switch s { + case "operational": + return status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_OPERATIONAL, nil + case "degraded", "degraded_performance": + return status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_DEGRADED_PERFORMANCE, nil + case "partial_outage": + return status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_PARTIAL_OUTAGE, nil + case "major_outage": + return status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_MAJOR_OUTAGE, nil + default: + return status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_UNSPECIFIED, + fmt.Errorf("invalid impact %q: must be one of operational, degraded, partial_outage, major_outage", s) + } +} + +func impactToString(i status_reportv1.PageComponentImpact) string { + switch i { + case status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_OPERATIONAL: + return "operational" + case status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_DEGRADED_PERFORMANCE: + return "degraded" + case status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_PARTIAL_OUTAGE: + return "partial_outage" + case status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_MAJOR_OUTAGE: + return "major_outage" + default: + return "" + } +} + +func impactColor(s string) string { + switch s { + case "operational": + return color.GreenString(s) + case "degraded": + return color.YellowString(s) + case "partial_outage": + return color.MagentaString(s) + case "major_outage": + return color.RedString(s) + default: + return s + } +} + +func currentImpacts(updates []*status_reportv1.StatusReportUpdate) map[string]status_reportv1.PageComponentImpact { + if len(updates) == 0 { + return nil + } + out := make(map[string]status_reportv1.PageComponentImpact) + for _, u := range updates { + for _, ci := range u.GetComponentImpacts() { + out[ci.GetPageComponentId()] = ci.GetImpact() + } + } + return out +} + +func affectedOrder(report *status_reportv1.StatusReport) []string { + if report == nil { + return nil + } + seen := make(map[string]struct{}) + out := make([]string, 0, len(report.GetPageComponentIds())) + for _, id := range report.GetPageComponentIds() { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + for _, u := range report.GetUpdates() { + for _, ci := range u.GetComponentImpacts() { + id := ci.GetPageComponentId() + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + } + return out +} + func StatusReportCmd() *cli.Command { return &cli.Command{ Name: "status-report", diff --git a/internal/statusreport/statusreport_add_update.go b/internal/statusreport/statusreport_add_update.go index 250e579..4571cfb 100644 --- a/internal/statusreport/statusreport_add_update.go +++ b/internal/statusreport/statusreport_add_update.go @@ -16,8 +16,17 @@ import ( output "github.com/openstatusHQ/cli/internal/cli" ) -func AddStatusReportUpdate(ctx context.Context, client status_reportv1connect.StatusReportServiceClient, reportId, status, message, date string, notify bool, s *output.Spinner) error { - if reportId == "" { +type AddStatusReportUpdateParams struct { + ReportID string + Status string + Message string + Date string + ComponentImpacts []*status_reportv1.ComponentImpact + Notify bool +} + +func AddStatusReportUpdate(ctx context.Context, client status_reportv1connect.StatusReportServiceClient, p AddStatusReportUpdateParams, s *output.Spinner) error { + if p.ReportID == "" { output.StopSpinner(s) fmt.Fprintln(os.Stderr, "Usage: openstatus status-report add-update --status --message ") fmt.Fprintln(os.Stderr, "") @@ -25,45 +34,102 @@ func AddStatusReportUpdate(ctx context.Context, client status_reportv1connect.St return fmt.Errorf("report ID is required") } - sdkStatus, err := statusToSDK(status) + sdkStatus, err := statusToSDK(p.Status) if err != nil { output.StopSpinner(s) return err } req := &status_reportv1.AddStatusReportUpdateRequest{ - StatusReportId: reportId, + StatusReportId: p.ReportID, Status: sdkStatus, - Message: message, + Message: p.Message, + } + + if p.Date != "" { + req.SetDate(p.Date) } - if date != "" { - req.SetDate(date) + if len(p.ComponentImpacts) > 0 { + req.SetComponentImpacts(p.ComponentImpacts) } - if notify { + if p.Notify { req.SetNotify(true) } resp, err := client.AddStatusReportUpdate(ctx, req) output.StopSpinner(s) if err != nil { - return output.FormatError(err, "status-report", reportId) + return output.FormatError(err, "status-report", p.ReportID) } report := resp.GetStatusReport() fmt.Printf("Status report %s updated to %s\n", report.GetId(), statusColor(statusToString(report.GetStatus()))) - if status == "resolved" { + printImpactsDiff(report) + + if p.Status == "resolved" { fmt.Println("Report resolved.") } return nil } -func AddStatusReportUpdateWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, reportId, status, message, date string, notify bool) error { +func printImpactsDiff(report *status_reportv1.StatusReport) { + updates := report.GetUpdates() + if len(updates) == 0 { + return + } + + now := currentImpacts(updates) + was := currentImpacts(updates[:len(updates)-1]) + + type diffRow struct { + id string + from string + to string + } + var rows []diffRow + for _, id := range affectedOrder(report) { + nowImpact, hasNow := now[id] + wasImpact, hasWas := was[id] + if !hasNow { + continue + } + if hasWas && wasImpact == nowImpact { + continue + } + fromStr := "(none)" + if hasWas { + fromStr = impactToString(wasImpact) + } + rows = append(rows, diffRow{ + id: id, + from: fromStr, + to: impactToString(nowImpact), + }) + } + + if len(rows) == 0 { + return + } + + fmt.Println("Impacts changed:") + maxID := 0 + for _, r := range rows { + if len(r.id) > maxID { + maxID = len(r.id) + } + } + for _, r := range rows { + fmt.Printf(" %-*s %s → %s\n", maxID, r.id, r.from, impactColor(r.to)) + } +} + +func AddStatusReportUpdateWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, p AddStatusReportUpdateParams) error { client := NewStatusReportClientWithHTTPClient(httpClient, apiKey) - return AddStatusReportUpdate(ctx, client, reportId, status, message, date, notify, nil) + return AddStatusReportUpdate(ctx, client, p, nil) } func GetStatusReportAddUpdateCmd() *cli.Command { @@ -90,6 +156,10 @@ func GetStatusReportAddUpdateCmd() *cli.Command { Name: "date", Usage: "Date for the update (RFC 3339 format, defaults to now)", }, + &cli.StringSliceFlag{ + Name: "impact", + Usage: "Per-component impact, repeatable: --impact = (level: operational, degraded, partial_outage, major_outage). Can also add new affected components.", + }, &cli.BoolFlag{ Name: "notify", Usage: "Notify subscribers about this update", @@ -108,6 +178,12 @@ func GetStatusReportAddUpdateCmd() *cli.Command { Notify: cmd.Bool("notify"), } + impacts, err := parseImpactsFlag(cmd.StringSlice("impact")) + if err != nil { + return cli.Exit(err.Error(), 1) + } + inputs.ComponentImpacts = impactsToMap(impacts) + needsWizard := inputs.ReportID == "" || inputs.Status == "" || inputs.Message == "" @@ -136,18 +212,21 @@ func GetStatusReportAddUpdateCmd() *cli.Command { date = time.Now().UTC().Format(time.RFC3339) } + componentImpacts, err := mapToImpacts(inputs.ComponentImpacts, nil) + if err != nil { + return cli.Exit(err.Error(), 1) + } + s := output.StartSpinner("Adding update...") client := NewStatusReportClient(apiKey) - err = AddStatusReportUpdate( - ctx, - client, - inputs.ReportID, - inputs.Status, - inputs.Message, - date, - inputs.Notify, - s, - ) + err = AddStatusReportUpdate(ctx, client, AddStatusReportUpdateParams{ + ReportID: inputs.ReportID, + Status: inputs.Status, + Message: inputs.Message, + Date: date, + ComponentImpacts: componentImpacts, + Notify: inputs.Notify, + }, s) if err != nil { return cli.Exit(err.Error(), 1) } diff --git a/internal/statusreport/statusreport_add_update_test.go b/internal/statusreport/statusreport_add_update_test.go index 7806072..f7be947 100644 --- a/internal/statusreport/statusreport_add_update_test.go +++ b/internal/statusreport/statusreport_add_update_test.go @@ -7,6 +7,8 @@ import ( "net/http" "testing" + status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" + "github.com/openstatusHQ/cli/internal/statusreport" ) @@ -31,7 +33,12 @@ func Test_AddStatusReportUpdate(t *testing.T) { err := statusreport.AddStatusReportUpdateWithHTTPClient( context.Background(), interceptor.GetHTTPClient(), "test-token", - "1", "identified", "Root cause found", "2026-03-20T10:30:00Z", false, + statusreport.AddStatusReportUpdateParams{ + ReportID: "1", + Status: "identified", + Message: "Root cause found", + Date: "2026-03-20T10:30:00Z", + }, ) if err != nil { t.Errorf("Expected no error, got %v", err) @@ -56,7 +63,12 @@ func Test_AddStatusReportUpdate(t *testing.T) { err := statusreport.AddStatusReportUpdateWithHTTPClient( context.Background(), interceptor.GetHTTPClient(), "test-token", - "1", "resolved", "Issue resolved", "", true, + statusreport.AddStatusReportUpdateParams{ + ReportID: "1", + Status: "resolved", + Message: "Issue resolved", + Notify: true, + }, ) if err != nil { t.Errorf("Expected no error, got %v", err) @@ -77,7 +89,11 @@ func Test_AddStatusReportUpdate(t *testing.T) { err := statusreport.AddStatusReportUpdateWithHTTPClient( context.Background(), interceptor.GetHTTPClient(), "test-token", - "1", "invalid", "Message", "", false, + statusreport.AddStatusReportUpdateParams{ + ReportID: "1", + Status: "invalid", + Message: "Message", + }, ) if err == nil { t.Error("Expected error for invalid status, got nil") @@ -98,7 +114,10 @@ func Test_AddStatusReportUpdate(t *testing.T) { err := statusreport.AddStatusReportUpdateWithHTTPClient( context.Background(), interceptor.GetHTTPClient(), "test-token", - "", "investigating", "Message", "", false, + statusreport.AddStatusReportUpdateParams{ + Status: "investigating", + Message: "Message", + }, ) if err == nil { t.Error("Expected error for empty report ID, got nil") @@ -123,10 +142,102 @@ func Test_AddStatusReportUpdate(t *testing.T) { err := statusreport.AddStatusReportUpdateWithHTTPClient( context.Background(), interceptor.GetHTTPClient(), "test-token", - "999", "investigating", "Message", "", false, + statusreport.AddStatusReportUpdateParams{ + ReportID: "999", + Status: "investigating", + Message: "Message", + }, ) if err == nil { t.Error("Expected error, got nil") } }) + + t.Run("Adds update with impacts sends correct request body", func(t *testing.T) { + body := `{"statusReport":{"id":"5","title":"X","status":"STATUS_REPORT_STATUS_MONITORING","createdAt":"2026-03-20T10:00:00Z","updatedAt":"2026-03-20T11:00:00Z"}}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + var capturedBody []byte + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + if err := captureRequestBody(req, &capturedBody); err != nil { + t.Fatalf("capture body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + impact := &status_reportv1.ComponentImpact{} + impact.SetPageComponentId("comp_api") + impact.SetImpact(status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_OPERATIONAL) + + err := statusreport.AddStatusReportUpdateWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", + statusreport.AddStatusReportUpdateParams{ + ReportID: "5", + Status: "monitoring", + Message: "Recovering", + ComponentImpacts: []*status_reportv1.ComponentImpact{impact}, + }, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + req := decodeBody(t, capturedBody, &status_reportv1.AddStatusReportUpdateRequest{}) + impacts := req.GetComponentImpacts() + if len(impacts) != 1 { + t.Fatalf("expected 1 component impact, got %d", len(impacts)) + } + if impacts[0].GetPageComponentId() != "comp_api" { + t.Errorf("expected comp_api, got %s", impacts[0].GetPageComponentId()) + } + if impacts[0].GetImpact() != status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_OPERATIONAL { + t.Errorf("expected operational, got %v", impacts[0].GetImpact()) + } + }) + + t.Run("Update without impacts omits componentImpacts", func(t *testing.T) { + body := `{"statusReport":{"id":"6","title":"X","status":"STATUS_REPORT_STATUS_IDENTIFIED","createdAt":"2026-03-20T10:00:00Z","updatedAt":"2026-03-20T11:00:00Z"}}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + var capturedBody []byte + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + if err := captureRequestBody(req, &capturedBody); err != nil { + t.Fatalf("capture body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + err := statusreport.AddStatusReportUpdateWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", + statusreport.AddStatusReportUpdateParams{ + ReportID: "6", + Status: "identified", + Message: "Root cause", + }, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + req := decodeBody(t, capturedBody, &status_reportv1.AddStatusReportUpdateRequest{}) + if len(req.GetComponentImpacts()) != 0 { + t.Errorf("expected no componentImpacts, got %v", req.GetComponentImpacts()) + } + }) } diff --git a/internal/statusreport/statusreport_create.go b/internal/statusreport/statusreport_create.go index 7e14e51..c8404b3 100644 --- a/internal/statusreport/statusreport_create.go +++ b/internal/statusreport/statusreport_create.go @@ -15,25 +15,40 @@ import ( output "github.com/openstatusHQ/cli/internal/cli" ) -func CreateStatusReport(ctx context.Context, client status_reportv1connect.StatusReportServiceClient, title, status, message, date, pageId string, componentIds []string, notify bool) (string, error) { - sdkStatus, err := statusToSDK(status) +type CreateStatusReportParams struct { + Title string + Status string + Message string + Date string + PageID string + ComponentIDs []string + ComponentImpacts []*status_reportv1.ComponentImpact + Notify bool +} + +func CreateStatusReport(ctx context.Context, client status_reportv1connect.StatusReportServiceClient, p CreateStatusReportParams) (string, error) { + sdkStatus, err := statusToSDK(p.Status) if err != nil { return "", err } req := &status_reportv1.CreateStatusReportRequest{ - Title: title, + Title: p.Title, Status: sdkStatus, - Message: message, - Date: date, - PageId: pageId, + Message: p.Message, + Date: p.Date, + PageId: p.PageID, + } + + if len(p.ComponentIDs) > 0 { + req.SetPageComponentIds(p.ComponentIDs) } - if len(componentIds) > 0 { - req.SetPageComponentIds(componentIds) + if len(p.ComponentImpacts) > 0 { + req.SetComponentImpacts(p.ComponentImpacts) } - if notify { + if p.Notify { req.SetNotify(true) } @@ -45,9 +60,9 @@ func CreateStatusReport(ctx context.Context, client status_reportv1connect.Statu return resp.GetStatusReport().GetId(), nil } -func CreateStatusReportWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, title, status, message, date, pageId string, componentIds []string, notify bool) (string, error) { +func CreateStatusReportWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, p CreateStatusReportParams) (string, error) { client := NewStatusReportClientWithHTTPClient(httpClient, apiKey) - return CreateStatusReport(ctx, client, title, status, message, date, pageId, componentIds, notify) + return CreateStatusReport(ctx, client, p) } func GetStatusReportCreateCmd() *cli.Command { @@ -80,7 +95,11 @@ func GetStatusReportCreateCmd() *cli.Command { }, &cli.StringFlag{ Name: "component-ids", - Usage: "Comma-separated page component IDs", + Usage: "Comma-separated page component IDs (legacy; mutually exclusive with --impact)", + }, + &cli.StringSliceFlag{ + Name: "impact", + Usage: "Per-component impact, repeatable: --impact = (level: operational, degraded, partial_outage, major_outage). Mutually exclusive with --component-ids.", }, &cli.BoolFlag{ Name: "notify", @@ -108,6 +127,15 @@ func GetStatusReportCreateCmd() *cli.Command { inputs.ComponentIDs = strings.Split(ids, ",") } + impacts, err := parseImpactsFlag(cmd.StringSlice("impact")) + if err != nil { + return cli.Exit(err.Error(), 1) + } + if len(impacts) > 0 && len(inputs.ComponentIDs) > 0 { + return cli.Exit("--component-ids and --impact are mutually exclusive; pick one", 1) + } + inputs.ComponentImpacts = impactsToMap(impacts) + needsWizard := inputs.Title == "" || inputs.Status == "" || inputs.Message == "" || inputs.PageID == "" @@ -139,19 +167,28 @@ func GetStatusReportCreateCmd() *cli.Command { date = time.Now().UTC().Format(time.RFC3339) } + componentImpacts, err := mapToImpacts(inputs.ComponentImpacts, inputs.ComponentIDs) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + componentIDs := inputs.ComponentIDs + if len(componentImpacts) > 0 { + componentIDs = nil + } + client := NewStatusReportClient(apiKey) s := output.StartSpinner("Creating status report...") - id, err := CreateStatusReport( - ctx, - client, - inputs.Title, - inputs.Status, - inputs.Message, - date, - inputs.PageID, - inputs.ComponentIDs, - inputs.Notify, - ) + id, err := CreateStatusReport(ctx, client, CreateStatusReportParams{ + Title: inputs.Title, + Status: inputs.Status, + Message: inputs.Message, + Date: date, + PageID: inputs.PageID, + ComponentIDs: componentIDs, + ComponentImpacts: componentImpacts, + Notify: inputs.Notify, + }) output.StopSpinner(s) if err != nil { return cli.Exit(err.Error(), 1) @@ -159,6 +196,7 @@ func GetStatusReportCreateCmd() *cli.Command { fmt.Printf("Status report created successfully (ID: %s)\n", 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 }, } diff --git a/internal/statusreport/statusreport_create_test.go b/internal/statusreport/statusreport_create_test.go index 2dcf008..a9ed6e9 100644 --- a/internal/statusreport/statusreport_create_test.go +++ b/internal/statusreport/statusreport_create_test.go @@ -7,6 +7,8 @@ import ( "net/http" "testing" + status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" + "github.com/openstatusHQ/cli/internal/statusreport" ) @@ -34,8 +36,13 @@ func Test_CreateStatusReport(t *testing.T) { id, err := statusreport.CreateStatusReportWithHTTPClient( context.Background(), interceptor.GetHTTPClient(), "test-token", - "API Outage", "investigating", "Investigating the issue", - "2026-03-20T10:00:00Z", "page-1", nil, false, + statusreport.CreateStatusReportParams{ + Title: "API Outage", + Status: "investigating", + Message: "Investigating the issue", + Date: "2026-03-20T10:00:00Z", + PageID: "page-1", + }, ) if err != nil { t.Errorf("Expected no error, got %v", err) @@ -63,8 +70,15 @@ func Test_CreateStatusReport(t *testing.T) { id, err := statusreport.CreateStatusReportWithHTTPClient( context.Background(), interceptor.GetHTTPClient(), "test-token", - "DB Issue", "investigating", "Looking into DB issues", - "2026-03-20T10:00:00Z", "page-1", []string{"c1", "c2"}, true, + statusreport.CreateStatusReportParams{ + Title: "DB Issue", + Status: "investigating", + Message: "Looking into DB issues", + Date: "2026-03-20T10:00:00Z", + PageID: "page-1", + ComponentIDs: []string{"c1", "c2"}, + Notify: true, + }, ) if err != nil { t.Errorf("Expected no error, got %v", err) @@ -88,8 +102,13 @@ func Test_CreateStatusReport(t *testing.T) { _, err := statusreport.CreateStatusReportWithHTTPClient( context.Background(), interceptor.GetHTTPClient(), "test-token", - "Title", "invalid-status", "Message", - "2026-03-20T10:00:00Z", "page-1", nil, false, + statusreport.CreateStatusReportParams{ + Title: "Title", + Status: "invalid-status", + Message: "Message", + Date: "2026-03-20T10:00:00Z", + PageID: "page-1", + }, ) if err == nil { t.Error("Expected error for invalid status, got nil") @@ -114,11 +133,117 @@ func Test_CreateStatusReport(t *testing.T) { _, err := statusreport.CreateStatusReportWithHTTPClient( context.Background(), interceptor.GetHTTPClient(), "test-token", - "Title", "investigating", "Message", - "2026-03-20T10:00:00Z", "page-1", nil, false, + statusreport.CreateStatusReportParams{ + Title: "Title", + Status: "investigating", + Message: "Message", + Date: "2026-03-20T10:00:00Z", + PageID: "page-1", + }, ) if err == nil { t.Error("Expected error, got nil") } }) + + t.Run("Creates report with component impacts sends correct request body", func(t *testing.T) { + body := `{"statusReport":{"id":"44","title":"DB Down","status":"STATUS_REPORT_STATUS_INVESTIGATING","createdAt":"2026-03-20T10:00:00Z","updatedAt":"2026-03-20T10:00:00Z"}}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + var capturedBody []byte + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + if err := captureRequestBody(req, &capturedBody); err != nil { + t.Fatalf("capture body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + impactAPI := &status_reportv1.ComponentImpact{} + impactAPI.SetPageComponentId("comp_api") + impactAPI.SetImpact(status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_MAJOR_OUTAGE) + impactDB := &status_reportv1.ComponentImpact{} + impactDB.SetPageComponentId("comp_db") + impactDB.SetImpact(status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_DEGRADED_PERFORMANCE) + + _, err := statusreport.CreateStatusReportWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", + statusreport.CreateStatusReportParams{ + Title: "DB Down", + Status: "investigating", + Message: "Looking", + Date: "2026-03-20T10:00:00Z", + PageID: "page-1", + ComponentImpacts: []*status_reportv1.ComponentImpact{impactAPI, impactDB}, + }, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + req := decodeBody(t, capturedBody, &status_reportv1.CreateStatusReportRequest{}) + impacts := req.GetComponentImpacts() + if len(impacts) != 2 { + t.Fatalf("expected 2 component impacts, got %d", len(impacts)) + } + if impacts[0].GetPageComponentId() != "comp_api" { + t.Errorf("expected comp_api first, got %s", impacts[0].GetPageComponentId()) + } + if impacts[0].GetImpact() != status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_MAJOR_OUTAGE { + t.Errorf("expected major_outage for comp_api, got %v", impacts[0].GetImpact()) + } + if impacts[1].GetPageComponentId() != "comp_db" { + t.Errorf("expected comp_db second, got %s", impacts[1].GetPageComponentId()) + } + if impacts[1].GetImpact() != status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_DEGRADED_PERFORMANCE { + t.Errorf("expected degraded for comp_db, got %v", impacts[1].GetImpact()) + } + }) + + t.Run("Creates report without impacts omits componentImpacts", func(t *testing.T) { + body := `{"statusReport":{"id":"45","title":"X","status":"STATUS_REPORT_STATUS_INVESTIGATING","createdAt":"2026-03-20T10:00:00Z","updatedAt":"2026-03-20T10:00:00Z"}}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + var capturedBody []byte + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + if err := captureRequestBody(req, &capturedBody); err != nil { + t.Fatalf("capture body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + _, err := statusreport.CreateStatusReportWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", + statusreport.CreateStatusReportParams{ + Title: "X", + Status: "investigating", + Message: "M", + Date: "2026-03-20T10:00:00Z", + PageID: "page-1", + }, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + req := decodeBody(t, capturedBody, &status_reportv1.CreateStatusReportRequest{}) + if len(req.GetComponentImpacts()) != 0 { + t.Errorf("expected no componentImpacts, got %v", req.GetComponentImpacts()) + } + }) } diff --git a/internal/statusreport/statusreport_impact_test.go b/internal/statusreport/statusreport_impact_test.go new file mode 100644 index 0000000..15df1e3 --- /dev/null +++ b/internal/statusreport/statusreport_impact_test.go @@ -0,0 +1,176 @@ +package statusreport + +import ( + "strings" + "testing" + + status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" +) + +func Test_parseImpactsFlag(t *testing.T) { + t.Parallel() + + t.Run("Empty input returns nil", func(t *testing.T) { + got, err := parseImpactsFlag(nil) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got != nil { + t.Errorf("expected nil slice, got %v", got) + } + }) + + t.Run("Single pair", func(t *testing.T) { + got, err := parseImpactsFlag([]string{"comp_api=major_outage"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 entry, got %d", len(got)) + } + if got[0].GetPageComponentId() != "comp_api" { + t.Errorf("expected comp_api, got %s", got[0].GetPageComponentId()) + } + if got[0].GetImpact() != status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_MAJOR_OUTAGE { + t.Errorf("expected major_outage, got %v", got[0].GetImpact()) + } + }) + + t.Run("Multiple repeated pairs", func(t *testing.T) { + got, err := parseImpactsFlag([]string{"comp_api=major_outage", "comp_db=degraded"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 entries, got %d", len(got)) + } + }) + + t.Run("Whitespace tolerated around key, =, and value", func(t *testing.T) { + got, err := parseImpactsFlag([]string{" comp_api = major_outage "}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(got) != 1 || got[0].GetPageComponentId() != "comp_api" { + t.Errorf("expected comp_api, got %v", got) + } + if got[0].GetImpact() != status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_MAJOR_OUTAGE { + t.Errorf("expected major_outage, got %v", got[0].GetImpact()) + } + }) + + t.Run("degraded_performance alias accepted", func(t *testing.T) { + got, err := parseImpactsFlag([]string{"comp_x=degraded_performance"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got[0].GetImpact() != status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_DEGRADED_PERFORMANCE { + t.Errorf("expected degraded_performance enum, got %v", got[0].GetImpact()) + } + }) + + t.Run("All four canonical tokens parse", func(t *testing.T) { + cases := map[string]status_reportv1.PageComponentImpact{ + "operational": status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_OPERATIONAL, + "degraded": status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_DEGRADED_PERFORMANCE, + "partial_outage": status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_PARTIAL_OUTAGE, + "major_outage": status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_MAJOR_OUTAGE, + } + for token, want := range cases { + got, err := parseImpactsFlag([]string{"c=" + token}) + if err != nil { + t.Errorf("%s: unexpected error %v", token, err) + continue + } + if got[0].GetImpact() != want { + t.Errorf("%s: expected %v, got %v", token, want, got[0].GetImpact()) + } + } + }) + + t.Run("Missing = errors", func(t *testing.T) { + _, err := parseImpactsFlag([]string{"comp_api"}) + if err == nil { + t.Fatal("expected error for missing =") + } + if !strings.Contains(err.Error(), "expected =") { + t.Errorf("expected helpful error, got %v", err) + } + }) + + t.Run("Empty key errors", func(t *testing.T) { + _, err := parseImpactsFlag([]string{"=major_outage"}) + if err == nil { + t.Fatal("expected error for empty key") + } + }) + + t.Run("Empty value errors", func(t *testing.T) { + _, err := parseImpactsFlag([]string{"comp_api="}) + if err == nil { + t.Fatal("expected error for empty value") + } + }) + + t.Run("Empty entry errors", func(t *testing.T) { + _, err := parseImpactsFlag([]string{" "}) + if err == nil { + t.Fatal("expected error for empty entry") + } + }) + + t.Run("Unknown impact level errors", func(t *testing.T) { + _, err := parseImpactsFlag([]string{"comp_api=catastrophic"}) + if err == nil { + t.Fatal("expected error for unknown impact") + } + if !strings.Contains(err.Error(), "operational, degraded, partial_outage, major_outage") { + t.Errorf("expected allowed-values list in error, got %v", err) + } + }) + + t.Run("Duplicate component_id errors", func(t *testing.T) { + _, err := parseImpactsFlag([]string{"comp_api=major_outage", "comp_api=degraded"}) + if err == nil { + t.Fatal("expected error for duplicate component") + } + if !strings.Contains(err.Error(), "specified more than once") { + t.Errorf("expected duplicate-key error, got %v", err) + } + }) +} + +func Test_impactToString_roundtrip(t *testing.T) { + t.Parallel() + + tokens := []string{"operational", "degraded", "partial_outage", "major_outage"} + for _, tok := range tokens { + sdk, err := impactToSDK(tok) + if err != nil { + t.Errorf("%s: impactToSDK error %v", tok, err) + continue + } + got := impactToString(sdk) + if got != tok { + t.Errorf("round-trip %s -> %v -> %s mismatch", tok, sdk, got) + } + } +} + +func Test_impactToString_unspecified(t *testing.T) { + t.Parallel() + + got := impactToString(status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_UNSPECIFIED) + if got != "" { + t.Errorf("UNSPECIFIED should map to empty string, got %q", got) + } +} + +func Test_impactSelectOptions(t *testing.T) { + t.Parallel() + + opts := impactSelectOptions() + if len(opts) != 4 { + t.Errorf("expected 4 options, got %d", len(opts)) + } +} diff --git a/internal/statusreport/statusreport_info.go b/internal/statusreport/statusreport_info.go index a76dcdc..ba955e4 100644 --- a/internal/statusreport/statusreport_info.go +++ b/internal/statusreport/statusreport_info.go @@ -20,19 +20,26 @@ import ( ) type statusReportDetail struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Components []string `json:"components,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Updates []statusReportUpdate `json:"updates,omitempty"` + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Components []string `json:"components,omitempty"` + CurrentImpacts []componentImpactJSON `json:"current_impacts,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Updates []statusReportUpdate `json:"updates,omitempty"` } type statusReportUpdate struct { - Date string `json:"date"` - Status string `json:"status"` - Message string `json:"message"` + Date string `json:"date"` + Status string `json:"status"` + Message string `json:"message"` + Impacts []componentImpactJSON `json:"impacts,omitempty"` +} + +type componentImpactJSON struct { + ComponentID string `json:"component_id"` + Impact string `json:"impact"` } func GetStatusReportInfo(ctx context.Context, client status_reportv1connect.StatusReportServiceClient, reportId string, s *output.Spinner) error { @@ -54,6 +61,9 @@ func GetStatusReportInfo(ctx context.Context, client status_reportv1connect.Stat report := resp.GetStatusReport() + affected := affectedOrder(report) + current := currentImpacts(report.GetUpdates()) + if output.IsJSONOutput() { detail := statusReportDetail{ ID: report.GetId(), @@ -63,12 +73,25 @@ func GetStatusReportInfo(ctx context.Context, client status_reportv1connect.Stat 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() { - detail.Updates = append(detail.Updates, statusReportUpdate{ + 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) } @@ -103,18 +126,17 @@ func GetStatusReportInfo(ctx context.Context, client status_reportv1connect.Stat {"ID", report.GetId()}, {"Title", report.GetTitle()}, {"Status", statusColor(statusToString(report.GetStatus()))}, + {"Created", output.FormatTimestamp(report.GetCreatedAt())}, + {"Updated", output.FormatTimestamp(report.GetUpdatedAt())}, } - if len(report.GetPageComponentIds()) > 0 { - data = append(data, []string{"Components", strings.Join(report.GetPageComponentIds(), ", ")}) - } - - data = append(data, []string{"Created", output.FormatTimestamp(report.GetCreatedAt())}) - data = append(data, []string{"Updated", output.FormatTimestamp(report.GetUpdatedAt())}) - table.Bulk(data) table.Render() + if len(affected) > 0 { + printAffectedSection(report, affected, current) + } + updates := report.GetUpdates() if len(updates) == 0 { fmt.Println("\nNo updates yet") @@ -128,11 +150,55 @@ func GetStatusReportInfo(ctx context.Context, client status_reportv1connect.Stat statusColor(statusToString(u.GetStatus())), u.GetMessage(), ) + if len(u.GetComponentImpacts()) > 0 { + parts := make([]string, 0, len(u.GetComponentImpacts())) + for _, ci := range u.GetComponentImpacts() { + parts = append(parts, ci.GetPageComponentId()+"="+impactColor(impactToString(ci.GetImpact()))) + } + fmt.Printf(" → %s\n", strings.Join(parts, ", ")) + } } return nil } +func printAffectedSection(report *status_reportv1.StatusReport, affected []string, current map[string]status_reportv1.PageComponentImpact) { + fmt.Println(aurora.Bold("\nAffected:")) + + priorPerComponent := make(map[string]status_reportv1.PageComponentImpact) + finalPerComponent := make(map[string]status_reportv1.PageComponentImpact) + for _, u := range report.GetUpdates() { + for _, ci := range u.GetComponentImpacts() { + id := ci.GetPageComponentId() + if prev, ok := finalPerComponent[id]; ok && prev != ci.GetImpact() { + priorPerComponent[id] = prev + } + finalPerComponent[id] = ci.GetImpact() + } + } + + maxID := 0 + for _, id := range affected { + if len(id) > maxID { + maxID = len(id) + } + } + + for _, id := range affected { + impact := current[id] + token := impactToString(impact) + display := "—" + if token != "" { + display = impactColor(token) + } + line := fmt.Sprintf(" %-*s %s", maxID, id, display) + if prior, ok := priorPerComponent[id]; ok { + line += " ← was " + impactToString(prior) + } + fmt.Println(line) + } +} + func GetStatusReportInfoWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, reportId string) error { client := NewStatusReportClientWithHTTPClient(httpClient, apiKey) return GetStatusReportInfo(ctx, client, reportId, nil) diff --git a/internal/statusreport/statusreport_wizard.go b/internal/statusreport/statusreport_wizard.go index ce27956..1be44c2 100644 --- a/internal/statusreport/statusreport_wizard.go +++ b/internal/statusreport/statusreport_wizard.go @@ -15,24 +15,66 @@ import ( ) type createInputs struct { - PageID string - PageName string - Title string - Status string - Message string - ComponentIDs []string - componentNames map[string]string - Notify bool - Confirmed bool + PageID string + PageName string + Title string + Status string + Message string + ComponentIDs []string + ComponentImpacts map[string]string + componentNames map[string]string + Notify bool + Confirmed bool } type addUpdateInputs struct { - ReportID string - ReportName string - Status string - Message string - Notify bool - Confirmed bool + ReportID string + ReportName string + Status string + Message string + ComponentImpacts map[string]string + priorImpacts map[string]string + Notify bool + Confirmed bool +} + +func impactsToMap(in []*status_reportv1.ComponentImpact) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for _, ci := range in { + out[ci.GetPageComponentId()] = impactToString(ci.GetImpact()) + } + return out +} + +func mapToImpacts(in map[string]string, order []string) ([]*status_reportv1.ComponentImpact, error) { + if len(in) == 0 { + return nil, nil + } + out := make([]*status_reportv1.ComponentImpact, 0, len(in)) + if len(order) == 0 { + order = make([]string, 0, len(in)) + for k := range in { + order = append(order, k) + } + } + for _, id := range order { + token, ok := in[id] + if !ok { + continue + } + level, err := impactToSDK(token) + if err != nil { + return nil, err + } + ci := &status_reportv1.ComponentImpact{} + ci.SetPageComponentId(id) + ci.SetImpact(level) + out = append(out, ci) + } + return out, nil } func statusSelectOptions() []huh.Option[string] { @@ -44,6 +86,15 @@ func statusSelectOptions() []huh.Option[string] { } } +func impactSelectOptions() []huh.Option[string] { + return []huh.Option[string]{ + huh.NewOption("Operational", "operational"), + huh.NewOption("Degraded performance", "degraded"), + huh.NewOption("Partial outage", "partial_outage"), + huh.NewOption("Major outage", "major_outage"), + } +} + func fetchStatusReports(ctx context.Context, apiKey string) ([]*status_reportv1.StatusReportSummary, error) { client := NewStatusReportClient(apiKey) @@ -125,8 +176,6 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs return nil, err } - var fields []huh.Field - if len(components) > 0 { groupMap := make(map[string]string, len(groups)) for _, g := range groups { @@ -146,14 +195,30 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs compOptions = append(compOptions, huh.NewOption(label, c.GetId())) } - if len(prefilled.ComponentIDs) == 0 { - fields = append(fields, huh.NewMultiSelect[string](). - Title("Components"). - Options(compOptions...). - Value(&inputs.ComponentIDs)) + if len(prefilled.ComponentIDs) == 0 && len(prefilled.ComponentImpacts) == 0 { + form2 := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Components"). + Options(compOptions...). + Value(&inputs.ComponentIDs), + ), + ).WithTheme(huh.ThemeBase()) + if err := form2.Run(); err != nil { + return nil, wizard.HandleFormError(err) + } + } + } + + if len(inputs.ComponentImpacts) == 0 && len(inputs.ComponentIDs) > 0 { + inputs.ComponentImpacts = make(map[string]string, len(inputs.ComponentIDs)) + for _, id := range inputs.ComponentIDs { + inputs.ComponentImpacts[id] = "partial_outage" } } + var fields []huh.Field + if inputs.Title == "" { fields = append(fields, huh.NewInput(). Title("Title"). @@ -175,6 +240,20 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs Value(&inputs.Message)) } + impactPickers := make(map[string]*string, len(inputs.ComponentIDs)) + for _, id := range inputs.ComponentIDs { + current := inputs.ComponentImpacts[id] + impactPickers[id] = ¤t + label := inputs.componentNames[id] + if label == "" { + label = id + } + fields = append(fields, huh.NewSelect[string](). + Title("Impact for "+label). + Options(impactSelectOptions()...). + Value(impactPickers[id])) + } + fields = append(fields, huh.NewConfirm(). Title("Notify subscribers?"). Value(&inputs.Notify)) @@ -186,15 +265,19 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs {"Page", inputs.PageName}, } if len(inputs.ComponentIDs) > 0 { - names := make([]string, 0, len(inputs.ComponentIDs)) + rows := make([]string, 0, len(inputs.ComponentIDs)) for _, id := range inputs.ComponentIDs { - if name, ok := inputs.componentNames[id]; ok { - names = append(names, name) - } else { - names = append(names, id) + name := inputs.componentNames[id] + if name == "" { + name = id } + token := "" + if impactPickers[id] != nil { + token = *impactPickers[id] + } + rows = append(rows, name+" ("+token+")") } - lines = append(lines, [2]string{"Components", strings.Join(names, ", ")}) + lines = append(lines, [2]string{"Components", strings.Join(rows, ", ")}) } lines = append(lines, [2]string{"Title", inputs.Title}, @@ -209,7 +292,7 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs return wizard.BuildSummary(lines) }, &inputs) - form2 := huh.NewForm( + form3 := huh.NewForm( huh.NewGroup(fields...), huh.NewGroup( summaryNote, @@ -219,7 +302,7 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs ), ).WithTheme(huh.ThemeBase()) - if err := form2.Run(); err != nil { + if err := form3.Run(); err != nil { return nil, wizard.HandleFormError(err) } @@ -228,6 +311,10 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs os.Exit(130) } + for id, ptr := range impactPickers { + inputs.ComponentImpacts[id] = *ptr + } + return &inputs, nil } @@ -278,6 +365,62 @@ func runAddUpdateWizard(ctx context.Context, apiKey string, prefilled *addUpdate inputs.ReportName = inputs.ReportID } + s = output.StartSpinner("Fetching status report details...") + client := NewStatusReportClient(apiKey) + getReq := &status_reportv1.GetStatusReportRequest{} + getReq.SetId(inputs.ReportID) + detail, err := client.GetStatusReport(ctx, getReq) + output.StopSpinner(s) + if err != nil { + return nil, output.FormatError(err, "status-report", inputs.ReportID) + } + report := detail.GetStatusReport() + + prior := currentImpacts(report.GetUpdates()) + inputs.priorImpacts = make(map[string]string, len(prior)) + for id, level := range prior { + inputs.priorImpacts[id] = impactToString(level) + } + + affectedIDs := affectedOrder(report) + + var selectedComponents []string + if len(affectedIDs) > 0 { + compOptions := make([]huh.Option[string], 0, len(affectedIDs)) + for _, id := range affectedIDs { + currentLabel := "—" + if v, ok := prior[id]; ok && v != status_reportv1.PageComponentImpact_PAGE_COMPONENT_IMPACT_UNSPECIFIED { + currentLabel = impactToString(v) + } + compOptions = append(compOptions, huh.NewOption(id+" (current: "+currentLabel+")", id)) + } + form2 := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Components to re-impact (leave empty to skip)"). + Options(compOptions...). + Value(&selectedComponents), + ), + ).WithTheme(huh.ThemeBase()) + if err := form2.Run(); err != nil { + return nil, wizard.HandleFormError(err) + } + } + + if inputs.ComponentImpacts == nil { + inputs.ComponentImpacts = make(map[string]string, len(selectedComponents)) + } + for _, id := range selectedComponents { + if _, ok := inputs.ComponentImpacts[id]; ok { + continue + } + if prev, ok := inputs.priorImpacts[id]; ok && prev != "" { + inputs.ComponentImpacts[id] = prev + } else { + inputs.ComponentImpacts[id] = "partial_outage" + } + } + var fields []huh.Field if inputs.Status == "" { @@ -294,6 +437,16 @@ func runAddUpdateWizard(ctx context.Context, apiKey string, prefilled *addUpdate Value(&inputs.Message)) } + impactPickers := make(map[string]*string, len(selectedComponents)) + for _, id := range selectedComponents { + current := inputs.ComponentImpacts[id] + impactPickers[id] = ¤t + fields = append(fields, huh.NewSelect[string](). + Title("Impact for "+id). + Options(impactSelectOptions()...). + Value(impactPickers[id])) + } + fields = append(fields, huh.NewConfirm(). Title("Notify subscribers?"). Value(&inputs.Notify)) @@ -306,6 +459,21 @@ func runAddUpdateWizard(ctx context.Context, apiKey string, prefilled *addUpdate {"Status", inputs.Status}, {"Message", inputs.Message}, } + if len(selectedComponents) > 0 { + rows := make([]string, 0, len(selectedComponents)) + for _, id := range selectedComponents { + prev := inputs.priorImpacts[id] + if prev == "" { + prev = "(none)" + } + next := "" + if impactPickers[id] != nil { + next = *impactPickers[id] + } + rows = append(rows, id+": "+prev+" → "+next) + } + lines = append(lines, [2]string{"Impacts", strings.Join(rows, ", ")}) + } notifyStr := "no" if inputs.Notify { notifyStr = "yes" @@ -314,7 +482,7 @@ func runAddUpdateWizard(ctx context.Context, apiKey string, prefilled *addUpdate return wizard.BuildSummary(lines) }, &inputs) - form2 := huh.NewForm( + form3 := huh.NewForm( huh.NewGroup(fields...), huh.NewGroup( summaryNote, @@ -324,10 +492,14 @@ func runAddUpdateWizard(ctx context.Context, apiKey string, prefilled *addUpdate ), ).WithTheme(huh.ThemeBase()) - if err := form2.Run(); err != nil { + if err := form3.Run(); err != nil { return nil, wizard.HandleFormError(err) } + for id, ptr := range impactPickers { + inputs.ComponentImpacts[id] = *ptr + } + if !inputs.Confirmed { fmt.Fprintln(os.Stderr, "Aborted.") os.Exit(130) diff --git a/internal/statusreport/testhelpers_test.go b/internal/statusreport/testhelpers_test.go new file mode 100644 index 0000000..f40c0a1 --- /dev/null +++ b/internal/statusreport/testhelpers_test.go @@ -0,0 +1,35 @@ +package statusreport_test + +import ( + "bytes" + "io" + "net/http" + "testing" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +func captureRequestBody(req *http.Request, dst *[]byte) error { + if req.Body == nil { + return nil + } + body, err := io.ReadAll(req.Body) + if err != nil { + return err + } + *dst = body + req.Body = io.NopCloser(bytes.NewReader(body)) + return nil +} + +func decodeBody[T proto.Message](t *testing.T, body []byte, into T) T { + t.Helper() + if len(body) == 0 { + t.Fatal("expected non-empty request body") + } + if err := protojson.Unmarshal(body, into); err != nil { + t.Fatalf("failed to unmarshal request body: %v\nbody: %s", err, string(body)) + } + return into +}