From 5dcc8c9fb476b0e58cce093364bbf1c558c4115e Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Ducasse Date: Thu, 11 Jun 2026 22:40:18 +0200 Subject: [PATCH] feat(status-report): add impact helpers and --impact flag parser --- internal/statusreport/impact_flag.go | 54 ++++++ internal/statusreport/statusreport.go | 46 +++++ .../statusreport/statusreport_impact_test.go | 176 ++++++++++++++++++ internal/statusreport/statusreport_wizard.go | 9 + 4 files changed, 285 insertions(+) create mode 100644 internal/statusreport/impact_flag.go create mode 100644 internal/statusreport/statusreport_impact_test.go 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..7ee51bb 100644 --- a/internal/statusreport/statusreport.go +++ b/internal/statusreport/statusreport.go @@ -77,6 +77,52 @@ 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 StatusReportCmd() *cli.Command { return &cli.Command{ Name: "status-report", 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_wizard.go b/internal/statusreport/statusreport_wizard.go index ce27956..6c40f25 100644 --- a/internal/statusreport/statusreport_wizard.go +++ b/internal/statusreport/statusreport_wizard.go @@ -44,6 +44,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) -- knotmirror 0.0.1