diff --git a/docs/openstatus-docs.md b/docs/openstatus-docs.md index e616c00..e99d70c 100644 --- a/docs/openstatus-docs.md +++ b/docs/openstatus-docs.md @@ -44,6 +44,46 @@ The following flags are supported: | `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` | | `--auto-accept` (`-y`) | Automatically accept the prompt | `false` | *none* | +### `monitors create` subcommand + +Create monitors (beta). + +> openstatus monitors create [options] + +Create the monitors defined in the openstatus.yaml file. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] monitors create [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Default value | Environment variables | +|-----------------------------|-------------------------------------------------------|:-----------------:|:----------------------:| +| `--config="…"` (`-c`) | The configuration file containing monitor information | `openstatus.yaml` | *none* | +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` | +| `--auto-accept` (`-y`) | Automatically accept the prompt | `false` | *none* | + +### `monitors delete` subcommand + +Delete a monitor. + +> openstatus monitors delete [MonitorID] [options] + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] monitors delete [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Default value | Environment variables | +|-----------------------------|---------------------------------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` | +| `--auto-accept` (`-y`) | Automatically accept the prompt | `false` | *none* | ### `monitors import` subcommand @@ -127,13 +167,147 @@ The following flags are supported: |-----------------------------|-----------------------------|:-------------:|:----------------------:| | `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` | +### `status-report` command (aliases: `sr`) + +Manage status reports. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] status-report [ARGUMENTS...] +``` + +### `status-report list` subcommand + +List all status reports. + +> openstatus status-report list [options] + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] status-report list [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Default value | Environment variables | +|-----------------------------|--------------------------------------------------------------------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` | +| `--status="…"` | Filter by status (investigating, identified, monitoring, resolved) | | *none* | +| `--limit="…"` | Maximum number of reports to return (1-100) | `0` | *none* | + +### `status-report info` subcommand + +Get status report details. + +> openstatus status-report info + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] status-report info [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Default value | Environment variables | +|-----------------------------|-----------------------------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` | + +### `status-report create` subcommand + +Create a status report. + +> openstatus status-report create --title "API Degradation" --status investigating --message "Investigating increased latency" --page-id 123 + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] status-report create [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Default value | Environment variables | +|-----------------------------|------------------------------------------------------------------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` | +| `--title="…"` | Title of the status report | | *none* | +| `--status="…"` | Initial status (investigating, identified, monitoring, resolved) | | *none* | +| `--message="…"` | Initial message describing the incident | | *none* | +| `--page-id="…"` | Status page ID to associate with this report | | *none* | +| `--component-ids="…"` | Comma-separated page component IDs | | *none* | +| `--notify` | Notify subscribers about this status report | `false` | *none* | +| `--date="…"` | Date when the event occurred (RFC 3339 format, defaults to now) | | *none* | + +### `status-report update` subcommand + +Update status report metadata. + +> openstatus status-report update [--title "New title"] [--component-ids id1,id2] + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] status-report update [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Default value | Environment variables | +|-----------------------------|-------------------------------------------------------------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` | +| `--title="…"` | New title for the report | | *none* | +| `--component-ids="…"` | Comma-separated page component IDs (replaces existing list) | | *none* | + +### `status-report delete` subcommand + +Delete a status report. + +> openstatus status-report delete + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] status-report delete [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Default value | Environment variables | +|-----------------------------|---------------------------------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` | +| `--auto-accept` (`-y`) | Automatically accept the prompt | `false` | *none* | + +### `status-report add-update` subcommand + +Add an update to a status report. + +> openstatus status-report add-update --status resolved --message "Issue has been resolved" + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] status-report add-update [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Default value | Environment variables | +|-----------------------------|--------------------------------------------------------------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` | +| `--status="…"` | New status (investigating, identified, monitoring, resolved) | | *none* | +| `--message="…"` | Message describing what changed | | *none* | +| `--date="…"` | Date for the update (RFC 3339 format, defaults to now) | | *none* | +| `--notify` | Notify subscribers about this update | `false` | *none* | + ### `run` command (aliases: `r`) Run your synthetics tests. > openstatus run [options] -Run the synthetic tests defined in the config.openstatus.yaml. +Run the synthetic tests defined in the config.openstatus.yaml. The config file should be in the following format: tests: ids: - monitor-id-1 - monitor-id-2. Usage: diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 0000000..05cc9e8 --- /dev/null +++ b/internal/api/client.go @@ -0,0 +1,20 @@ +package api + +import ( + "context" + + "connectrpc.com/connect" +) + +const APIBaseURL = "https://api.openstatus.dev/v1" + +const ConnectBaseURL = "https://api.openstatus.dev/rpc" + +func NewAuthInterceptor(apiKey string) connect.UnaryInterceptorFunc { + return func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + req.Header().Set("x-openstatus-key", apiKey) + return next(ctx, req) + } + } +} diff --git a/internal/cmd/app.go b/internal/cmd/app.go index b8e0c8f..716c8b4 100644 --- a/internal/cmd/app.go +++ b/internal/cmd/app.go @@ -3,6 +3,7 @@ package cmd import ( "github.com/openstatusHQ/cli/internal/monitors" "github.com/openstatusHQ/cli/internal/run" + "github.com/openstatusHQ/cli/internal/statusreport" "github.com/openstatusHQ/cli/internal/whoami" "github.com/urfave/cli/v3" ) @@ -16,6 +17,7 @@ func NewApp() *cli.Command { Version: "v1.0.1", Commands: []*cli.Command{ monitors.MonitorsCmd(), + statusreport.StatusReportCmd(), run.RunCmd(), whoami.WhoamiCmd(), }, diff --git a/internal/cmd/app_test.go b/internal/cmd/app_test.go index 2b14563..103b696 100644 --- a/internal/cmd/app_test.go +++ b/internal/cmd/app_test.go @@ -32,14 +32,15 @@ func Test_NewApp(t *testing.T) { t.Run("Has expected commands", func(t *testing.T) { app := cmd.NewApp() - if len(app.Commands) != 3 { - t.Errorf("Expected 3 commands, got %d", len(app.Commands)) + if len(app.Commands) != 4 { + t.Errorf("Expected 4 commands, got %d", len(app.Commands)) } expectedCommands := map[string]bool{ - "monitors": false, - "run": false, - "whoami": false, + "monitors": false, + "status-report": false, + "run": false, + "whoami": false, } for _, subcmd := range app.Commands { diff --git a/internal/monitors/monitors.go b/internal/monitors/monitors.go index ba4339c..eb1907d 100644 --- a/internal/monitors/monitors.go +++ b/internal/monitors/monitors.go @@ -1,7 +1,6 @@ package monitors import ( - "context" "encoding/json" "fmt" "net/http" @@ -10,42 +9,25 @@ import ( monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" "connectrpc.com/connect" + "github.com/openstatusHQ/cli/internal/api" "github.com/openstatusHQ/cli/internal/config" "github.com/urfave/cli/v3" ) -// APIBaseURL is the base URL for the OpenStatus API -const APIBaseURL = "https://api.openstatus.dev/v1" - -// ConnectBaseURL is the base URL for the Connect RPC API -const ConnectBaseURL = "https://api.openstatus.dev/rpc" - -// NewAuthInterceptor creates an interceptor that adds the API key to all requests -func NewAuthInterceptor(apiKey string) connect.UnaryInterceptorFunc { - return func(next connect.UnaryFunc) connect.UnaryFunc { - return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { - req.Header().Set("x-openstatus-key", apiKey) - return next(ctx, req) - } - } -} - -// NewMonitorClient creates a new Monitor service client with authentication func NewMonitorClient(apiKey string) monitorv1connect.MonitorServiceClient { return monitorv1connect.NewMonitorServiceClient( http.DefaultClient, - ConnectBaseURL, - connect.WithInterceptors(NewAuthInterceptor(apiKey)), + api.ConnectBaseURL, + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), connect.WithProtoJSON(), ) } -// NewMonitorClientWithHTTPClient creates a new Monitor service client with a custom HTTP client func NewMonitorClientWithHTTPClient(httpClient *http.Client, apiKey string) monitorv1connect.MonitorServiceClient { return monitorv1connect.NewMonitorServiceClient( httpClient, - ConnectBaseURL, - connect.WithInterceptors(NewAuthInterceptor(apiKey)), + api.ConnectBaseURL, + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), connect.WithProtoJSON(), ) } diff --git a/internal/run/run.go b/internal/run/run.go index 599f9db..e5e74ed 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -14,6 +14,7 @@ import ( "github.com/fatih/color" "github.com/logrusorgru/aurora/v4" + "github.com/openstatusHQ/cli/internal/api" "github.com/openstatusHQ/cli/internal/config" "github.com/openstatusHQ/cli/internal/monitors" "github.com/rodaine/table" @@ -26,7 +27,7 @@ func MonitorTrigger(httpClient *http.Client, apiKey string, monitorId string) er return fmt.Errorf("Monitor ID is required") } - url := fmt.Sprintf("%s/monitor/%s/run", monitors.APIBaseURL, monitorId) + url := fmt.Sprintf("%s/monitor/%s/run", api.APIBaseURL, monitorId) httpClient.Timeout = 2 * time.Minute diff --git a/internal/statusreport/statusreport.go b/internal/statusreport/statusreport.go new file mode 100644 index 0000000..55c309a --- /dev/null +++ b/internal/statusreport/statusreport.go @@ -0,0 +1,93 @@ +package statusreport + +import ( + "fmt" + "net/http" + + status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_report/v1/status_reportv1connect" + "connectrpc.com/connect" + "github.com/fatih/color" + "github.com/openstatusHQ/cli/internal/api" + "github.com/urfave/cli/v3" +) + +func NewStatusReportClient(apiKey string) status_reportv1connect.StatusReportServiceClient { + return status_reportv1connect.NewStatusReportServiceClient( + http.DefaultClient, + api.ConnectBaseURL, + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), + connect.WithProtoJSON(), + ) +} + +func NewStatusReportClientWithHTTPClient(httpClient *http.Client, apiKey string) status_reportv1connect.StatusReportServiceClient { + return status_reportv1connect.NewStatusReportServiceClient( + httpClient, + api.ConnectBaseURL, + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), + connect.WithProtoJSON(), + ) +} + +func statusToSDK(s string) (status_reportv1.StatusReportStatus, error) { + switch s { + case "investigating": + return status_reportv1.StatusReportStatus_STATUS_REPORT_STATUS_INVESTIGATING, nil + case "identified": + return status_reportv1.StatusReportStatus_STATUS_REPORT_STATUS_IDENTIFIED, nil + case "monitoring": + return status_reportv1.StatusReportStatus_STATUS_REPORT_STATUS_MONITORING, nil + case "resolved": + return status_reportv1.StatusReportStatus_STATUS_REPORT_STATUS_RESOLVED, nil + default: + return status_reportv1.StatusReportStatus_STATUS_REPORT_STATUS_UNSPECIFIED, + fmt.Errorf("invalid status %q: must be one of investigating, identified, monitoring, resolved", s) + } +} + +func statusToString(s status_reportv1.StatusReportStatus) string { + switch s { + case status_reportv1.StatusReportStatus_STATUS_REPORT_STATUS_INVESTIGATING: + return "investigating" + case status_reportv1.StatusReportStatus_STATUS_REPORT_STATUS_IDENTIFIED: + return "identified" + case status_reportv1.StatusReportStatus_STATUS_REPORT_STATUS_MONITORING: + return "monitoring" + case status_reportv1.StatusReportStatus_STATUS_REPORT_STATUS_RESOLVED: + return "resolved" + default: + return "unknown" + } +} + +func statusColor(s string) string { + switch s { + case "investigating": + return color.RedString(s) + case "identified": + return color.YellowString(s) + case "monitoring": + return color.BlueString(s) + case "resolved": + return color.GreenString(s) + default: + return s + } +} + +func StatusReportCmd() *cli.Command { + return &cli.Command{ + Name: "status-report", + Aliases: []string{"sr"}, + Usage: "Manage status reports", + Commands: []*cli.Command{ + GetStatusReportListCmd(), + GetStatusReportInfoCmd(), + GetStatusReportCreateCmd(), + GetStatusReportUpdateCmd(), + GetStatusReportDeleteCmd(), + GetStatusReportAddUpdateCmd(), + }, + } +} diff --git a/internal/statusreport/statusreport_add_update.go b/internal/statusreport/statusreport_add_update.go new file mode 100644 index 0000000..0e37c4b --- /dev/null +++ b/internal/statusreport/statusreport_add_update.go @@ -0,0 +1,113 @@ +package statusreport + +import ( + "context" + "fmt" + "net/http" + "time" + + status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_report/v1/status_reportv1connect" + "github.com/urfave/cli/v3" +) + +func AddStatusReportUpdate(client status_reportv1connect.StatusReportServiceClient, reportId, status, message, date string, notify bool) error { + if reportId == "" { + return fmt.Errorf("report ID is required") + } + + sdkStatus, err := statusToSDK(status) + if err != nil { + return err + } + + req := &status_reportv1.AddStatusReportUpdateRequest{ + StatusReportId: reportId, + Status: sdkStatus, + Message: message, + } + + if date != "" { + req.SetDate(date) + } + + if notify { + req.SetNotify(true) + } + + resp, err := client.AddStatusReportUpdate(context.Background(), req) + if err != nil { + return fmt.Errorf("failed to add status report update: %w", err) + } + + report := resp.GetStatusReport() + fmt.Printf("Status report %s updated to %s\n", report.GetId(), statusColor(statusToString(report.GetStatus()))) + + if status == "resolved" { + fmt.Println("Report resolved.") + } + + return nil +} + +func AddStatusReportUpdateWithHTTPClient(httpClient *http.Client, apiKey string, reportId, status, message, date string, notify bool) error { + client := NewStatusReportClientWithHTTPClient(httpClient, apiKey) + return AddStatusReportUpdate(client, reportId, status, message, date, notify) +} + +func GetStatusReportAddUpdateCmd() *cli.Command { + return &cli.Command{ + Name: "add-update", + Usage: "Add an update to a status report", + UsageText: "openstatus status-report add-update --status resolved --message \"Issue has been resolved\"", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + Required: true, + }, + &cli.StringFlag{ + Name: "status", + Usage: "New status (investigating, identified, monitoring, resolved)", + Required: true, + }, + &cli.StringFlag{ + Name: "message", + Usage: "Message describing what changed", + Required: true, + }, + &cli.StringFlag{ + Name: "date", + Usage: "Date for the update (RFC 3339 format, defaults to now)", + }, + &cli.BoolFlag{ + Name: "notify", + Usage: "Notify subscribers about this update", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + reportId := cmd.Args().Get(0) + + date := cmd.String("date") + if date == "" { + date = time.Now().UTC().Format(time.RFC3339) + } + + client := NewStatusReportClient(cmd.String("access-token")) + err := AddStatusReportUpdate( + client, + reportId, + cmd.String("status"), + cmd.String("message"), + date, + cmd.Bool("notify"), + ) + if err != nil { + return cli.Exit(err.Error(), 1) + } + return nil + }, + } +} diff --git a/internal/statusreport/statusreport_add_update_test.go b/internal/statusreport/statusreport_add_update_test.go new file mode 100644 index 0000000..a4e8fc5 --- /dev/null +++ b/internal/statusreport/statusreport_add_update_test.go @@ -0,0 +1,131 @@ +package statusreport_test + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/openstatusHQ/cli/internal/statusreport" +) + +func Test_AddStatusReportUpdate(t *testing.T) { + t.Parallel() + + t.Run("Successfully adds update", func(t *testing.T) { + body := `{"statusReport":{"id":"1","title":"API Outage","status":"STATUS_REPORT_STATUS_IDENTIFIED","updates":[{"id":"u1","status":"STATUS_REPORT_STATUS_INVESTIGATING","date":"2026-03-20T10:00:00Z","message":"Investigating"},{"id":"u2","status":"STATUS_REPORT_STATUS_IDENTIFIED","date":"2026-03-20T10:30:00Z","message":"Root cause found"}],"createdAt":"2026-03-20T10:00:00Z","updatedAt":"2026-03-20T10:30: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 := statusreport.AddStatusReportUpdateWithHTTPClient( + interceptor.GetHTTPClient(), "test-token", + "1", "identified", "Root cause found", "2026-03-20T10:30:00Z", false, + ) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Adds update with notify", func(t *testing.T) { + body := `{"statusReport":{"id":"1","title":"API Outage","status":"STATUS_REPORT_STATUS_RESOLVED","createdAt":"2026-03-20T10:00:00Z","updatedAt":"2026-03-20T12: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 := statusreport.AddStatusReportUpdateWithHTTPClient( + interceptor.GetHTTPClient(), "test-token", + "1", "resolved", "Issue resolved", "", true, + ) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Invalid status 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 := statusreport.AddStatusReportUpdateWithHTTPClient( + interceptor.GetHTTPClient(), "test-token", + "1", "invalid", "Message", "", false, + ) + if err == nil { + t.Error("Expected error for invalid status, got nil") + } + }) + + t.Run("Empty report 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 := statusreport.AddStatusReportUpdateWithHTTPClient( + interceptor.GetHTTPClient(), "test-token", + "", "investigating", "Message", "", false, + ) + if err == nil { + t.Error("Expected error for empty report ID, got nil") + } + }) + + t.Run("API error returns error", func(t *testing.T) { + body := `{"code":"not_found","message":"status report not found"}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + err := statusreport.AddStatusReportUpdateWithHTTPClient( + interceptor.GetHTTPClient(), "test-token", + "999", "investigating", "Message", "", false, + ) + if err == nil { + t.Error("Expected error, got nil") + } + }) +} diff --git a/internal/statusreport/statusreport_create.go b/internal/statusreport/statusreport_create.go new file mode 100644 index 0000000..77b738a --- /dev/null +++ b/internal/statusreport/statusreport_create.go @@ -0,0 +1,127 @@ +package statusreport + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_report/v1/status_reportv1connect" + "github.com/urfave/cli/v3" +) + +func CreateStatusReport(client status_reportv1connect.StatusReportServiceClient, title, status, message, date, pageId string, componentIds []string, notify bool) (string, error) { + sdkStatus, err := statusToSDK(status) + if err != nil { + return "", err + } + + req := &status_reportv1.CreateStatusReportRequest{ + Title: title, + Status: sdkStatus, + Message: message, + Date: date, + PageId: pageId, + } + + if len(componentIds) > 0 { + req.SetPageComponentIds(componentIds) + } + + if notify { + req.SetNotify(true) + } + + resp, err := client.CreateStatusReport(context.Background(), req) + if err != nil { + return "", fmt.Errorf("failed to create status report: %w", err) + } + + return resp.GetStatusReport().GetId(), nil +} + +func CreateStatusReportWithHTTPClient(httpClient *http.Client, apiKey string, title, status, message, date, pageId string, componentIds []string, notify bool) (string, error) { + client := NewStatusReportClientWithHTTPClient(httpClient, apiKey) + return CreateStatusReport(client, title, status, message, date, pageId, componentIds, notify) +} + +func GetStatusReportCreateCmd() *cli.Command { + return &cli.Command{ + Name: "create", + Usage: "Create a status report", + UsageText: "openstatus status-report create --title \"API Degradation\" --status investigating --message \"Investigating increased latency\" --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"), + Required: true, + }, + &cli.StringFlag{ + Name: "title", + Usage: "Title of the status report", + Required: true, + }, + &cli.StringFlag{ + Name: "status", + Usage: "Initial status (investigating, identified, monitoring, resolved)", + Required: true, + }, + &cli.StringFlag{ + Name: "message", + Usage: "Initial message describing the incident", + Required: true, + }, + &cli.StringFlag{ + Name: "page-id", + Usage: "Status page ID to associate with this report", + Required: true, + }, + &cli.StringFlag{ + Name: "component-ids", + Usage: "Comma-separated page component IDs", + }, + &cli.BoolFlag{ + Name: "notify", + Usage: "Notify subscribers about this status report", + }, + &cli.StringFlag{ + Name: "date", + Usage: "Date when the event occurred (RFC 3339 format, defaults to now)", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + date := cmd.String("date") + if date == "" { + date = time.Now().UTC().Format(time.RFC3339) + } + + var componentIds []string + if ids := cmd.String("component-ids"); ids != "" { + componentIds = strings.Split(ids, ",") + } + + client := NewStatusReportClient(cmd.String("access-token")) + id, err := CreateStatusReport( + client, + cmd.String("title"), + cmd.String("status"), + cmd.String("message"), + date, + cmd.String("page-id"), + componentIds, + cmd.Bool("notify"), + ) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + 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) + return nil + }, + } +} diff --git a/internal/statusreport/statusreport_create_test.go b/internal/statusreport/statusreport_create_test.go new file mode 100644 index 0000000..492a323 --- /dev/null +++ b/internal/statusreport/statusreport_create_test.go @@ -0,0 +1,123 @@ +package statusreport_test + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/openstatusHQ/cli/internal/statusreport" +) + +func Test_CreateStatusReport(t *testing.T) { + t.Parallel() + + t.Run("Successfully creates report", func(t *testing.T) { + body := `{"statusReport":{"id":"42","title":"API Outage","status":"STATUS_REPORT_STATUS_INVESTIGATING","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 := statusreport.CreateStatusReportWithHTTPClient( + interceptor.GetHTTPClient(), "test-token", + "API Outage", "investigating", "Investigating the issue", + "2026-03-20T10:00:00Z", "page-1", 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 report with optional fields", func(t *testing.T) { + body := `{"statusReport":{"id":"43","title":"DB Issue","status":"STATUS_REPORT_STATUS_INVESTIGATING","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 := statusreport.CreateStatusReportWithHTTPClient( + interceptor.GetHTTPClient(), "test-token", + "DB Issue", "investigating", "Looking into DB issues", + "2026-03-20T10:00:00Z", "page-1", []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("Invalid status 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 := statusreport.CreateStatusReportWithHTTPClient( + interceptor.GetHTTPClient(), "test-token", + "Title", "invalid-status", "Message", + "2026-03-20T10:00:00Z", "page-1", nil, false, + ) + if err == nil { + t.Error("Expected error for invalid status, 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 := statusreport.CreateStatusReportWithHTTPClient( + interceptor.GetHTTPClient(), "test-token", + "Title", "investigating", "Message", + "2026-03-20T10:00:00Z", "page-1", nil, false, + ) + if err == nil { + t.Error("Expected error, got nil") + } + }) +} diff --git a/internal/statusreport/statusreport_delete.go b/internal/statusreport/statusreport_delete.go new file mode 100644 index 0000000..276c4eb --- /dev/null +++ b/internal/statusreport/statusreport_delete.go @@ -0,0 +1,75 @@ +package statusreport + +import ( + "context" + "fmt" + "net/http" + + status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_report/v1/status_reportv1connect" + confirmation "github.com/openstatusHQ/cli/internal/cli" + "github.com/urfave/cli/v3" +) + +func DeleteStatusReport(client status_reportv1connect.StatusReportServiceClient, reportId string) error { + if reportId == "" { + return fmt.Errorf("report ID is required") + } + + _, err := client.DeleteStatusReport(context.Background(), &status_reportv1.DeleteStatusReportRequest{ + Id: reportId, + }) + if err != nil { + return fmt.Errorf("failed to delete status report: %w", err) + } + + return nil +} + +func DeleteStatusReportWithHTTPClient(httpClient *http.Client, apiKey string, reportId string) error { + client := NewStatusReportClientWithHTTPClient(httpClient, apiKey) + return DeleteStatusReport(client, reportId) +} + +func GetStatusReportDeleteCmd() *cli.Command { + return &cli.Command{ + Name: "delete", + Usage: "Delete a status report", + UsageText: "openstatus status-report delete ", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + Required: true, + }, + &cli.BoolFlag{ + Name: "auto-accept", + Usage: "Automatically accept the prompt", + Aliases: []string{"y"}, + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + reportId := cmd.Args().Get(0) + + if !cmd.Bool("auto-accept") { + confirmed, err := confirmation.AskForConfirmation(fmt.Sprintf("You are about to delete status report: %s, do you want to continue", reportId)) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to read input: %v", err), 1) + } + if !confirmed { + return nil + } + } + + client := NewStatusReportClient(cmd.String("access-token")) + err := DeleteStatusReport(client, reportId) + if err != nil { + return cli.Exit(err.Error(), 1) + } + fmt.Printf("Status report %s deleted successfully\n", reportId) + return nil + }, + } +} diff --git a/internal/statusreport/statusreport_delete_test.go b/internal/statusreport/statusreport_delete_test.go new file mode 100644 index 0000000..cbd7d6d --- /dev/null +++ b/internal/statusreport/statusreport_delete_test.go @@ -0,0 +1,85 @@ +package statusreport_test + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/openstatusHQ/cli/internal/statusreport" +) + +func Test_DeleteStatusReport(t *testing.T) { + t.Parallel() + + t.Run("Report ID is required", 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 := statusreport.DeleteStatusReportWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "") + if err == nil { + t.Error("Expected error for empty report ID, got nil") + } + if err.Error() != "report ID is required" { + t.Errorf("Expected 'report ID is required' error, got %v", err) + } + }) + + t.Run("Delete report successfully", func(t *testing.T) { + body := `{"success":true}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodPost { + t.Errorf("Expected POST method (Connect RPC), got %s", req.Method) + } + 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 + }, + } + + err := statusreport.DeleteStatusReportWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "123") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Delete report fails with error status", func(t *testing.T) { + body := `{"code":"not_found","message":"status report not found"}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + err := statusreport.DeleteStatusReportWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "999") + if err == nil { + t.Error("Expected error for not found, got nil") + } + }) +} diff --git a/internal/statusreport/statusreport_info.go b/internal/statusreport/statusreport_info.go new file mode 100644 index 0000000..56f0f44 --- /dev/null +++ b/internal/statusreport/statusreport_info.go @@ -0,0 +1,122 @@ +package statusreport + +import ( + "context" + "fmt" + "net/http" + "os" + "strings" + + status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_report/v1/status_reportv1connect" + "github.com/logrusorgru/aurora/v4" + "github.com/olekukonko/tablewriter" + "github.com/olekukonko/tablewriter/renderer" + "github.com/olekukonko/tablewriter/tw" + "github.com/urfave/cli/v3" +) + +func GetStatusReportInfo(client status_reportv1connect.StatusReportServiceClient, reportId string) error { + if reportId == "" { + return fmt.Errorf("report ID is required") + } + + resp, err := client.GetStatusReport(context.Background(), &status_reportv1.GetStatusReportRequest{ + Id: reportId, + }) + if err != nil { + return fmt.Errorf("status report not found. Run 'openstatus status-report list' to see available reports") + } + + report := resp.GetStatusReport() + + fmt.Println(aurora.Bold("Status Report:")) + 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", report.GetId()}, + {"Title", report.GetTitle()}, + {"Status", statusColor(statusToString(report.GetStatus()))}, + } + + if len(report.GetPageComponentIds()) > 0 { + data = append(data, []string{"Components", strings.Join(report.GetPageComponentIds(), ", ")}) + } + + data = append(data, []string{"Created", report.GetCreatedAt()}) + data = append(data, []string{"Updated", report.GetUpdatedAt()}) + + table.Bulk(data) + table.Render() + + updates := report.GetUpdates() + if len(updates) == 0 { + fmt.Println("\nNo updates yet") + return nil + } + + fmt.Println(aurora.Bold("\nUpdate Timeline:")) + for _, u := range updates { + fmt.Printf(" %s [%s] %s\n", + u.GetDate(), + statusColor(statusToString(u.GetStatus())), + u.GetMessage(), + ) + } + + return nil +} + +func GetStatusReportInfoWithHTTPClient(httpClient *http.Client, apiKey string, reportId string) error { + client := NewStatusReportClientWithHTTPClient(httpClient, apiKey) + return GetStatusReportInfo(client, reportId) +} + +func GetStatusReportInfoCmd() *cli.Command { + return &cli.Command{ + Name: "info", + Usage: "Get status report details", + UsageText: "openstatus status-report info ", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + Required: true, + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + reportId := cmd.Args().Get(0) + client := NewStatusReportClient(cmd.String("access-token")) + err := GetStatusReportInfo(client, reportId) + if err != nil { + return cli.Exit(err.Error(), 1) + } + return nil + }, + } +} diff --git a/internal/statusreport/statusreport_info_test.go b/internal/statusreport/statusreport_info_test.go new file mode 100644 index 0000000..c8d3bdd --- /dev/null +++ b/internal/statusreport/statusreport_info_test.go @@ -0,0 +1,101 @@ +package statusreport_test + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/openstatusHQ/cli/internal/statusreport" +) + +func Test_GetStatusReportInfo(t *testing.T) { + t.Parallel() + + t.Run("Successfully returns report with updates", func(t *testing.T) { + body := `{"statusReport":{"id":"1","title":"API Outage","status":"STATUS_REPORT_STATUS_MONITORING","pageComponentIds":["c1","c2"],"updates":[{"id":"u1","status":"STATUS_REPORT_STATUS_INVESTIGATING","date":"2026-03-20T10:00:00Z","message":"Investigating the issue"},{"id":"u2","status":"STATUS_REPORT_STATUS_IDENTIFIED","date":"2026-03-20T10:30:00Z","message":"Root cause identified"}],"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 := statusreport.GetStatusReportInfoWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "1") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Report with no updates", func(t *testing.T) { + body := `{"statusReport":{"id":"2","title":"Scheduled Maintenance","status":"STATUS_REPORT_STATUS_INVESTIGATING","updates":[],"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 := statusreport.GetStatusReportInfoWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "2") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Missing report 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 := statusreport.GetStatusReportInfoWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "") + if err == nil { + t.Error("Expected error for empty report ID, got nil") + } + if err.Error() != "report ID is required" { + t.Errorf("Expected 'report ID is required' error, got %v", err) + } + }) + + t.Run("Report not found returns actionable error", func(t *testing.T) { + body := `{"code":"not_found","message":"status report not found"}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + err := statusreport.GetStatusReportInfoWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "999") + if err == nil { + t.Error("Expected error, got nil") + } + }) +} diff --git a/internal/statusreport/statusreport_list.go b/internal/statusreport/statusreport_list.go new file mode 100644 index 0000000..a78fcb7 --- /dev/null +++ b/internal/statusreport/statusreport_list.go @@ -0,0 +1,98 @@ +package statusreport + +import ( + "context" + "fmt" + "net/http" + + status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_report/v1/status_reportv1connect" + "github.com/fatih/color" + "github.com/rodaine/table" + "github.com/urfave/cli/v3" +) + +func ListStatusReports(client status_reportv1connect.StatusReportServiceClient, statusFilter string, limit int) error { + req := &status_reportv1.ListStatusReportsRequest{} + + if limit > 0 { + l := int32(limit) + req.SetLimit(l) + } + + if statusFilter != "" { + s, err := statusToSDK(statusFilter) + if err != nil { + return err + } + req.SetStatuses([]status_reportv1.StatusReportStatus{s}) + } + + resp, err := client.ListStatusReports(context.Background(), req) + if err != nil { + return fmt.Errorf("failed to list status reports: %w", err) + } + + reports := resp.GetStatusReports() + if len(reports) == 0 { + fmt.Println("No status reports found") + return nil + } + + headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() + columnFmt := color.New(color.FgYellow).SprintfFunc() + + tbl := table.New("ID", "Title", "Status", "Created", "Updated") + tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) + + for _, r := range reports { + tbl.AddRow( + r.GetId(), + r.GetTitle(), + statusColor(statusToString(r.GetStatus())), + r.GetCreatedAt(), + r.GetUpdatedAt(), + ) + } + + tbl.Print() + return nil +} + +func ListStatusReportsWithHTTPClient(httpClient *http.Client, apiKey string, statusFilter string, limit int) error { + client := NewStatusReportClientWithHTTPClient(httpClient, apiKey) + return ListStatusReports(client, statusFilter, limit) +} + +func GetStatusReportListCmd() *cli.Command { + return &cli.Command{ + Name: "list", + Usage: "List all status reports", + UsageText: "openstatus status-report list [options]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + Required: true, + }, + &cli.StringFlag{ + Name: "status", + Usage: "Filter by status (investigating, identified, monitoring, resolved)", + }, + &cli.IntFlag{ + Name: "limit", + Usage: "Maximum number of reports to return (1-100)", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + client := NewStatusReportClient(cmd.String("access-token")) + err := ListStatusReports(client, cmd.String("status"), int(cmd.Int("limit"))) + if err != nil { + return cli.Exit(err.Error(), 1) + } + return nil + }, + } +} diff --git a/internal/statusreport/statusreport_list_test.go b/internal/statusreport/statusreport_list_test.go new file mode 100644 index 0000000..0c8f75a --- /dev/null +++ b/internal/statusreport/statusreport_list_test.go @@ -0,0 +1,127 @@ +package statusreport_test + +import ( + "bytes" + "io" + "log" + "net/http" + "os" + "testing" + + "github.com/openstatusHQ/cli/internal/statusreport" +) + +func Test_ListStatusReports(t *testing.T) { + t.Parallel() + + t.Run("Successfully returns reports", func(t *testing.T) { + body := `{"statusReports":[{"id":"1","title":"API Outage","status":"STATUS_REPORT_STATUS_INVESTIGATING","createdAt":"2026-03-20T10:00:00Z","updatedAt":"2026-03-20T10:30:00Z"},{"id":"2","title":"Database Maintenance","status":"STATUS_REPORT_STATUS_RESOLVED","createdAt":"2026-03-19T08:00:00Z","updatedAt":"2026-03-19T12: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 + }, + } + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + err := statusreport.ListStatusReportsWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "", 0) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Returns empty list", func(t *testing.T) { + body := `{"statusReports":[],"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 := statusreport.ListStatusReportsWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "", 0) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Filters by status", func(t *testing.T) { + body := `{"statusReports":[{"id":"1","title":"API Outage","status":"STATUS_REPORT_STATUS_INVESTIGATING","createdAt":"2026-03-20T10:00:00Z","updatedAt":"2026-03-20T10:30:00Z"}],"totalSize":1}` + 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 := statusreport.ListStatusReportsWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "investigating", 0) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Invalid status filter 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 := statusreport.ListStatusReportsWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "invalid", 0) + if err == nil { + t.Error("Expected error for invalid status, 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 := statusreport.ListStatusReportsWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "", 0) + if err == nil { + t.Error("Expected error, got nil") + } + }) +} diff --git a/internal/statusreport/statusreport_test.go b/internal/statusreport/statusreport_test.go new file mode 100644 index 0000000..aa966e9 --- /dev/null +++ b/internal/statusreport/statusreport_test.go @@ -0,0 +1,86 @@ +package statusreport_test + +import ( + "net/http" + "testing" + + "github.com/openstatusHQ/cli/internal/statusreport" +) + +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_StatusReportCmd(t *testing.T) { + t.Parallel() + + t.Run("Returns valid command", func(t *testing.T) { + cmd := statusreport.StatusReportCmd() + + if cmd == nil { + t.Fatal("Expected non-nil command") + } + + if cmd.Name != "status-report" { + t.Errorf("Expected command name 'status-report', got %s", cmd.Name) + } + + if cmd.Usage != "Manage status reports" { + t.Errorf("Expected usage 'Manage status reports', got %s", cmd.Usage) + } + }) + + t.Run("Has expected subcommands", func(t *testing.T) { + cmd := statusreport.StatusReportCmd() + + if len(cmd.Commands) != 6 { + t.Errorf("Expected 6 subcommands, got %d", len(cmd.Commands)) + } + + expectedSubcommands := map[string]bool{ + "list": false, + "info": false, + "create": false, + "update": false, + "delete": false, + "add-update": 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 sr alias", func(t *testing.T) { + cmd := statusreport.StatusReportCmd() + + found := false + for _, alias := range cmd.Aliases { + if alias == "sr" { + found = true + break + } + } + if !found { + t.Error("Expected 'sr' alias not found") + } + }) +} diff --git a/internal/statusreport/statusreport_update.go b/internal/statusreport/statusreport_update.go new file mode 100644 index 0000000..24276ed --- /dev/null +++ b/internal/statusreport/statusreport_update.go @@ -0,0 +1,90 @@ +package statusreport + +import ( + "context" + "fmt" + "net/http" + "strings" + + status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_report/v1/status_reportv1connect" + "github.com/urfave/cli/v3" +) + +func UpdateStatusReport(client status_reportv1connect.StatusReportServiceClient, reportId string, title string, componentIds []string, hasTitle bool, hasComponents bool) error { + if reportId == "" { + return fmt.Errorf("report ID is required") + } + + if !hasTitle && !hasComponents { + return fmt.Errorf("at least one of --title or --component-ids must be provided") + } + + req := &status_reportv1.UpdateStatusReportRequest{ + Id: reportId, + } + + if hasTitle { + req.SetTitle(title) + } + + if hasComponents { + req.SetPageComponentIds(componentIds) + } + + _, err := client.UpdateStatusReport(context.Background(), req) + if err != nil { + return fmt.Errorf("failed to update status report: %w", err) + } + + fmt.Printf("Status report %s updated successfully\n", reportId) + return nil +} + +func UpdateStatusReportWithHTTPClient(httpClient *http.Client, apiKey string, reportId string, title string, componentIds []string, hasTitle bool, hasComponents bool) error { + client := NewStatusReportClientWithHTTPClient(httpClient, apiKey) + return UpdateStatusReport(client, reportId, title, componentIds, hasTitle, hasComponents) +} + +func GetStatusReportUpdateCmd() *cli.Command { + return &cli.Command{ + Name: "update", + Usage: "Update status report metadata", + UsageText: "openstatus status-report update [--title \"New title\"] [--component-ids id1,id2]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + Required: true, + }, + &cli.StringFlag{ + Name: "title", + Usage: "New title for the report", + }, + &cli.StringFlag{ + Name: "component-ids", + Usage: "Comma-separated page component IDs (replaces existing list)", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + reportId := cmd.Args().Get(0) + + hasTitle := cmd.IsSet("title") + hasComponents := cmd.IsSet("component-ids") + + var componentIds []string + if ids := cmd.String("component-ids"); ids != "" { + componentIds = strings.Split(ids, ",") + } + + client := NewStatusReportClient(cmd.String("access-token")) + err := UpdateStatusReport(client, reportId, cmd.String("title"), componentIds, hasTitle, hasComponents) + if err != nil { + return cli.Exit(err.Error(), 1) + } + return nil + }, + } +} diff --git a/internal/statusreport/statusreport_update_test.go b/internal/statusreport/statusreport_update_test.go new file mode 100644 index 0000000..2fbe1fe --- /dev/null +++ b/internal/statusreport/statusreport_update_test.go @@ -0,0 +1,134 @@ +package statusreport_test + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/openstatusHQ/cli/internal/statusreport" +) + +func Test_UpdateStatusReport(t *testing.T) { + t.Parallel() + + t.Run("Update title only", func(t *testing.T) { + body := `{"statusReport":{"id":"1","title":"New Title","status":"STATUS_REPORT_STATUS_INVESTIGATING","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 := statusreport.UpdateStatusReportWithHTTPClient( + interceptor.GetHTTPClient(), "test-token", + "1", "New Title", nil, true, false, + ) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Update component-ids only", func(t *testing.T) { + body := `{"statusReport":{"id":"1","title":"Title","status":"STATUS_REPORT_STATUS_INVESTIGATING","pageComponentIds":["c1","c2"],"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 := statusreport.UpdateStatusReportWithHTTPClient( + interceptor.GetHTTPClient(), "test-token", + "1", "", []string{"c1", "c2"}, false, true, + ) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Update both title and component-ids", func(t *testing.T) { + body := `{"statusReport":{"id":"1","title":"Updated Title","status":"STATUS_REPORT_STATUS_INVESTIGATING","pageComponentIds":["c3"],"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 := statusreport.UpdateStatusReportWithHTTPClient( + interceptor.GetHTTPClient(), "test-token", + "1", "Updated Title", []string{"c3"}, true, true, + ) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Neither flag 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 := statusreport.UpdateStatusReportWithHTTPClient( + interceptor.GetHTTPClient(), "test-token", + "1", "", nil, false, false, + ) + if err == nil { + t.Error("Expected error, got nil") + } + if err.Error() != "at least one of --title or --component-ids must be provided" { + t.Errorf("Expected validation error, got %v", err) + } + }) + + t.Run("Empty report 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 := statusreport.UpdateStatusReportWithHTTPClient( + interceptor.GetHTTPClient(), "test-token", + "", "Title", nil, true, false, + ) + if err == nil { + t.Error("Expected error for empty report ID, got nil") + } + }) +} diff --git a/internal/whoami/whoami.go b/internal/whoami/whoami.go index dda2ae7..4bf6e68 100644 --- a/internal/whoami/whoami.go +++ b/internal/whoami/whoami.go @@ -7,6 +7,7 @@ import ( "io" "net/http" + "github.com/openstatusHQ/cli/internal/api" "github.com/urfave/cli/v3" ) @@ -17,7 +18,7 @@ type Whoami struct { } func GetWhoamiCmd(httpClient *http.Client, apiKey string) error { - url := "https://api.openstatus.dev/v1/whoami" // Using monitors.APIBaseURL would create circular import + url := fmt.Sprintf("%s/whoami", api.APIBaseURL) req, err := http.NewRequest("GET", url, nil) if err != nil {