diff --git a/internal/cli/output.go b/internal/cli/output.go index 505ce2c..5ec6419 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "sync/atomic" + "time" "github.com/fatih/color" "github.com/mattn/go-isatty" @@ -39,6 +40,14 @@ func PrintJSON(v any) error { return nil } +func FormatTimestamp(rfc3339 string) string { + t, err := time.Parse(time.RFC3339, rfc3339) + if err != nil { + return rfc3339 + } + return t.UTC().Format("2006-01-02 15:04 UTC") +} + func InitColorSettings(noColorFlag bool) { if noColorFlag || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" || !IsTerminal() { color.NoColor = true diff --git a/internal/cli/output_test.go b/internal/cli/output_test.go new file mode 100644 index 0000000..5837022 --- /dev/null +++ b/internal/cli/output_test.go @@ -0,0 +1,41 @@ +package cli_test + +import ( + "testing" + + "github.com/openstatusHQ/cli/internal/cli" +) + +func Test_FormatTimestamp(t *testing.T) { + t.Parallel() + + t.Run("valid RFC 3339 returns shortened format", func(t *testing.T) { + result := cli.FormatTimestamp("2026-04-01T10:00:00Z") + expected := "2026-04-01 10:00 UTC" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } + }) + + t.Run("timezone offset converted to UTC", func(t *testing.T) { + result := cli.FormatTimestamp("2026-04-01T12:00:00+02:00") + expected := "2026-04-01 10:00 UTC" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } + }) + + t.Run("invalid string returned as-is", func(t *testing.T) { + result := cli.FormatTimestamp("not-a-date") + if result != "not-a-date" { + t.Errorf("expected 'not-a-date', got %q", result) + } + }) + + t.Run("empty string returned as-is", func(t *testing.T) { + result := cli.FormatTimestamp("") + if result != "" { + t.Errorf("expected empty string, got %q", result) + } + }) +} diff --git a/internal/cmd/app.go b/internal/cmd/app.go index 5407a29..39ff2aa 100644 --- a/internal/cmd/app.go +++ b/internal/cmd/app.go @@ -8,6 +8,7 @@ import ( output "github.com/openstatusHQ/cli/internal/cli" "github.com/openstatusHQ/cli/internal/login" + "github.com/openstatusHQ/cli/internal/maintenance" "github.com/openstatusHQ/cli/internal/monitors" "github.com/openstatusHQ/cli/internal/run" "github.com/openstatusHQ/cli/internal/statuspage" @@ -30,6 +31,8 @@ Get started: openstatus login Save your API token openstatus status-report create Report an incident openstatus status-report list View active incidents + openstatus maintenance create Schedule a maintenance window + openstatus maintenance list View maintenance windows openstatus monitors apply Sync monitors from config openstatus monitors list List your monitors openstatus run Run synthetic tests @@ -65,6 +68,7 @@ https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new`, Commands: []*cli.Command{ monitors.MonitorsCmd(), statusreport.StatusReportCmd(), + maintenance.MaintenanceCmd(), statuspage.StatusPageCmd(), run.RunCmd(), whoami.WhoamiCmd(), diff --git a/internal/cmd/app_test.go b/internal/cmd/app_test.go index d26a233..ceb00c6 100644 --- a/internal/cmd/app_test.go +++ b/internal/cmd/app_test.go @@ -32,13 +32,14 @@ func Test_NewApp(t *testing.T) { t.Run("Has expected commands", func(t *testing.T) { app := cmd.NewApp() - if len(app.Commands) != 7 { - t.Errorf("Expected 7 commands, got %d", len(app.Commands)) + if len(app.Commands) != 8 { + t.Errorf("Expected 8 commands, got %d", len(app.Commands)) } expectedCommands := map[string]bool{ "monitors": false, "status-report": false, + "maintenance": false, "status-page": false, "run": false, "whoami": false, diff --git a/internal/maintenance/maintenance.go b/internal/maintenance/maintenance.go new file mode 100644 index 0000000..27f8ca8 --- /dev/null +++ b/internal/maintenance/maintenance.go @@ -0,0 +1,79 @@ +package maintenance + +import ( + "net/http" + "time" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/maintenance/v1/maintenancev1connect" + "connectrpc.com/connect" + "github.com/fatih/color" + "github.com/openstatusHQ/cli/internal/api" + "github.com/urfave/cli/v3" +) + +func NewMaintenanceClient(apiKey string) maintenancev1connect.MaintenanceServiceClient { + return maintenancev1connect.NewMaintenanceServiceClient( + api.DefaultHTTPClient, + api.ConnectBaseURL, + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), + connect.WithProtoJSON(), + ) +} + +func NewMaintenanceClientWithHTTPClient(httpClient *http.Client, apiKey string) maintenancev1connect.MaintenanceServiceClient { + return maintenancev1connect.NewMaintenanceServiceClient( + httpClient, + api.ConnectBaseURL, + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), + connect.WithProtoJSON(), + ) +} + +func timeWindowStatus(from, to string) string { + fromTime, err := time.Parse(time.RFC3339, from) + if err != nil { + return "unknown" + } + toTime, err := time.Parse(time.RFC3339, to) + if err != nil { + return "unknown" + } + + now := time.Now().UTC() + switch { + case now.Before(fromTime): + return "scheduled" + case now.After(toTime): + return "completed" + default: + return "in_progress" + } +} + +func statusColor(s string) string { + switch s { + case "scheduled": + return color.BlueString(s) + case "in_progress": + return color.YellowString(s) + case "completed": + return color.GreenString(s) + default: + return s + } +} + +func MaintenanceCmd() *cli.Command { + return &cli.Command{ + Name: "maintenance", + Aliases: []string{"mt"}, + Usage: "Manage maintenance windows", + Commands: []*cli.Command{ + GetMaintenanceListCmd(), + GetMaintenanceInfoCmd(), + GetMaintenanceCreateCmd(), + GetMaintenanceUpdateCmd(), + GetMaintenanceDeleteCmd(), + }, + } +} diff --git a/internal/maintenance/maintenance_create.go b/internal/maintenance/maintenance_create.go new file mode 100644 index 0000000..3e5b57d --- /dev/null +++ b/internal/maintenance/maintenance_create.go @@ -0,0 +1,157 @@ +package maintenance + +import ( + "context" + "fmt" + "net/http" + "strings" + + maintenancev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/maintenance/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/maintenance/v1/maintenancev1connect" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" + "github.com/urfave/cli/v3" +) + +func CreateMaintenance(ctx context.Context, client maintenancev1connect.MaintenanceServiceClient, title, message, from, to, pageId string, componentIds []string, notify bool) (string, error) { + req := &maintenancev1.CreateMaintenanceRequest{ + Title: title, + Message: message, + From: from, + To: to, + PageId: pageId, + } + + if len(componentIds) > 0 { + req.SetPageComponentIds(componentIds) + } + + if notify { + req.SetNotify(true) + } + + resp, err := client.CreateMaintenance(ctx, req) + if err != nil { + return "", output.FormatError(err, "maintenance", "") + } + + return resp.GetMaintenance().GetId(), nil +} + +func CreateMaintenanceWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, title, message, from, to, pageId string, componentIds []string, notify bool) (string, error) { + client := NewMaintenanceClientWithHTTPClient(httpClient, apiKey) + return CreateMaintenance(ctx, client, title, message, from, to, pageId, componentIds, notify) +} + +func GetMaintenanceCreateCmd() *cli.Command { + return &cli.Command{ + Name: "create", + Usage: "Create a maintenance window", + UsageText: "openstatus maintenance create --title \"DB Migration\" --message \"Upgrading database\" --from 2026-04-01T10:00:00Z --to 2026-04-01T12:00:00Z --page-id 123", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + }, + &cli.StringFlag{ + Name: "title", + Usage: "Title of the maintenance", + }, + &cli.StringFlag{ + Name: "message", + Usage: "Message describing the maintenance", + }, + &cli.StringFlag{ + Name: "from", + Usage: "Start time of the maintenance window (RFC 3339 format)", + }, + &cli.StringFlag{ + Name: "to", + Usage: "End time of the maintenance window (RFC 3339 format)", + }, + &cli.StringFlag{ + Name: "page-id", + Usage: "Status page ID to associate with this maintenance", + }, + &cli.StringFlag{ + Name: "component-ids", + Usage: "Comma-separated page component IDs", + }, + &cli.BoolFlag{ + Name: "notify", + Usage: "Notify subscribers about this maintenance", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + inputs := &createInputs{ + PageID: cmd.String("page-id"), + Title: cmd.String("title"), + Message: cmd.String("message"), + From: cmd.String("from"), + To: cmd.String("to"), + Notify: cmd.Bool("notify"), + } + if ids := cmd.String("component-ids"); ids != "" { + inputs.ComponentIDs = strings.Split(ids, ",") + } + + needsWizard := inputs.Title == "" || inputs.Message == "" || + inputs.From == "" || inputs.To == "" || inputs.PageID == "" + + if needsWizard { + if output.IsJSONOutput() || !output.IsStdinTerminal() { + var missing []string + if inputs.Title == "" { + missing = append(missing, "--title") + } + if inputs.Message == "" { + missing = append(missing, "--message") + } + if inputs.From == "" { + missing = append(missing, "--from") + } + if inputs.To == "" { + missing = append(missing, "--to") + } + if inputs.PageID == "" { + missing = append(missing, "--page-id") + } + return cli.Exit(fmt.Sprintf("missing required flags: %s", strings.Join(missing, ", ")), 1) + } + inputs, err = runCreateWizard(ctx, apiKey, inputs) + if err != nil { + return cli.Exit(err.Error(), 1) + } + } + + client := NewMaintenanceClient(apiKey) + s := output.StartSpinner("Creating maintenance...") + id, err := CreateMaintenance( + ctx, + client, + inputs.Title, + inputs.Message, + inputs.From, + inputs.To, + inputs.PageID, + inputs.ComponentIDs, + inputs.Notify, + ) + output.StopSpinner(s) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + fmt.Printf("Maintenance created successfully (ID: %s)\n", id) + fmt.Printf("Run 'openstatus maintenance info %s' to see details\n", id) + return nil + }, + } +} diff --git a/internal/maintenance/maintenance_create_test.go b/internal/maintenance/maintenance_create_test.go new file mode 100644 index 0000000..a4e9bf5 --- /dev/null +++ b/internal/maintenance/maintenance_create_test.go @@ -0,0 +1,102 @@ +package maintenance_test + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" + + "github.com/openstatusHQ/cli/internal/maintenance" +) + +func Test_CreateMaintenance(t *testing.T) { + t.Parallel() + + t.Run("Successfully creates maintenance", func(t *testing.T) { + body := `{"maintenance":{"id":"42","title":"DB Migration","message":"Upgrading","from":"2026-04-01T10:00:00Z","to":"2026-04-01T12:00:00Z","pageId":"p1","createdAt":"2026-03-20T10:00:00Z","updatedAt":"2026-03-20T10:00:00Z"}}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + if req.Header.Get("x-openstatus-key") != "test-token" { + t.Errorf("Expected x-openstatus-key header, got %s", req.Header.Get("x-openstatus-key")) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + id, err := maintenance.CreateMaintenanceWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", + "DB Migration", "Upgrading", "2026-04-01T10:00:00Z", "2026-04-01T12:00:00Z", + "p1", nil, false, + ) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if id != "42" { + t.Errorf("Expected ID '42', got %s", id) + } + }) + + t.Run("Creates maintenance with optional fields", func(t *testing.T) { + body := `{"maintenance":{"id":"43","title":"DB Migration","message":"Upgrading","from":"2026-04-01T10:00:00Z","to":"2026-04-01T12:00:00Z","pageId":"p1","pageComponentIds":["c1","c2"],"createdAt":"2026-03-20T10:00:00Z","updatedAt":"2026-03-20T10:00:00Z"}}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + id, err := maintenance.CreateMaintenanceWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", + "DB Migration", "Upgrading", "2026-04-01T10:00:00Z", "2026-04-01T12:00:00Z", + "p1", []string{"c1", "c2"}, true, + ) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if id != "43" { + t.Errorf("Expected ID '43', got %s", id) + } + }) + + t.Run("API error returns error", func(t *testing.T) { + body := `{"code":"internal","message":"internal error"}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + _, err := maintenance.CreateMaintenanceWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", + "Title", "Message", "2026-04-01T10:00:00Z", "2026-04-01T12:00:00Z", + "p1", nil, false, + ) + if err == nil { + t.Error("Expected error, got nil") + } + }) +} diff --git a/internal/maintenance/maintenance_delete.go b/internal/maintenance/maintenance_delete.go new file mode 100644 index 0000000..d369530 --- /dev/null +++ b/internal/maintenance/maintenance_delete.go @@ -0,0 +1,91 @@ +package maintenance + +import ( + "context" + "fmt" + "net/http" + "os" + + maintenancev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/maintenance/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/maintenance/v1/maintenancev1connect" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" + "github.com/urfave/cli/v3" +) + +func DeleteMaintenance(ctx context.Context, client maintenancev1connect.MaintenanceServiceClient, maintenanceId string) error { + if maintenanceId == "" { + fmt.Fprintln(os.Stderr, "Usage: openstatus maintenance delete ") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Example: openstatus maintenance delete 12345") + return fmt.Errorf("maintenance ID is required") + } + + _, err := client.DeleteMaintenance(ctx, &maintenancev1.DeleteMaintenanceRequest{ + Id: maintenanceId, + }) + if err != nil { + return output.FormatError(err, "maintenance", maintenanceId) + } + + return nil +} + +func DeleteMaintenanceWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, maintenanceId string) error { + client := NewMaintenanceClientWithHTTPClient(httpClient, apiKey) + return DeleteMaintenance(ctx, client, maintenanceId) +} + +func GetMaintenanceDeleteCmd() *cli.Command { + return &cli.Command{ + Name: "delete", + Usage: "Delete a maintenance window", + UsageText: `openstatus maintenance delete + openstatus maintenance delete 12345 -y`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + }, + &cli.BoolFlag{ + Name: "auto-accept", + Usage: "Automatically accept the prompt", + Aliases: []string{"y"}, + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } + maintenanceId := cmd.Args().Get(0) + if maintenanceId == "" { + fmt.Fprintln(os.Stderr, "Usage: openstatus maintenance delete ") + return cli.Exit("maintenance ID is required", 1) + } + + if !cmd.Bool("auto-accept") { + confirmed, err := output.AskForConfirmation(fmt.Sprintf("You are about to delete maintenance: %s, do you want to continue", maintenanceId)) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to read input: %v", err), 1) + } + if !confirmed { + return nil + } + } + + client := NewMaintenanceClient(apiKey) + s := output.StartSpinner("Deleting maintenance...") + err = DeleteMaintenance(ctx, client, maintenanceId) + output.StopSpinner(s) + if err != nil { + return cli.Exit(err.Error(), 1) + } + fmt.Printf("Maintenance %s deleted successfully\n", maintenanceId) + fmt.Println("Run 'openstatus maintenance list' to see remaining maintenances") + return nil + }, + } +} diff --git a/internal/maintenance/maintenance_delete_test.go b/internal/maintenance/maintenance_delete_test.go new file mode 100644 index 0000000..8be080e --- /dev/null +++ b/internal/maintenance/maintenance_delete_test.go @@ -0,0 +1,83 @@ +package maintenance_test + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" + + "github.com/openstatusHQ/cli/internal/maintenance" +) + +func Test_DeleteMaintenance(t *testing.T) { + t.Parallel() + + t.Run("Successfully deletes maintenance", func(t *testing.T) { + body := `{}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + err := maintenance.DeleteMaintenanceWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", "42", + ) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Missing ID returns error", func(t *testing.T) { + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + err := maintenance.DeleteMaintenanceWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", "", + ) + if err == nil { + t.Error("Expected error for missing ID, got nil") + } + }) + + t.Run("API error returns error", func(t *testing.T) { + body := `{"code":"internal","message":"internal error"}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + err := maintenance.DeleteMaintenanceWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", "42", + ) + if err == nil { + t.Error("Expected error, got nil") + } + }) +} diff --git a/internal/maintenance/maintenance_info.go b/internal/maintenance/maintenance_info.go new file mode 100644 index 0000000..20af563 --- /dev/null +++ b/internal/maintenance/maintenance_info.go @@ -0,0 +1,151 @@ +package maintenance + +import ( + "context" + "fmt" + "net/http" + "os" + "strings" + + maintenancev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/maintenance/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/maintenance/v1/maintenancev1connect" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" + "github.com/logrusorgru/aurora/v4" + "github.com/olekukonko/tablewriter" + "github.com/olekukonko/tablewriter/renderer" + "github.com/olekukonko/tablewriter/tw" + "github.com/urfave/cli/v3" +) + +type maintenanceDetail struct { + ID string `json:"id"` + Title string `json:"title"` + Message string `json:"message"` + Status string `json:"status"` + From string `json:"from"` + To string `json:"to"` + PageID string `json:"page_id"` + Components []string `json:"components,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func GetMaintenanceInfo(ctx context.Context, client maintenancev1connect.MaintenanceServiceClient, maintenanceId string, s *output.Spinner) error { + if maintenanceId == "" { + output.StopSpinner(s) + fmt.Fprintln(os.Stderr, "Usage: openstatus maintenance info ") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Example: openstatus maintenance info 12345") + return fmt.Errorf("maintenance ID is required") + } + + resp, err := client.GetMaintenance(ctx, &maintenancev1.GetMaintenanceRequest{ + Id: maintenanceId, + }) + output.StopSpinner(s) + if err != nil { + return output.FormatError(err, "maintenance", maintenanceId) + } + + m := resp.GetMaintenance() + + if output.IsJSONOutput() { + detail := maintenanceDetail{ + ID: m.GetId(), + Title: m.GetTitle(), + Message: m.GetMessage(), + Status: timeWindowStatus(m.GetFrom(), m.GetTo()), + From: m.GetFrom(), + To: m.GetTo(), + PageID: m.GetPageId(), + Components: m.GetPageComponentIds(), + CreatedAt: m.GetCreatedAt(), + UpdatedAt: m.GetUpdatedAt(), + } + return output.PrintJSON(detail) + } + + fmt.Println(aurora.Bold("Maintenance:")) + table := tablewriter.NewTable(os.Stdout, + tablewriter.WithRenderer(renderer.NewBlueprint()), + tablewriter.WithRendition(tw.Rendition{ + Symbols: tw.NewSymbolCustom("custom").WithColumn("="), + Borders: tw.Border{ + Top: tw.Off, + Left: tw.Off, + Right: tw.Off, + Bottom: tw.Off, + }, + Settings: tw.Settings{ + Lines: tw.Lines{ + ShowHeaderLine: tw.Off, + ShowFooterLine: tw.On, + }, + Separators: tw.Separators{ + BetweenRows: tw.Off, + BetweenColumns: tw.On, + }, + }, + }), + tablewriter.WithRowAlignment(tw.AlignLeft), + tablewriter.WithHeaderAlignment(tw.AlignLeft), + ) + + data := [][]string{ + {"ID", m.GetId()}, + {"Title", m.GetTitle()}, + {"Message", m.GetMessage()}, + {"Status", statusColor(timeWindowStatus(m.GetFrom(), m.GetTo()))}, + {"From", output.FormatTimestamp(m.GetFrom())}, + {"To", output.FormatTimestamp(m.GetTo())}, + } + + if len(m.GetPageComponentIds()) > 0 { + data = append(data, []string{"Components", strings.Join(m.GetPageComponentIds(), ", ")}) + } + + data = append(data, []string{"Created", output.FormatTimestamp(m.GetCreatedAt())}) + data = append(data, []string{"Updated", output.FormatTimestamp(m.GetUpdatedAt())}) + + table.Bulk(data) + table.Render() + + return nil +} + +func GetMaintenanceInfoWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, maintenanceId string) error { + client := NewMaintenanceClientWithHTTPClient(httpClient, apiKey) + return GetMaintenanceInfo(ctx, client, maintenanceId, nil) +} + +func GetMaintenanceInfoCmd() *cli.Command { + return &cli.Command{ + Name: "info", + Usage: "Get maintenance window details", + UsageText: `openstatus maintenance info + openstatus maintenance info 12345`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } + maintenanceId := cmd.Args().Get(0) + s := output.StartSpinner("Fetching maintenance...") + client := NewMaintenanceClient(apiKey) + err = GetMaintenanceInfo(ctx, client, maintenanceId, s) + if err != nil { + return cli.Exit(err.Error(), 1) + } + return nil + }, + } +} diff --git a/internal/maintenance/maintenance_info_test.go b/internal/maintenance/maintenance_info_test.go new file mode 100644 index 0000000..cb8d58f --- /dev/null +++ b/internal/maintenance/maintenance_info_test.go @@ -0,0 +1,59 @@ +package maintenance_test + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" + + "github.com/openstatusHQ/cli/internal/maintenance" +) + +func Test_GetMaintenanceInfo(t *testing.T) { + t.Parallel() + + t.Run("Successfully gets maintenance info", func(t *testing.T) { + body := `{"maintenance":{"id":"42","title":"DB Migration","message":"Upgrading to PG16","from":"2026-04-01T10:00:00Z","to":"2026-04-01T12:00:00Z","pageId":"p1","pageComponentIds":["c1"],"createdAt":"2026-03-20T10:00:00Z","updatedAt":"2026-03-20T10:00:00Z"}}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + err := maintenance.GetMaintenanceInfoWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", "42", + ) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Missing ID returns error", func(t *testing.T) { + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + err := maintenance.GetMaintenanceInfoWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", "", + ) + if err == nil { + t.Error("Expected error for missing ID, got nil") + } + }) +} diff --git a/internal/maintenance/maintenance_internal_test.go b/internal/maintenance/maintenance_internal_test.go new file mode 100644 index 0000000..954248b --- /dev/null +++ b/internal/maintenance/maintenance_internal_test.go @@ -0,0 +1,84 @@ +package maintenance + +import ( + "testing" + "time" +) + +func Test_timeWindowStatus(t *testing.T) { + t.Parallel() + + now := time.Now().UTC() + + t.Run("future window returns scheduled", func(t *testing.T) { + from := now.Add(1 * time.Hour).Format(time.RFC3339) + to := now.Add(2 * time.Hour).Format(time.RFC3339) + if s := timeWindowStatus(from, to); s != "scheduled" { + t.Errorf("expected 'scheduled', got %q", s) + } + }) + + t.Run("current window returns in_progress", func(t *testing.T) { + from := now.Add(-1 * time.Hour).Format(time.RFC3339) + to := now.Add(1 * time.Hour).Format(time.RFC3339) + if s := timeWindowStatus(from, to); s != "in_progress" { + t.Errorf("expected 'in_progress', got %q", s) + } + }) + + t.Run("past window returns completed", func(t *testing.T) { + from := now.Add(-2 * time.Hour).Format(time.RFC3339) + to := now.Add(-1 * time.Hour).Format(time.RFC3339) + if s := timeWindowStatus(from, to); s != "completed" { + t.Errorf("expected 'completed', got %q", s) + } + }) + + t.Run("invalid from returns unknown", func(t *testing.T) { + to := now.Add(1 * time.Hour).Format(time.RFC3339) + if s := timeWindowStatus("bad", to); s != "unknown" { + t.Errorf("expected 'unknown', got %q", s) + } + }) + + t.Run("invalid to returns unknown", func(t *testing.T) { + from := now.Add(-1 * time.Hour).Format(time.RFC3339) + if s := timeWindowStatus(from, "bad"); s != "unknown" { + t.Errorf("expected 'unknown', got %q", s) + } + }) + + t.Run("empty strings return unknown", func(t *testing.T) { + if s := timeWindowStatus("", ""); s != "unknown" { + t.Errorf("expected 'unknown', got %q", s) + } + }) +} + +func Test_statusColor(t *testing.T) { + t.Parallel() + + t.Run("scheduled returns non-empty", func(t *testing.T) { + if s := statusColor("scheduled"); s == "" { + t.Error("expected non-empty string") + } + }) + + t.Run("in_progress returns non-empty", func(t *testing.T) { + if s := statusColor("in_progress"); s == "" { + t.Error("expected non-empty string") + } + }) + + t.Run("completed returns non-empty", func(t *testing.T) { + if s := statusColor("completed"); s == "" { + t.Error("expected non-empty string") + } + }) + + t.Run("unknown passes through", func(t *testing.T) { + if s := statusColor("unknown"); s != "unknown" { + t.Errorf("expected 'unknown', got %q", s) + } + }) +} diff --git a/internal/maintenance/maintenance_list.go b/internal/maintenance/maintenance_list.go new file mode 100644 index 0000000..65be76d --- /dev/null +++ b/internal/maintenance/maintenance_list.go @@ -0,0 +1,135 @@ +package maintenance + +import ( + "context" + "fmt" + "net/http" + + maintenancev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/maintenance/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/maintenance/v1/maintenancev1connect" + "github.com/fatih/color" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" + "github.com/rodaine/table" + "github.com/urfave/cli/v3" +) + +type maintenanceListEntry struct { + ID string `json:"id"` + Title string `json:"title"` + Message string `json:"message"` + Status string `json:"status"` + From string `json:"from"` + To string `json:"to"` + PageID string `json:"page_id"` + Components []string `json:"components,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func ListMaintenances(ctx context.Context, client maintenancev1connect.MaintenanceServiceClient, pageId string, limit int, s *output.Spinner) error { + req := &maintenancev1.ListMaintenancesRequest{} + + if limit > 0 { + l := int32(limit) + req.SetLimit(l) + } + + if pageId != "" { + req.SetPageId(pageId) + } + + resp, err := client.ListMaintenances(ctx, req) + output.StopSpinner(s) + if err != nil { + return output.FormatError(err, "maintenance", "") + } + + maintenances := resp.GetMaintenances() + + if output.IsJSONOutput() { + entries := make([]maintenanceListEntry, 0, len(maintenances)) + for _, m := range maintenances { + entries = append(entries, maintenanceListEntry{ + ID: m.GetId(), + Title: m.GetTitle(), + Message: m.GetMessage(), + Status: timeWindowStatus(m.GetFrom(), m.GetTo()), + From: m.GetFrom(), + To: m.GetTo(), + PageID: m.GetPageId(), + Components: m.GetPageComponentIds(), + CreatedAt: m.GetCreatedAt(), + UpdatedAt: m.GetUpdatedAt(), + }) + } + return output.PrintJSON(entries) + } + + if len(maintenances) == 0 { + fmt.Println("No maintenances found") + return nil + } + + headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() + columnFmt := color.New(color.FgYellow).SprintfFunc() + + tbl := table.New("ID", "Title", "Status", "From", "To") + tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) + + for _, m := range maintenances { + tbl.AddRow( + m.GetId(), + m.GetTitle(), + statusColor(timeWindowStatus(m.GetFrom(), m.GetTo())), + output.FormatTimestamp(m.GetFrom()), + output.FormatTimestamp(m.GetTo()), + ) + } + + tbl.Print() + return nil +} + +func ListMaintenancesWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, pageId string, limit int) error { + client := NewMaintenanceClientWithHTTPClient(httpClient, apiKey) + return ListMaintenances(ctx, client, pageId, limit, nil) +} + +func GetMaintenanceListCmd() *cli.Command { + return &cli.Command{ + Name: "list", + Usage: "List all maintenance windows", + UsageText: `openstatus maintenance list + openstatus maintenance list --page-id abc --limit 10`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + }, + &cli.StringFlag{ + Name: "page-id", + Usage: "Filter by status page ID", + }, + &cli.IntFlag{ + Name: "limit", + Usage: "Maximum number of maintenances to return (1-100)", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } + s := output.StartSpinner("Fetching maintenances...") + client := NewMaintenanceClient(apiKey) + err = ListMaintenances(ctx, client, cmd.String("page-id"), int(cmd.Int("limit")), s) + if err != nil { + return cli.Exit(err.Error(), 1) + } + return nil + }, + } +} diff --git a/internal/maintenance/maintenance_list_test.go b/internal/maintenance/maintenance_list_test.go new file mode 100644 index 0000000..f3ee218 --- /dev/null +++ b/internal/maintenance/maintenance_list_test.go @@ -0,0 +1,87 @@ +package maintenance_test + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" + + "github.com/openstatusHQ/cli/internal/maintenance" +) + +func Test_ListMaintenances(t *testing.T) { + t.Parallel() + + t.Run("Successfully lists maintenances", func(t *testing.T) { + body := `{"maintenances":[{"id":"1","title":"M1","message":"msg1","from":"2026-04-01T10:00:00Z","to":"2026-04-01T12:00:00Z","pageId":"p1","createdAt":"2026-03-20T10:00:00Z","updatedAt":"2026-03-20T10:00:00Z"},{"id":"2","title":"M2","message":"msg2","from":"2026-04-02T10:00:00Z","to":"2026-04-02T12:00:00Z","pageId":"p1","createdAt":"2026-03-21T10:00:00Z","updatedAt":"2026-03-21T10:00:00Z"}],"totalSize":2}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + err := maintenance.ListMaintenancesWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", "", 0, + ) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Empty list", func(t *testing.T) { + body := `{"maintenances":[],"totalSize":0}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + err := maintenance.ListMaintenancesWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", "", 0, + ) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("With page_id filter", func(t *testing.T) { + body := `{"maintenances":[],"totalSize":0}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + err := maintenance.ListMaintenancesWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", "page-123", 10, + ) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) +} diff --git a/internal/maintenance/maintenance_test.go b/internal/maintenance/maintenance_test.go new file mode 100644 index 0000000..1652809 --- /dev/null +++ b/internal/maintenance/maintenance_test.go @@ -0,0 +1,85 @@ +package maintenance_test + +import ( + "net/http" + "testing" + + "github.com/openstatusHQ/cli/internal/maintenance" +) + +type interceptorHTTPClient struct { + f func(req *http.Request) (*http.Response, error) +} + +func (i *interceptorHTTPClient) RoundTrip(req *http.Request) (*http.Response, error) { + return i.f(req) +} + +func (i *interceptorHTTPClient) GetHTTPClient() *http.Client { + return &http.Client{ + Transport: i, + } +} + +func Test_MaintenanceCmd(t *testing.T) { + t.Parallel() + + t.Run("Returns valid command", func(t *testing.T) { + cmd := maintenance.MaintenanceCmd() + + if cmd == nil { + t.Fatal("Expected non-nil command") + } + + if cmd.Name != "maintenance" { + t.Errorf("Expected command name 'maintenance', got %s", cmd.Name) + } + + if cmd.Usage != "Manage maintenance windows" { + t.Errorf("Expected usage 'Manage maintenance windows', got %s", cmd.Usage) + } + }) + + t.Run("Has expected subcommands", func(t *testing.T) { + cmd := maintenance.MaintenanceCmd() + + if len(cmd.Commands) != 5 { + t.Errorf("Expected 5 subcommands, got %d", len(cmd.Commands)) + } + + expectedSubcommands := map[string]bool{ + "list": false, + "info": false, + "create": false, + "update": false, + "delete": false, + } + + for _, subcmd := range cmd.Commands { + if _, exists := expectedSubcommands[subcmd.Name]; exists { + expectedSubcommands[subcmd.Name] = true + } + } + + for name, found := range expectedSubcommands { + if !found { + t.Errorf("Expected subcommand '%s' not found", name) + } + } + }) + + t.Run("Has mt alias", func(t *testing.T) { + cmd := maintenance.MaintenanceCmd() + + found := false + for _, alias := range cmd.Aliases { + if alias == "mt" { + found = true + break + } + } + if !found { + t.Error("Expected 'mt' alias not found") + } + }) +} diff --git a/internal/maintenance/maintenance_update.go b/internal/maintenance/maintenance_update.go new file mode 100644 index 0000000..8e73d3c --- /dev/null +++ b/internal/maintenance/maintenance_update.go @@ -0,0 +1,125 @@ +package maintenance + +import ( + "context" + "fmt" + "net/http" + "os" + "strings" + + maintenancev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/maintenance/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/maintenance/v1/maintenancev1connect" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" + "github.com/urfave/cli/v3" +) + +func UpdateMaintenance(ctx context.Context, client maintenancev1connect.MaintenanceServiceClient, id, title, message, from, to string, componentIds []string, hasTitle, hasMessage, hasFrom, hasTo, hasComponents bool) error { + if id == "" { + fmt.Fprintln(os.Stderr, "Usage: openstatus maintenance update [--title ...] [--message ...] [--from ...] [--to ...]") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Example: openstatus maintenance update 12345 --title \"Updated title\"") + return fmt.Errorf("maintenance ID is required") + } + + if !hasTitle && !hasMessage && !hasFrom && !hasTo && !hasComponents { + return fmt.Errorf("at least one of --title, --message, --from, --to, or --component-ids must be provided") + } + + req := &maintenancev1.UpdateMaintenanceRequest{ + Id: id, + } + + if hasTitle { + req.SetTitle(title) + } + if hasMessage { + req.SetMessage(message) + } + if hasFrom { + req.SetFrom(from) + } + if hasTo { + req.SetTo(to) + } + if hasComponents { + req.SetPageComponentIds(componentIds) + } + + _, err := client.UpdateMaintenance(ctx, req) + if err != nil { + return output.FormatError(err, "maintenance", id) + } + + return nil +} + +func UpdateMaintenanceWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, id, title, message, from, to string, componentIds []string, hasTitle, hasMessage, hasFrom, hasTo, hasComponents bool) error { + client := NewMaintenanceClientWithHTTPClient(httpClient, apiKey) + return UpdateMaintenance(ctx, client, id, title, message, from, to, componentIds, hasTitle, hasMessage, hasFrom, hasTo, hasComponents) +} + +func GetMaintenanceUpdateCmd() *cli.Command { + return &cli.Command{ + Name: "update", + Usage: "Update a maintenance window", + UsageText: "openstatus maintenance update [--title \"New title\"] [--message \"New message\"] [--from ...] [--to ...]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + }, + &cli.StringFlag{ + Name: "title", + Usage: "New title for the maintenance", + }, + &cli.StringFlag{ + Name: "message", + Usage: "New message for the maintenance", + }, + &cli.StringFlag{ + Name: "from", + Usage: "New start time (RFC 3339 format)", + }, + &cli.StringFlag{ + Name: "to", + Usage: "New end time (RFC 3339 format)", + }, + &cli.StringFlag{ + Name: "component-ids", + Usage: "Comma-separated page component IDs (replaces existing list)", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } + maintenanceId := cmd.Args().Get(0) + + hasTitle := cmd.IsSet("title") + hasMessage := cmd.IsSet("message") + hasFrom := cmd.IsSet("from") + hasTo := cmd.IsSet("to") + hasComponents := cmd.IsSet("component-ids") && cmd.String("component-ids") != "" + + var componentIds []string + if ids := cmd.String("component-ids"); ids != "" { + componentIds = strings.Split(ids, ",") + } + + client := NewMaintenanceClient(apiKey) + s := output.StartSpinner("Updating maintenance...") + err = UpdateMaintenance(ctx, client, maintenanceId, cmd.String("title"), cmd.String("message"), cmd.String("from"), cmd.String("to"), componentIds, hasTitle, hasMessage, hasFrom, hasTo, hasComponents) + output.StopSpinner(s) + if err != nil { + return cli.Exit(err.Error(), 1) + } + fmt.Printf("Maintenance %s updated successfully\n", maintenanceId) + fmt.Println("Run 'openstatus maintenance info " + maintenanceId + "' to see the maintenance") + return nil + }, + } +} diff --git a/internal/maintenance/maintenance_update_test.go b/internal/maintenance/maintenance_update_test.go new file mode 100644 index 0000000..ee0e437 --- /dev/null +++ b/internal/maintenance/maintenance_update_test.go @@ -0,0 +1,111 @@ +package maintenance_test + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" + + "github.com/openstatusHQ/cli/internal/maintenance" +) + +func Test_UpdateMaintenance(t *testing.T) { + t.Parallel() + + t.Run("Successfully updates maintenance", func(t *testing.T) { + body := `{"maintenance":{"id":"42","title":"Updated","message":"msg","from":"2026-04-01T10:00:00Z","to":"2026-04-01T12:00:00Z","pageId":"p1","createdAt":"2026-03-20T10:00:00Z","updatedAt":"2026-03-20T11:00:00Z"}}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + err := maintenance.UpdateMaintenanceWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", + "42", "Updated", "", "", "", nil, + true, false, false, false, false, + ) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Partial update with title only", func(t *testing.T) { + body := `{"maintenance":{"id":"42","title":"New Title","message":"msg","from":"2026-04-01T10:00:00Z","to":"2026-04-01T12:00:00Z","pageId":"p1","createdAt":"2026-03-20T10:00:00Z","updatedAt":"2026-03-20T11:00:00Z"}}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + err := maintenance.UpdateMaintenanceWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", + "42", "New Title", "", "", "", nil, + true, false, false, false, false, + ) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("No flags provided returns error", func(t *testing.T) { + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + err := maintenance.UpdateMaintenanceWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", + "42", "", "", "", "", nil, + false, false, false, false, false, + ) + if err == nil { + t.Error("Expected error for no flags, got nil") + } + }) + + t.Run("Missing ID returns error", func(t *testing.T) { + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + err := maintenance.UpdateMaintenanceWithHTTPClient( + context.Background(), interceptor.GetHTTPClient(), "test-token", + "", "Title", "", "", "", nil, + true, false, false, false, false, + ) + if err == nil { + t.Error("Expected error for missing ID, got nil") + } + }) +} diff --git a/internal/maintenance/maintenance_wizard.go b/internal/maintenance/maintenance_wizard.go new file mode 100644 index 0000000..fbead7d --- /dev/null +++ b/internal/maintenance/maintenance_wizard.go @@ -0,0 +1,190 @@ +package maintenance + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/charmbracelet/huh" + output "github.com/openstatusHQ/cli/internal/cli" + "github.com/openstatusHQ/cli/internal/statuspage" + "github.com/openstatusHQ/cli/internal/wizard" +) + +type createInputs struct { + PageID string + PageName string + Title string + Message string + From string + To string + ComponentIDs []string + componentNames map[string]string + Notify bool + Confirmed bool +} + +func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs) (*createInputs, error) { + inputs := *prefilled + + s := output.StartSpinner("Fetching status pages...") + pages, err := wizard.FetchStatusPages(ctx, apiKey) + output.StopSpinner(s) + if err != nil { + return nil, err + } + if len(pages) == 0 { + return nil, fmt.Errorf("no status pages found. Create one at https://www.openstatus.dev first, then run this command again") + } + + if inputs.PageID == "" { + pageOptions := make([]huh.Option[string], 0, len(pages)) + for _, p := range pages { + label := p.GetTitle() + " (" + statuspage.StatusPageURL(p) + ")" + pageOptions = append(pageOptions, huh.NewOption(label, p.GetId())) + } + + form1 := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Status page"). + Options(pageOptions...). + Value(&inputs.PageID), + ), + ).WithTheme(huh.ThemeBase()) + + if err := form1.Run(); err != nil { + return nil, wizard.HandleFormError(err) + } + } + + for _, p := range pages { + if p.GetId() == inputs.PageID { + inputs.PageName = p.GetTitle() + break + } + } + + s = output.StartSpinner("Fetching page components...") + components, groups, err := wizard.FetchPageComponents(ctx, apiKey, inputs.PageID) + output.StopSpinner(s) + if err != nil { + return nil, err + } + + var fields []huh.Field + + if len(components) > 0 { + groupMap := make(map[string]string, len(groups)) + for _, g := range groups { + groupMap[g.GetId()] = g.GetName() + } + + inputs.componentNames = make(map[string]string, len(components)) + compOptions := make([]huh.Option[string], 0, len(components)) + for _, c := range components { + label := c.GetName() + if gid := c.GetGroupId(); gid != "" { + if gname, ok := groupMap[gid]; ok { + label += " (" + gname + ")" + } + } + inputs.componentNames[c.GetId()] = label + 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 inputs.Title == "" { + fields = append(fields, huh.NewInput(). + Title("Title"). + Validate(wizard.NotEmpty("title")). + Value(&inputs.Title)) + } + + if inputs.Message == "" { + fields = append(fields, huh.NewText(). + Title("Message"). + Validate(wizard.NotEmpty("message")). + Value(&inputs.Message)) + } + + if inputs.From == "" { + fields = append(fields, huh.NewInput(). + Title("From (RFC 3339)"). + Placeholder("2006-01-02T15:04:05Z"). + Validate(wizard.ValidRFC3339("from")). + Value(&inputs.From)) + } + + if inputs.To == "" { + fields = append(fields, huh.NewInput(). + Title("To (RFC 3339)"). + Placeholder("2006-01-02T15:04:05Z"). + Validate(wizard.ValidRFC3339("to")). + Value(&inputs.To)) + } + + fields = append(fields, huh.NewConfirm(). + Title("Notify subscribers?"). + Value(&inputs.Notify)) + + summaryNote := huh.NewNote(). + Title("Summary"). + DescriptionFunc(func() string { + lines := [][2]string{ + {"Page", inputs.PageName}, + } + if len(inputs.ComponentIDs) > 0 { + names := 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) + } + } + lines = append(lines, [2]string{"Components", strings.Join(names, ", ")}) + } + lines = append(lines, + [2]string{"Title", inputs.Title}, + [2]string{"Message", inputs.Message}, + [2]string{"From", inputs.From}, + [2]string{"To", inputs.To}, + ) + notifyStr := "no" + if inputs.Notify { + notifyStr = "yes" + } + lines = append(lines, [2]string{"Notify", notifyStr}) + return wizard.BuildSummary(lines) + }, &inputs) + + form2 := huh.NewForm( + huh.NewGroup(fields...), + huh.NewGroup( + summaryNote, + huh.NewConfirm(). + Title("Create this maintenance?"). + Value(&inputs.Confirmed), + ), + ).WithTheme(huh.ThemeBase()) + + if err := form2.Run(); err != nil { + return nil, wizard.HandleFormError(err) + } + + if !inputs.Confirmed { + fmt.Fprintln(os.Stderr, "Aborted.") + os.Exit(130) + } + + return &inputs, nil +} diff --git a/internal/statuspage/statuspage_list.go b/internal/statuspage/statuspage_list.go index 45805d8..6e996a0 100644 --- a/internal/statuspage/statuspage_list.go +++ b/internal/statuspage/statuspage_list.go @@ -43,7 +43,7 @@ func ListStatusPages(ctx context.Context, client status_pagev1connect.StatusPage entries = append(entries, statusPageListEntry{ ID: p.GetId(), Title: p.GetTitle(), - URL: statusPageURL(p), + URL: StatusPageURL(p), }) } return output.PrintJSON(entries) @@ -66,7 +66,7 @@ func ListStatusPages(ctx context.Context, client status_pagev1connect.StatusPage tbl.AddRow( p.GetId(), p.GetTitle(), - statusPageURL(p), + StatusPageURL(p), ) } @@ -74,7 +74,7 @@ func ListStatusPages(ctx context.Context, client status_pagev1connect.StatusPage return nil } -func statusPageURL(p *status_pagev1.StatusPageSummary) string { +func StatusPageURL(p *status_pagev1.StatusPageSummary) string { if d := p.GetCustomDomain(); d != "" { d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://") return "https://" + d diff --git a/internal/statusreport/statusreport_info.go b/internal/statusreport/statusreport_info.go index 03b526e..0c255c9 100644 --- a/internal/statusreport/statusreport_info.go +++ b/internal/statusreport/statusreport_info.go @@ -108,8 +108,8 @@ func GetStatusReportInfo(ctx context.Context, client status_reportv1connect.Stat data = append(data, []string{"Components", strings.Join(report.GetPageComponentIds(), ", ")}) } - data = append(data, []string{"Created", report.GetCreatedAt()}) - data = append(data, []string{"Updated", report.GetUpdatedAt()}) + data = append(data, []string{"Created", output.FormatTimestamp(report.GetCreatedAt())}) + data = append(data, []string{"Updated", output.FormatTimestamp(report.GetUpdatedAt())}) table.Bulk(data) table.Render() @@ -123,7 +123,7 @@ func GetStatusReportInfo(ctx context.Context, client status_reportv1connect.Stat fmt.Println(aurora.Bold("\nUpdate Timeline:")) for _, u := range updates { fmt.Printf(" %s [%s] %s\n", - u.GetDate(), + output.FormatTimestamp(u.GetDate()), statusColor(statusToString(u.GetStatus())), u.GetMessage(), ) diff --git a/internal/statusreport/statusreport_list.go b/internal/statusreport/statusreport_list.go index 85b5599..089b427 100644 --- a/internal/statusreport/statusreport_list.go +++ b/internal/statusreport/statusreport_list.go @@ -77,8 +77,8 @@ func ListStatusReports(ctx context.Context, client status_reportv1connect.Status r.GetId(), r.GetTitle(), statusColor(statusToString(r.GetStatus())), - r.GetCreatedAt(), - r.GetUpdatedAt(), + output.FormatTimestamp(r.GetCreatedAt()), + output.FormatTimestamp(r.GetUpdatedAt()), ) } diff --git a/internal/statusreport/statusreport_wizard.go b/internal/statusreport/statusreport_wizard.go index aeb06cf..bc0af39 100644 --- a/internal/statusreport/statusreport_wizard.go +++ b/internal/statusreport/statusreport_wizard.go @@ -2,18 +2,15 @@ package statusreport import ( "context" - "errors" "fmt" "os" "strings" - status_pagev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_page/v1" - "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_page/v1/status_pagev1connect" status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" - "connectrpc.com/connect" "github.com/charmbracelet/huh" - "github.com/openstatusHQ/cli/internal/api" output "github.com/openstatusHQ/cli/internal/cli" + "github.com/openstatusHQ/cli/internal/statuspage" + "github.com/openstatusHQ/cli/internal/wizard" ) type createInputs struct { @@ -23,7 +20,7 @@ type createInputs struct { Status string Message string ComponentIDs []string - componentNames map[string]string // component ID -> display name, for summary + componentNames map[string]string Notify bool Confirmed bool } @@ -37,23 +34,6 @@ type addUpdateInputs struct { Confirmed bool } -func notEmpty(fieldName string) func(string) error { - return func(s string) error { - if strings.TrimSpace(s) == "" { - return fmt.Errorf("%s cannot be empty", fieldName) - } - return nil - } -} - -func handleFormError(err error) error { - if errors.Is(err, huh.ErrUserAborted) { - fmt.Fprintln(os.Stderr, "Aborted.") - os.Exit(130) - } - return err -} - func statusSelectOptions() []huh.Option[string] { return []huh.Option[string]{ huh.NewOption("Investigating", "investigating"), @@ -63,50 +43,9 @@ func statusSelectOptions() []huh.Option[string] { } } -func buildSummary(lines [][2]string) string { - var sb strings.Builder - for _, line := range lines { - sb.WriteString(fmt.Sprintf(" %s: %s\n", line[0], line[1])) - } - return sb.String() -} - -// Data-fetching functions - -func fetchStatusPages(ctx context.Context, apiKey string) ([]*status_pagev1.StatusPageSummary, error) { - client := status_pagev1connect.NewStatusPageServiceClient( - api.DefaultHTTPClient, - api.ConnectBaseURL, - connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), - connect.WithProtoJSON(), - ) - resp, err := client.ListStatusPages(ctx, &status_pagev1.ListStatusPagesRequest{}) - if err != nil { - return nil, output.FormatError(err, "status-page", "") - } - return resp.GetStatusPages(), nil -} - -func fetchPageComponents(ctx context.Context, apiKey string, pageID string) ([]*status_pagev1.PageComponent, []*status_pagev1.PageComponentGroup, error) { - client := status_pagev1connect.NewStatusPageServiceClient( - api.DefaultHTTPClient, - api.ConnectBaseURL, - connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), - connect.WithProtoJSON(), - ) - req := &status_pagev1.GetStatusPageContentRequest{} - req.SetId(pageID) - resp, err := client.GetStatusPageContent(ctx, req) - if err != nil { - return nil, nil, output.FormatError(err, "status-page", pageID) - } - return resp.GetComponents(), resp.GetGroups(), nil -} - func fetchStatusReports(ctx context.Context, apiKey string) ([]*status_reportv1.StatusReportSummary, error) { client := NewStatusReportClient(apiKey) - // Try unresolved reports first req := &status_reportv1.ListStatusReportsRequest{} l := int32(20) req.SetLimit(l) @@ -126,7 +65,6 @@ func fetchStatusReports(ctx context.Context, apiKey string) ([]*status_reportv1. return reports, nil } - // Fall back to all recent reports reqAll := &status_reportv1.ListStatusReportsRequest{} reqAll.SetLimit(l) resp, err = client.ListStatusReports(ctx, reqAll) @@ -141,9 +79,8 @@ func fetchStatusReports(ctx context.Context, apiKey string) ([]*status_reportv1. func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs) (*createInputs, error) { inputs := *prefilled - // Fetch status pages s := output.StartSpinner("Fetching status pages...") - pages, err := fetchStatusPages(ctx, apiKey) + pages, err := wizard.FetchStatusPages(ctx, apiKey) output.StopSpinner(s) if err != nil { return nil, err @@ -152,11 +89,10 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs return nil, fmt.Errorf("no status pages found. Create one at https://www.openstatus.dev first, then run this command again") } - // Form 1: page select (if needed) if inputs.PageID == "" { pageOptions := make([]huh.Option[string], 0, len(pages)) for _, p := range pages { - label := p.GetTitle() + " (" + statusPageURL(p) + ")" + label := p.GetTitle() + " (" + statuspage.StatusPageURL(p) + ")" pageOptions = append(pageOptions, huh.NewOption(label, p.GetId())) } @@ -170,11 +106,10 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs ).WithTheme(huh.ThemeBase()) if err := form1.Run(); err != nil { - return nil, handleFormError(err) + return nil, wizard.HandleFormError(err) } } - // Resolve page name for summary for _, p := range pages { if p.GetId() == inputs.PageID { inputs.PageName = p.GetTitle() @@ -182,18 +117,15 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs } } - // Fetch components for selected page s = output.StartSpinner("Fetching page components...") - components, groups, err := fetchPageComponents(ctx, apiKey, inputs.PageID) + components, groups, err := wizard.FetchPageComponents(ctx, apiKey, inputs.PageID) output.StopSpinner(s) if err != nil { return nil, err } - // Build Form 2 fields dynamically var fields []huh.Field - // Build component name map for summary display if len(components) > 0 { groupMap := make(map[string]string, len(groups)) for _, g := range groups { @@ -213,7 +145,6 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs compOptions = append(compOptions, huh.NewOption(label, c.GetId())) } - // Component multi-select (only if not prefilled) if len(prefilled.ComponentIDs) == 0 { fields = append(fields, huh.NewMultiSelect[string](). Title("Components"). @@ -225,7 +156,7 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs if inputs.Title == "" { fields = append(fields, huh.NewInput(). Title("Title"). - Validate(notEmpty("title")). + Validate(wizard.NotEmpty("title")). Value(&inputs.Title)) } @@ -239,7 +170,7 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs if inputs.Message == "" { fields = append(fields, huh.NewText(). Title("Message"). - Validate(notEmpty("message")). + Validate(wizard.NotEmpty("message")). Value(&inputs.Message)) } @@ -247,7 +178,6 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs Title("Notify subscribers?"). Value(&inputs.Notify)) - // Summary + confirm group summaryNote := huh.NewNote(). Title("Summary"). DescriptionFunc(func() string { @@ -275,7 +205,7 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs notifyStr = "yes" } lines = append(lines, [2]string{"Notify", notifyStr}) - return buildSummary(lines) + return wizard.BuildSummary(lines) }, &inputs) form2 := huh.NewForm( @@ -289,7 +219,7 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs ).WithTheme(huh.ThemeBase()) if err := form2.Run(); err != nil { - return nil, handleFormError(err) + return nil, wizard.HandleFormError(err) } if !inputs.Confirmed { @@ -305,7 +235,6 @@ func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs func runAddUpdateWizard(ctx context.Context, apiKey string, prefilled *addUpdateInputs) (*addUpdateInputs, error) { inputs := *prefilled - // Fetch reports for selection or name resolution s := output.StartSpinner("Fetching status reports...") reports, err := fetchStatusReports(ctx, apiKey) output.StopSpinner(s) @@ -314,7 +243,6 @@ func runAddUpdateWizard(ctx context.Context, apiKey string, prefilled *addUpdate } if inputs.ReportID == "" { - // Form 1: report select if len(reports) == 0 { return nil, fmt.Errorf("no status reports found") } @@ -335,11 +263,10 @@ func runAddUpdateWizard(ctx context.Context, apiKey string, prefilled *addUpdate ).WithTheme(huh.ThemeBase()) if err := form1.Run(); err != nil { - return nil, handleFormError(err) + return nil, wizard.HandleFormError(err) } } - // Resolve report name for summary for _, r := range reports { if r.GetId() == inputs.ReportID { inputs.ReportName = r.GetTitle() @@ -350,7 +277,6 @@ func runAddUpdateWizard(ctx context.Context, apiKey string, prefilled *addUpdate inputs.ReportName = inputs.ReportID } - // Build Form 2 fields dynamically var fields []huh.Field if inputs.Status == "" { @@ -363,7 +289,7 @@ func runAddUpdateWizard(ctx context.Context, apiKey string, prefilled *addUpdate if inputs.Message == "" { fields = append(fields, huh.NewInput(). Title("Message"). - Validate(notEmpty("message")). + Validate(wizard.NotEmpty("message")). Value(&inputs.Message)) } @@ -384,7 +310,7 @@ func runAddUpdateWizard(ctx context.Context, apiKey string, prefilled *addUpdate notifyStr = "yes" } lines = append(lines, [2]string{"Notify", notifyStr}) - return buildSummary(lines) + return wizard.BuildSummary(lines) }, &inputs) form2 := huh.NewForm( @@ -398,7 +324,7 @@ func runAddUpdateWizard(ctx context.Context, apiKey string, prefilled *addUpdate ).WithTheme(huh.ThemeBase()) if err := form2.Run(); err != nil { - return nil, handleFormError(err) + return nil, wizard.HandleFormError(err) } if !inputs.Confirmed { @@ -409,10 +335,3 @@ func runAddUpdateWizard(ctx context.Context, apiKey string, prefilled *addUpdate return &inputs, nil } -func statusPageURL(p *status_pagev1.StatusPageSummary) string { - if d := p.GetCustomDomain(); d != "" { - d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://") - return "https://" + d - } - return "https://" + p.GetSlug() + ".openstatus.dev" -} diff --git a/internal/statusreport/statusreport_wizard_test.go b/internal/statusreport/statusreport_wizard_test.go index 9ae1ed1..81f16bd 100644 --- a/internal/statusreport/statusreport_wizard_test.go +++ b/internal/statusreport/statusreport_wizard_test.go @@ -4,59 +4,6 @@ import ( "testing" ) -func Test_notEmpty(t *testing.T) { - t.Parallel() - - validator := notEmpty("title") - - t.Run("empty string returns error", func(t *testing.T) { - if err := validator(""); err == nil { - t.Error("expected error for empty string") - } - }) - - t.Run("whitespace-only returns error", func(t *testing.T) { - if err := validator(" "); err == nil { - t.Error("expected error for whitespace-only string") - } - }) - - t.Run("non-empty string returns nil", func(t *testing.T) { - if err := validator("hello"); err != nil { - t.Errorf("expected nil, got %v", err) - } - }) - - t.Run("error message contains field name", func(t *testing.T) { - err := validator("") - if err == nil { - t.Fatal("expected error") - } - if err.Error() != "title cannot be empty" { - t.Errorf("expected 'title cannot be empty', got %q", err.Error()) - } - }) -} - -func Test_buildSummary(t *testing.T) { - t.Parallel() - - lines := [][2]string{ - {"Page", "Production"}, - {"Status", "investigating"}, - } - result := buildSummary(lines) - if result == "" { - t.Error("expected non-empty summary") - } - if !contains(result, "Page") || !contains(result, "Production") { - t.Errorf("summary missing expected content: %s", result) - } - if !contains(result, "Status") || !contains(result, "investigating") { - t.Errorf("summary missing expected content: %s", result) - } -} - func Test_statusSelectOptions(t *testing.T) { t.Parallel() @@ -65,16 +12,3 @@ func Test_statusSelectOptions(t *testing.T) { t.Errorf("expected 4 options, got %d", len(opts)) } } - -func contains(s, substr string) bool { - return len(s) >= len(substr) && searchString(s, substr) -} - -func searchString(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/internal/wizard/statuspage.go b/internal/wizard/statuspage.go new file mode 100644 index 0000000..26bc70f --- /dev/null +++ b/internal/wizard/statuspage.go @@ -0,0 +1,41 @@ +package wizard + +import ( + "context" + + status_pagev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_page/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_page/v1/status_pagev1connect" + "connectrpc.com/connect" + "github.com/openstatusHQ/cli/internal/api" + output "github.com/openstatusHQ/cli/internal/cli" +) + +func FetchStatusPages(ctx context.Context, apiKey string) ([]*status_pagev1.StatusPageSummary, error) { + client := status_pagev1connect.NewStatusPageServiceClient( + api.DefaultHTTPClient, + api.ConnectBaseURL, + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), + connect.WithProtoJSON(), + ) + resp, err := client.ListStatusPages(ctx, &status_pagev1.ListStatusPagesRequest{}) + if err != nil { + return nil, output.FormatError(err, "status-page", "") + } + return resp.GetStatusPages(), nil +} + +func FetchPageComponents(ctx context.Context, apiKey string, pageID string) ([]*status_pagev1.PageComponent, []*status_pagev1.PageComponentGroup, error) { + client := status_pagev1connect.NewStatusPageServiceClient( + api.DefaultHTTPClient, + api.ConnectBaseURL, + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), + connect.WithProtoJSON(), + ) + req := &status_pagev1.GetStatusPageContentRequest{} + req.SetId(pageID) + resp, err := client.GetStatusPageContent(ctx, req) + if err != nil { + return nil, nil, output.FormatError(err, "status-page", pageID) + } + return resp.GetComponents(), resp.GetGroups(), nil +} diff --git a/internal/wizard/validate.go b/internal/wizard/validate.go new file mode 100644 index 0000000..9e5aed5 --- /dev/null +++ b/internal/wizard/validate.go @@ -0,0 +1,18 @@ +package wizard + +import ( + "fmt" + "time" +) + +func ValidRFC3339(fieldName string) func(string) error { + return func(s string) error { + if s == "" { + return fmt.Errorf("%s cannot be empty", fieldName) + } + if _, err := time.Parse(time.RFC3339, s); err != nil { + return fmt.Errorf("%s must be valid RFC 3339 format (e.g. 2006-01-02T15:04:05Z)", fieldName) + } + return nil + } +} diff --git a/internal/wizard/wizard.go b/internal/wizard/wizard.go new file mode 100644 index 0000000..9d680ea --- /dev/null +++ b/internal/wizard/wizard.go @@ -0,0 +1,35 @@ +package wizard + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/charmbracelet/huh" +) + +func NotEmpty(fieldName string) func(string) error { + return func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("%s cannot be empty", fieldName) + } + return nil + } +} + +func HandleFormError(err error) error { + if errors.Is(err, huh.ErrUserAborted) { + fmt.Fprintln(os.Stderr, "Aborted.") + os.Exit(130) + } + return err +} + +func BuildSummary(lines [][2]string) string { + var sb strings.Builder + for _, line := range lines { + sb.WriteString(fmt.Sprintf(" %s: %s\n", line[0], line[1])) + } + return sb.String() +} diff --git a/internal/wizard/wizard_test.go b/internal/wizard/wizard_test.go new file mode 100644 index 0000000..5d3fcbd --- /dev/null +++ b/internal/wizard/wizard_test.go @@ -0,0 +1,101 @@ +package wizard_test + +import ( + "strings" + "testing" + + "github.com/openstatusHQ/cli/internal/wizard" +) + +func Test_NotEmpty(t *testing.T) { + t.Parallel() + + validator := wizard.NotEmpty("title") + + t.Run("empty string returns error", func(t *testing.T) { + if err := validator(""); err == nil { + t.Error("expected error for empty string") + } + }) + + t.Run("whitespace-only returns error", func(t *testing.T) { + if err := validator(" "); err == nil { + t.Error("expected error for whitespace-only string") + } + }) + + t.Run("non-empty string returns nil", func(t *testing.T) { + if err := validator("hello"); err != nil { + t.Errorf("expected nil, got %v", err) + } + }) + + t.Run("error message contains field name", func(t *testing.T) { + err := validator("") + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "title cannot be empty" { + t.Errorf("expected 'title cannot be empty', got %q", err.Error()) + } + }) +} + +func Test_BuildSummary(t *testing.T) { + t.Parallel() + + lines := [][2]string{ + {"Page", "Production"}, + {"Status", "investigating"}, + } + result := wizard.BuildSummary(lines) + if result == "" { + t.Error("expected non-empty summary") + } + if !strings.Contains(result, "Page") || !strings.Contains(result, "Production") { + t.Errorf("summary missing expected content: %s", result) + } + if !strings.Contains(result, "Status") || !strings.Contains(result, "investigating") { + t.Errorf("summary missing expected content: %s", result) + } +} + +func Test_ValidRFC3339(t *testing.T) { + t.Parallel() + + validator := wizard.ValidRFC3339("from") + + t.Run("valid RFC 3339 passes", func(t *testing.T) { + if err := validator("2026-04-01T10:00:00Z"); err != nil { + t.Errorf("expected nil, got %v", err) + } + }) + + t.Run("valid RFC 3339 with offset passes", func(t *testing.T) { + if err := validator("2026-04-01T12:00:00+02:00"); err != nil { + t.Errorf("expected nil, got %v", err) + } + }) + + t.Run("invalid string fails", func(t *testing.T) { + if err := validator("not-a-date"); err == nil { + t.Error("expected error for invalid string") + } + }) + + t.Run("empty string fails", func(t *testing.T) { + if err := validator(""); err == nil { + t.Error("expected error for empty string") + } + }) + + t.Run("error message contains field name", func(t *testing.T) { + err := validator("bad") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "from") { + t.Errorf("expected error to contain 'from', got %q", err.Error()) + } + }) +}