diff --git a/docs/openstatus-docs.md b/docs/openstatus-docs.md index e1e2bb8..5e349e3 100644 --- a/docs/openstatus-docs.md +++ b/docs/openstatus-docs.md @@ -491,6 +491,55 @@ The following flags are supported: |-----------------------------|-----------------------------|:-------------:|:----------------------:| | `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` | +### `notification` command (aliases: `n`) + +Manage notifications. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] notification [ARGUMENTS...] +``` + +### `notification list` subcommand + +List all notifications. + +> openstatus notification list +> openstatus notification list --limit 10 + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] notification 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` | +| `--limit="…"` | Maximum number of notifications to return (1-100) | `0` | *none* | + +### `notification info` subcommand + +Get notification details. + +> openstatus notification info +> openstatus notification info 12345 + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] notification 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` | + ### `run` command (aliases: `r`) Run your uptime tests. diff --git a/docs/openstatus.1 b/docs/openstatus.1 index 2dad6ef..b2ab37e 100644 --- a/docs/openstatus.1 +++ b/docs/openstatus.1 @@ -1229,6 +1229,97 @@ T}@T{ \f[CR]OPENSTATUS_API_TOKEN\f[R] T} .TE +.SS \f[CR]notification\f[R] command (aliases: \f[CR]n\f[R]) +Manage notifications. +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] notification [ARGUMENTS...] +.EE +.SS \f[CR]notification list\f[R] subcommand +List all notifications. +.RS +.PP +openstatus notification list openstatus notification list \(enlimit 10 +.RE +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] notification list [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(17.1n) lw(30.0n) cw(8.8n) cw(14.1n). +T{ +Name +T}@T{ +Description +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-limit=\(dq\&...\(dq\f[R] +T}@T{ +Maximum number of notifications to return (1\-100) +T}@T{ +\f[CR]0\f[R] +T}@T{ +\f[I]none\f[R] +T} +.TE +.SS \f[CR]notification info\f[R] subcommand +Get notification details. +.RS +.PP +openstatus notification info openstatus notification info 12345 +.RE +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] notification info [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(20.9n) lw(20.9n) cw(10.8n) cw(17.3n). +T{ +Name +T}@T{ +Description +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +.TE .SS \f[CR]run\f[R] command (aliases: \f[CR]r\f[R]) Run your uptime tests. .RS diff --git a/internal/cmd/app.go b/internal/cmd/app.go index 5bf305c..2ae11f5 100644 --- a/internal/cmd/app.go +++ b/internal/cmd/app.go @@ -10,6 +10,7 @@ import ( "github.com/openstatusHQ/cli/internal/login" "github.com/openstatusHQ/cli/internal/maintenance" "github.com/openstatusHQ/cli/internal/monitors" + "github.com/openstatusHQ/cli/internal/notification" "github.com/openstatusHQ/cli/internal/run" "github.com/openstatusHQ/cli/internal/statuspage" "github.com/openstatusHQ/cli/internal/statusreport" @@ -71,6 +72,7 @@ https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new`, statusreport.StatusReportCmd(), maintenance.MaintenanceCmd(), statuspage.StatusPageCmd(), + notification.NotificationCmd(), run.RunCmd(), whoami.WhoamiCmd(), login.LoginCmd(), diff --git a/internal/cmd/app_test.go b/internal/cmd/app_test.go index 4ecbe78..9849851 100644 --- a/internal/cmd/app_test.go +++ b/internal/cmd/app_test.go @@ -32,8 +32,8 @@ func Test_NewApp(t *testing.T) { t.Run("Has expected commands", func(t *testing.T) { app := cmd.NewApp() - if len(app.Commands) != 9 { - t.Errorf("Expected 9 commands, got %d", len(app.Commands)) + if len(app.Commands) != 10 { + t.Errorf("Expected 10 commands, got %d", len(app.Commands)) } expectedCommands := map[string]bool{ @@ -41,6 +41,7 @@ func Test_NewApp(t *testing.T) { "status-report": false, "maintenance": false, "status-page": false, + "notification": false, "run": false, "whoami": false, "login": false, diff --git a/internal/notification/notification.go b/internal/notification/notification.go new file mode 100644 index 0000000..95bf089 --- /dev/null +++ b/internal/notification/notification.go @@ -0,0 +1,83 @@ +package notification + +import ( + "net/http" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/notification/v1/notificationv1connect" + notificationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/notification/v1" + "connectrpc.com/connect" + "github.com/openstatusHQ/cli/internal/api" + "github.com/urfave/cli/v3" +) + +func NewNotificationClient(apiKey string) notificationv1connect.NotificationServiceClient { + return notificationv1connect.NewNotificationServiceClient( + api.DefaultHTTPClient, + api.ConnectBaseURL, + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), + connect.WithProtoJSON(), + ) +} + +func NewNotificationClientWithHTTPClient(httpClient *http.Client, apiKey string) notificationv1connect.NotificationServiceClient { + return notificationv1connect.NewNotificationServiceClient( + httpClient, + api.ConnectBaseURL, + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), + connect.WithProtoJSON(), + ) +} + +func providerToString(p notificationv1.NotificationProvider) string { + switch p { + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_DISCORD: + return "discord" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_EMAIL: + return "email" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_GOOGLE_CHAT: + return "google_chat" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_GRAFANA_ONCALL: + return "grafana_oncall" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_NTFY: + return "ntfy" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_PAGERDUTY: + return "pagerduty" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_OPSGENIE: + return "opsgenie" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_SLACK: + return "slack" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_SMS: + return "sms" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_TELEGRAM: + return "telegram" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_WEBHOOK: + return "webhook" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_WHATSAPP: + return "whatsapp" + default: + return "unknown" + } +} + +func opsgenieRegionToString(r notificationv1.OpsgenieRegion) string { + switch r { + case notificationv1.OpsgenieRegion_OPSGENIE_REGION_US: + return "us" + case notificationv1.OpsgenieRegion_OPSGENIE_REGION_EU: + return "eu" + default: + return "unknown" + } +} + +func NotificationCmd() *cli.Command { + return &cli.Command{ + Name: "notification", + Aliases: []string{"n"}, + Usage: "Manage notifications", + Commands: []*cli.Command{ + GetNotificationListCmd(), + GetNotificationInfoCmd(), + }, + } +} diff --git a/internal/notification/notification_info.go b/internal/notification/notification_info.go new file mode 100644 index 0000000..137a6f4 --- /dev/null +++ b/internal/notification/notification_info.go @@ -0,0 +1,252 @@ +package notification + +import ( + "context" + "fmt" + "net/http" + "os" + "strings" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/notification/v1/notificationv1connect" + notificationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/notification/v1" + "github.com/logrusorgru/aurora/v4" + "github.com/olekukonko/tablewriter" + "github.com/olekukonko/tablewriter/renderer" + "github.com/olekukonko/tablewriter/tw" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" + "github.com/urfave/cli/v3" +) + +type notificationDetail struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + Data map[string]any `json:"data"` + MonitorIDs []string `json:"monitor_ids"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func extractNotificationData(data *notificationv1.NotificationData) (map[string]any, [][]string) { + if data == nil { + return map[string]any{}, [][]string{} + } + + switch d := data.GetData().(type) { + case *notificationv1.NotificationData_Discord: + url := d.Discord.GetWebhookUrl() + return map[string]any{"discord": map[string]any{"webhook_url": url}}, + [][]string{{"Webhook URL", url}} + + case *notificationv1.NotificationData_Email: + email := d.Email.GetEmail() + return map[string]any{"email": map[string]any{"email": email}}, + [][]string{{"Email", email}} + + case *notificationv1.NotificationData_Slack: + url := d.Slack.GetWebhookUrl() + return map[string]any{"slack": map[string]any{"webhook_url": url}}, + [][]string{{"Webhook URL", url}} + + case *notificationv1.NotificationData_GoogleChat: + url := d.GoogleChat.GetWebhookUrl() + return map[string]any{"google_chat": map[string]any{"webhook_url": url}}, + [][]string{{"Webhook URL", url}} + + case *notificationv1.NotificationData_GrafanaOncall: + url := d.GrafanaOncall.GetWebhookUrl() + return map[string]any{"grafana_oncall": map[string]any{"webhook_url": url}}, + [][]string{{"Webhook URL", url}} + + case *notificationv1.NotificationData_Pagerduty: + key := d.Pagerduty.GetIntegrationKey() + return map[string]any{"pagerduty": map[string]any{"integration_key": key}}, + [][]string{{"Integration Key", key}} + + case *notificationv1.NotificationData_Opsgenie: + apiKey := d.Opsgenie.GetApiKey() + region := opsgenieRegionToString(d.Opsgenie.GetRegion()) + return map[string]any{"opsgenie": map[string]any{"api_key": apiKey, "region": region}}, + [][]string{{"API Key", apiKey}, {"Region", region}} + + case *notificationv1.NotificationData_Ntfy: + topic := d.Ntfy.GetTopic() + serverUrl := d.Ntfy.GetServerUrl() + hasToken := d.Ntfy.HasToken() + + jsonMap := map[string]any{"topic": topic, "has_token": hasToken} + rows := [][]string{{"Topic", topic}} + + if serverUrl != "" { + jsonMap["server_url"] = serverUrl + rows = append(rows, []string{"Server URL", serverUrl}) + } + + tokenStatus := "not configured" + if hasToken { + tokenStatus = "configured" + } + rows = append(rows, []string{"Token", tokenStatus}) + + return map[string]any{"ntfy": jsonMap}, rows + + case *notificationv1.NotificationData_Sms: + phone := d.Sms.GetPhoneNumber() + return map[string]any{"sms": map[string]any{"phone_number": phone}}, + [][]string{{"Phone Number", phone}} + + case *notificationv1.NotificationData_Telegram: + chatId := d.Telegram.GetChatId() + return map[string]any{"telegram": map[string]any{"chat_id": chatId}}, + [][]string{{"Chat ID", chatId}} + + case *notificationv1.NotificationData_Webhook: + endpoint := d.Webhook.GetEndpoint() + jsonMap := map[string]any{"endpoint": endpoint} + rows := [][]string{{"Endpoint", endpoint}} + + headers := d.Webhook.GetHeaders() + if len(headers) > 0 { + headerList := make([]map[string]any, 0, len(headers)) + for _, h := range headers { + headerList = append(headerList, map[string]any{"key": h.GetKey(), "value": h.GetValue()}) + rows = append(rows, []string{"Header", h.GetKey() + ": " + h.GetValue()}) + } + jsonMap["headers"] = headerList + } + + return map[string]any{"webhook": jsonMap}, rows + + case *notificationv1.NotificationData_Whatsapp: + phone := d.Whatsapp.GetPhoneNumber() + return map[string]any{"whatsapp": map[string]any{"phone_number": phone}}, + [][]string{{"Phone Number", phone}} + + default: + return map[string]any{}, [][]string{} + } +} + +func GetNotificationInfo(ctx context.Context, client notificationv1connect.NotificationServiceClient, notificationId string, s *output.Spinner) error { + if notificationId == "" { + output.StopSpinner(s) + fmt.Fprintln(os.Stderr, "Usage: openstatus notification info ") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Example: openstatus notification info 12345") + return fmt.Errorf("notification ID is required") + } + + req := ¬ificationv1.GetNotificationRequest{} + req.SetId(notificationId) + resp, err := client.GetNotification(ctx, req) + output.StopSpinner(s) + if err != nil { + return output.FormatError(err, "notification", notificationId) + } + + n := resp.GetNotification() + dataMap, dataRows := extractNotificationData(n.GetData()) + + if output.IsJSONOutput() { + monitorIDs := n.GetMonitorIds() + if monitorIDs == nil { + monitorIDs = []string{} + } + detail := notificationDetail{ + ID: n.GetId(), + Name: n.GetName(), + Provider: providerToString(n.GetProvider()), + Data: dataMap, + MonitorIDs: monitorIDs, + CreatedAt: n.GetCreatedAt(), + UpdatedAt: n.GetUpdatedAt(), + } + return output.PrintJSON(detail) + } + + fmt.Println(aurora.Bold("Notification:")) + tbl := 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", n.GetId()}, + {"Name", n.GetName()}, + {"Provider", providerToString(n.GetProvider())}, + } + + data = append(data, dataRows...) + + monitorIDs := n.GetMonitorIds() + if len(monitorIDs) > 0 { + data = append(data, []string{"Monitor IDs", strings.Join(monitorIDs, ", ")}) + } else { + data = append(data, []string{"Monitor IDs", "none"}) + } + + data = append(data, []string{"Created At", output.FormatTimestamp(n.GetCreatedAt())}) + data = append(data, []string{"Updated At", output.FormatTimestamp(n.GetUpdatedAt())}) + + tbl.Bulk(data) + tbl.Render() + + return nil +} + +func GetNotificationInfoWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, notificationId string) error { + client := NewNotificationClientWithHTTPClient(httpClient, apiKey) + return GetNotificationInfo(ctx, client, notificationId, nil) +} + +func GetNotificationInfoCmd() *cli.Command { + return &cli.Command{ + Name: "info", + Usage: "Get notification details", + UsageText: `openstatus notification info + openstatus notification 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) + } + notificationId := cmd.Args().Get(0) + s := output.StartSpinner("Fetching notification...") + client := NewNotificationClient(apiKey) + err = GetNotificationInfo(ctx, client, notificationId, s) + if err != nil { + return cli.Exit(err.Error(), 1) + } + return nil + }, + } +} diff --git a/internal/notification/notification_info_test.go b/internal/notification/notification_info_test.go new file mode 100644 index 0000000..b7a3569 --- /dev/null +++ b/internal/notification/notification_info_test.go @@ -0,0 +1,102 @@ +package notification_test + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" + + "github.com/openstatusHQ/cli/internal/notification" +) + +func Test_GetNotificationInfo(t *testing.T) { + t.Parallel() + + t.Run("Successfully returns notification with slack data", func(t *testing.T) { + body := `{"notification":{"id":"1","name":"Slack Alerts","provider":"NOTIFICATION_PROVIDER_SLACK","data":{"slack":{"webhookUrl":"https://hooks.slack.com/services/T00/B00/xxx"}},"monitorIds":["m1","m2"],"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00: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 := notification.GetNotificationInfoWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", "1") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Successfully returns notification with opsgenie data", func(t *testing.T) { + body := `{"notification":{"id":"2","name":"OpsGenie Alerts","provider":"NOTIFICATION_PROVIDER_OPSGENIE","data":{"opsgenie":{"apiKey":"test-key","region":"OPSGENIE_REGION_EU"}},"monitorIds":[],"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00: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 := notification.GetNotificationInfoWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", "2") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Missing notification 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 := notification.GetNotificationInfoWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", "") + if err == nil { + t.Error("Expected error for empty notification ID, got nil") + } + if err.Error() != "notification ID is required" { + t.Errorf("Expected 'notification ID is required' error, got %v", err) + } + }) + + t.Run("Notification not found returns error", func(t *testing.T) { + body := `{"code":"not_found","message":"notification 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 := notification.GetNotificationInfoWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", "999") + if err == nil { + t.Error("Expected error, got nil") + } + }) +} diff --git a/internal/notification/notification_list.go b/internal/notification/notification_list.go new file mode 100644 index 0000000..422853d --- /dev/null +++ b/internal/notification/notification_list.go @@ -0,0 +1,120 @@ +package notification + +import ( + "context" + "fmt" + "net/http" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/notification/v1/notificationv1connect" + notificationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/notification/v1" + "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 notificationListEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + MonitorCount int32 `json:"monitor_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func ListNotifications(ctx context.Context, client notificationv1connect.NotificationServiceClient, limit int, s *output.Spinner) error { + req := ¬ificationv1.ListNotificationsRequest{} + + if limit > 0 { + l := int32(limit) + req.SetLimit(l) + } + + resp, err := client.ListNotifications(ctx, req) + output.StopSpinner(s) + if err != nil { + return output.FormatError(err, "notification", "") + } + + notifications := resp.GetNotifications() + + if output.IsJSONOutput() { + entries := make([]notificationListEntry, 0, len(notifications)) + for _, n := range notifications { + entries = append(entries, notificationListEntry{ + ID: n.GetId(), + Name: n.GetName(), + Provider: providerToString(n.GetProvider()), + MonitorCount: n.GetMonitorCount(), + CreatedAt: n.GetCreatedAt(), + UpdatedAt: n.GetUpdatedAt(), + }) + } + return output.PrintJSON(entries) + } + + if len(notifications) == 0 { + if !output.IsQuiet() { + fmt.Println("No notifications found") + } + return nil + } + + headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() + columnFmt := color.New(color.FgYellow).SprintfFunc() + + tbl := table.New("ID", "Name", "Provider", "Monitors") + tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) + + for _, n := range notifications { + tbl.AddRow( + n.GetId(), + n.GetName(), + providerToString(n.GetProvider()), + n.GetMonitorCount(), + ) + } + + tbl.Print() + return nil +} + +func ListNotificationsWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, limit int) error { + client := NewNotificationClientWithHTTPClient(httpClient, apiKey) + return ListNotifications(ctx, client, limit, nil) +} + +func GetNotificationListCmd() *cli.Command { + return &cli.Command{ + Name: "list", + Usage: "List all notifications", + UsageText: `openstatus notification list + openstatus notification list --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.IntFlag{ + Name: "limit", + Usage: "Maximum number of notifications 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 notifications...") + client := NewNotificationClient(apiKey) + err = ListNotifications(ctx, client, int(cmd.Int("limit")), s) + if err != nil { + return cli.Exit(err.Error(), 1) + } + return nil + }, + } +} diff --git a/internal/notification/notification_list_test.go b/internal/notification/notification_list_test.go new file mode 100644 index 0000000..dcb25a7 --- /dev/null +++ b/internal/notification/notification_list_test.go @@ -0,0 +1,115 @@ +package notification_test + +import ( + "bytes" + "context" + "io" + "log" + "net/http" + "os" + "testing" + + "github.com/openstatusHQ/cli/internal/notification" +) + +func Test_ListNotifications(t *testing.T) { + t.Parallel() + + t.Run("Successfully returns notifications", func(t *testing.T) { + body := `{"notifications":[{"id":"1","name":"Slack Alerts","provider":"NOTIFICATION_PROVIDER_SLACK","monitorCount":3,"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z"},{"id":"2","name":"Email Alerts","provider":"NOTIFICATION_PROVIDER_EMAIL","monitorCount":0,"createdAt":"2026-02-01T00:00:00Z","updatedAt":"2026-02-01T00: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.Stderr) + }) + err := notification.ListNotificationsWithHTTPClient(context.Background(), 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 := `{"notifications":[],"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 := notification.ListNotificationsWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", 0) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Respects limit parameter", func(t *testing.T) { + body := `{"notifications":[{"id":"1","name":"Slack Alerts","provider":"NOTIFICATION_PROVIDER_SLACK","monitorCount":1,"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00: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 + }, + } + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stderr) + }) + err := notification.ListNotificationsWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", 5) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + 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 := notification.ListNotificationsWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", 0) + if err == nil { + t.Error("Expected error, got nil") + } + }) +} diff --git a/internal/notification/notification_test.go b/internal/notification/notification_test.go new file mode 100644 index 0000000..68787b9 --- /dev/null +++ b/internal/notification/notification_test.go @@ -0,0 +1,82 @@ +package notification_test + +import ( + "net/http" + "testing" + + "github.com/openstatusHQ/cli/internal/notification" +) + +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_NotificationCmd(t *testing.T) { + t.Parallel() + + t.Run("Returns valid command", func(t *testing.T) { + cmd := notification.NotificationCmd() + + if cmd == nil { + t.Fatal("Expected non-nil command") + } + + if cmd.Name != "notification" { + t.Errorf("Expected command name 'notification', got %s", cmd.Name) + } + + if cmd.Usage != "Manage notifications" { + t.Errorf("Expected usage 'Manage notifications', got %s", cmd.Usage) + } + }) + + t.Run("Has expected subcommands", func(t *testing.T) { + cmd := notification.NotificationCmd() + + if len(cmd.Commands) != 2 { + t.Errorf("Expected 2 subcommands, got %d", len(cmd.Commands)) + } + + expectedSubcommands := map[string]bool{ + "list": false, + "info": 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 n alias", func(t *testing.T) { + cmd := notification.NotificationCmd() + + found := false + for _, alias := range cmd.Aliases { + if alias == "n" { + found = true + break + } + } + if !found { + t.Error("Expected 'n' alias not found") + } + }) +} diff --git a/skills/cli/SKILL.md b/skills/cli/SKILL.md index 2d1fe8c..d50c53b 100644 --- a/skills/cli/SKILL.md +++ b/skills/cli/SKILL.md @@ -1,14 +1,14 @@ --- name: openstatus-cli description: | - OpenStatus CLI for managing uptime monitors, incident reports, status pages, maintenance windows, and synthetic tests. Use this skill whenever the user wants to monitor a website or API, set up uptime checks, create or manage monitors, report an incident, update a status page, schedule maintenance, run synthetic tests, check latency or availability, define monitors as code, generate Terraform configuration, export to Terraform, or use the openstatus command. Also trigger when the user says "is my site up", "check my endpoint", "create a status report", "monitor this URL", "run uptime tests", "set up monitoring", "our API is down", "schedule maintenance", "maintenance window", "planned downtime", "terraform", "generate terraform", "export to terraform", "infrastructure as code", or mentions openstatus in any context. This skill knows the full CLI — commands, flags, config format, and workflows — so Claude can act without guessing. + OpenStatus CLI for managing uptime monitors, incident reports, status pages, notifications, maintenance windows, and synthetic tests. Use this skill whenever the user wants to monitor a website or API, set up uptime checks, create or manage monitors, report an incident, update a status page, view notifications, schedule maintenance, run synthetic tests, check latency or availability, define monitors as code, generate Terraform configuration, export to Terraform, or use the openstatus command. Also trigger when the user says "is my site up", "check my endpoint", "create a status report", "monitor this URL", "run uptime tests", "set up monitoring", "our API is down", "schedule maintenance", "maintenance window", "planned downtime", "terraform", "generate terraform", "export to terraform", "infrastructure as code", "list notifications", "notification channels", or mentions openstatus in any context. This skill knows the full CLI — commands, flags, config format, and workflows — so Claude can act without guessing. allowed-tools: - Bash(openstatus *) --- # OpenStatus CLI -Manage uptime monitors, incident reports, status pages, and maintenance windows from the terminal. The CLI supports monitors-as-code via YAML config files. +Manage uptime monitors, incident reports, status pages, notifications, and maintenance windows from the terminal. The CLI supports monitors-as-code via YAML config files. Run `openstatus --help` or `openstatus --help` for full option details. @@ -45,6 +45,8 @@ Token resolution order: | Delete incident | `status-report delete ` | Remove a status report | | List status pages | `status-page list` | See all your status pages | | Get status page details | `status-page info ` | View page config, components, theme | +| List notifications | `notification list` | See all notification channels in the workspace | +| Get notification details | `notification info ` | View provider config, linked monitors | | Create a maintenance window | `maintenance create` | Plan a maintenance window for a status page | | List maintenance windows | `maintenance list` | See scheduled/active/completed maintenance | | Get maintenance details | `maintenance info ` | View full details of a maintenance window | @@ -54,7 +56,7 @@ Token resolution order: | Generate Terraform config | `terraform generate` | Export workspace resources to Terraform HCL files | | Check workspace | `whoami` | Verify auth and workspace info | -Command aliases: `monitors` = `m`, `status-report` = `sr`, `status-page` = `sp`, `maintenance` = `mt`, `terraform` = `tf`, `run` = `r`, `whoami` = `w`. +Command aliases: `monitors` = `m`, `status-report` = `sr`, `status-page` = `sp`, `notification` = `n`, `maintenance` = `mt`, `terraform` = `tf`, `run` = `r`, `whoami` = `w`. ## Workflows @@ -290,6 +292,15 @@ openstatus maintenance info openstatus status-page info ``` +**Notification channels:** +```bash +openstatus notification list # all notification channels +openstatus notification list --limit 10 # limit results +openstatus notification info # provider config, linked monitors +``` + +Supported providers: discord, email, google_chat, grafana_oncall, ntfy, pagerduty, opsgenie, slack, sms, telegram, webhook, whatsapp. + ### Terraform export Generate Terraform HCL configuration from all workspace resources. This creates ready-to-use `.tf` files with import blocks for adopting Terraform on an existing OpenStatus setup.