From 7a309aa00c5af95672ce519ddaee0227dfa66c98 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 21:02:31 +0000 Subject: [PATCH 01/25] Add full Cronitor API support via 'cronitor api' command Implements comprehensive CLI interface for all Cronitor API resources: - monitors: list, get, create, update, delete, pause/unpause - issues: list, get, create, update, delete, bulk operations - statuspages: list, get, create, update, delete - components: list, get, create, update, delete - incidents: list, get, create, update, resolve - metrics: list with time ranges, aggregates - notifications: list, get, create, update, delete - environments: list, get, create, update, delete Features: - Generic API client with proper authentication and error handling - Support for --data and --file flags for JSON input - Support for stdin JSON input via piping - Table and JSON output formats - Pagination support with --page flag - Environment filtering with --env flag - Monitor-specific --with-events flag for latest events - Comprehensive BATS test suite Usage examples: cronitor api monitors cronitor api monitors get cronitor api monitors create --data '{"key":"my-job","type":"job"}' cronitor api issues --state open cronitor api metrics --monitor --aggregates https://claude.ai/code/session_01UDueW9A6SuxugCcsbAMfdB --- cmd/api.go | 221 ++++++++++++++++++++++++ cmd/api_components.go | 194 +++++++++++++++++++++ cmd/api_environments.go | 188 ++++++++++++++++++++ cmd/api_incidents.go | 212 +++++++++++++++++++++++ cmd/api_issues.go | 235 +++++++++++++++++++++++++ cmd/api_metrics.go | 154 +++++++++++++++++ cmd/api_monitors.go | 323 ++++++++++++++++++++++++++++++++++ cmd/api_notifications.go | 192 ++++++++++++++++++++ cmd/api_statuspages.go | 185 ++++++++++++++++++++ lib/api_client.go | 201 +++++++++++++++++++++ tests/test-api.bats | 365 +++++++++++++++++++++++++++++++++++++++ 11 files changed, 2470 insertions(+) create mode 100644 cmd/api.go create mode 100644 cmd/api_components.go create mode 100644 cmd/api_environments.go create mode 100644 cmd/api_incidents.go create mode 100644 cmd/api_issues.go create mode 100644 cmd/api_metrics.go create mode 100644 cmd/api_monitors.go create mode 100644 cmd/api_notifications.go create mode 100644 cmd/api_statuspages.go create mode 100644 lib/api_client.go create mode 100644 tests/test-api.bats diff --git a/cmd/api.go b/cmd/api.go new file mode 100644 index 0000000..3d1f719 --- /dev/null +++ b/cmd/api.go @@ -0,0 +1,221 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// API command flags +var ( + apiData string + apiFile string + apiFormat string + apiPage int + apiEnv string + apiMonitor string + apiOutput string + apiRaw bool +) + +// apiCmd represents the api command +var apiCmd = &cobra.Command{ + Use: "api", + Short: "Interact with the Cronitor API", + Long: ` +Interact with the Cronitor API to manage monitors, issues, status pages, and more. + +This command provides access to all Cronitor API resources: + monitors - Manage monitors (jobs, checks, heartbeats, sites) + issues - Manage issues and incidents + statuspages - Manage status pages + components - Manage status page components + incidents - Manage status page incidents + metrics - View monitor metrics and performance data + notifications - Manage notification lists + environments - Manage environments + +Examples: + List all monitors: + $ cronitor api monitors + + Get a specific monitor: + $ cronitor api monitors get + + Get a monitor with latest events: + $ cronitor api monitors get --with-events + + Create a new monitor: + $ cronitor api monitors create --data '{"key":"my-job","type":"job"}' + + Update a monitor: + $ cronitor api monitors update --data '{"name":"Updated Name"}' + + Delete a monitor: + $ cronitor api monitors delete + + List issues: + $ cronitor api issues + + Get metrics for a monitor: + $ cronitor api metrics --monitor +`, + Args: func(cmd *cobra.Command, args []string) error { + if len(viper.GetString(varApiKey)) < 10 { + return errors.New("you must provide an API key with this command or save a key using 'cronitor configure'") + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + RootCmd.AddCommand(apiCmd) + + // Global API flags + apiCmd.PersistentFlags().StringVarP(&apiData, "data", "d", "", "JSON data for create/update operations") + apiCmd.PersistentFlags().StringVarP(&apiFile, "file", "f", "", "JSON file for create/update operations") + apiCmd.PersistentFlags().StringVar(&apiFormat, "format", "json", "Output format: json, table") + apiCmd.PersistentFlags().IntVar(&apiPage, "page", 1, "Page number for paginated results") + apiCmd.PersistentFlags().StringVar(&apiEnv, "env", "", "Filter by environment") + apiCmd.PersistentFlags().StringVar(&apiMonitor, "monitor", "", "Filter by monitor key") + apiCmd.PersistentFlags().StringVarP(&apiOutput, "output", "o", "", "Output to file instead of stdout") + apiCmd.PersistentFlags().BoolVar(&apiRaw, "raw", false, "Output raw JSON without formatting") +} + +// getAPIClient returns a configured API client +func getAPIClient() *lib.APIClient { + return lib.NewAPIClient(dev, log) +} + +// getRequestBody returns the request body from --data or --file flag +func getRequestBody() ([]byte, error) { + if apiData != "" && apiFile != "" { + return nil, errors.New("cannot specify both --data and --file") + } + + if apiData != "" { + // Validate JSON + var js json.RawMessage + if err := json.Unmarshal([]byte(apiData), &js); err != nil { + return nil, fmt.Errorf("invalid JSON in --data: %w", err) + } + return []byte(apiData), nil + } + + if apiFile != "" { + data, err := os.ReadFile(apiFile) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", apiFile, err) + } + // Validate JSON + var js json.RawMessage + if err := json.Unmarshal(data, &js); err != nil { + return nil, fmt.Errorf("invalid JSON in file %s: %w", apiFile, err) + } + return data, nil + } + + return nil, nil +} + +// outputResponse outputs the API response in the requested format +func outputResponse(resp *lib.APIResponse, tableHeaders []string, tableExtractor func([]byte) [][]string) { + if !resp.IsSuccess() { + fatal(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError()), 1) + } + + var output string + if apiFormat == "table" && tableHeaders != nil && tableExtractor != nil { + output = formatAsTable(resp.Body, tableHeaders, tableExtractor) + } else if apiRaw { + output = string(resp.Body) + } else { + output = resp.FormatJSON() + } + + writeOutput(output) +} + +// formatAsTable formats the response as a table +func formatAsTable(data []byte, headers []string, extractor func([]byte) [][]string) string { + rows := extractor(data) + if rows == nil { + return string(data) + } + + var buf strings.Builder + table := tablewriter.NewWriter(&buf) + table.SetHeader(headers) + table.SetAutoWrapText(false) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.AppendBulk(rows) + table.Render() + + return buf.String() +} + +// writeOutput writes the output to stdout or a file +func writeOutput(output string) { + if apiOutput != "" { + if err := os.WriteFile(apiOutput, []byte(output), 0644); err != nil { + fatal(fmt.Sprintf("Failed to write to file %s: %s", apiOutput, err), 1) + } + fmt.Printf("Output written to %s\n", apiOutput) + } else { + fmt.Println(output) + } +} + +// readStdinIfEmpty reads from stdin if no data is provided +func readStdinIfEmpty() ([]byte, error) { + body, err := getRequestBody() + if err != nil { + return nil, err + } + + if body == nil { + // Check if stdin has data + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + body, err = io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("failed to read from stdin: %w", err) + } + // Validate JSON + var js json.RawMessage + if err := json.Unmarshal(body, &js); err != nil { + return nil, fmt.Errorf("invalid JSON from stdin: %w", err) + } + } + } + + return body, nil +} + +// buildQueryParams builds query parameters from common flags +func buildQueryParams() map[string]string { + params := make(map[string]string) + if apiPage > 1 { + params["page"] = strconv.Itoa(apiPage) + } + if apiEnv != "" { + params["env"] = apiEnv + } + if apiMonitor != "" { + params["monitor"] = apiMonitor + } + return params +} diff --git a/cmd/api_components.go b/cmd/api_components.go new file mode 100644 index 0000000..a244590 --- /dev/null +++ b/cmd/api_components.go @@ -0,0 +1,194 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" +) + +var componentStatuspage string + +var apiComponentsCmd = &cobra.Command{ + Use: "components [action] [key]", + Short: "Manage status page components", + Long: ` +Manage Cronitor status page components. + +Components are the building blocks of status pages. Each component represents +a monitor or group of monitors that feed into your status page. + +Actions: + list - List all components (default) + get - Get a specific component by key + create - Create a new component + update - Update an existing component + delete - Delete a component + +Examples: + List all components: + $ cronitor api components + + List components for a specific status page: + $ cronitor api components --statuspage + + Get a specific component: + $ cronitor api components get + + Create a component: + $ cronitor api components create --data '{"name":"API Server","statuspage":"my-status-page","monitor":"api-check"}' + + Update a component: + $ cronitor api components update --data '{"name":"Updated Name"}' + + Delete a component: + $ cronitor api components delete + + Output as table: + $ cronitor api components --format table +`, + Run: func(cmd *cobra.Command, args []string) { + action := "list" + var key string + + if len(args) > 0 { + action = args[0] + } + if len(args) > 1 { + key = args[1] + } + + client := getAPIClient() + + switch action { + case "list": + listComponents(client) + case "get": + if key == "" { + fatal("component key is required for get action", 1) + } + getComponent(client, key) + case "create": + createComponent(client) + case "update": + if key == "" { + fatal("component key is required for update action", 1) + } + updateComponent(client, key) + case "delete": + if key == "" { + fatal("component key is required for delete action", 1) + } + deleteComponent(client, key) + default: + // Treat first arg as a key for get if it doesn't match an action + getComponent(client, action) + } + }, +} + +func init() { + apiCmd.AddCommand(apiComponentsCmd) + apiComponentsCmd.Flags().StringVar(&componentStatuspage, "statuspage", "", "Filter by status page key") +} + +func listComponents(client *lib.APIClient) { + params := buildQueryParams() + if componentStatuspage != "" { + params["statuspage"] = componentStatuspage + } + + resp, err := client.GET("/statuspage_components", params) + if err != nil { + fatal(fmt.Sprintf("Failed to list components: %s", err), 1) + } + + outputResponse(resp, []string{"Key", "Name", "Status Page", "Monitor", "Status"}, + func(data []byte) [][]string { + var result struct { + Components []struct { + Key string `json:"key"` + Name string `json:"name"` + StatusPage string `json:"statuspage"` + Monitor string `json:"monitor"` + Status string `json:"status"` + } `json:"components"` + } + if err := json.Unmarshal(data, &result); err != nil { + return nil + } + + rows := make([][]string, len(result.Components)) + for i, c := range result.Components { + rows[i] = []string{c.Key, c.Name, c.StatusPage, c.Monitor, c.Status} + } + return rows + }) +} + +func getComponent(client *lib.APIClient, key string) { + resp, err := client.GET(fmt.Sprintf("/statuspage_components/%s", key), nil) + if err != nil { + fatal(fmt.Sprintf("Failed to get component: %s", err), 1) + } + + if resp.IsNotFound() { + fatal(fmt.Sprintf("Component '%s' could not be found", key), 1) + } + + outputResponse(resp, nil, nil) +} + +func createComponent(client *lib.APIClient) { + body, err := readStdinIfEmpty() + if err != nil { + fatal(err.Error(), 1) + } + + if body == nil { + fatal("request body is required for create action (use --data, --file, or pipe JSON to stdin)", 1) + } + + resp, err := client.POST("/statuspage_components", body, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to create component: %s", err), 1) + } + + outputResponse(resp, nil, nil) +} + +func updateComponent(client *lib.APIClient, key string) { + body, err := readStdinIfEmpty() + if err != nil { + fatal(err.Error(), 1) + } + + if body == nil { + fatal("request body is required for update action (use --data, --file, or pipe JSON to stdin)", 1) + } + + resp, err := client.PUT(fmt.Sprintf("/statuspage_components/%s", key), body, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to update component: %s", err), 1) + } + + outputResponse(resp, nil, nil) +} + +func deleteComponent(client *lib.APIClient, key string) { + resp, err := client.DELETE(fmt.Sprintf("/statuspage_components/%s", key), nil, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to delete component: %s", err), 1) + } + + if resp.IsNotFound() { + fatal(fmt.Sprintf("Component '%s' could not be found", key), 1) + } + + if resp.IsSuccess() { + fmt.Printf("Component '%s' deleted successfully\n", key) + } else { + outputResponse(resp, nil, nil) + } +} diff --git a/cmd/api_environments.go b/cmd/api_environments.go new file mode 100644 index 0000000..c8e6599 --- /dev/null +++ b/cmd/api_environments.go @@ -0,0 +1,188 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" +) + +var apiEnvironmentsCmd = &cobra.Command{ + Use: "environments [action] [key]", + Short: "Manage environments", + Long: ` +Manage Cronitor environments. + +Environments allow you to separate monitoring data between different +deployment stages (e.g., staging, production) while sharing monitor +configurations. + +Actions: + list - List all environments (default) + get - Get a specific environment by key + create - Create a new environment + update - Update an existing environment + delete - Delete an environment + +Examples: + List all environments: + $ cronitor api environments + + Get a specific environment: + $ cronitor api environments get + + Create an environment: + $ cronitor api environments create --data '{"key":"staging","name":"Staging"}' + + Update an environment: + $ cronitor api environments update --data '{"name":"Updated Name"}' + + Delete an environment: + $ cronitor api environments delete + + Output as table: + $ cronitor api environments --format table +`, + Run: func(cmd *cobra.Command, args []string) { + action := "list" + var key string + + if len(args) > 0 { + action = args[0] + } + if len(args) > 1 { + key = args[1] + } + + client := getAPIClient() + + switch action { + case "list": + listEnvironments(client) + case "get": + if key == "" { + fatal("environment key is required for get action", 1) + } + getEnvironment(client, key) + case "create": + createEnvironment(client) + case "update": + if key == "" { + fatal("environment key is required for update action", 1) + } + updateEnvironment(client, key) + case "delete": + if key == "" { + fatal("environment key is required for delete action", 1) + } + deleteEnvironment(client, key) + default: + // Treat first arg as a key for get if it doesn't match an action + getEnvironment(client, action) + } + }, +} + +func init() { + apiCmd.AddCommand(apiEnvironmentsCmd) +} + +func listEnvironments(client *lib.APIClient) { + params := buildQueryParams() + resp, err := client.GET("/environments", params) + if err != nil { + fatal(fmt.Sprintf("Failed to list environments: %s", err), 1) + } + + outputResponse(resp, []string{"Key", "Name", "Default", "Created"}, + func(data []byte) [][]string { + var result struct { + Environments []struct { + Key string `json:"key"` + Name string `json:"name"` + IsDefault bool `json:"is_default"` + CreatedAt string `json:"created_at"` + } `json:"environments"` + } + if err := json.Unmarshal(data, &result); err != nil { + return nil + } + + rows := make([][]string, len(result.Environments)) + for i, e := range result.Environments { + isDefault := "" + if e.IsDefault { + isDefault = "Yes" + } + rows[i] = []string{e.Key, e.Name, isDefault, e.CreatedAt} + } + return rows + }) +} + +func getEnvironment(client *lib.APIClient, key string) { + resp, err := client.GET(fmt.Sprintf("/environments/%s", key), nil) + if err != nil { + fatal(fmt.Sprintf("Failed to get environment: %s", err), 1) + } + + if resp.IsNotFound() { + fatal(fmt.Sprintf("Environment '%s' could not be found", key), 1) + } + + outputResponse(resp, nil, nil) +} + +func createEnvironment(client *lib.APIClient) { + body, err := readStdinIfEmpty() + if err != nil { + fatal(err.Error(), 1) + } + + if body == nil { + fatal("request body is required for create action (use --data, --file, or pipe JSON to stdin)", 1) + } + + resp, err := client.POST("/environments", body, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to create environment: %s", err), 1) + } + + outputResponse(resp, nil, nil) +} + +func updateEnvironment(client *lib.APIClient, key string) { + body, err := readStdinIfEmpty() + if err != nil { + fatal(err.Error(), 1) + } + + if body == nil { + fatal("request body is required for update action (use --data, --file, or pipe JSON to stdin)", 1) + } + + resp, err := client.PUT(fmt.Sprintf("/environments/%s", key), body, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to update environment: %s", err), 1) + } + + outputResponse(resp, nil, nil) +} + +func deleteEnvironment(client *lib.APIClient, key string) { + resp, err := client.DELETE(fmt.Sprintf("/environments/%s", key), nil, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to delete environment: %s", err), 1) + } + + if resp.IsNotFound() { + fatal(fmt.Sprintf("Environment '%s' could not be found", key), 1) + } + + if resp.IsSuccess() { + fmt.Printf("Environment '%s' deleted successfully\n", key) + } else { + outputResponse(resp, nil, nil) + } +} diff --git a/cmd/api_incidents.go b/cmd/api_incidents.go new file mode 100644 index 0000000..0bf972f --- /dev/null +++ b/cmd/api_incidents.go @@ -0,0 +1,212 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" +) + +var incidentStatuspage string + +var apiIncidentsCmd = &cobra.Command{ + Use: "incidents [action] [key]", + Short: "Manage status page incidents", + Long: ` +Manage Cronitor status page incidents. + +Incidents allow you to communicate about problems as they occur - either +privately with teammates or publicly on your status pages. Incidents can +be created automatically by monitor failures or manually for planned +maintenance. + +Actions: + list - List all incidents (default) + get - Get a specific incident by ID + create - Create a new incident + update - Update/add message to an existing incident + resolve - Resolve an incident + +Examples: + List all incidents: + $ cronitor api incidents + + List incidents for a specific status page: + $ cronitor api incidents --statuspage + + Get a specific incident: + $ cronitor api incidents get + + Create an incident: + $ cronitor api incidents create --data '{"title":"API Degradation","message":"Investigating elevated error rates","severity":"warning","statuspage":"my-status-page"}' + + Add an update to an incident: + $ cronitor api incidents update --data '{"message":"Root cause identified, deploying fix"}' + + Resolve an incident: + $ cronitor api incidents resolve --data '{"message":"Issue has been resolved"}' + + Output as table: + $ cronitor api incidents --format table +`, + Run: func(cmd *cobra.Command, args []string) { + action := "list" + var key string + + if len(args) > 0 { + action = args[0] + } + if len(args) > 1 { + key = args[1] + } + + client := getAPIClient() + + switch action { + case "list": + listIncidents(client) + case "get": + if key == "" { + fatal("incident ID is required for get action", 1) + } + getIncident(client, key) + case "create": + createIncident(client) + case "update": + if key == "" { + fatal("incident ID is required for update action", 1) + } + updateIncident(client, key) + case "resolve": + if key == "" { + fatal("incident ID is required for resolve action", 1) + } + resolveIncident(client, key) + default: + // Treat first arg as an ID for get if it doesn't match an action + getIncident(client, action) + } + }, +} + +func init() { + apiCmd.AddCommand(apiIncidentsCmd) + apiIncidentsCmd.Flags().StringVar(&incidentStatuspage, "statuspage", "", "Filter by status page key") +} + +func listIncidents(client *lib.APIClient) { + params := buildQueryParams() + if incidentStatuspage != "" { + params["statuspage"] = incidentStatuspage + } + + resp, err := client.GET("/incidents", params) + if err != nil { + fatal(fmt.Sprintf("Failed to list incidents: %s", err), 1) + } + + outputResponse(resp, []string{"ID", "Title", "Status", "Severity", "Status Page", "Created"}, + func(data []byte) [][]string { + var result struct { + Incidents []struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Severity string `json:"severity"` + StatusPage string `json:"statuspage"` + CreatedAt string `json:"created_at"` + } `json:"incidents"` + } + if err := json.Unmarshal(data, &result); err != nil { + return nil + } + + rows := make([][]string, len(result.Incidents)) + for i, inc := range result.Incidents { + rows[i] = []string{inc.ID, inc.Title, inc.Status, inc.Severity, inc.StatusPage, inc.CreatedAt} + } + return rows + }) +} + +func getIncident(client *lib.APIClient, id string) { + resp, err := client.GET(fmt.Sprintf("/incidents/%s", id), nil) + if err != nil { + fatal(fmt.Sprintf("Failed to get incident: %s", err), 1) + } + + if resp.IsNotFound() { + fatal(fmt.Sprintf("Incident '%s' could not be found", id), 1) + } + + outputResponse(resp, nil, nil) +} + +func createIncident(client *lib.APIClient) { + body, err := readStdinIfEmpty() + if err != nil { + fatal(err.Error(), 1) + } + + if body == nil { + fatal("request body is required for create action (use --data, --file, or pipe JSON to stdin)", 1) + } + + resp, err := client.POST("/incidents", body, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to create incident: %s", err), 1) + } + + outputResponse(resp, nil, nil) +} + +func updateIncident(client *lib.APIClient, id string) { + body, err := readStdinIfEmpty() + if err != nil { + fatal(err.Error(), 1) + } + + if body == nil { + fatal("request body is required for update action (use --data, --file, or pipe JSON to stdin)", 1) + } + + // Add update via the updates endpoint + resp, err := client.POST(fmt.Sprintf("/incidents/%s/updates", id), body, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to update incident: %s", err), 1) + } + + outputResponse(resp, nil, nil) +} + +func resolveIncident(client *lib.APIClient, id string) { + body, err := readStdinIfEmpty() + if err != nil { + fatal(err.Error(), 1) + } + + // Build resolve payload + var payload map[string]interface{} + if body != nil { + if err := json.Unmarshal(body, &payload); err != nil { + payload = make(map[string]interface{}) + } + } else { + payload = make(map[string]interface{}) + } + payload["status"] = "resolved" + + resolveBody, _ := json.Marshal(payload) + + resp, err := client.PUT(fmt.Sprintf("/incidents/%s", id), resolveBody, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to resolve incident: %s", err), 1) + } + + if resp.IsSuccess() { + fmt.Printf("Incident '%s' resolved successfully\n", id) + } else { + outputResponse(resp, nil, nil) + } +} diff --git a/cmd/api_issues.go b/cmd/api_issues.go new file mode 100644 index 0000000..0a565f3 --- /dev/null +++ b/cmd/api_issues.go @@ -0,0 +1,235 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" +) + +var issueState string +var issueSeverity string + +var apiIssuesCmd = &cobra.Command{ + Use: "issues [action] [key]", + Short: "Manage issues and incidents", + Long: ` +Manage Cronitor issues and incidents. + +Issues are Cronitor's incident management hub - they help your team coordinate +response when monitors fail. Issues automatically open when monitors fail and +close when they recover. + +Actions: + list - List all issues (default) + get - Get a specific issue by key + create - Create a new issue + update - Update an existing issue + delete - Delete an issue + bulk - Perform bulk operations on issues + +Examples: + List all issues: + $ cronitor api issues + + List open issues: + $ cronitor api issues --state open + + List issues filtered by severity: + $ cronitor api issues --severity critical + + Get a specific issue: + $ cronitor api issues get + + Create an issue: + $ cronitor api issues create --data '{"title":"Service Outage","severity":"critical","monitors":["web-api"]}' + + Update an issue: + $ cronitor api issues update --data '{"state":"resolved"}' + + Add an update to an issue: + $ cronitor api issues update --data '{"message":"Investigating the root cause"}' + + Delete an issue: + $ cronitor api issues delete + + Bulk resolve issues: + $ cronitor api issues bulk --data '{"action":"resolve","keys":["issue-1","issue-2"]}' + + Output as table: + $ cronitor api issues --format table +`, + Run: func(cmd *cobra.Command, args []string) { + action := "list" + var key string + + if len(args) > 0 { + action = args[0] + } + if len(args) > 1 { + key = args[1] + } + + client := getAPIClient() + + switch action { + case "list": + listIssues(client) + case "get": + if key == "" { + fatal("issue key is required for get action", 1) + } + getIssue(client, key) + case "create": + createIssue(client) + case "update": + if key == "" { + fatal("issue key is required for update action", 1) + } + updateIssue(client, key) + case "delete": + if key == "" { + fatal("issue key is required for delete action", 1) + } + deleteIssue(client, key) + case "bulk": + bulkIssues(client) + default: + // Treat first arg as a key for get if it doesn't match an action + getIssue(client, action) + } + }, +} + +func init() { + apiCmd.AddCommand(apiIssuesCmd) + apiIssuesCmd.Flags().StringVar(&issueState, "state", "", "Filter by state (open, resolved)") + apiIssuesCmd.Flags().StringVar(&issueSeverity, "severity", "", "Filter by severity (critical, warning, info)") +} + +func listIssues(client *lib.APIClient) { + params := buildQueryParams() + if issueState != "" { + params["state"] = issueState + } + if issueSeverity != "" { + params["severity"] = issueSeverity + } + + resp, err := client.GET("/issues", params) + if err != nil { + fatal(fmt.Sprintf("Failed to list issues: %s", err), 1) + } + + outputResponse(resp, []string{"Key", "Title", "State", "Severity", "Monitors", "Created"}, + func(data []byte) [][]string { + var result struct { + Issues []struct { + Key string `json:"key"` + Title string `json:"title"` + State string `json:"state"` + Severity string `json:"severity"` + Monitors []string `json:"monitors"` + CreatedAt string `json:"created_at"` + } `json:"issues"` + } + if err := json.Unmarshal(data, &result); err != nil { + return nil + } + + rows := make([][]string, len(result.Issues)) + for i, issue := range result.Issues { + monitors := "" + if len(issue.Monitors) > 0 { + monitors = fmt.Sprintf("%v", issue.Monitors) + } + rows[i] = []string{issue.Key, issue.Title, issue.State, issue.Severity, monitors, issue.CreatedAt} + } + return rows + }) +} + +func getIssue(client *lib.APIClient, key string) { + resp, err := client.GET(fmt.Sprintf("/issues/%s", key), nil) + if err != nil { + fatal(fmt.Sprintf("Failed to get issue: %s", err), 1) + } + + if resp.IsNotFound() { + fatal(fmt.Sprintf("Issue '%s' could not be found", key), 1) + } + + outputResponse(resp, nil, nil) +} + +func createIssue(client *lib.APIClient) { + body, err := readStdinIfEmpty() + if err != nil { + fatal(err.Error(), 1) + } + + if body == nil { + fatal("request body is required for create action (use --data, --file, or pipe JSON to stdin)", 1) + } + + resp, err := client.POST("/issues", body, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to create issue: %s", err), 1) + } + + outputResponse(resp, nil, nil) +} + +func updateIssue(client *lib.APIClient, key string) { + body, err := readStdinIfEmpty() + if err != nil { + fatal(err.Error(), 1) + } + + if body == nil { + fatal("request body is required for update action (use --data, --file, or pipe JSON to stdin)", 1) + } + + resp, err := client.PUT(fmt.Sprintf("/issues/%s", key), body, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to update issue: %s", err), 1) + } + + outputResponse(resp, nil, nil) +} + +func deleteIssue(client *lib.APIClient, key string) { + resp, err := client.DELETE(fmt.Sprintf("/issues/%s", key), nil, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to delete issue: %s", err), 1) + } + + if resp.IsNotFound() { + fatal(fmt.Sprintf("Issue '%s' could not be found", key), 1) + } + + if resp.IsSuccess() { + fmt.Printf("Issue '%s' deleted successfully\n", key) + } else { + outputResponse(resp, nil, nil) + } +} + +func bulkIssues(client *lib.APIClient) { + body, err := readStdinIfEmpty() + if err != nil { + fatal(err.Error(), 1) + } + + if body == nil { + fatal("request body is required for bulk action (use --data, --file, or pipe JSON to stdin)", 1) + } + + resp, err := client.POST("/issues/bulk", body, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to perform bulk operation: %s", err), 1) + } + + outputResponse(resp, nil, nil) +} diff --git a/cmd/api_metrics.go b/cmd/api_metrics.go new file mode 100644 index 0000000..c211d50 --- /dev/null +++ b/cmd/api_metrics.go @@ -0,0 +1,154 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" +) + +var metricsStart string +var metricsEnd string +var metricsGroup string +var metricsAggregates bool + +var apiMetricsCmd = &cobra.Command{ + Use: "metrics", + Short: "View monitor metrics and performance data", + Long: ` +View monitor metrics and performance data. + +The metrics API provides detailed time-series metrics data for your monitors, +including performance statistics, success rates, and execution counts. + +Examples: + Get metrics for all monitors: + $ cronitor api metrics + + Get metrics for a specific monitor: + $ cronitor api metrics --monitor + + Get metrics with time range: + $ cronitor api metrics --monitor --start 2024-01-01 --end 2024-01-31 + + Get metrics for a group: + $ cronitor api metrics --group + + Get aggregated statistics (summary without time-series): + $ cronitor api metrics --aggregates + + Get aggregates for a specific monitor: + $ cronitor api metrics --aggregates --monitor + + Filter by environment: + $ cronitor api metrics --monitor --env production + + Output as table: + $ cronitor api metrics --monitor --format table +`, + Run: func(cmd *cobra.Command, args []string) { + client := getAPIClient() + + if metricsAggregates { + getAggregates(client) + } else { + getMetrics(client) + } + }, +} + +func init() { + apiCmd.AddCommand(apiMetricsCmd) + apiMetricsCmd.Flags().StringVar(&metricsStart, "start", "", "Start date/time for metrics (e.g., 2024-01-01)") + apiMetricsCmd.Flags().StringVar(&metricsEnd, "end", "", "End date/time for metrics (e.g., 2024-01-31)") + apiMetricsCmd.Flags().StringVar(&metricsGroup, "group", "", "Filter by monitor group") + apiMetricsCmd.Flags().BoolVar(&metricsAggregates, "aggregates", false, "Get aggregated statistics instead of time-series") +} + +func getMetrics(client *lib.APIClient) { + params := buildQueryParams() + if metricsStart != "" { + params["start"] = metricsStart + } + if metricsEnd != "" { + params["end"] = metricsEnd + } + if metricsGroup != "" { + params["group"] = metricsGroup + } + + resp, err := client.GET("/metrics", params) + if err != nil { + fatal(fmt.Sprintf("Failed to get metrics: %s", err), 1) + } + + outputResponse(resp, []string{"Monitor", "Metric", "Value", "Timestamp"}, + func(data []byte) [][]string { + var result struct { + Metrics []struct { + Monitor string `json:"monitor"` + Metric string `json:"metric"` + Value float64 `json:"value"` + Timestamp string `json:"timestamp"` + } `json:"metrics"` + } + if err := json.Unmarshal(data, &result); err != nil { + return nil + } + + rows := make([][]string, len(result.Metrics)) + for i, m := range result.Metrics { + rows[i] = []string{m.Monitor, m.Metric, fmt.Sprintf("%.2f", m.Value), m.Timestamp} + } + return rows + }) +} + +func getAggregates(client *lib.APIClient) { + params := buildQueryParams() + if metricsStart != "" { + params["start"] = metricsStart + } + if metricsEnd != "" { + params["end"] = metricsEnd + } + if metricsGroup != "" { + params["group"] = metricsGroup + } + + resp, err := client.GET("/aggregates", params) + if err != nil { + fatal(fmt.Sprintf("Failed to get aggregates: %s", err), 1) + } + + outputResponse(resp, []string{"Monitor", "Total Runs", "Successes", "Failures", "Avg Duration", "Success Rate"}, + func(data []byte) [][]string { + var result struct { + Aggregates []struct { + Monitor string `json:"monitor"` + TotalRuns int `json:"total_runs"` + Successes int `json:"successes"` + Failures int `json:"failures"` + AvgDuration float64 `json:"avg_duration"` + SuccessRate float64 `json:"success_rate"` + } `json:"aggregates"` + } + if err := json.Unmarshal(data, &result); err != nil { + return nil + } + + rows := make([][]string, len(result.Aggregates)) + for i, a := range result.Aggregates { + rows[i] = []string{ + a.Monitor, + fmt.Sprintf("%d", a.TotalRuns), + fmt.Sprintf("%d", a.Successes), + fmt.Sprintf("%d", a.Failures), + fmt.Sprintf("%.2fs", a.AvgDuration), + fmt.Sprintf("%.1f%%", a.SuccessRate*100), + } + } + return rows + }) +} diff --git a/cmd/api_monitors.go b/cmd/api_monitors.go new file mode 100644 index 0000000..22d7e19 --- /dev/null +++ b/cmd/api_monitors.go @@ -0,0 +1,323 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" +) + +var pauseHours string +var withLatestEvents bool + +var apiMonitorsCmd = &cobra.Command{ + Use: "monitors [action] [key]", + Short: "Manage monitors", + Long: ` +Manage Cronitor monitors (jobs, checks, heartbeats, sites). + +Actions: + list - List all monitors (default) + get - Get a specific monitor by key + create - Create a new monitor + update - Update an existing monitor + delete - Delete one or more monitors + pause - Pause a monitor (stop alerting) + unpause - Unpause a monitor (resume alerting) + +Examples: + List all monitors: + $ cronitor api monitors + + List monitors with pagination: + $ cronitor api monitors --page 2 + + Get a specific monitor: + $ cronitor api monitors get my-job-key + + Create a monitor from JSON data: + $ cronitor api monitors create --data '{"key":"my-job","type":"job","schedule":"0 * * * *"}' + + Create a monitor from a file: + $ cronitor api monitors create --file monitor.json + + Update a monitor: + $ cronitor api monitors update my-job-key --data '{"name":"Updated Name"}' + + Delete a monitor: + $ cronitor api monitors delete my-job-key + + Delete multiple monitors: + $ cronitor api monitors delete --data '["key1","key2","key3"]' + + Pause a monitor for 24 hours: + $ cronitor api monitors pause my-job-key --hours 24 + + Pause a monitor indefinitely: + $ cronitor api monitors pause my-job-key + + Unpause a monitor: + $ cronitor api monitors unpause my-job-key + + Output as table: + $ cronitor api monitors --format table + + Get a monitor with latest events: + $ cronitor api monitors get my-job-key --with-events +`, + Run: func(cmd *cobra.Command, args []string) { + action := "list" + var key string + + if len(args) > 0 { + action = args[0] + } + if len(args) > 1 { + key = args[1] + } + + client := getAPIClient() + + switch action { + case "list": + listMonitors(client) + case "get": + if key == "" { + fatal("monitor key is required for get action", 1) + } + getMonitor(client, key) + case "create": + createMonitor(client) + case "update": + if key == "" { + fatal("monitor key is required for update action", 1) + } + updateMonitor(client, key) + case "delete": + deleteMonitors(client, key) + case "pause": + if key == "" { + fatal("monitor key is required for pause action", 1) + } + pauseMonitor(client, key) + case "unpause": + if key == "" { + fatal("monitor key is required for unpause action", 1) + } + unpauseMonitor(client, key) + default: + // Treat first arg as a key for get if it doesn't match an action + getMonitor(client, action) + } + }, +} + +func init() { + apiCmd.AddCommand(apiMonitorsCmd) + apiMonitorsCmd.Flags().StringVar(&pauseHours, "hours", "", "Number of hours to pause (for pause action)") + apiMonitorsCmd.Flags().BoolVar(&withLatestEvents, "with-events", false, "Include latest events in monitor response") +} + +func listMonitors(client *lib.APIClient) { + params := buildQueryParams() + resp, err := client.GET("/monitors", params) + if err != nil { + fatal(fmt.Sprintf("Failed to list monitors: %s", err), 1) + } + + outputResponse(resp, []string{"Key", "Name", "Type", "Status", "Alerts"}, + func(data []byte) [][]string { + var result struct { + Monitors []struct { + Key string `json:"key"` + Name string `json:"name"` + Type string `json:"type"` + Passing bool `json:"passing"` + Paused bool `json:"paused"` + } `json:"monitors"` + } + if err := json.Unmarshal(data, &result); err != nil { + return nil + } + + rows := make([][]string, len(result.Monitors)) + for i, m := range result.Monitors { + status := "Passing" + if !m.Passing { + status = "Failing" + } + alerts := "On" + if m.Paused { + alerts = "Muted" + } + name := m.Name + if name == "" { + name = m.Key + } + rows[i] = []string{m.Key, name, m.Type, status, alerts} + } + return rows + }) +} + +func getMonitor(client *lib.APIClient, key string) { + params := buildQueryParams() + if withLatestEvents { + params["withLatestEvents"] = "true" + } + resp, err := client.GET(fmt.Sprintf("/monitors/%s", key), params) + if err != nil { + fatal(fmt.Sprintf("Failed to get monitor: %s", err), 1) + } + + if resp.IsNotFound() { + fatal(fmt.Sprintf("Monitor '%s' could not be found", key), 1) + } + + outputResponse(resp, nil, nil) +} + +func createMonitor(client *lib.APIClient) { + body, err := readStdinIfEmpty() + if err != nil { + fatal(err.Error(), 1) + } + + if body == nil { + fatal("request body is required for create action (use --data, --file, or pipe JSON to stdin)", 1) + } + + // Check if it's an array (bulk create) or single object + var testArray []json.RawMessage + isBulk := json.Unmarshal(body, &testArray) == nil && len(testArray) > 0 + + var resp *lib.APIResponse + if isBulk { + // Bulk create uses PUT + resp, err = client.PUT("/monitors", body, nil) + } else { + // Single create uses POST + resp, err = client.POST("/monitors", body, nil) + } + + if err != nil { + fatal(fmt.Sprintf("Failed to create monitor(s): %s", err), 1) + } + + outputResponse(resp, nil, nil) +} + +func updateMonitor(client *lib.APIClient, key string) { + body, err := readStdinIfEmpty() + if err != nil { + fatal(err.Error(), 1) + } + + if body == nil { + fatal("request body is required for update action (use --data, --file, or pipe JSON to stdin)", 1) + } + + // Ensure key is in the body + var bodyMap map[string]interface{} + if err := json.Unmarshal(body, &bodyMap); err != nil { + fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) + } + bodyMap["key"] = key + body, _ = json.Marshal(bodyMap) + + // Wrap in array for PUT endpoint + body = []byte(fmt.Sprintf("[%s]", string(body))) + + resp, err := client.PUT("/monitors", body, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to update monitor: %s", err), 1) + } + + outputResponse(resp, nil, nil) +} + +func deleteMonitors(client *lib.APIClient, key string) { + var resp *lib.APIResponse + var err error + + if key != "" { + // Single delete + resp, err = client.DELETE(fmt.Sprintf("/monitors/%s", key), nil, nil) + } else { + // Bulk delete requires body + body, bodyErr := readStdinIfEmpty() + if bodyErr != nil { + fatal(bodyErr.Error(), 1) + } + + if body == nil { + fatal("monitor key or JSON array of keys is required for delete action", 1) + } + + resp, err = client.DELETE("/monitors", body, nil) + } + + if err != nil { + fatal(fmt.Sprintf("Failed to delete monitor(s): %s", err), 1) + } + + if resp.IsNotFound() { + fatal(fmt.Sprintf("Monitor '%s' could not be found", key), 1) + } + + if resp.IsSuccess() { + if key != "" { + fmt.Printf("Monitor '%s' deleted successfully\n", key) + } else { + fmt.Println("Monitors deleted successfully") + } + } else { + outputResponse(resp, nil, nil) + } +} + +func pauseMonitor(client *lib.APIClient, key string) { + endpoint := fmt.Sprintf("/monitors/%s/pause", key) + if pauseHours != "" { + endpoint = fmt.Sprintf("%s/%s", endpoint, pauseHours) + } + + resp, err := client.GET(endpoint, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to pause monitor: %s", err), 1) + } + + if resp.IsNotFound() { + fatal(fmt.Sprintf("Monitor '%s' could not be found", key), 1) + } + + if resp.IsSuccess() { + if pauseHours != "" { + fmt.Printf("Monitor '%s' paused for %s hours\n", key, pauseHours) + } else { + fmt.Printf("Monitor '%s' paused indefinitely\n", key) + } + } else { + outputResponse(resp, nil, nil) + } +} + +func unpauseMonitor(client *lib.APIClient, key string) { + endpoint := fmt.Sprintf("/monitors/%s/pause/0", key) + + resp, err := client.GET(endpoint, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to unpause monitor: %s", err), 1) + } + + if resp.IsNotFound() { + fatal(fmt.Sprintf("Monitor '%s' could not be found", key), 1) + } + + if resp.IsSuccess() { + fmt.Printf("Monitor '%s' unpaused\n", key) + } else { + outputResponse(resp, nil, nil) + } +} diff --git a/cmd/api_notifications.go b/cmd/api_notifications.go new file mode 100644 index 0000000..07844be --- /dev/null +++ b/cmd/api_notifications.go @@ -0,0 +1,192 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" +) + +var apiNotificationsCmd = &cobra.Command{ + Use: "notifications [action] [key]", + Short: "Manage notification lists", + Long: ` +Manage Cronitor notification lists. + +Notification lists define where alerts are sent when monitors detect issues. +Each list can contain multiple notification channels like email, Slack, +PagerDuty, webhooks, and more. + +Actions: + list - List all notification lists (default) + get - Get a specific notification list by key + create - Create a new notification list + update - Update an existing notification list + delete - Delete a notification list + +Examples: + List all notification lists: + $ cronitor api notifications + + Get a specific notification list: + $ cronitor api notifications get + + Create a notification list: + $ cronitor api notifications create --data '{"key":"ops-team","name":"Ops Team","templates":["email:ops@company.com","slack:#alerts"]}' + + Update a notification list: + $ cronitor api notifications update --data '{"name":"Updated Name"}' + + Delete a notification list: + $ cronitor api notifications delete + + Output as table: + $ cronitor api notifications --format table +`, + Run: func(cmd *cobra.Command, args []string) { + action := "list" + var key string + + if len(args) > 0 { + action = args[0] + } + if len(args) > 1 { + key = args[1] + } + + client := getAPIClient() + + switch action { + case "list": + listNotifications(client) + case "get": + if key == "" { + fatal("notification list key is required for get action", 1) + } + getNotification(client, key) + case "create": + createNotification(client) + case "update": + if key == "" { + fatal("notification list key is required for update action", 1) + } + updateNotification(client, key) + case "delete": + if key == "" { + fatal("notification list key is required for delete action", 1) + } + deleteNotification(client, key) + default: + // Treat first arg as a key for get if it doesn't match an action + getNotification(client, action) + } + }, +} + +func init() { + apiCmd.AddCommand(apiNotificationsCmd) +} + +func listNotifications(client *lib.APIClient) { + params := buildQueryParams() + resp, err := client.GET("/notification-lists", params) + if err != nil { + fatal(fmt.Sprintf("Failed to list notification lists: %s", err), 1) + } + + outputResponse(resp, []string{"Key", "Name", "Channels", "Environments"}, + func(data []byte) [][]string { + var result struct { + NotificationLists []struct { + Key string `json:"key"` + Name string `json:"name"` + Templates []string `json:"templates"` + Environments []string `json:"environments"` + } `json:"notification_lists"` + } + if err := json.Unmarshal(data, &result); err != nil { + return nil + } + + rows := make([][]string, len(result.NotificationLists)) + for i, n := range result.NotificationLists { + channels := fmt.Sprintf("%d channels", len(n.Templates)) + if len(n.Templates) <= 3 { + channels = fmt.Sprintf("%v", n.Templates) + } + envs := "all" + if len(n.Environments) > 0 { + envs = fmt.Sprintf("%v", n.Environments) + } + rows[i] = []string{n.Key, n.Name, channels, envs} + } + return rows + }) +} + +func getNotification(client *lib.APIClient, key string) { + resp, err := client.GET(fmt.Sprintf("/notification-lists/%s", key), nil) + if err != nil { + fatal(fmt.Sprintf("Failed to get notification list: %s", err), 1) + } + + if resp.IsNotFound() { + fatal(fmt.Sprintf("Notification list '%s' could not be found", key), 1) + } + + outputResponse(resp, nil, nil) +} + +func createNotification(client *lib.APIClient) { + body, err := readStdinIfEmpty() + if err != nil { + fatal(err.Error(), 1) + } + + if body == nil { + fatal("request body is required for create action (use --data, --file, or pipe JSON to stdin)", 1) + } + + resp, err := client.POST("/notification-lists", body, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to create notification list: %s", err), 1) + } + + outputResponse(resp, nil, nil) +} + +func updateNotification(client *lib.APIClient, key string) { + body, err := readStdinIfEmpty() + if err != nil { + fatal(err.Error(), 1) + } + + if body == nil { + fatal("request body is required for update action (use --data, --file, or pipe JSON to stdin)", 1) + } + + resp, err := client.PUT(fmt.Sprintf("/notification-lists/%s", key), body, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to update notification list: %s", err), 1) + } + + outputResponse(resp, nil, nil) +} + +func deleteNotification(client *lib.APIClient, key string) { + resp, err := client.DELETE(fmt.Sprintf("/notification-lists/%s", key), nil, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to delete notification list: %s", err), 1) + } + + if resp.IsNotFound() { + fatal(fmt.Sprintf("Notification list '%s' could not be found", key), 1) + } + + if resp.IsSuccess() { + fmt.Printf("Notification list '%s' deleted successfully\n", key) + } else { + outputResponse(resp, nil, nil) + } +} diff --git a/cmd/api_statuspages.go b/cmd/api_statuspages.go new file mode 100644 index 0000000..5eb350c --- /dev/null +++ b/cmd/api_statuspages.go @@ -0,0 +1,185 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" +) + +var apiStatuspagesCmd = &cobra.Command{ + Use: "statuspages [action] [key]", + Short: "Manage status pages", + Long: ` +Manage Cronitor status pages. + +Status pages turn your Cronitor monitoring data into public (or private) +communication. Your monitors feed directly into status components, creating +a real-time view of your system health. + +Actions: + list - List all status pages (default) + get - Get a specific status page by key + create - Create a new status page + update - Update an existing status page + delete - Delete a status page + +Examples: + List all status pages: + $ cronitor api statuspages + + Get a specific status page: + $ cronitor api statuspages get + + Create a status page: + $ cronitor api statuspages create --data '{"name":"API Status","hosted_subdomain":"api-status"}' + + Update a status page: + $ cronitor api statuspages update --data '{"name":"Updated Status Page"}' + + Delete a status page: + $ cronitor api statuspages delete + + Output as table: + $ cronitor api statuspages --format table +`, + Run: func(cmd *cobra.Command, args []string) { + action := "list" + var key string + + if len(args) > 0 { + action = args[0] + } + if len(args) > 1 { + key = args[1] + } + + client := getAPIClient() + + switch action { + case "list": + listStatuspages(client) + case "get": + if key == "" { + fatal("status page key is required for get action", 1) + } + getStatuspage(client, key) + case "create": + createStatuspage(client) + case "update": + if key == "" { + fatal("status page key is required for update action", 1) + } + updateStatuspage(client, key) + case "delete": + if key == "" { + fatal("status page key is required for delete action", 1) + } + deleteStatuspage(client, key) + default: + // Treat first arg as a key for get if it doesn't match an action + getStatuspage(client, action) + } + }, +} + +func init() { + apiCmd.AddCommand(apiStatuspagesCmd) +} + +func listStatuspages(client *lib.APIClient) { + params := buildQueryParams() + resp, err := client.GET("/statuspages", params) + if err != nil { + fatal(fmt.Sprintf("Failed to list status pages: %s", err), 1) + } + + outputResponse(resp, []string{"Key", "Name", "Subdomain", "Status", "Environment"}, + func(data []byte) [][]string { + var result struct { + StatusPages []struct { + Key string `json:"key"` + Name string `json:"name"` + HostedSubdomain string `json:"hosted_subdomain"` + Status string `json:"status"` + Environment string `json:"environment"` + } `json:"statuspages"` + } + if err := json.Unmarshal(data, &result); err != nil { + return nil + } + + rows := make([][]string, len(result.StatusPages)) + for i, sp := range result.StatusPages { + rows[i] = []string{sp.Key, sp.Name, sp.HostedSubdomain, sp.Status, sp.Environment} + } + return rows + }) +} + +func getStatuspage(client *lib.APIClient, key string) { + resp, err := client.GET(fmt.Sprintf("/statuspages/%s", key), nil) + if err != nil { + fatal(fmt.Sprintf("Failed to get status page: %s", err), 1) + } + + if resp.IsNotFound() { + fatal(fmt.Sprintf("Status page '%s' could not be found", key), 1) + } + + outputResponse(resp, nil, nil) +} + +func createStatuspage(client *lib.APIClient) { + body, err := readStdinIfEmpty() + if err != nil { + fatal(err.Error(), 1) + } + + if body == nil { + fatal("request body is required for create action (use --data, --file, or pipe JSON to stdin)", 1) + } + + resp, err := client.POST("/statuspages", body, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to create status page: %s", err), 1) + } + + outputResponse(resp, nil, nil) +} + +func updateStatuspage(client *lib.APIClient, key string) { + body, err := readStdinIfEmpty() + if err != nil { + fatal(err.Error(), 1) + } + + if body == nil { + fatal("request body is required for update action (use --data, --file, or pipe JSON to stdin)", 1) + } + + resp, err := client.PUT(fmt.Sprintf("/statuspages/%s", key), body, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to update status page: %s", err), 1) + } + + outputResponse(resp, nil, nil) +} + +func deleteStatuspage(client *lib.APIClient, key string) { + resp, err := client.DELETE(fmt.Sprintf("/statuspages/%s", key), nil, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to delete status page: %s", err), 1) + } + + if resp.IsNotFound() { + fatal(fmt.Sprintf("Status page '%s' could not be found", key), 1) + } + + if resp.IsSuccess() { + fmt.Printf("Status page '%s' deleted successfully\n", key) + } else { + outputResponse(resp, nil, nil) + } +} diff --git a/lib/api_client.go b/lib/api_client.go new file mode 100644 index 0000000..b2baf32 --- /dev/null +++ b/lib/api_client.go @@ -0,0 +1,201 @@ +package lib + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/spf13/viper" +) + +// APIClient provides a generic interface for Cronitor API operations +type APIClient struct { + BaseURL string + ApiKey string + UserAgent string + IsDev bool + Logger func(string) +} + +// APIResponse wraps the raw response with metadata +type APIResponse struct { + StatusCode int + Body []byte + Headers http.Header +} + +// PaginatedResponse represents a paginated API response +type PaginatedResponse struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalCount int `json:"total_count"` + Data json.RawMessage `json:"data"` +} + +// NewAPIClient creates a new API client with the given configuration +func NewAPIClient(isDev bool, logger func(string)) *APIClient { + baseURL := "https://cronitor.io/api" + if isDev { + baseURL = "http://dev.cronitor.io/api" + } + + return &APIClient{ + BaseURL: baseURL, + ApiKey: viper.GetString("CRONITOR_API_KEY"), + UserAgent: "CronitorCLI", + IsDev: isDev, + Logger: logger, + } +} + +// Request makes a generic API request +func (c *APIClient) Request(method, endpoint string, body []byte, queryParams map[string]string) (*APIResponse, error) { + // Build URL with query parameters + reqURL := fmt.Sprintf("%s%s", c.BaseURL, endpoint) + if len(queryParams) > 0 { + params := url.Values{} + for k, v := range queryParams { + if v != "" { + params.Add(k, v) + } + } + if encoded := params.Encode(); encoded != "" { + reqURL = fmt.Sprintf("%s?%s", reqURL, encoded) + } + } + + c.log(fmt.Sprintf("API Request: %s %s", method, reqURL)) + + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + c.log(fmt.Sprintf("Request Body: %s", string(body))) + } + + req, err := http.NewRequest(method, reqURL, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set authentication + apiKey := viper.GetString("CRONITOR_API_KEY") + if apiKey == "" { + apiKey = c.ApiKey + } + req.SetBasicAuth(apiKey, "") + + // Set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", c.UserAgent) + req.Header.Set("Cronitor-Version", "2025-11-28") + + client := &http.Client{ + Timeout: 120 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + c.log(fmt.Sprintf("Response Status: %d", resp.StatusCode)) + c.log(fmt.Sprintf("Response Body: %s", string(respBody))) + + return &APIResponse{ + StatusCode: resp.StatusCode, + Body: respBody, + Headers: resp.Header, + }, nil +} + +// GET makes a GET request +func (c *APIClient) GET(endpoint string, queryParams map[string]string) (*APIResponse, error) { + return c.Request("GET", endpoint, nil, queryParams) +} + +// POST makes a POST request +func (c *APIClient) POST(endpoint string, body []byte, queryParams map[string]string) (*APIResponse, error) { + return c.Request("POST", endpoint, body, queryParams) +} + +// PUT makes a PUT request +func (c *APIClient) PUT(endpoint string, body []byte, queryParams map[string]string) (*APIResponse, error) { + return c.Request("PUT", endpoint, body, queryParams) +} + +// DELETE makes a DELETE request +func (c *APIClient) DELETE(endpoint string, body []byte, queryParams map[string]string) (*APIResponse, error) { + return c.Request("DELETE", endpoint, body, queryParams) +} + +// PATCH makes a PATCH request +func (c *APIClient) PATCH(endpoint string, body []byte, queryParams map[string]string) (*APIResponse, error) { + return c.Request("PATCH", endpoint, body, queryParams) +} + +func (c *APIClient) log(msg string) { + if c.Logger != nil { + c.Logger(msg) + } +} + +// IsSuccess returns true if the status code indicates success +func (r *APIResponse) IsSuccess() bool { + return r.StatusCode >= 200 && r.StatusCode < 300 +} + +// IsNotFound returns true if the status code is 404 +func (r *APIResponse) IsNotFound() bool { + return r.StatusCode == 404 +} + +// FormatJSON pretty-prints the response body as JSON +func (r *APIResponse) FormatJSON() string { + var buf bytes.Buffer + if err := json.Indent(&buf, r.Body, "", " "); err != nil { + return string(r.Body) + } + return buf.String() +} + +// ParseError attempts to extract an error message from the response +func (r *APIResponse) ParseError() string { + // Try to parse as JSON error + var errResp struct { + Error string `json:"error"` + Message string `json:"message"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + + if err := json.Unmarshal(r.Body, &errResp); err == nil { + if errResp.Error != "" { + return errResp.Error + } + if errResp.Message != "" { + return errResp.Message + } + if len(errResp.Errors) > 0 { + var messages []string + for _, e := range errResp.Errors { + messages = append(messages, e.Message) + } + return strings.Join(messages, "; ") + } + } + + // Fall back to raw body + return string(r.Body) +} diff --git a/tests/test-api.bats b/tests/test-api.bats new file mode 100644 index 0000000..17e10aa --- /dev/null +++ b/tests/test-api.bats @@ -0,0 +1,365 @@ +#!/usr/bin/env bats + +setup() { + SCRIPT_DIR="$(dirname $BATS_TEST_FILENAME)" + cd $SCRIPT_DIR + + rm -f $CLI_LOGFILE +} + +################# +# API COMMAND TESTS +################# + +@test "API command shows help" { + ../cronitor api --help | grep -q "Interact with the Cronitor API" +} + +@test "API command lists available subcommands" { + ../cronitor api --help | grep -q "monitors" + ../cronitor api --help | grep -q "issues" + ../cronitor api --help | grep -q "statuspages" + ../cronitor api --help | grep -q "components" + ../cronitor api --help | grep -q "incidents" + ../cronitor api --help | grep -q "metrics" + ../cronitor api --help | grep -q "notifications" + ../cronitor api --help | grep -q "environments" +} + +@test "API command requires API key" { + # When no API key is configured, the command should fail + run ../cronitor api monitors 2>&1 + [ "$status" -eq 1 ] +} + +################# +# MONITORS SUBCOMMAND TESTS +################# + +@test "API monitors shows help" { + ../cronitor api monitors --help | grep -q "Manage Cronitor monitors" +} + +@test "API monitors help shows all actions" { + ../cronitor api monitors --help | grep -q "list" + ../cronitor api monitors --help | grep -q "get" + ../cronitor api monitors --help | grep -q "create" + ../cronitor api monitors --help | grep -q "update" + ../cronitor api monitors --help | grep -q "delete" + ../cronitor api monitors --help | grep -q "pause" + ../cronitor api monitors --help | grep -q "unpause" +} + +@test "API monitors has --hours flag for pause" { + ../cronitor api monitors --help | grep -q "\-\-hours" +} + +@test "API monitors has --with-events flag" { + ../cronitor api monitors --help | grep -q "\-\-with-events" +} + +@test "API monitors get requires key" { + run ../cronitor api monitors get -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"key is required"* ]] +} + +@test "API monitors update requires key" { + run ../cronitor api monitors update -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"key is required"* ]] +} + +@test "API monitors pause requires key" { + run ../cronitor api monitors pause -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"key is required"* ]] +} + +@test "API monitors create requires body" { + run ../cronitor api monitors create -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"request body is required"* ]] +} + +################# +# ISSUES SUBCOMMAND TESTS +################# + +@test "API issues shows help" { + ../cronitor api issues --help | grep -q "Manage Cronitor issues" +} + +@test "API issues help shows all actions" { + ../cronitor api issues --help | grep -q "list" + ../cronitor api issues --help | grep -q "get" + ../cronitor api issues --help | grep -q "create" + ../cronitor api issues --help | grep -q "update" + ../cronitor api issues --help | grep -q "delete" + ../cronitor api issues --help | grep -q "bulk" +} + +@test "API issues has --state flag" { + ../cronitor api issues --help | grep -q "\-\-state" +} + +@test "API issues has --severity flag" { + ../cronitor api issues --help | grep -q "\-\-severity" +} + +@test "API issues get requires key" { + run ../cronitor api issues get -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"key is required"* ]] +} + +@test "API issues create requires body" { + run ../cronitor api issues create -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"request body is required"* ]] +} + +################# +# STATUSPAGES SUBCOMMAND TESTS +################# + +@test "API statuspages shows help" { + ../cronitor api statuspages --help | grep -q "Manage Cronitor status pages" +} + +@test "API statuspages help shows all actions" { + ../cronitor api statuspages --help | grep -q "list" + ../cronitor api statuspages --help | grep -q "get" + ../cronitor api statuspages --help | grep -q "create" + ../cronitor api statuspages --help | grep -q "update" + ../cronitor api statuspages --help | grep -q "delete" +} + +@test "API statuspages get requires key" { + run ../cronitor api statuspages get -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"key is required"* ]] +} + +@test "API statuspages create requires body" { + run ../cronitor api statuspages create -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"request body is required"* ]] +} + +################# +# COMPONENTS SUBCOMMAND TESTS +################# + +@test "API components shows help" { + ../cronitor api components --help | grep -qi "status page components" +} + +@test "API components help shows all actions" { + ../cronitor api components --help | grep -q "list" + ../cronitor api components --help | grep -q "get" + ../cronitor api components --help | grep -q "create" + ../cronitor api components --help | grep -q "update" + ../cronitor api components --help | grep -q "delete" +} + +@test "API components has --statuspage flag" { + ../cronitor api components --help | grep -q "\-\-statuspage" +} + +@test "API components get requires key" { + run ../cronitor api components get -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"key is required"* ]] +} + +################# +# INCIDENTS SUBCOMMAND TESTS +################# + +@test "API incidents shows help" { + ../cronitor api incidents --help | grep -qi "status page incidents" +} + +@test "API incidents help shows all actions" { + ../cronitor api incidents --help | grep -q "list" + ../cronitor api incidents --help | grep -q "get" + ../cronitor api incidents --help | grep -q "create" + ../cronitor api incidents --help | grep -q "update" + ../cronitor api incidents --help | grep -q "resolve" +} + +@test "API incidents has --statuspage flag" { + ../cronitor api incidents --help | grep -q "\-\-statuspage" +} + +@test "API incidents get requires key" { + run ../cronitor api incidents get -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"ID is required"* ]] +} + +@test "API incidents resolve requires key" { + run ../cronitor api incidents resolve -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"ID is required"* ]] +} + +################# +# METRICS SUBCOMMAND TESTS +################# + +@test "API metrics shows help" { + ../cronitor api metrics --help | grep -q "View monitor metrics" +} + +@test "API metrics has time range flags" { + ../cronitor api metrics --help | grep -q "\-\-start" + ../cronitor api metrics --help | grep -q "\-\-end" +} + +@test "API metrics has --aggregates flag" { + ../cronitor api metrics --help | grep -q "\-\-aggregates" +} + +@test "API metrics has --group flag" { + ../cronitor api metrics --help | grep -q "\-\-group" +} + +################# +# NOTIFICATIONS SUBCOMMAND TESTS +################# + +@test "API notifications shows help" { + ../cronitor api notifications --help | grep -qi "notification lists" +} + +@test "API notifications help shows all actions" { + ../cronitor api notifications --help | grep -q "list" + ../cronitor api notifications --help | grep -q "get" + ../cronitor api notifications --help | grep -q "create" + ../cronitor api notifications --help | grep -q "update" + ../cronitor api notifications --help | grep -q "delete" +} + +@test "API notifications get requires key" { + run ../cronitor api notifications get -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"key is required"* ]] +} + +@test "API notifications create requires body" { + run ../cronitor api notifications create -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"request body is required"* ]] +} + +################# +# ENVIRONMENTS SUBCOMMAND TESTS +################# + +@test "API environments shows help" { + ../cronitor api environments --help | grep -qi "environments" +} + +@test "API environments help shows all actions" { + ../cronitor api environments --help | grep -q "list" + ../cronitor api environments --help | grep -q "get" + ../cronitor api environments --help | grep -q "create" + ../cronitor api environments --help | grep -q "update" + ../cronitor api environments --help | grep -q "delete" +} + +@test "API environments get requires key" { + run ../cronitor api environments get -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"key is required"* ]] +} + +@test "API environments create requires body" { + run ../cronitor api environments create -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"request body is required"* ]] +} + +################# +# GLOBAL FLAGS TESTS +################# + +@test "API command has --data flag" { + ../cronitor api --help | grep -q "\-d, \-\-data" +} + +@test "API command has --file flag" { + ../cronitor api --help | grep -q "\-f, \-\-file" +} + +@test "API command has --format flag" { + ../cronitor api --help | grep -q "\-\-format" +} + +@test "API command has --page flag" { + ../cronitor api --help | grep -q "\-\-page" +} + +@test "API command has --output flag" { + ../cronitor api --help | grep -q "\-o, \-\-output" +} + +@test "API command has --raw flag" { + ../cronitor api --help | grep -q "\-\-raw" +} + +@test "API command has --env global flag" { + ../cronitor api --help | grep -q "\-\-env" +} + +################# +# JSON VALIDATION TESTS +################# + +@test "API monitors create rejects invalid JSON" { + run ../cronitor api monitors create -k test-api-key --data 'not valid json' 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"invalid JSON"* ]] +} + +@test "API issues create rejects invalid JSON" { + run ../cronitor api issues create -k test-api-key --data '{broken' 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"invalid JSON"* ]] +} + +################# +# INTEGRATION TESTS (SKIPPED BY DEFAULT) +################# + +@test "API monitors list integration test" { + skip "Integration test requires valid API key" + # ../cronitor api monitors -k $CRONITOR_API_KEY +} + +@test "API monitors get integration test" { + skip "Integration test requires valid API key and existing monitor" + # ../cronitor api monitors get test-monitor -k $CRONITOR_API_KEY +} + +@test "API monitors create integration test" { + skip "Integration test requires valid API key" + # ../cronitor api monitors create -k $CRONITOR_API_KEY --data '{"key":"test-cli-monitor","type":"job"}' +} + +@test "API issues list integration test" { + skip "Integration test requires valid API key" + # ../cronitor api issues -k $CRONITOR_API_KEY +} + +@test "API statuspages list integration test" { + skip "Integration test requires valid API key" + # ../cronitor api statuspages -k $CRONITOR_API_KEY +} + +@test "API metrics integration test" { + skip "Integration test requires valid API key" + # ../cronitor api metrics -k $CRONITOR_API_KEY +} From 52574c8f730dcf6f5a2e3b500c5e327a3ddf4cbf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 21:14:12 +0000 Subject: [PATCH 02/25] API - Refactor to cleaner flag-based pattern Replace action-word subcommands (list, get, create, update, delete) with a cleaner flag-based pattern inspired by Vercel and Sentry CLIs: - `cronitor api monitors` lists all monitors - `cronitor api monitors ` gets a specific monitor - `cronitor api monitors --new '{...}'` creates a new monitor - `cronitor api monitors --update '{...}'` updates a monitor - `cronitor api monitors --delete` deletes a monitor Applied consistently across all API resources: monitors, issues, statuspages, components, incidents, notifications, and environments. https://claude.ai/code/session_01UDueW9A6SuxugCcsbAMfdB --- cmd/api_components.go | 93 +++++++---------- cmd/api_environments.go | 102 ++++++++---------- cmd/api_incidents.go | 145 ++++++++++---------------- cmd/api_issues.go | 165 +++++++++++++---------------- cmd/api_monitors.go | 194 +++++++++++++--------------------- cmd/api_notifications.go | 114 ++++++++------------ cmd/api_statuspages.go | 92 +++++++--------- tests/test-api.bats | 219 +++++++++++++++++++-------------------- 8 files changed, 470 insertions(+), 654 deletions(-) diff --git a/cmd/api_components.go b/cmd/api_components.go index a244590..48a3689 100644 --- a/cmd/api_components.go +++ b/cmd/api_components.go @@ -8,10 +8,15 @@ import ( "github.com/spf13/cobra" ) -var componentStatuspage string +var ( + componentNew string + componentUpdate string + componentDelete bool + componentStatuspage string +) var apiComponentsCmd = &cobra.Command{ - Use: "components [action] [key]", + Use: "components [key]", Short: "Manage status page components", Long: ` Manage Cronitor status page components. @@ -19,13 +24,6 @@ Manage Cronitor status page components. Components are the building blocks of status pages. Each component represents a monitor or group of monitors that feed into your status page. -Actions: - list - List all components (default) - get - Get a specific component by key - create - Create a new component - update - Update an existing component - delete - Delete a component - Examples: List all components: $ cronitor api components @@ -34,62 +32,53 @@ Examples: $ cronitor api components --statuspage Get a specific component: - $ cronitor api components get + $ cronitor api components Create a component: - $ cronitor api components create --data '{"name":"API Server","statuspage":"my-status-page","monitor":"api-check"}' + $ cronitor api components --new '{"name":"API Server","statuspage":"my-status-page","monitor":"api-check"}' Update a component: - $ cronitor api components update --data '{"name":"Updated Name"}' + $ cronitor api components --update '{"name":"Updated Name"}' Delete a component: - $ cronitor api components delete + $ cronitor api components --delete Output as table: $ cronitor api components --format table `, Run: func(cmd *cobra.Command, args []string) { - action := "list" - var key string - + client := getAPIClient() + key := "" if len(args) > 0 { - action = args[0] - } - if len(args) > 1 { - key = args[1] + key = args[0] } - client := getAPIClient() - - switch action { - case "list": - listComponents(client) - case "get": + switch { + case componentNew != "": + createComponent(client, componentNew) + case componentUpdate != "": if key == "" { - fatal("component key is required for get action", 1) + fatal("component key is required for --update", 1) } - getComponent(client, key) - case "create": - createComponent(client) - case "update": + updateComponent(client, key, componentUpdate) + case componentDelete: if key == "" { - fatal("component key is required for update action", 1) - } - updateComponent(client, key) - case "delete": - if key == "" { - fatal("component key is required for delete action", 1) + fatal("component key is required for --delete", 1) } deleteComponent(client, key) + case key != "": + getComponent(client, key) default: - // Treat first arg as a key for get if it doesn't match an action - getComponent(client, action) + listComponents(client) } }, } func init() { apiCmd.AddCommand(apiComponentsCmd) + apiComponentsCmd.Flags().StringVar(&componentNew, "new", "", "Create component with JSON data") + apiComponentsCmd.Flags().StringVar(&componentUpdate, "update", "", "Update component with JSON data") + apiComponentsCmd.Flags().BoolVar(&componentDelete, "delete", false, "Delete the component") apiComponentsCmd.Flags().StringVar(&componentStatuspage, "statuspage", "", "Filter by status page key") } @@ -140,14 +129,12 @@ func getComponent(client *lib.APIClient, key string) { outputResponse(resp, nil, nil) } -func createComponent(client *lib.APIClient) { - body, err := readStdinIfEmpty() - if err != nil { - fatal(err.Error(), 1) - } +func createComponent(client *lib.APIClient, jsonData string) { + body := []byte(jsonData) - if body == nil { - fatal("request body is required for create action (use --data, --file, or pipe JSON to stdin)", 1) + var js json.RawMessage + if err := json.Unmarshal(body, &js); err != nil { + fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) } resp, err := client.POST("/statuspage_components", body, nil) @@ -158,14 +145,12 @@ func createComponent(client *lib.APIClient) { outputResponse(resp, nil, nil) } -func updateComponent(client *lib.APIClient, key string) { - body, err := readStdinIfEmpty() - if err != nil { - fatal(err.Error(), 1) - } +func updateComponent(client *lib.APIClient, key string, jsonData string) { + body := []byte(jsonData) - if body == nil { - fatal("request body is required for update action (use --data, --file, or pipe JSON to stdin)", 1) + var js json.RawMessage + if err := json.Unmarshal(body, &js); err != nil { + fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) } resp, err := client.PUT(fmt.Sprintf("/statuspage_components/%s", key), body, nil) @@ -187,7 +172,7 @@ func deleteComponent(client *lib.APIClient, key string) { } if resp.IsSuccess() { - fmt.Printf("Component '%s' deleted successfully\n", key) + fmt.Printf("Component '%s' deleted\n", key) } else { outputResponse(resp, nil, nil) } diff --git a/cmd/api_environments.go b/cmd/api_environments.go index c8e6599..5da0d06 100644 --- a/cmd/api_environments.go +++ b/cmd/api_environments.go @@ -8,84 +8,73 @@ import ( "github.com/spf13/cobra" ) +var ( + environmentNew string + environmentUpdate string + environmentDelete bool +) + var apiEnvironmentsCmd = &cobra.Command{ - Use: "environments [action] [key]", + Use: "environments [key]", Short: "Manage environments", Long: ` Manage Cronitor environments. -Environments allow you to separate monitoring data between different -deployment stages (e.g., staging, production) while sharing monitor -configurations. - -Actions: - list - List all environments (default) - get - Get a specific environment by key - create - Create a new environment - update - Update an existing environment - delete - Delete an environment +Environments separate monitoring data between deployment stages (staging, production) +while sharing monitor configurations. Examples: List all environments: $ cronitor api environments Get a specific environment: - $ cronitor api environments get + $ cronitor api environments Create an environment: - $ cronitor api environments create --data '{"key":"staging","name":"Staging"}' + $ cronitor api environments --new '{"key":"staging","name":"Staging"}' Update an environment: - $ cronitor api environments update --data '{"name":"Updated Name"}' + $ cronitor api environments --update '{"name":"Updated Name"}' Delete an environment: - $ cronitor api environments delete + $ cronitor api environments --delete Output as table: $ cronitor api environments --format table `, Run: func(cmd *cobra.Command, args []string) { - action := "list" - var key string - + client := getAPIClient() + key := "" if len(args) > 0 { - action = args[0] + key = args[0] } - if len(args) > 1 { - key = args[1] - } - - client := getAPIClient() - switch action { - case "list": - listEnvironments(client) - case "get": + switch { + case environmentNew != "": + createEnvironment(client, environmentNew) + case environmentUpdate != "": if key == "" { - fatal("environment key is required for get action", 1) + fatal("environment key is required for --update", 1) } - getEnvironment(client, key) - case "create": - createEnvironment(client) - case "update": - if key == "" { - fatal("environment key is required for update action", 1) - } - updateEnvironment(client, key) - case "delete": + updateEnvironment(client, key, environmentUpdate) + case environmentDelete: if key == "" { - fatal("environment key is required for delete action", 1) + fatal("environment key is required for --delete", 1) } deleteEnvironment(client, key) + case key != "": + getEnvironment(client, key) default: - // Treat first arg as a key for get if it doesn't match an action - getEnvironment(client, action) + listEnvironments(client) } }, } func init() { apiCmd.AddCommand(apiEnvironmentsCmd) + apiEnvironmentsCmd.Flags().StringVar(&environmentNew, "new", "", "Create environment with JSON data") + apiEnvironmentsCmd.Flags().StringVar(&environmentUpdate, "update", "", "Update environment with JSON data") + apiEnvironmentsCmd.Flags().BoolVar(&environmentDelete, "delete", false, "Delete the environment") } func listEnvironments(client *lib.APIClient) { @@ -95,14 +84,13 @@ func listEnvironments(client *lib.APIClient) { fatal(fmt.Sprintf("Failed to list environments: %s", err), 1) } - outputResponse(resp, []string{"Key", "Name", "Default", "Created"}, + outputResponse(resp, []string{"Key", "Name", "Default"}, func(data []byte) [][]string { var result struct { Environments []struct { Key string `json:"key"` Name string `json:"name"` IsDefault bool `json:"is_default"` - CreatedAt string `json:"created_at"` } `json:"environments"` } if err := json.Unmarshal(data, &result); err != nil { @@ -115,7 +103,7 @@ func listEnvironments(client *lib.APIClient) { if e.IsDefault { isDefault = "Yes" } - rows[i] = []string{e.Key, e.Name, isDefault, e.CreatedAt} + rows[i] = []string{e.Key, e.Name, isDefault} } return rows }) @@ -134,14 +122,12 @@ func getEnvironment(client *lib.APIClient, key string) { outputResponse(resp, nil, nil) } -func createEnvironment(client *lib.APIClient) { - body, err := readStdinIfEmpty() - if err != nil { - fatal(err.Error(), 1) - } +func createEnvironment(client *lib.APIClient, jsonData string) { + body := []byte(jsonData) - if body == nil { - fatal("request body is required for create action (use --data, --file, or pipe JSON to stdin)", 1) + var js json.RawMessage + if err := json.Unmarshal(body, &js); err != nil { + fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) } resp, err := client.POST("/environments", body, nil) @@ -152,14 +138,12 @@ func createEnvironment(client *lib.APIClient) { outputResponse(resp, nil, nil) } -func updateEnvironment(client *lib.APIClient, key string) { - body, err := readStdinIfEmpty() - if err != nil { - fatal(err.Error(), 1) - } +func updateEnvironment(client *lib.APIClient, key string, jsonData string) { + body := []byte(jsonData) - if body == nil { - fatal("request body is required for update action (use --data, --file, or pipe JSON to stdin)", 1) + var js json.RawMessage + if err := json.Unmarshal(body, &js); err != nil { + fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) } resp, err := client.PUT(fmt.Sprintf("/environments/%s", key), body, nil) @@ -181,7 +165,7 @@ func deleteEnvironment(client *lib.APIClient, key string) { } if resp.IsSuccess() { - fmt.Printf("Environment '%s' deleted successfully\n", key) + fmt.Printf("Environment '%s' deleted\n", key) } else { outputResponse(resp, nil, nil) } diff --git a/cmd/api_incidents.go b/cmd/api_incidents.go index 0bf972f..0af6ac6 100644 --- a/cmd/api_incidents.go +++ b/cmd/api_incidents.go @@ -8,90 +8,77 @@ import ( "github.com/spf13/cobra" ) -var incidentStatuspage string +var ( + incidentNew string + incidentUpdate string + incidentResolve bool + incidentStatuspage string +) var apiIncidentsCmd = &cobra.Command{ - Use: "incidents [action] [key]", + Use: "incidents [id]", Short: "Manage status page incidents", Long: ` Manage Cronitor status page incidents. -Incidents allow you to communicate about problems as they occur - either -privately with teammates or publicly on your status pages. Incidents can -be created automatically by monitor failures or manually for planned -maintenance. - -Actions: - list - List all incidents (default) - get - Get a specific incident by ID - create - Create a new incident - update - Update/add message to an existing incident - resolve - Resolve an incident +Incidents communicate problems on your status pages - either created +automatically by monitor failures or manually for planned maintenance. Examples: List all incidents: $ cronitor api incidents - List incidents for a specific status page: + Filter by status page: $ cronitor api incidents --statuspage Get a specific incident: - $ cronitor api incidents get + $ cronitor api incidents Create an incident: - $ cronitor api incidents create --data '{"title":"API Degradation","message":"Investigating elevated error rates","severity":"warning","statuspage":"my-status-page"}' + $ cronitor api incidents --new '{"title":"API Degradation","severity":"warning","statuspage":"my-page"}' - Add an update to an incident: - $ cronitor api incidents update --data '{"message":"Root cause identified, deploying fix"}' + Update an incident: + $ cronitor api incidents --update '{"message":"Deploying fix..."}' Resolve an incident: - $ cronitor api incidents resolve --data '{"message":"Issue has been resolved"}' + $ cronitor api incidents --resolve Output as table: $ cronitor api incidents --format table `, Run: func(cmd *cobra.Command, args []string) { - action := "list" - var key string - + client := getAPIClient() + id := "" if len(args) > 0 { - action = args[0] - } - if len(args) > 1 { - key = args[1] + id = args[0] } - client := getAPIClient() - - switch action { - case "list": - listIncidents(client) - case "get": - if key == "" { - fatal("incident ID is required for get action", 1) - } - getIncident(client, key) - case "create": - createIncident(client) - case "update": - if key == "" { - fatal("incident ID is required for update action", 1) + switch { + case incidentNew != "": + createIncident(client, incidentNew) + case incidentUpdate != "": + if id == "" { + fatal("incident ID is required for --update", 1) } - updateIncident(client, key) - case "resolve": - if key == "" { - fatal("incident ID is required for resolve action", 1) + updateIncident(client, id, incidentUpdate) + case incidentResolve: + if id == "" { + fatal("incident ID is required for --resolve", 1) } - resolveIncident(client, key) + resolveIncident(client, id) + case id != "": + getIncident(client, id) default: - // Treat first arg as an ID for get if it doesn't match an action - getIncident(client, action) + listIncidents(client) } }, } func init() { apiCmd.AddCommand(apiIncidentsCmd) + apiIncidentsCmd.Flags().StringVar(&incidentNew, "new", "", "Create incident with JSON data") + apiIncidentsCmd.Flags().StringVar(&incidentUpdate, "update", "", "Add update to incident with JSON data") + apiIncidentsCmd.Flags().BoolVar(&incidentResolve, "resolve", false, "Resolve the incident") apiIncidentsCmd.Flags().StringVar(&incidentStatuspage, "statuspage", "", "Filter by status page key") } @@ -106,16 +93,15 @@ func listIncidents(client *lib.APIClient) { fatal(fmt.Sprintf("Failed to list incidents: %s", err), 1) } - outputResponse(resp, []string{"ID", "Title", "Status", "Severity", "Status Page", "Created"}, + outputResponse(resp, []string{"ID", "Title", "Status", "Severity", "Created"}, func(data []byte) [][]string { var result struct { Incidents []struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Severity string `json:"severity"` - StatusPage string `json:"statuspage"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Severity string `json:"severity"` + CreatedAt string `json:"created_at"` } `json:"incidents"` } if err := json.Unmarshal(data, &result); err != nil { @@ -124,7 +110,7 @@ func listIncidents(client *lib.APIClient) { rows := make([][]string, len(result.Incidents)) for i, inc := range result.Incidents { - rows[i] = []string{inc.ID, inc.Title, inc.Status, inc.Severity, inc.StatusPage, inc.CreatedAt} + rows[i] = []string{inc.ID, inc.Title, inc.Status, inc.Severity, inc.CreatedAt} } return rows }) @@ -143,14 +129,12 @@ func getIncident(client *lib.APIClient, id string) { outputResponse(resp, nil, nil) } -func createIncident(client *lib.APIClient) { - body, err := readStdinIfEmpty() - if err != nil { - fatal(err.Error(), 1) - } +func createIncident(client *lib.APIClient, jsonData string) { + body := []byte(jsonData) - if body == nil { - fatal("request body is required for create action (use --data, --file, or pipe JSON to stdin)", 1) + var js json.RawMessage + if err := json.Unmarshal(body, &js); err != nil { + fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) } resp, err := client.POST("/incidents", body, nil) @@ -161,17 +145,14 @@ func createIncident(client *lib.APIClient) { outputResponse(resp, nil, nil) } -func updateIncident(client *lib.APIClient, id string) { - body, err := readStdinIfEmpty() - if err != nil { - fatal(err.Error(), 1) - } +func updateIncident(client *lib.APIClient, id string, jsonData string) { + body := []byte(jsonData) - if body == nil { - fatal("request body is required for update action (use --data, --file, or pipe JSON to stdin)", 1) + var js json.RawMessage + if err := json.Unmarshal(body, &js); err != nil { + fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) } - // Add update via the updates endpoint resp, err := client.POST(fmt.Sprintf("/incidents/%s/updates", id), body, nil) if err != nil { fatal(fmt.Sprintf("Failed to update incident: %s", err), 1) @@ -181,31 +162,15 @@ func updateIncident(client *lib.APIClient, id string) { } func resolveIncident(client *lib.APIClient, id string) { - body, err := readStdinIfEmpty() - if err != nil { - fatal(err.Error(), 1) - } - - // Build resolve payload - var payload map[string]interface{} - if body != nil { - if err := json.Unmarshal(body, &payload); err != nil { - payload = make(map[string]interface{}) - } - } else { - payload = make(map[string]interface{}) - } - payload["status"] = "resolved" - - resolveBody, _ := json.Marshal(payload) + body := []byte(`{"status":"resolved"}`) - resp, err := client.PUT(fmt.Sprintf("/incidents/%s", id), resolveBody, nil) + resp, err := client.PUT(fmt.Sprintf("/incidents/%s", id), body, nil) if err != nil { fatal(fmt.Sprintf("Failed to resolve incident: %s", err), 1) } if resp.IsSuccess() { - fmt.Printf("Incident '%s' resolved successfully\n", id) + fmt.Printf("Incident '%s' resolved\n", id) } else { outputResponse(resp, nil, nil) } diff --git a/cmd/api_issues.go b/cmd/api_issues.go index 0a565f3..bed4949 100644 --- a/cmd/api_issues.go +++ b/cmd/api_issues.go @@ -8,26 +8,23 @@ import ( "github.com/spf13/cobra" ) -var issueState string -var issueSeverity string +var ( + issueNew string + issueUpdate string + issueDelete bool + issueResolve bool + issueState string + issueSeverity string +) var apiIssuesCmd = &cobra.Command{ - Use: "issues [action] [key]", - Short: "Manage issues and incidents", + Use: "issues [key]", + Short: "Manage issues", Long: ` Manage Cronitor issues and incidents. Issues are Cronitor's incident management hub - they help your team coordinate -response when monitors fail. Issues automatically open when monitors fail and -close when they recover. - -Actions: - list - List all issues (default) - get - Get a specific issue by key - create - Create a new issue - update - Update an existing issue - delete - Delete an issue - bulk - Perform bulk operations on issues +response when monitors fail. Examples: List all issues: @@ -36,74 +33,66 @@ Examples: List open issues: $ cronitor api issues --state open - List issues filtered by severity: + Filter by severity: $ cronitor api issues --severity critical Get a specific issue: - $ cronitor api issues get + $ cronitor api issues Create an issue: - $ cronitor api issues create --data '{"title":"Service Outage","severity":"critical","monitors":["web-api"]}' + $ cronitor api issues --new '{"title":"Service Outage","severity":"critical"}' Update an issue: - $ cronitor api issues update --data '{"state":"resolved"}' + $ cronitor api issues --update '{"message":"Investigating..."}' - Add an update to an issue: - $ cronitor api issues update --data '{"message":"Investigating the root cause"}' + Resolve an issue: + $ cronitor api issues --resolve Delete an issue: - $ cronitor api issues delete - - Bulk resolve issues: - $ cronitor api issues bulk --data '{"action":"resolve","keys":["issue-1","issue-2"]}' + $ cronitor api issues --delete Output as table: $ cronitor api issues --format table `, Run: func(cmd *cobra.Command, args []string) { - action := "list" - var key string - + client := getAPIClient() + key := "" if len(args) > 0 { - action = args[0] + key = args[0] } - if len(args) > 1 { - key = args[1] - } - - client := getAPIClient() - switch action { - case "list": - listIssues(client) - case "get": + switch { + case issueNew != "": + createIssue(client, issueNew) + case issueUpdate != "": if key == "" { - fatal("issue key is required for get action", 1) + fatal("issue key is required for --update", 1) } - getIssue(client, key) - case "create": - createIssue(client) - case "update": + updateIssue(client, key, issueUpdate) + case issueResolve: if key == "" { - fatal("issue key is required for update action", 1) + fatal("issue key is required for --resolve", 1) } - updateIssue(client, key) - case "delete": + resolveIssue(client, key) + case issueDelete: if key == "" { - fatal("issue key is required for delete action", 1) + fatal("issue key is required for --delete", 1) } deleteIssue(client, key) - case "bulk": - bulkIssues(client) + case key != "": + getIssue(client, key) default: - // Treat first arg as a key for get if it doesn't match an action - getIssue(client, action) + listIssues(client) } }, } func init() { apiCmd.AddCommand(apiIssuesCmd) + apiIssuesCmd.Flags().StringVar(&issueNew, "new", "", "Create issue with JSON data") + apiIssuesCmd.Flags().StringVar(&issueUpdate, "update", "", "Update issue with JSON data") + apiIssuesCmd.Flags().BoolVar(&issueDelete, "delete", false, "Delete the issue") + apiIssuesCmd.Flags().BoolVar(&issueResolve, "resolve", false, "Resolve the issue") apiIssuesCmd.Flags().StringVar(&issueState, "state", "", "Filter by state (open, resolved)") apiIssuesCmd.Flags().StringVar(&issueSeverity, "severity", "", "Filter by severity (critical, warning, info)") } @@ -122,16 +111,15 @@ func listIssues(client *lib.APIClient) { fatal(fmt.Sprintf("Failed to list issues: %s", err), 1) } - outputResponse(resp, []string{"Key", "Title", "State", "Severity", "Monitors", "Created"}, + outputResponse(resp, []string{"Key", "Title", "State", "Severity", "Created"}, func(data []byte) [][]string { var result struct { Issues []struct { - Key string `json:"key"` - Title string `json:"title"` - State string `json:"state"` - Severity string `json:"severity"` - Monitors []string `json:"monitors"` - CreatedAt string `json:"created_at"` + Key string `json:"key"` + Title string `json:"title"` + State string `json:"state"` + Severity string `json:"severity"` + CreatedAt string `json:"created_at"` } `json:"issues"` } if err := json.Unmarshal(data, &result); err != nil { @@ -140,11 +128,7 @@ func listIssues(client *lib.APIClient) { rows := make([][]string, len(result.Issues)) for i, issue := range result.Issues { - monitors := "" - if len(issue.Monitors) > 0 { - monitors = fmt.Sprintf("%v", issue.Monitors) - } - rows[i] = []string{issue.Key, issue.Title, issue.State, issue.Severity, monitors, issue.CreatedAt} + rows[i] = []string{issue.Key, issue.Title, issue.State, issue.Severity, issue.CreatedAt} } return rows }) @@ -163,14 +147,12 @@ func getIssue(client *lib.APIClient, key string) { outputResponse(resp, nil, nil) } -func createIssue(client *lib.APIClient) { - body, err := readStdinIfEmpty() - if err != nil { - fatal(err.Error(), 1) - } +func createIssue(client *lib.APIClient, jsonData string) { + body := []byte(jsonData) - if body == nil { - fatal("request body is required for create action (use --data, --file, or pipe JSON to stdin)", 1) + var js json.RawMessage + if err := json.Unmarshal(body, &js); err != nil { + fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) } resp, err := client.POST("/issues", body, nil) @@ -181,14 +163,12 @@ func createIssue(client *lib.APIClient) { outputResponse(resp, nil, nil) } -func updateIssue(client *lib.APIClient, key string) { - body, err := readStdinIfEmpty() - if err != nil { - fatal(err.Error(), 1) - } +func updateIssue(client *lib.APIClient, key string, jsonData string) { + body := []byte(jsonData) - if body == nil { - fatal("request body is required for update action (use --data, --file, or pipe JSON to stdin)", 1) + var js json.RawMessage + if err := json.Unmarshal(body, &js); err != nil { + fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) } resp, err := client.PUT(fmt.Sprintf("/issues/%s", key), body, nil) @@ -199,37 +179,34 @@ func updateIssue(client *lib.APIClient, key string) { outputResponse(resp, nil, nil) } -func deleteIssue(client *lib.APIClient, key string) { - resp, err := client.DELETE(fmt.Sprintf("/issues/%s", key), nil, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to delete issue: %s", err), 1) - } +func resolveIssue(client *lib.APIClient, key string) { + body := []byte(`{"state":"resolved"}`) - if resp.IsNotFound() { - fatal(fmt.Sprintf("Issue '%s' could not be found", key), 1) + resp, err := client.PUT(fmt.Sprintf("/issues/%s", key), body, nil) + if err != nil { + fatal(fmt.Sprintf("Failed to resolve issue: %s", err), 1) } if resp.IsSuccess() { - fmt.Printf("Issue '%s' deleted successfully\n", key) + fmt.Printf("Issue '%s' resolved\n", key) } else { outputResponse(resp, nil, nil) } } -func bulkIssues(client *lib.APIClient) { - body, err := readStdinIfEmpty() +func deleteIssue(client *lib.APIClient, key string) { + resp, err := client.DELETE(fmt.Sprintf("/issues/%s", key), nil, nil) if err != nil { - fatal(err.Error(), 1) + fatal(fmt.Sprintf("Failed to delete issue: %s", err), 1) } - if body == nil { - fatal("request body is required for bulk action (use --data, --file, or pipe JSON to stdin)", 1) + if resp.IsNotFound() { + fatal(fmt.Sprintf("Issue '%s' could not be found", key), 1) } - resp, err := client.POST("/issues/bulk", body, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to perform bulk operation: %s", err), 1) + if resp.IsSuccess() { + fmt.Printf("Issue '%s' deleted\n", key) + } else { + outputResponse(resp, nil, nil) } - - outputResponse(resp, nil, nil) } diff --git a/cmd/api_monitors.go b/cmd/api_monitors.go index 22d7e19..5c7af55 100644 --- a/cmd/api_monitors.go +++ b/cmd/api_monitors.go @@ -8,115 +8,103 @@ import ( "github.com/spf13/cobra" ) -var pauseHours string -var withLatestEvents bool +var ( + monitorNew string + monitorUpdate string + monitorDelete bool + monitorPause string + monitorUnpause bool + withLatestEvents bool +) var apiMonitorsCmd = &cobra.Command{ - Use: "monitors [action] [key]", + Use: "monitors [key]", Short: "Manage monitors", Long: ` Manage Cronitor monitors (jobs, checks, heartbeats, sites). -Actions: - list - List all monitors (default) - get - Get a specific monitor by key - create - Create a new monitor - update - Update an existing monitor - delete - Delete one or more monitors - pause - Pause a monitor (stop alerting) - unpause - Unpause a monitor (resume alerting) - Examples: List all monitors: $ cronitor api monitors - List monitors with pagination: + List with pagination: $ cronitor api monitors --page 2 Get a specific monitor: - $ cronitor api monitors get my-job-key + $ cronitor api monitors - Create a monitor from JSON data: - $ cronitor api monitors create --data '{"key":"my-job","type":"job","schedule":"0 * * * *"}' + Get with latest events: + $ cronitor api monitors --with-events - Create a monitor from a file: - $ cronitor api monitors create --file monitor.json + Create a monitor: + $ cronitor api monitors --new '{"key":"my-job","type":"job"}' Update a monitor: - $ cronitor api monitors update my-job-key --data '{"name":"Updated Name"}' + $ cronitor api monitors --update '{"name":"New Name"}' Delete a monitor: - $ cronitor api monitors delete my-job-key - - Delete multiple monitors: - $ cronitor api monitors delete --data '["key1","key2","key3"]' + $ cronitor api monitors --delete - Pause a monitor for 24 hours: - $ cronitor api monitors pause my-job-key --hours 24 + Pause a monitor (indefinitely): + $ cronitor api monitors --pause - Pause a monitor indefinitely: - $ cronitor api monitors pause my-job-key + Pause for 24 hours: + $ cronitor api monitors --pause 24 Unpause a monitor: - $ cronitor api monitors unpause my-job-key + $ cronitor api monitors --unpause Output as table: $ cronitor api monitors --format table - - Get a monitor with latest events: - $ cronitor api monitors get my-job-key --with-events `, Run: func(cmd *cobra.Command, args []string) { - action := "list" - var key string - + client := getAPIClient() + key := "" if len(args) > 0 { - action = args[0] - } - if len(args) > 1 { - key = args[1] + key = args[0] } - client := getAPIClient() - - switch action { - case "list": - listMonitors(client) - case "get": + // Determine action based on flags + switch { + case monitorNew != "": + createMonitor(client, monitorNew) + case monitorUpdate != "": if key == "" { - fatal("monitor key is required for get action", 1) + fatal("monitor key is required for --update", 1) } - getMonitor(client, key) - case "create": - createMonitor(client) - case "update": + updateMonitor(client, key, monitorUpdate) + case monitorDelete: if key == "" { - fatal("monitor key is required for update action", 1) + fatal("monitor key is required for --delete", 1) } - updateMonitor(client, key) - case "delete": - deleteMonitors(client, key) - case "pause": + deleteMonitor(client, key) + case cmd.Flags().Changed("pause"): if key == "" { - fatal("monitor key is required for pause action", 1) + fatal("monitor key is required for --pause", 1) } - pauseMonitor(client, key) - case "unpause": + pauseMonitor(client, key, monitorPause) + case monitorUnpause: if key == "" { - fatal("monitor key is required for unpause action", 1) + fatal("monitor key is required for --unpause", 1) } unpauseMonitor(client, key) + case key != "": + getMonitor(client, key) default: - // Treat first arg as a key for get if it doesn't match an action - getMonitor(client, action) + listMonitors(client) } }, } func init() { apiCmd.AddCommand(apiMonitorsCmd) - apiMonitorsCmd.Flags().StringVar(&pauseHours, "hours", "", "Number of hours to pause (for pause action)") - apiMonitorsCmd.Flags().BoolVar(&withLatestEvents, "with-events", false, "Include latest events in monitor response") + apiMonitorsCmd.Flags().StringVar(&monitorNew, "new", "", "Create monitor with JSON data") + apiMonitorsCmd.Flags().StringVar(&monitorUpdate, "update", "", "Update monitor with JSON data") + apiMonitorsCmd.Flags().BoolVar(&monitorDelete, "delete", false, "Delete the monitor") + apiMonitorsCmd.Flags().StringVar(&monitorPause, "pause", "", "Pause monitor (optionally specify hours)") + apiMonitorsCmd.Flags().BoolVar(&monitorUnpause, "unpause", false, "Unpause the monitor") + apiMonitorsCmd.Flags().BoolVar(&withLatestEvents, "with-events", false, "Include latest events") + apiMonitorsCmd.Flags().Lookup("pause").NoOptDefVal = "0" // Allow --pause without value } func listMonitors(client *lib.APIClient) { @@ -178,14 +166,13 @@ func getMonitor(client *lib.APIClient, key string) { outputResponse(resp, nil, nil) } -func createMonitor(client *lib.APIClient) { - body, err := readStdinIfEmpty() - if err != nil { - fatal(err.Error(), 1) - } +func createMonitor(client *lib.APIClient, jsonData string) { + body := []byte(jsonData) - if body == nil { - fatal("request body is required for create action (use --data, --file, or pipe JSON to stdin)", 1) + // Validate JSON + var js json.RawMessage + if err := json.Unmarshal(body, &js); err != nil { + fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) } // Check if it's an array (bulk create) or single object @@ -193,38 +180,28 @@ func createMonitor(client *lib.APIClient) { isBulk := json.Unmarshal(body, &testArray) == nil && len(testArray) > 0 var resp *lib.APIResponse + var err error if isBulk { - // Bulk create uses PUT resp, err = client.PUT("/monitors", body, nil) } else { - // Single create uses POST resp, err = client.POST("/monitors", body, nil) } if err != nil { - fatal(fmt.Sprintf("Failed to create monitor(s): %s", err), 1) + fatal(fmt.Sprintf("Failed to create monitor: %s", err), 1) } outputResponse(resp, nil, nil) } -func updateMonitor(client *lib.APIClient, key string) { - body, err := readStdinIfEmpty() - if err != nil { - fatal(err.Error(), 1) - } - - if body == nil { - fatal("request body is required for update action (use --data, --file, or pipe JSON to stdin)", 1) - } - - // Ensure key is in the body +func updateMonitor(client *lib.APIClient, key string, jsonData string) { + // Parse and add key to body var bodyMap map[string]interface{} - if err := json.Unmarshal(body, &bodyMap); err != nil { + if err := json.Unmarshal([]byte(jsonData), &bodyMap); err != nil { fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) } bodyMap["key"] = key - body, _ = json.Marshal(bodyMap) + body, _ := json.Marshal(bodyMap) // Wrap in array for PUT endpoint body = []byte(fmt.Sprintf("[%s]", string(body))) @@ -237,29 +214,10 @@ func updateMonitor(client *lib.APIClient, key string) { outputResponse(resp, nil, nil) } -func deleteMonitors(client *lib.APIClient, key string) { - var resp *lib.APIResponse - var err error - - if key != "" { - // Single delete - resp, err = client.DELETE(fmt.Sprintf("/monitors/%s", key), nil, nil) - } else { - // Bulk delete requires body - body, bodyErr := readStdinIfEmpty() - if bodyErr != nil { - fatal(bodyErr.Error(), 1) - } - - if body == nil { - fatal("monitor key or JSON array of keys is required for delete action", 1) - } - - resp, err = client.DELETE("/monitors", body, nil) - } - +func deleteMonitor(client *lib.APIClient, key string) { + resp, err := client.DELETE(fmt.Sprintf("/monitors/%s", key), nil, nil) if err != nil { - fatal(fmt.Sprintf("Failed to delete monitor(s): %s", err), 1) + fatal(fmt.Sprintf("Failed to delete monitor: %s", err), 1) } if resp.IsNotFound() { @@ -267,20 +225,16 @@ func deleteMonitors(client *lib.APIClient, key string) { } if resp.IsSuccess() { - if key != "" { - fmt.Printf("Monitor '%s' deleted successfully\n", key) - } else { - fmt.Println("Monitors deleted successfully") - } + fmt.Printf("Monitor '%s' deleted\n", key) } else { outputResponse(resp, nil, nil) } } -func pauseMonitor(client *lib.APIClient, key string) { +func pauseMonitor(client *lib.APIClient, key string, hours string) { endpoint := fmt.Sprintf("/monitors/%s/pause", key) - if pauseHours != "" { - endpoint = fmt.Sprintf("%s/%s", endpoint, pauseHours) + if hours != "" && hours != "0" { + endpoint = fmt.Sprintf("%s/%s", endpoint, hours) } resp, err := client.GET(endpoint, nil) @@ -293,10 +247,10 @@ func pauseMonitor(client *lib.APIClient, key string) { } if resp.IsSuccess() { - if pauseHours != "" { - fmt.Printf("Monitor '%s' paused for %s hours\n", key, pauseHours) + if hours != "" && hours != "0" { + fmt.Printf("Monitor '%s' paused for %s hours\n", key, hours) } else { - fmt.Printf("Monitor '%s' paused indefinitely\n", key) + fmt.Printf("Monitor '%s' paused\n", key) } } else { outputResponse(resp, nil, nil) @@ -304,9 +258,7 @@ func pauseMonitor(client *lib.APIClient, key string) { } func unpauseMonitor(client *lib.APIClient, key string) { - endpoint := fmt.Sprintf("/monitors/%s/pause/0", key) - - resp, err := client.GET(endpoint, nil) + resp, err := client.GET(fmt.Sprintf("/monitors/%s/pause/0", key), nil) if err != nil { fatal(fmt.Sprintf("Failed to unpause monitor: %s", err), 1) } diff --git a/cmd/api_notifications.go b/cmd/api_notifications.go index 07844be..c1b2cc9 100644 --- a/cmd/api_notifications.go +++ b/cmd/api_notifications.go @@ -8,84 +8,72 @@ import ( "github.com/spf13/cobra" ) +var ( + notificationNew string + notificationUpdate string + notificationDelete bool +) + var apiNotificationsCmd = &cobra.Command{ - Use: "notifications [action] [key]", + Use: "notifications [key]", Short: "Manage notification lists", Long: ` Manage Cronitor notification lists. Notification lists define where alerts are sent when monitors detect issues. -Each list can contain multiple notification channels like email, Slack, -PagerDuty, webhooks, and more. - -Actions: - list - List all notification lists (default) - get - Get a specific notification list by key - create - Create a new notification list - update - Update an existing notification list - delete - Delete a notification list Examples: List all notification lists: $ cronitor api notifications Get a specific notification list: - $ cronitor api notifications get + $ cronitor api notifications Create a notification list: - $ cronitor api notifications create --data '{"key":"ops-team","name":"Ops Team","templates":["email:ops@company.com","slack:#alerts"]}' + $ cronitor api notifications --new '{"key":"ops-team","name":"Ops","templates":["email:ops@co.com"]}' Update a notification list: - $ cronitor api notifications update --data '{"name":"Updated Name"}' + $ cronitor api notifications --update '{"name":"Updated Name"}' Delete a notification list: - $ cronitor api notifications delete + $ cronitor api notifications --delete Output as table: $ cronitor api notifications --format table `, Run: func(cmd *cobra.Command, args []string) { - action := "list" - var key string - + client := getAPIClient() + key := "" if len(args) > 0 { - action = args[0] + key = args[0] } - if len(args) > 1 { - key = args[1] - } - - client := getAPIClient() - switch action { - case "list": - listNotifications(client) - case "get": + switch { + case notificationNew != "": + createNotification(client, notificationNew) + case notificationUpdate != "": if key == "" { - fatal("notification list key is required for get action", 1) + fatal("notification list key is required for --update", 1) } - getNotification(client, key) - case "create": - createNotification(client) - case "update": - if key == "" { - fatal("notification list key is required for update action", 1) - } - updateNotification(client, key) - case "delete": + updateNotification(client, key, notificationUpdate) + case notificationDelete: if key == "" { - fatal("notification list key is required for delete action", 1) + fatal("notification list key is required for --delete", 1) } deleteNotification(client, key) + case key != "": + getNotification(client, key) default: - // Treat first arg as a key for get if it doesn't match an action - getNotification(client, action) + listNotifications(client) } }, } func init() { apiCmd.AddCommand(apiNotificationsCmd) + apiNotificationsCmd.Flags().StringVar(¬ificationNew, "new", "", "Create notification list with JSON data") + apiNotificationsCmd.Flags().StringVar(¬ificationUpdate, "update", "", "Update notification list with JSON data") + apiNotificationsCmd.Flags().BoolVar(¬ificationDelete, "delete", false, "Delete the notification list") } func listNotifications(client *lib.APIClient) { @@ -95,14 +83,13 @@ func listNotifications(client *lib.APIClient) { fatal(fmt.Sprintf("Failed to list notification lists: %s", err), 1) } - outputResponse(resp, []string{"Key", "Name", "Channels", "Environments"}, + outputResponse(resp, []string{"Key", "Name", "Channels"}, func(data []byte) [][]string { var result struct { NotificationLists []struct { - Key string `json:"key"` - Name string `json:"name"` - Templates []string `json:"templates"` - Environments []string `json:"environments"` + Key string `json:"key"` + Name string `json:"name"` + Templates []string `json:"templates"` } `json:"notification_lists"` } if err := json.Unmarshal(data, &result); err != nil { @@ -111,15 +98,8 @@ func listNotifications(client *lib.APIClient) { rows := make([][]string, len(result.NotificationLists)) for i, n := range result.NotificationLists { - channels := fmt.Sprintf("%d channels", len(n.Templates)) - if len(n.Templates) <= 3 { - channels = fmt.Sprintf("%v", n.Templates) - } - envs := "all" - if len(n.Environments) > 0 { - envs = fmt.Sprintf("%v", n.Environments) - } - rows[i] = []string{n.Key, n.Name, channels, envs} + channels := fmt.Sprintf("%d", len(n.Templates)) + rows[i] = []string{n.Key, n.Name, channels} } return rows }) @@ -138,14 +118,12 @@ func getNotification(client *lib.APIClient, key string) { outputResponse(resp, nil, nil) } -func createNotification(client *lib.APIClient) { - body, err := readStdinIfEmpty() - if err != nil { - fatal(err.Error(), 1) - } +func createNotification(client *lib.APIClient, jsonData string) { + body := []byte(jsonData) - if body == nil { - fatal("request body is required for create action (use --data, --file, or pipe JSON to stdin)", 1) + var js json.RawMessage + if err := json.Unmarshal(body, &js); err != nil { + fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) } resp, err := client.POST("/notification-lists", body, nil) @@ -156,14 +134,12 @@ func createNotification(client *lib.APIClient) { outputResponse(resp, nil, nil) } -func updateNotification(client *lib.APIClient, key string) { - body, err := readStdinIfEmpty() - if err != nil { - fatal(err.Error(), 1) - } +func updateNotification(client *lib.APIClient, key string, jsonData string) { + body := []byte(jsonData) - if body == nil { - fatal("request body is required for update action (use --data, --file, or pipe JSON to stdin)", 1) + var js json.RawMessage + if err := json.Unmarshal(body, &js); err != nil { + fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) } resp, err := client.PUT(fmt.Sprintf("/notification-lists/%s", key), body, nil) @@ -185,7 +161,7 @@ func deleteNotification(client *lib.APIClient, key string) { } if resp.IsSuccess() { - fmt.Printf("Notification list '%s' deleted successfully\n", key) + fmt.Printf("Notification list '%s' deleted\n", key) } else { outputResponse(resp, nil, nil) } diff --git a/cmd/api_statuspages.go b/cmd/api_statuspages.go index 5eb350c..62ec1ce 100644 --- a/cmd/api_statuspages.go +++ b/cmd/api_statuspages.go @@ -8,8 +8,14 @@ import ( "github.com/spf13/cobra" ) +var ( + statuspageNew string + statuspageUpdate string + statuspageDelete bool +) + var apiStatuspagesCmd = &cobra.Command{ - Use: "statuspages [action] [key]", + Use: "statuspages [key]", Short: "Manage status pages", Long: ` Manage Cronitor status pages. @@ -18,74 +24,58 @@ Status pages turn your Cronitor monitoring data into public (or private) communication. Your monitors feed directly into status components, creating a real-time view of your system health. -Actions: - list - List all status pages (default) - get - Get a specific status page by key - create - Create a new status page - update - Update an existing status page - delete - Delete a status page - Examples: List all status pages: $ cronitor api statuspages Get a specific status page: - $ cronitor api statuspages get + $ cronitor api statuspages Create a status page: - $ cronitor api statuspages create --data '{"name":"API Status","hosted_subdomain":"api-status"}' + $ cronitor api statuspages --new '{"name":"API Status","hosted_subdomain":"api-status"}' Update a status page: - $ cronitor api statuspages update --data '{"name":"Updated Status Page"}' + $ cronitor api statuspages --update '{"name":"Updated Status Page"}' Delete a status page: - $ cronitor api statuspages delete + $ cronitor api statuspages --delete Output as table: $ cronitor api statuspages --format table `, Run: func(cmd *cobra.Command, args []string) { - action := "list" - var key string - + client := getAPIClient() + key := "" if len(args) > 0 { - action = args[0] + key = args[0] } - if len(args) > 1 { - key = args[1] - } - - client := getAPIClient() - switch action { - case "list": - listStatuspages(client) - case "get": + switch { + case statuspageNew != "": + createStatuspage(client, statuspageNew) + case statuspageUpdate != "": if key == "" { - fatal("status page key is required for get action", 1) + fatal("status page key is required for --update", 1) } - getStatuspage(client, key) - case "create": - createStatuspage(client) - case "update": - if key == "" { - fatal("status page key is required for update action", 1) - } - updateStatuspage(client, key) - case "delete": + updateStatuspage(client, key, statuspageUpdate) + case statuspageDelete: if key == "" { - fatal("status page key is required for delete action", 1) + fatal("status page key is required for --delete", 1) } deleteStatuspage(client, key) + case key != "": + getStatuspage(client, key) default: - // Treat first arg as a key for get if it doesn't match an action - getStatuspage(client, action) + listStatuspages(client) } }, } func init() { apiCmd.AddCommand(apiStatuspagesCmd) + apiStatuspagesCmd.Flags().StringVar(&statuspageNew, "new", "", "Create status page with JSON data") + apiStatuspagesCmd.Flags().StringVar(&statuspageUpdate, "update", "", "Update status page with JSON data") + apiStatuspagesCmd.Flags().BoolVar(&statuspageDelete, "delete", false, "Delete the status page") } func listStatuspages(client *lib.APIClient) { @@ -131,14 +121,12 @@ func getStatuspage(client *lib.APIClient, key string) { outputResponse(resp, nil, nil) } -func createStatuspage(client *lib.APIClient) { - body, err := readStdinIfEmpty() - if err != nil { - fatal(err.Error(), 1) - } +func createStatuspage(client *lib.APIClient, jsonData string) { + body := []byte(jsonData) - if body == nil { - fatal("request body is required for create action (use --data, --file, or pipe JSON to stdin)", 1) + var js json.RawMessage + if err := json.Unmarshal(body, &js); err != nil { + fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) } resp, err := client.POST("/statuspages", body, nil) @@ -149,14 +137,12 @@ func createStatuspage(client *lib.APIClient) { outputResponse(resp, nil, nil) } -func updateStatuspage(client *lib.APIClient, key string) { - body, err := readStdinIfEmpty() - if err != nil { - fatal(err.Error(), 1) - } +func updateStatuspage(client *lib.APIClient, key string, jsonData string) { + body := []byte(jsonData) - if body == nil { - fatal("request body is required for update action (use --data, --file, or pipe JSON to stdin)", 1) + var js json.RawMessage + if err := json.Unmarshal(body, &js); err != nil { + fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) } resp, err := client.PUT(fmt.Sprintf("/statuspages/%s", key), body, nil) @@ -178,7 +164,7 @@ func deleteStatuspage(client *lib.APIClient, key string) { } if resp.IsSuccess() { - fmt.Printf("Status page '%s' deleted successfully\n", key) + fmt.Printf("Status page '%s' deleted\n", key) } else { outputResponse(resp, nil, nil) } diff --git a/tests/test-api.bats b/tests/test-api.bats index 17e10aa..f6435a6 100644 --- a/tests/test-api.bats +++ b/tests/test-api.bats @@ -40,46 +40,41 @@ setup() { ../cronitor api monitors --help | grep -q "Manage Cronitor monitors" } -@test "API monitors help shows all actions" { - ../cronitor api monitors --help | grep -q "list" - ../cronitor api monitors --help | grep -q "get" - ../cronitor api monitors --help | grep -q "create" - ../cronitor api monitors --help | grep -q "update" - ../cronitor api monitors --help | grep -q "delete" - ../cronitor api monitors --help | grep -q "pause" - ../cronitor api monitors --help | grep -q "unpause" -} - -@test "API monitors has --hours flag for pause" { - ../cronitor api monitors --help | grep -q "\-\-hours" +@test "API monitors help shows examples" { + ../cronitor api monitors --help | grep -q "cronitor api monitors" + ../cronitor api monitors --help | grep -q "\-\-new" + ../cronitor api monitors --help | grep -q "\-\-update" + ../cronitor api monitors --help | grep -q "\-\-delete" + ../cronitor api monitors --help | grep -q "\-\-pause" + ../cronitor api monitors --help | grep -q "\-\-unpause" } @test "API monitors has --with-events flag" { ../cronitor api monitors --help | grep -q "\-\-with-events" } -@test "API monitors get requires key" { - run ../cronitor api monitors get -k test-api-key 2>&1 +@test "API monitors update requires key" { + run ../cronitor api monitors --update '{}' -k test-api-key 2>&1 [ "$status" -eq 1 ] [[ "$output" == *"key is required"* ]] } -@test "API monitors update requires key" { - run ../cronitor api monitors update -k test-api-key 2>&1 +@test "API monitors pause requires key" { + run ../cronitor api monitors --pause -k test-api-key 2>&1 [ "$status" -eq 1 ] [[ "$output" == *"key is required"* ]] } -@test "API monitors pause requires key" { - run ../cronitor api monitors pause -k test-api-key 2>&1 +@test "API monitors delete requires key" { + run ../cronitor api monitors --delete -k test-api-key 2>&1 [ "$status" -eq 1 ] [[ "$output" == *"key is required"* ]] } -@test "API monitors create requires body" { - run ../cronitor api monitors create -k test-api-key 2>&1 +@test "API monitors new rejects invalid JSON" { + run ../cronitor api monitors --new 'not valid json' -k test-api-key 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"request body is required"* ]] + [[ "$output" == *"Invalid JSON"* ]] } ################# @@ -90,13 +85,12 @@ setup() { ../cronitor api issues --help | grep -q "Manage Cronitor issues" } -@test "API issues help shows all actions" { - ../cronitor api issues --help | grep -q "list" - ../cronitor api issues --help | grep -q "get" - ../cronitor api issues --help | grep -q "create" - ../cronitor api issues --help | grep -q "update" - ../cronitor api issues --help | grep -q "delete" - ../cronitor api issues --help | grep -q "bulk" +@test "API issues help shows examples" { + ../cronitor api issues --help | grep -q "cronitor api issues" + ../cronitor api issues --help | grep -q "\-\-new" + ../cronitor api issues --help | grep -q "\-\-update" + ../cronitor api issues --help | grep -q "\-\-delete" + ../cronitor api issues --help | grep -q "\-\-resolve" } @test "API issues has --state flag" { @@ -107,16 +101,22 @@ setup() { ../cronitor api issues --help | grep -q "\-\-severity" } -@test "API issues get requires key" { - run ../cronitor api issues get -k test-api-key 2>&1 +@test "API issues update requires key" { + run ../cronitor api issues --update '{}' -k test-api-key 2>&1 [ "$status" -eq 1 ] [[ "$output" == *"key is required"* ]] } -@test "API issues create requires body" { - run ../cronitor api issues create -k test-api-key 2>&1 +@test "API issues delete requires key" { + run ../cronitor api issues --delete -k test-api-key 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"request body is required"* ]] + [[ "$output" == *"key is required"* ]] +} + +@test "API issues new rejects invalid JSON" { + run ../cronitor api issues --new '{broken' -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid JSON"* ]] } ################# @@ -127,24 +127,29 @@ setup() { ../cronitor api statuspages --help | grep -q "Manage Cronitor status pages" } -@test "API statuspages help shows all actions" { - ../cronitor api statuspages --help | grep -q "list" - ../cronitor api statuspages --help | grep -q "get" - ../cronitor api statuspages --help | grep -q "create" - ../cronitor api statuspages --help | grep -q "update" - ../cronitor api statuspages --help | grep -q "delete" +@test "API statuspages help shows examples" { + ../cronitor api statuspages --help | grep -q "cronitor api statuspages" + ../cronitor api statuspages --help | grep -q "\-\-new" + ../cronitor api statuspages --help | grep -q "\-\-update" + ../cronitor api statuspages --help | grep -q "\-\-delete" } -@test "API statuspages get requires key" { - run ../cronitor api statuspages get -k test-api-key 2>&1 +@test "API statuspages update requires key" { + run ../cronitor api statuspages --update '{}' -k test-api-key 2>&1 [ "$status" -eq 1 ] [[ "$output" == *"key is required"* ]] } -@test "API statuspages create requires body" { - run ../cronitor api statuspages create -k test-api-key 2>&1 +@test "API statuspages delete requires key" { + run ../cronitor api statuspages --delete -k test-api-key 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"request body is required"* ]] + [[ "$output" == *"key is required"* ]] +} + +@test "API statuspages new rejects invalid JSON" { + run ../cronitor api statuspages --new 'invalid' -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid JSON"* ]] } ################# @@ -155,20 +160,25 @@ setup() { ../cronitor api components --help | grep -qi "status page components" } -@test "API components help shows all actions" { - ../cronitor api components --help | grep -q "list" - ../cronitor api components --help | grep -q "get" - ../cronitor api components --help | grep -q "create" - ../cronitor api components --help | grep -q "update" - ../cronitor api components --help | grep -q "delete" +@test "API components help shows examples" { + ../cronitor api components --help | grep -q "cronitor api components" + ../cronitor api components --help | grep -q "\-\-new" + ../cronitor api components --help | grep -q "\-\-update" + ../cronitor api components --help | grep -q "\-\-delete" } @test "API components has --statuspage flag" { ../cronitor api components --help | grep -q "\-\-statuspage" } -@test "API components get requires key" { - run ../cronitor api components get -k test-api-key 2>&1 +@test "API components update requires key" { + run ../cronitor api components --update '{}' -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"key is required"* ]] +} + +@test "API components delete requires key" { + run ../cronitor api components --delete -k test-api-key 2>&1 [ "$status" -eq 1 ] [[ "$output" == *"key is required"* ]] } @@ -181,26 +191,25 @@ setup() { ../cronitor api incidents --help | grep -qi "status page incidents" } -@test "API incidents help shows all actions" { - ../cronitor api incidents --help | grep -q "list" - ../cronitor api incidents --help | grep -q "get" - ../cronitor api incidents --help | grep -q "create" - ../cronitor api incidents --help | grep -q "update" - ../cronitor api incidents --help | grep -q "resolve" +@test "API incidents help shows examples" { + ../cronitor api incidents --help | grep -q "cronitor api incidents" + ../cronitor api incidents --help | grep -q "\-\-new" + ../cronitor api incidents --help | grep -q "\-\-update" + ../cronitor api incidents --help | grep -q "\-\-resolve" } @test "API incidents has --statuspage flag" { ../cronitor api incidents --help | grep -q "\-\-statuspage" } -@test "API incidents get requires key" { - run ../cronitor api incidents get -k test-api-key 2>&1 +@test "API incidents update requires ID" { + run ../cronitor api incidents --update '{}' -k test-api-key 2>&1 [ "$status" -eq 1 ] [[ "$output" == *"ID is required"* ]] } -@test "API incidents resolve requires key" { - run ../cronitor api incidents resolve -k test-api-key 2>&1 +@test "API incidents resolve requires ID" { + run ../cronitor api incidents --resolve -k test-api-key 2>&1 [ "$status" -eq 1 ] [[ "$output" == *"ID is required"* ]] } @@ -234,24 +243,29 @@ setup() { ../cronitor api notifications --help | grep -qi "notification lists" } -@test "API notifications help shows all actions" { - ../cronitor api notifications --help | grep -q "list" - ../cronitor api notifications --help | grep -q "get" - ../cronitor api notifications --help | grep -q "create" - ../cronitor api notifications --help | grep -q "update" - ../cronitor api notifications --help | grep -q "delete" +@test "API notifications help shows examples" { + ../cronitor api notifications --help | grep -q "cronitor api notifications" + ../cronitor api notifications --help | grep -q "\-\-new" + ../cronitor api notifications --help | grep -q "\-\-update" + ../cronitor api notifications --help | grep -q "\-\-delete" } -@test "API notifications get requires key" { - run ../cronitor api notifications get -k test-api-key 2>&1 +@test "API notifications update requires key" { + run ../cronitor api notifications --update '{}' -k test-api-key 2>&1 [ "$status" -eq 1 ] [[ "$output" == *"key is required"* ]] } -@test "API notifications create requires body" { - run ../cronitor api notifications create -k test-api-key 2>&1 +@test "API notifications delete requires key" { + run ../cronitor api notifications --delete -k test-api-key 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"request body is required"* ]] + [[ "$output" == *"key is required"* ]] +} + +@test "API notifications new rejects invalid JSON" { + run ../cronitor api notifications --new 'bad json' -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid JSON"* ]] } ################# @@ -262,38 +276,35 @@ setup() { ../cronitor api environments --help | grep -qi "environments" } -@test "API environments help shows all actions" { - ../cronitor api environments --help | grep -q "list" - ../cronitor api environments --help | grep -q "get" - ../cronitor api environments --help | grep -q "create" - ../cronitor api environments --help | grep -q "update" - ../cronitor api environments --help | grep -q "delete" +@test "API environments help shows examples" { + ../cronitor api environments --help | grep -q "cronitor api environments" + ../cronitor api environments --help | grep -q "\-\-new" + ../cronitor api environments --help | grep -q "\-\-update" + ../cronitor api environments --help | grep -q "\-\-delete" +} + +@test "API environments update requires key" { + run ../cronitor api environments --update '{}' -k test-api-key 2>&1 + [ "$status" -eq 1 ] + [[ "$output" == *"key is required"* ]] } -@test "API environments get requires key" { - run ../cronitor api environments get -k test-api-key 2>&1 +@test "API environments delete requires key" { + run ../cronitor api environments --delete -k test-api-key 2>&1 [ "$status" -eq 1 ] [[ "$output" == *"key is required"* ]] } -@test "API environments create requires body" { - run ../cronitor api environments create -k test-api-key 2>&1 +@test "API environments new rejects invalid JSON" { + run ../cronitor api environments --new 'bad json' -k test-api-key 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"request body is required"* ]] + [[ "$output" == *"Invalid JSON"* ]] } ################# # GLOBAL FLAGS TESTS ################# -@test "API command has --data flag" { - ../cronitor api --help | grep -q "\-d, \-\-data" -} - -@test "API command has --file flag" { - ../cronitor api --help | grep -q "\-f, \-\-file" -} - @test "API command has --format flag" { ../cronitor api --help | grep -q "\-\-format" } @@ -310,26 +321,6 @@ setup() { ../cronitor api --help | grep -q "\-\-raw" } -@test "API command has --env global flag" { - ../cronitor api --help | grep -q "\-\-env" -} - -################# -# JSON VALIDATION TESTS -################# - -@test "API monitors create rejects invalid JSON" { - run ../cronitor api monitors create -k test-api-key --data 'not valid json' 2>&1 - [ "$status" -eq 1 ] - [[ "$output" == *"invalid JSON"* ]] -} - -@test "API issues create rejects invalid JSON" { - run ../cronitor api issues create -k test-api-key --data '{broken' 2>&1 - [ "$status" -eq 1 ] - [[ "$output" == *"invalid JSON"* ]] -} - ################# # INTEGRATION TESTS (SKIPPED BY DEFAULT) ################# @@ -341,12 +332,12 @@ setup() { @test "API monitors get integration test" { skip "Integration test requires valid API key and existing monitor" - # ../cronitor api monitors get test-monitor -k $CRONITOR_API_KEY + # ../cronitor api monitors test-monitor -k $CRONITOR_API_KEY } @test "API monitors create integration test" { skip "Integration test requires valid API key" - # ../cronitor api monitors create -k $CRONITOR_API_KEY --data '{"key":"test-cli-monitor","type":"job"}' + # ../cronitor api monitors --new '{"key":"test-cli-monitor","type":"job"}' -k $CRONITOR_API_KEY } @test "API issues list integration test" { From 2bf032dca9b13b46b36bb18c3d58b694806d0a96 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 21:34:49 +0000 Subject: [PATCH 03/25] API - Remove incidents and components (not separate resources) Incidents are the same as issues, and components are part of status pages rather than standalone API resources. https://claude.ai/code/session_01UDueW9A6SuxugCcsbAMfdB --- cmd/api_components.go | 179 ------------------------------------------ cmd/api_incidents.go | 177 ----------------------------------------- tests/test-api.bats | 64 --------------- 3 files changed, 420 deletions(-) delete mode 100644 cmd/api_components.go delete mode 100644 cmd/api_incidents.go diff --git a/cmd/api_components.go b/cmd/api_components.go deleted file mode 100644 index 48a3689..0000000 --- a/cmd/api_components.go +++ /dev/null @@ -1,179 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - - "github.com/cronitorio/cronitor-cli/lib" - "github.com/spf13/cobra" -) - -var ( - componentNew string - componentUpdate string - componentDelete bool - componentStatuspage string -) - -var apiComponentsCmd = &cobra.Command{ - Use: "components [key]", - Short: "Manage status page components", - Long: ` -Manage Cronitor status page components. - -Components are the building blocks of status pages. Each component represents -a monitor or group of monitors that feed into your status page. - -Examples: - List all components: - $ cronitor api components - - List components for a specific status page: - $ cronitor api components --statuspage - - Get a specific component: - $ cronitor api components - - Create a component: - $ cronitor api components --new '{"name":"API Server","statuspage":"my-status-page","monitor":"api-check"}' - - Update a component: - $ cronitor api components --update '{"name":"Updated Name"}' - - Delete a component: - $ cronitor api components --delete - - Output as table: - $ cronitor api components --format table -`, - Run: func(cmd *cobra.Command, args []string) { - client := getAPIClient() - key := "" - if len(args) > 0 { - key = args[0] - } - - switch { - case componentNew != "": - createComponent(client, componentNew) - case componentUpdate != "": - if key == "" { - fatal("component key is required for --update", 1) - } - updateComponent(client, key, componentUpdate) - case componentDelete: - if key == "" { - fatal("component key is required for --delete", 1) - } - deleteComponent(client, key) - case key != "": - getComponent(client, key) - default: - listComponents(client) - } - }, -} - -func init() { - apiCmd.AddCommand(apiComponentsCmd) - apiComponentsCmd.Flags().StringVar(&componentNew, "new", "", "Create component with JSON data") - apiComponentsCmd.Flags().StringVar(&componentUpdate, "update", "", "Update component with JSON data") - apiComponentsCmd.Flags().BoolVar(&componentDelete, "delete", false, "Delete the component") - apiComponentsCmd.Flags().StringVar(&componentStatuspage, "statuspage", "", "Filter by status page key") -} - -func listComponents(client *lib.APIClient) { - params := buildQueryParams() - if componentStatuspage != "" { - params["statuspage"] = componentStatuspage - } - - resp, err := client.GET("/statuspage_components", params) - if err != nil { - fatal(fmt.Sprintf("Failed to list components: %s", err), 1) - } - - outputResponse(resp, []string{"Key", "Name", "Status Page", "Monitor", "Status"}, - func(data []byte) [][]string { - var result struct { - Components []struct { - Key string `json:"key"` - Name string `json:"name"` - StatusPage string `json:"statuspage"` - Monitor string `json:"monitor"` - Status string `json:"status"` - } `json:"components"` - } - if err := json.Unmarshal(data, &result); err != nil { - return nil - } - - rows := make([][]string, len(result.Components)) - for i, c := range result.Components { - rows[i] = []string{c.Key, c.Name, c.StatusPage, c.Monitor, c.Status} - } - return rows - }) -} - -func getComponent(client *lib.APIClient, key string) { - resp, err := client.GET(fmt.Sprintf("/statuspage_components/%s", key), nil) - if err != nil { - fatal(fmt.Sprintf("Failed to get component: %s", err), 1) - } - - if resp.IsNotFound() { - fatal(fmt.Sprintf("Component '%s' could not be found", key), 1) - } - - outputResponse(resp, nil, nil) -} - -func createComponent(client *lib.APIClient, jsonData string) { - body := []byte(jsonData) - - var js json.RawMessage - if err := json.Unmarshal(body, &js); err != nil { - fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) - } - - resp, err := client.POST("/statuspage_components", body, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to create component: %s", err), 1) - } - - outputResponse(resp, nil, nil) -} - -func updateComponent(client *lib.APIClient, key string, jsonData string) { - body := []byte(jsonData) - - var js json.RawMessage - if err := json.Unmarshal(body, &js); err != nil { - fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) - } - - resp, err := client.PUT(fmt.Sprintf("/statuspage_components/%s", key), body, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to update component: %s", err), 1) - } - - outputResponse(resp, nil, nil) -} - -func deleteComponent(client *lib.APIClient, key string) { - resp, err := client.DELETE(fmt.Sprintf("/statuspage_components/%s", key), nil, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to delete component: %s", err), 1) - } - - if resp.IsNotFound() { - fatal(fmt.Sprintf("Component '%s' could not be found", key), 1) - } - - if resp.IsSuccess() { - fmt.Printf("Component '%s' deleted\n", key) - } else { - outputResponse(resp, nil, nil) - } -} diff --git a/cmd/api_incidents.go b/cmd/api_incidents.go deleted file mode 100644 index 0af6ac6..0000000 --- a/cmd/api_incidents.go +++ /dev/null @@ -1,177 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - - "github.com/cronitorio/cronitor-cli/lib" - "github.com/spf13/cobra" -) - -var ( - incidentNew string - incidentUpdate string - incidentResolve bool - incidentStatuspage string -) - -var apiIncidentsCmd = &cobra.Command{ - Use: "incidents [id]", - Short: "Manage status page incidents", - Long: ` -Manage Cronitor status page incidents. - -Incidents communicate problems on your status pages - either created -automatically by monitor failures or manually for planned maintenance. - -Examples: - List all incidents: - $ cronitor api incidents - - Filter by status page: - $ cronitor api incidents --statuspage - - Get a specific incident: - $ cronitor api incidents - - Create an incident: - $ cronitor api incidents --new '{"title":"API Degradation","severity":"warning","statuspage":"my-page"}' - - Update an incident: - $ cronitor api incidents --update '{"message":"Deploying fix..."}' - - Resolve an incident: - $ cronitor api incidents --resolve - - Output as table: - $ cronitor api incidents --format table -`, - Run: func(cmd *cobra.Command, args []string) { - client := getAPIClient() - id := "" - if len(args) > 0 { - id = args[0] - } - - switch { - case incidentNew != "": - createIncident(client, incidentNew) - case incidentUpdate != "": - if id == "" { - fatal("incident ID is required for --update", 1) - } - updateIncident(client, id, incidentUpdate) - case incidentResolve: - if id == "" { - fatal("incident ID is required for --resolve", 1) - } - resolveIncident(client, id) - case id != "": - getIncident(client, id) - default: - listIncidents(client) - } - }, -} - -func init() { - apiCmd.AddCommand(apiIncidentsCmd) - apiIncidentsCmd.Flags().StringVar(&incidentNew, "new", "", "Create incident with JSON data") - apiIncidentsCmd.Flags().StringVar(&incidentUpdate, "update", "", "Add update to incident with JSON data") - apiIncidentsCmd.Flags().BoolVar(&incidentResolve, "resolve", false, "Resolve the incident") - apiIncidentsCmd.Flags().StringVar(&incidentStatuspage, "statuspage", "", "Filter by status page key") -} - -func listIncidents(client *lib.APIClient) { - params := buildQueryParams() - if incidentStatuspage != "" { - params["statuspage"] = incidentStatuspage - } - - resp, err := client.GET("/incidents", params) - if err != nil { - fatal(fmt.Sprintf("Failed to list incidents: %s", err), 1) - } - - outputResponse(resp, []string{"ID", "Title", "Status", "Severity", "Created"}, - func(data []byte) [][]string { - var result struct { - Incidents []struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Severity string `json:"severity"` - CreatedAt string `json:"created_at"` - } `json:"incidents"` - } - if err := json.Unmarshal(data, &result); err != nil { - return nil - } - - rows := make([][]string, len(result.Incidents)) - for i, inc := range result.Incidents { - rows[i] = []string{inc.ID, inc.Title, inc.Status, inc.Severity, inc.CreatedAt} - } - return rows - }) -} - -func getIncident(client *lib.APIClient, id string) { - resp, err := client.GET(fmt.Sprintf("/incidents/%s", id), nil) - if err != nil { - fatal(fmt.Sprintf("Failed to get incident: %s", err), 1) - } - - if resp.IsNotFound() { - fatal(fmt.Sprintf("Incident '%s' could not be found", id), 1) - } - - outputResponse(resp, nil, nil) -} - -func createIncident(client *lib.APIClient, jsonData string) { - body := []byte(jsonData) - - var js json.RawMessage - if err := json.Unmarshal(body, &js); err != nil { - fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) - } - - resp, err := client.POST("/incidents", body, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to create incident: %s", err), 1) - } - - outputResponse(resp, nil, nil) -} - -func updateIncident(client *lib.APIClient, id string, jsonData string) { - body := []byte(jsonData) - - var js json.RawMessage - if err := json.Unmarshal(body, &js); err != nil { - fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) - } - - resp, err := client.POST(fmt.Sprintf("/incidents/%s/updates", id), body, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to update incident: %s", err), 1) - } - - outputResponse(resp, nil, nil) -} - -func resolveIncident(client *lib.APIClient, id string) { - body := []byte(`{"status":"resolved"}`) - - resp, err := client.PUT(fmt.Sprintf("/incidents/%s", id), body, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to resolve incident: %s", err), 1) - } - - if resp.IsSuccess() { - fmt.Printf("Incident '%s' resolved\n", id) - } else { - outputResponse(resp, nil, nil) - } -} diff --git a/tests/test-api.bats b/tests/test-api.bats index f6435a6..2e5d065 100644 --- a/tests/test-api.bats +++ b/tests/test-api.bats @@ -19,8 +19,6 @@ setup() { ../cronitor api --help | grep -q "monitors" ../cronitor api --help | grep -q "issues" ../cronitor api --help | grep -q "statuspages" - ../cronitor api --help | grep -q "components" - ../cronitor api --help | grep -q "incidents" ../cronitor api --help | grep -q "metrics" ../cronitor api --help | grep -q "notifications" ../cronitor api --help | grep -q "environments" @@ -152,68 +150,6 @@ setup() { [[ "$output" == *"Invalid JSON"* ]] } -################# -# COMPONENTS SUBCOMMAND TESTS -################# - -@test "API components shows help" { - ../cronitor api components --help | grep -qi "status page components" -} - -@test "API components help shows examples" { - ../cronitor api components --help | grep -q "cronitor api components" - ../cronitor api components --help | grep -q "\-\-new" - ../cronitor api components --help | grep -q "\-\-update" - ../cronitor api components --help | grep -q "\-\-delete" -} - -@test "API components has --statuspage flag" { - ../cronitor api components --help | grep -q "\-\-statuspage" -} - -@test "API components update requires key" { - run ../cronitor api components --update '{}' -k test-api-key 2>&1 - [ "$status" -eq 1 ] - [[ "$output" == *"key is required"* ]] -} - -@test "API components delete requires key" { - run ../cronitor api components --delete -k test-api-key 2>&1 - [ "$status" -eq 1 ] - [[ "$output" == *"key is required"* ]] -} - -################# -# INCIDENTS SUBCOMMAND TESTS -################# - -@test "API incidents shows help" { - ../cronitor api incidents --help | grep -qi "status page incidents" -} - -@test "API incidents help shows examples" { - ../cronitor api incidents --help | grep -q "cronitor api incidents" - ../cronitor api incidents --help | grep -q "\-\-new" - ../cronitor api incidents --help | grep -q "\-\-update" - ../cronitor api incidents --help | grep -q "\-\-resolve" -} - -@test "API incidents has --statuspage flag" { - ../cronitor api incidents --help | grep -q "\-\-statuspage" -} - -@test "API incidents update requires ID" { - run ../cronitor api incidents --update '{}' -k test-api-key 2>&1 - [ "$status" -eq 1 ] - [[ "$output" == *"ID is required"* ]] -} - -@test "API incidents resolve requires ID" { - run ../cronitor api incidents --resolve -k test-api-key 2>&1 - [ "$status" -eq 1 ] - [[ "$output" == *"ID is required"* ]] -} - ################# # METRICS SUBCOMMAND TESTS ################# From d4a40c1843dff90ed4989380de6be96b323fd7ab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 22:32:50 +0000 Subject: [PATCH 04/25] Redesign API commands as top-level resource commands with subcommands Replace `cronitor api ` with `cronitor `: cronitor monitor list cronitor monitor get cronitor monitor create --data '{...}' cronitor monitor update --data '{...}' cronitor monitor delete cronitor monitor pause cronitor monitor unpause Same pattern for: statuspage, issue, notification, environment Features: - Beautiful table output using lipgloss styling - Colored status indicators (passing/failing/paused) - Consistent --format, --output, --page flags - Aliases: `env` for environment, `notifications` for notification https://claude.ai/code/session_01UDueW9A6SuxugCcsbAMfdB --- cmd/api.go | 221 ------------------ cmd/api_environments.go | 172 -------------- cmd/api_issues.go | 212 ----------------- cmd/api_metrics.go | 154 ------------- cmd/api_monitors.go | 275 ----------------------- cmd/api_notifications.go | 168 -------------- cmd/api_statuspages.go | 171 -------------- cmd/environment.go | 278 +++++++++++++++++++++++ cmd/issue.go | 359 +++++++++++++++++++++++++++++ cmd/monitor.go | 474 +++++++++++++++++++++++++++++++++++++++ cmd/notification.go | 282 +++++++++++++++++++++++ cmd/statuspage.go | 283 +++++++++++++++++++++++ cmd/ui.go | 235 +++++++++++++++++++ tests/test-api.bats | 298 ++++++++++++------------ 14 files changed, 2052 insertions(+), 1530 deletions(-) delete mode 100644 cmd/api.go delete mode 100644 cmd/api_environments.go delete mode 100644 cmd/api_issues.go delete mode 100644 cmd/api_metrics.go delete mode 100644 cmd/api_monitors.go delete mode 100644 cmd/api_notifications.go delete mode 100644 cmd/api_statuspages.go create mode 100644 cmd/environment.go create mode 100644 cmd/issue.go create mode 100644 cmd/monitor.go create mode 100644 cmd/notification.go create mode 100644 cmd/statuspage.go create mode 100644 cmd/ui.go diff --git a/cmd/api.go b/cmd/api.go deleted file mode 100644 index 3d1f719..0000000 --- a/cmd/api.go +++ /dev/null @@ -1,221 +0,0 @@ -package cmd - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "os" - "strconv" - "strings" - - "github.com/cronitorio/cronitor-cli/lib" - "github.com/olekukonko/tablewriter" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -// API command flags -var ( - apiData string - apiFile string - apiFormat string - apiPage int - apiEnv string - apiMonitor string - apiOutput string - apiRaw bool -) - -// apiCmd represents the api command -var apiCmd = &cobra.Command{ - Use: "api", - Short: "Interact with the Cronitor API", - Long: ` -Interact with the Cronitor API to manage monitors, issues, status pages, and more. - -This command provides access to all Cronitor API resources: - monitors - Manage monitors (jobs, checks, heartbeats, sites) - issues - Manage issues and incidents - statuspages - Manage status pages - components - Manage status page components - incidents - Manage status page incidents - metrics - View monitor metrics and performance data - notifications - Manage notification lists - environments - Manage environments - -Examples: - List all monitors: - $ cronitor api monitors - - Get a specific monitor: - $ cronitor api monitors get - - Get a monitor with latest events: - $ cronitor api monitors get --with-events - - Create a new monitor: - $ cronitor api monitors create --data '{"key":"my-job","type":"job"}' - - Update a monitor: - $ cronitor api monitors update --data '{"name":"Updated Name"}' - - Delete a monitor: - $ cronitor api monitors delete - - List issues: - $ cronitor api issues - - Get metrics for a monitor: - $ cronitor api metrics --monitor -`, - Args: func(cmd *cobra.Command, args []string) error { - if len(viper.GetString(varApiKey)) < 10 { - return errors.New("you must provide an API key with this command or save a key using 'cronitor configure'") - } - return nil - }, - Run: func(cmd *cobra.Command, args []string) { - cmd.Help() - }, -} - -func init() { - RootCmd.AddCommand(apiCmd) - - // Global API flags - apiCmd.PersistentFlags().StringVarP(&apiData, "data", "d", "", "JSON data for create/update operations") - apiCmd.PersistentFlags().StringVarP(&apiFile, "file", "f", "", "JSON file for create/update operations") - apiCmd.PersistentFlags().StringVar(&apiFormat, "format", "json", "Output format: json, table") - apiCmd.PersistentFlags().IntVar(&apiPage, "page", 1, "Page number for paginated results") - apiCmd.PersistentFlags().StringVar(&apiEnv, "env", "", "Filter by environment") - apiCmd.PersistentFlags().StringVar(&apiMonitor, "monitor", "", "Filter by monitor key") - apiCmd.PersistentFlags().StringVarP(&apiOutput, "output", "o", "", "Output to file instead of stdout") - apiCmd.PersistentFlags().BoolVar(&apiRaw, "raw", false, "Output raw JSON without formatting") -} - -// getAPIClient returns a configured API client -func getAPIClient() *lib.APIClient { - return lib.NewAPIClient(dev, log) -} - -// getRequestBody returns the request body from --data or --file flag -func getRequestBody() ([]byte, error) { - if apiData != "" && apiFile != "" { - return nil, errors.New("cannot specify both --data and --file") - } - - if apiData != "" { - // Validate JSON - var js json.RawMessage - if err := json.Unmarshal([]byte(apiData), &js); err != nil { - return nil, fmt.Errorf("invalid JSON in --data: %w", err) - } - return []byte(apiData), nil - } - - if apiFile != "" { - data, err := os.ReadFile(apiFile) - if err != nil { - return nil, fmt.Errorf("failed to read file %s: %w", apiFile, err) - } - // Validate JSON - var js json.RawMessage - if err := json.Unmarshal(data, &js); err != nil { - return nil, fmt.Errorf("invalid JSON in file %s: %w", apiFile, err) - } - return data, nil - } - - return nil, nil -} - -// outputResponse outputs the API response in the requested format -func outputResponse(resp *lib.APIResponse, tableHeaders []string, tableExtractor func([]byte) [][]string) { - if !resp.IsSuccess() { - fatal(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError()), 1) - } - - var output string - if apiFormat == "table" && tableHeaders != nil && tableExtractor != nil { - output = formatAsTable(resp.Body, tableHeaders, tableExtractor) - } else if apiRaw { - output = string(resp.Body) - } else { - output = resp.FormatJSON() - } - - writeOutput(output) -} - -// formatAsTable formats the response as a table -func formatAsTable(data []byte, headers []string, extractor func([]byte) [][]string) string { - rows := extractor(data) - if rows == nil { - return string(data) - } - - var buf strings.Builder - table := tablewriter.NewWriter(&buf) - table.SetHeader(headers) - table.SetAutoWrapText(false) - table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) - table.SetAlignment(tablewriter.ALIGN_LEFT) - table.AppendBulk(rows) - table.Render() - - return buf.String() -} - -// writeOutput writes the output to stdout or a file -func writeOutput(output string) { - if apiOutput != "" { - if err := os.WriteFile(apiOutput, []byte(output), 0644); err != nil { - fatal(fmt.Sprintf("Failed to write to file %s: %s", apiOutput, err), 1) - } - fmt.Printf("Output written to %s\n", apiOutput) - } else { - fmt.Println(output) - } -} - -// readStdinIfEmpty reads from stdin if no data is provided -func readStdinIfEmpty() ([]byte, error) { - body, err := getRequestBody() - if err != nil { - return nil, err - } - - if body == nil { - // Check if stdin has data - stat, _ := os.Stdin.Stat() - if (stat.Mode() & os.ModeCharDevice) == 0 { - body, err = io.ReadAll(os.Stdin) - if err != nil { - return nil, fmt.Errorf("failed to read from stdin: %w", err) - } - // Validate JSON - var js json.RawMessage - if err := json.Unmarshal(body, &js); err != nil { - return nil, fmt.Errorf("invalid JSON from stdin: %w", err) - } - } - } - - return body, nil -} - -// buildQueryParams builds query parameters from common flags -func buildQueryParams() map[string]string { - params := make(map[string]string) - if apiPage > 1 { - params["page"] = strconv.Itoa(apiPage) - } - if apiEnv != "" { - params["env"] = apiEnv - } - if apiMonitor != "" { - params["monitor"] = apiMonitor - } - return params -} diff --git a/cmd/api_environments.go b/cmd/api_environments.go deleted file mode 100644 index 5da0d06..0000000 --- a/cmd/api_environments.go +++ /dev/null @@ -1,172 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - - "github.com/cronitorio/cronitor-cli/lib" - "github.com/spf13/cobra" -) - -var ( - environmentNew string - environmentUpdate string - environmentDelete bool -) - -var apiEnvironmentsCmd = &cobra.Command{ - Use: "environments [key]", - Short: "Manage environments", - Long: ` -Manage Cronitor environments. - -Environments separate monitoring data between deployment stages (staging, production) -while sharing monitor configurations. - -Examples: - List all environments: - $ cronitor api environments - - Get a specific environment: - $ cronitor api environments - - Create an environment: - $ cronitor api environments --new '{"key":"staging","name":"Staging"}' - - Update an environment: - $ cronitor api environments --update '{"name":"Updated Name"}' - - Delete an environment: - $ cronitor api environments --delete - - Output as table: - $ cronitor api environments --format table -`, - Run: func(cmd *cobra.Command, args []string) { - client := getAPIClient() - key := "" - if len(args) > 0 { - key = args[0] - } - - switch { - case environmentNew != "": - createEnvironment(client, environmentNew) - case environmentUpdate != "": - if key == "" { - fatal("environment key is required for --update", 1) - } - updateEnvironment(client, key, environmentUpdate) - case environmentDelete: - if key == "" { - fatal("environment key is required for --delete", 1) - } - deleteEnvironment(client, key) - case key != "": - getEnvironment(client, key) - default: - listEnvironments(client) - } - }, -} - -func init() { - apiCmd.AddCommand(apiEnvironmentsCmd) - apiEnvironmentsCmd.Flags().StringVar(&environmentNew, "new", "", "Create environment with JSON data") - apiEnvironmentsCmd.Flags().StringVar(&environmentUpdate, "update", "", "Update environment with JSON data") - apiEnvironmentsCmd.Flags().BoolVar(&environmentDelete, "delete", false, "Delete the environment") -} - -func listEnvironments(client *lib.APIClient) { - params := buildQueryParams() - resp, err := client.GET("/environments", params) - if err != nil { - fatal(fmt.Sprintf("Failed to list environments: %s", err), 1) - } - - outputResponse(resp, []string{"Key", "Name", "Default"}, - func(data []byte) [][]string { - var result struct { - Environments []struct { - Key string `json:"key"` - Name string `json:"name"` - IsDefault bool `json:"is_default"` - } `json:"environments"` - } - if err := json.Unmarshal(data, &result); err != nil { - return nil - } - - rows := make([][]string, len(result.Environments)) - for i, e := range result.Environments { - isDefault := "" - if e.IsDefault { - isDefault = "Yes" - } - rows[i] = []string{e.Key, e.Name, isDefault} - } - return rows - }) -} - -func getEnvironment(client *lib.APIClient, key string) { - resp, err := client.GET(fmt.Sprintf("/environments/%s", key), nil) - if err != nil { - fatal(fmt.Sprintf("Failed to get environment: %s", err), 1) - } - - if resp.IsNotFound() { - fatal(fmt.Sprintf("Environment '%s' could not be found", key), 1) - } - - outputResponse(resp, nil, nil) -} - -func createEnvironment(client *lib.APIClient, jsonData string) { - body := []byte(jsonData) - - var js json.RawMessage - if err := json.Unmarshal(body, &js); err != nil { - fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) - } - - resp, err := client.POST("/environments", body, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to create environment: %s", err), 1) - } - - outputResponse(resp, nil, nil) -} - -func updateEnvironment(client *lib.APIClient, key string, jsonData string) { - body := []byte(jsonData) - - var js json.RawMessage - if err := json.Unmarshal(body, &js); err != nil { - fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) - } - - resp, err := client.PUT(fmt.Sprintf("/environments/%s", key), body, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to update environment: %s", err), 1) - } - - outputResponse(resp, nil, nil) -} - -func deleteEnvironment(client *lib.APIClient, key string) { - resp, err := client.DELETE(fmt.Sprintf("/environments/%s", key), nil, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to delete environment: %s", err), 1) - } - - if resp.IsNotFound() { - fatal(fmt.Sprintf("Environment '%s' could not be found", key), 1) - } - - if resp.IsSuccess() { - fmt.Printf("Environment '%s' deleted\n", key) - } else { - outputResponse(resp, nil, nil) - } -} diff --git a/cmd/api_issues.go b/cmd/api_issues.go deleted file mode 100644 index bed4949..0000000 --- a/cmd/api_issues.go +++ /dev/null @@ -1,212 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - - "github.com/cronitorio/cronitor-cli/lib" - "github.com/spf13/cobra" -) - -var ( - issueNew string - issueUpdate string - issueDelete bool - issueResolve bool - issueState string - issueSeverity string -) - -var apiIssuesCmd = &cobra.Command{ - Use: "issues [key]", - Short: "Manage issues", - Long: ` -Manage Cronitor issues and incidents. - -Issues are Cronitor's incident management hub - they help your team coordinate -response when monitors fail. - -Examples: - List all issues: - $ cronitor api issues - - List open issues: - $ cronitor api issues --state open - - Filter by severity: - $ cronitor api issues --severity critical - - Get a specific issue: - $ cronitor api issues - - Create an issue: - $ cronitor api issues --new '{"title":"Service Outage","severity":"critical"}' - - Update an issue: - $ cronitor api issues --update '{"message":"Investigating..."}' - - Resolve an issue: - $ cronitor api issues --resolve - - Delete an issue: - $ cronitor api issues --delete - - Output as table: - $ cronitor api issues --format table -`, - Run: func(cmd *cobra.Command, args []string) { - client := getAPIClient() - key := "" - if len(args) > 0 { - key = args[0] - } - - switch { - case issueNew != "": - createIssue(client, issueNew) - case issueUpdate != "": - if key == "" { - fatal("issue key is required for --update", 1) - } - updateIssue(client, key, issueUpdate) - case issueResolve: - if key == "" { - fatal("issue key is required for --resolve", 1) - } - resolveIssue(client, key) - case issueDelete: - if key == "" { - fatal("issue key is required for --delete", 1) - } - deleteIssue(client, key) - case key != "": - getIssue(client, key) - default: - listIssues(client) - } - }, -} - -func init() { - apiCmd.AddCommand(apiIssuesCmd) - apiIssuesCmd.Flags().StringVar(&issueNew, "new", "", "Create issue with JSON data") - apiIssuesCmd.Flags().StringVar(&issueUpdate, "update", "", "Update issue with JSON data") - apiIssuesCmd.Flags().BoolVar(&issueDelete, "delete", false, "Delete the issue") - apiIssuesCmd.Flags().BoolVar(&issueResolve, "resolve", false, "Resolve the issue") - apiIssuesCmd.Flags().StringVar(&issueState, "state", "", "Filter by state (open, resolved)") - apiIssuesCmd.Flags().StringVar(&issueSeverity, "severity", "", "Filter by severity (critical, warning, info)") -} - -func listIssues(client *lib.APIClient) { - params := buildQueryParams() - if issueState != "" { - params["state"] = issueState - } - if issueSeverity != "" { - params["severity"] = issueSeverity - } - - resp, err := client.GET("/issues", params) - if err != nil { - fatal(fmt.Sprintf("Failed to list issues: %s", err), 1) - } - - outputResponse(resp, []string{"Key", "Title", "State", "Severity", "Created"}, - func(data []byte) [][]string { - var result struct { - Issues []struct { - Key string `json:"key"` - Title string `json:"title"` - State string `json:"state"` - Severity string `json:"severity"` - CreatedAt string `json:"created_at"` - } `json:"issues"` - } - if err := json.Unmarshal(data, &result); err != nil { - return nil - } - - rows := make([][]string, len(result.Issues)) - for i, issue := range result.Issues { - rows[i] = []string{issue.Key, issue.Title, issue.State, issue.Severity, issue.CreatedAt} - } - return rows - }) -} - -func getIssue(client *lib.APIClient, key string) { - resp, err := client.GET(fmt.Sprintf("/issues/%s", key), nil) - if err != nil { - fatal(fmt.Sprintf("Failed to get issue: %s", err), 1) - } - - if resp.IsNotFound() { - fatal(fmt.Sprintf("Issue '%s' could not be found", key), 1) - } - - outputResponse(resp, nil, nil) -} - -func createIssue(client *lib.APIClient, jsonData string) { - body := []byte(jsonData) - - var js json.RawMessage - if err := json.Unmarshal(body, &js); err != nil { - fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) - } - - resp, err := client.POST("/issues", body, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to create issue: %s", err), 1) - } - - outputResponse(resp, nil, nil) -} - -func updateIssue(client *lib.APIClient, key string, jsonData string) { - body := []byte(jsonData) - - var js json.RawMessage - if err := json.Unmarshal(body, &js); err != nil { - fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) - } - - resp, err := client.PUT(fmt.Sprintf("/issues/%s", key), body, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to update issue: %s", err), 1) - } - - outputResponse(resp, nil, nil) -} - -func resolveIssue(client *lib.APIClient, key string) { - body := []byte(`{"state":"resolved"}`) - - resp, err := client.PUT(fmt.Sprintf("/issues/%s", key), body, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to resolve issue: %s", err), 1) - } - - if resp.IsSuccess() { - fmt.Printf("Issue '%s' resolved\n", key) - } else { - outputResponse(resp, nil, nil) - } -} - -func deleteIssue(client *lib.APIClient, key string) { - resp, err := client.DELETE(fmt.Sprintf("/issues/%s", key), nil, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to delete issue: %s", err), 1) - } - - if resp.IsNotFound() { - fatal(fmt.Sprintf("Issue '%s' could not be found", key), 1) - } - - if resp.IsSuccess() { - fmt.Printf("Issue '%s' deleted\n", key) - } else { - outputResponse(resp, nil, nil) - } -} diff --git a/cmd/api_metrics.go b/cmd/api_metrics.go deleted file mode 100644 index c211d50..0000000 --- a/cmd/api_metrics.go +++ /dev/null @@ -1,154 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - - "github.com/cronitorio/cronitor-cli/lib" - "github.com/spf13/cobra" -) - -var metricsStart string -var metricsEnd string -var metricsGroup string -var metricsAggregates bool - -var apiMetricsCmd = &cobra.Command{ - Use: "metrics", - Short: "View monitor metrics and performance data", - Long: ` -View monitor metrics and performance data. - -The metrics API provides detailed time-series metrics data for your monitors, -including performance statistics, success rates, and execution counts. - -Examples: - Get metrics for all monitors: - $ cronitor api metrics - - Get metrics for a specific monitor: - $ cronitor api metrics --monitor - - Get metrics with time range: - $ cronitor api metrics --monitor --start 2024-01-01 --end 2024-01-31 - - Get metrics for a group: - $ cronitor api metrics --group - - Get aggregated statistics (summary without time-series): - $ cronitor api metrics --aggregates - - Get aggregates for a specific monitor: - $ cronitor api metrics --aggregates --monitor - - Filter by environment: - $ cronitor api metrics --monitor --env production - - Output as table: - $ cronitor api metrics --monitor --format table -`, - Run: func(cmd *cobra.Command, args []string) { - client := getAPIClient() - - if metricsAggregates { - getAggregates(client) - } else { - getMetrics(client) - } - }, -} - -func init() { - apiCmd.AddCommand(apiMetricsCmd) - apiMetricsCmd.Flags().StringVar(&metricsStart, "start", "", "Start date/time for metrics (e.g., 2024-01-01)") - apiMetricsCmd.Flags().StringVar(&metricsEnd, "end", "", "End date/time for metrics (e.g., 2024-01-31)") - apiMetricsCmd.Flags().StringVar(&metricsGroup, "group", "", "Filter by monitor group") - apiMetricsCmd.Flags().BoolVar(&metricsAggregates, "aggregates", false, "Get aggregated statistics instead of time-series") -} - -func getMetrics(client *lib.APIClient) { - params := buildQueryParams() - if metricsStart != "" { - params["start"] = metricsStart - } - if metricsEnd != "" { - params["end"] = metricsEnd - } - if metricsGroup != "" { - params["group"] = metricsGroup - } - - resp, err := client.GET("/metrics", params) - if err != nil { - fatal(fmt.Sprintf("Failed to get metrics: %s", err), 1) - } - - outputResponse(resp, []string{"Monitor", "Metric", "Value", "Timestamp"}, - func(data []byte) [][]string { - var result struct { - Metrics []struct { - Monitor string `json:"monitor"` - Metric string `json:"metric"` - Value float64 `json:"value"` - Timestamp string `json:"timestamp"` - } `json:"metrics"` - } - if err := json.Unmarshal(data, &result); err != nil { - return nil - } - - rows := make([][]string, len(result.Metrics)) - for i, m := range result.Metrics { - rows[i] = []string{m.Monitor, m.Metric, fmt.Sprintf("%.2f", m.Value), m.Timestamp} - } - return rows - }) -} - -func getAggregates(client *lib.APIClient) { - params := buildQueryParams() - if metricsStart != "" { - params["start"] = metricsStart - } - if metricsEnd != "" { - params["end"] = metricsEnd - } - if metricsGroup != "" { - params["group"] = metricsGroup - } - - resp, err := client.GET("/aggregates", params) - if err != nil { - fatal(fmt.Sprintf("Failed to get aggregates: %s", err), 1) - } - - outputResponse(resp, []string{"Monitor", "Total Runs", "Successes", "Failures", "Avg Duration", "Success Rate"}, - func(data []byte) [][]string { - var result struct { - Aggregates []struct { - Monitor string `json:"monitor"` - TotalRuns int `json:"total_runs"` - Successes int `json:"successes"` - Failures int `json:"failures"` - AvgDuration float64 `json:"avg_duration"` - SuccessRate float64 `json:"success_rate"` - } `json:"aggregates"` - } - if err := json.Unmarshal(data, &result); err != nil { - return nil - } - - rows := make([][]string, len(result.Aggregates)) - for i, a := range result.Aggregates { - rows[i] = []string{ - a.Monitor, - fmt.Sprintf("%d", a.TotalRuns), - fmt.Sprintf("%d", a.Successes), - fmt.Sprintf("%d", a.Failures), - fmt.Sprintf("%.2fs", a.AvgDuration), - fmt.Sprintf("%.1f%%", a.SuccessRate*100), - } - } - return rows - }) -} diff --git a/cmd/api_monitors.go b/cmd/api_monitors.go deleted file mode 100644 index 5c7af55..0000000 --- a/cmd/api_monitors.go +++ /dev/null @@ -1,275 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - - "github.com/cronitorio/cronitor-cli/lib" - "github.com/spf13/cobra" -) - -var ( - monitorNew string - monitorUpdate string - monitorDelete bool - monitorPause string - monitorUnpause bool - withLatestEvents bool -) - -var apiMonitorsCmd = &cobra.Command{ - Use: "monitors [key]", - Short: "Manage monitors", - Long: ` -Manage Cronitor monitors (jobs, checks, heartbeats, sites). - -Examples: - List all monitors: - $ cronitor api monitors - - List with pagination: - $ cronitor api monitors --page 2 - - Get a specific monitor: - $ cronitor api monitors - - Get with latest events: - $ cronitor api monitors --with-events - - Create a monitor: - $ cronitor api monitors --new '{"key":"my-job","type":"job"}' - - Update a monitor: - $ cronitor api monitors --update '{"name":"New Name"}' - - Delete a monitor: - $ cronitor api monitors --delete - - Pause a monitor (indefinitely): - $ cronitor api monitors --pause - - Pause for 24 hours: - $ cronitor api monitors --pause 24 - - Unpause a monitor: - $ cronitor api monitors --unpause - - Output as table: - $ cronitor api monitors --format table -`, - Run: func(cmd *cobra.Command, args []string) { - client := getAPIClient() - key := "" - if len(args) > 0 { - key = args[0] - } - - // Determine action based on flags - switch { - case monitorNew != "": - createMonitor(client, monitorNew) - case monitorUpdate != "": - if key == "" { - fatal("monitor key is required for --update", 1) - } - updateMonitor(client, key, monitorUpdate) - case monitorDelete: - if key == "" { - fatal("monitor key is required for --delete", 1) - } - deleteMonitor(client, key) - case cmd.Flags().Changed("pause"): - if key == "" { - fatal("monitor key is required for --pause", 1) - } - pauseMonitor(client, key, monitorPause) - case monitorUnpause: - if key == "" { - fatal("monitor key is required for --unpause", 1) - } - unpauseMonitor(client, key) - case key != "": - getMonitor(client, key) - default: - listMonitors(client) - } - }, -} - -func init() { - apiCmd.AddCommand(apiMonitorsCmd) - apiMonitorsCmd.Flags().StringVar(&monitorNew, "new", "", "Create monitor with JSON data") - apiMonitorsCmd.Flags().StringVar(&monitorUpdate, "update", "", "Update monitor with JSON data") - apiMonitorsCmd.Flags().BoolVar(&monitorDelete, "delete", false, "Delete the monitor") - apiMonitorsCmd.Flags().StringVar(&monitorPause, "pause", "", "Pause monitor (optionally specify hours)") - apiMonitorsCmd.Flags().BoolVar(&monitorUnpause, "unpause", false, "Unpause the monitor") - apiMonitorsCmd.Flags().BoolVar(&withLatestEvents, "with-events", false, "Include latest events") - apiMonitorsCmd.Flags().Lookup("pause").NoOptDefVal = "0" // Allow --pause without value -} - -func listMonitors(client *lib.APIClient) { - params := buildQueryParams() - resp, err := client.GET("/monitors", params) - if err != nil { - fatal(fmt.Sprintf("Failed to list monitors: %s", err), 1) - } - - outputResponse(resp, []string{"Key", "Name", "Type", "Status", "Alerts"}, - func(data []byte) [][]string { - var result struct { - Monitors []struct { - Key string `json:"key"` - Name string `json:"name"` - Type string `json:"type"` - Passing bool `json:"passing"` - Paused bool `json:"paused"` - } `json:"monitors"` - } - if err := json.Unmarshal(data, &result); err != nil { - return nil - } - - rows := make([][]string, len(result.Monitors)) - for i, m := range result.Monitors { - status := "Passing" - if !m.Passing { - status = "Failing" - } - alerts := "On" - if m.Paused { - alerts = "Muted" - } - name := m.Name - if name == "" { - name = m.Key - } - rows[i] = []string{m.Key, name, m.Type, status, alerts} - } - return rows - }) -} - -func getMonitor(client *lib.APIClient, key string) { - params := buildQueryParams() - if withLatestEvents { - params["withLatestEvents"] = "true" - } - resp, err := client.GET(fmt.Sprintf("/monitors/%s", key), params) - if err != nil { - fatal(fmt.Sprintf("Failed to get monitor: %s", err), 1) - } - - if resp.IsNotFound() { - fatal(fmt.Sprintf("Monitor '%s' could not be found", key), 1) - } - - outputResponse(resp, nil, nil) -} - -func createMonitor(client *lib.APIClient, jsonData string) { - body := []byte(jsonData) - - // Validate JSON - var js json.RawMessage - if err := json.Unmarshal(body, &js); err != nil { - fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) - } - - // Check if it's an array (bulk create) or single object - var testArray []json.RawMessage - isBulk := json.Unmarshal(body, &testArray) == nil && len(testArray) > 0 - - var resp *lib.APIResponse - var err error - if isBulk { - resp, err = client.PUT("/monitors", body, nil) - } else { - resp, err = client.POST("/monitors", body, nil) - } - - if err != nil { - fatal(fmt.Sprintf("Failed to create monitor: %s", err), 1) - } - - outputResponse(resp, nil, nil) -} - -func updateMonitor(client *lib.APIClient, key string, jsonData string) { - // Parse and add key to body - var bodyMap map[string]interface{} - if err := json.Unmarshal([]byte(jsonData), &bodyMap); err != nil { - fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) - } - bodyMap["key"] = key - body, _ := json.Marshal(bodyMap) - - // Wrap in array for PUT endpoint - body = []byte(fmt.Sprintf("[%s]", string(body))) - - resp, err := client.PUT("/monitors", body, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to update monitor: %s", err), 1) - } - - outputResponse(resp, nil, nil) -} - -func deleteMonitor(client *lib.APIClient, key string) { - resp, err := client.DELETE(fmt.Sprintf("/monitors/%s", key), nil, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to delete monitor: %s", err), 1) - } - - if resp.IsNotFound() { - fatal(fmt.Sprintf("Monitor '%s' could not be found", key), 1) - } - - if resp.IsSuccess() { - fmt.Printf("Monitor '%s' deleted\n", key) - } else { - outputResponse(resp, nil, nil) - } -} - -func pauseMonitor(client *lib.APIClient, key string, hours string) { - endpoint := fmt.Sprintf("/monitors/%s/pause", key) - if hours != "" && hours != "0" { - endpoint = fmt.Sprintf("%s/%s", endpoint, hours) - } - - resp, err := client.GET(endpoint, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to pause monitor: %s", err), 1) - } - - if resp.IsNotFound() { - fatal(fmt.Sprintf("Monitor '%s' could not be found", key), 1) - } - - if resp.IsSuccess() { - if hours != "" && hours != "0" { - fmt.Printf("Monitor '%s' paused for %s hours\n", key, hours) - } else { - fmt.Printf("Monitor '%s' paused\n", key) - } - } else { - outputResponse(resp, nil, nil) - } -} - -func unpauseMonitor(client *lib.APIClient, key string) { - resp, err := client.GET(fmt.Sprintf("/monitors/%s/pause/0", key), nil) - if err != nil { - fatal(fmt.Sprintf("Failed to unpause monitor: %s", err), 1) - } - - if resp.IsNotFound() { - fatal(fmt.Sprintf("Monitor '%s' could not be found", key), 1) - } - - if resp.IsSuccess() { - fmt.Printf("Monitor '%s' unpaused\n", key) - } else { - outputResponse(resp, nil, nil) - } -} diff --git a/cmd/api_notifications.go b/cmd/api_notifications.go deleted file mode 100644 index c1b2cc9..0000000 --- a/cmd/api_notifications.go +++ /dev/null @@ -1,168 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - - "github.com/cronitorio/cronitor-cli/lib" - "github.com/spf13/cobra" -) - -var ( - notificationNew string - notificationUpdate string - notificationDelete bool -) - -var apiNotificationsCmd = &cobra.Command{ - Use: "notifications [key]", - Short: "Manage notification lists", - Long: ` -Manage Cronitor notification lists. - -Notification lists define where alerts are sent when monitors detect issues. - -Examples: - List all notification lists: - $ cronitor api notifications - - Get a specific notification list: - $ cronitor api notifications - - Create a notification list: - $ cronitor api notifications --new '{"key":"ops-team","name":"Ops","templates":["email:ops@co.com"]}' - - Update a notification list: - $ cronitor api notifications --update '{"name":"Updated Name"}' - - Delete a notification list: - $ cronitor api notifications --delete - - Output as table: - $ cronitor api notifications --format table -`, - Run: func(cmd *cobra.Command, args []string) { - client := getAPIClient() - key := "" - if len(args) > 0 { - key = args[0] - } - - switch { - case notificationNew != "": - createNotification(client, notificationNew) - case notificationUpdate != "": - if key == "" { - fatal("notification list key is required for --update", 1) - } - updateNotification(client, key, notificationUpdate) - case notificationDelete: - if key == "" { - fatal("notification list key is required for --delete", 1) - } - deleteNotification(client, key) - case key != "": - getNotification(client, key) - default: - listNotifications(client) - } - }, -} - -func init() { - apiCmd.AddCommand(apiNotificationsCmd) - apiNotificationsCmd.Flags().StringVar(¬ificationNew, "new", "", "Create notification list with JSON data") - apiNotificationsCmd.Flags().StringVar(¬ificationUpdate, "update", "", "Update notification list with JSON data") - apiNotificationsCmd.Flags().BoolVar(¬ificationDelete, "delete", false, "Delete the notification list") -} - -func listNotifications(client *lib.APIClient) { - params := buildQueryParams() - resp, err := client.GET("/notification-lists", params) - if err != nil { - fatal(fmt.Sprintf("Failed to list notification lists: %s", err), 1) - } - - outputResponse(resp, []string{"Key", "Name", "Channels"}, - func(data []byte) [][]string { - var result struct { - NotificationLists []struct { - Key string `json:"key"` - Name string `json:"name"` - Templates []string `json:"templates"` - } `json:"notification_lists"` - } - if err := json.Unmarshal(data, &result); err != nil { - return nil - } - - rows := make([][]string, len(result.NotificationLists)) - for i, n := range result.NotificationLists { - channels := fmt.Sprintf("%d", len(n.Templates)) - rows[i] = []string{n.Key, n.Name, channels} - } - return rows - }) -} - -func getNotification(client *lib.APIClient, key string) { - resp, err := client.GET(fmt.Sprintf("/notification-lists/%s", key), nil) - if err != nil { - fatal(fmt.Sprintf("Failed to get notification list: %s", err), 1) - } - - if resp.IsNotFound() { - fatal(fmt.Sprintf("Notification list '%s' could not be found", key), 1) - } - - outputResponse(resp, nil, nil) -} - -func createNotification(client *lib.APIClient, jsonData string) { - body := []byte(jsonData) - - var js json.RawMessage - if err := json.Unmarshal(body, &js); err != nil { - fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) - } - - resp, err := client.POST("/notification-lists", body, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to create notification list: %s", err), 1) - } - - outputResponse(resp, nil, nil) -} - -func updateNotification(client *lib.APIClient, key string, jsonData string) { - body := []byte(jsonData) - - var js json.RawMessage - if err := json.Unmarshal(body, &js); err != nil { - fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) - } - - resp, err := client.PUT(fmt.Sprintf("/notification-lists/%s", key), body, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to update notification list: %s", err), 1) - } - - outputResponse(resp, nil, nil) -} - -func deleteNotification(client *lib.APIClient, key string) { - resp, err := client.DELETE(fmt.Sprintf("/notification-lists/%s", key), nil, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to delete notification list: %s", err), 1) - } - - if resp.IsNotFound() { - fatal(fmt.Sprintf("Notification list '%s' could not be found", key), 1) - } - - if resp.IsSuccess() { - fmt.Printf("Notification list '%s' deleted\n", key) - } else { - outputResponse(resp, nil, nil) - } -} diff --git a/cmd/api_statuspages.go b/cmd/api_statuspages.go deleted file mode 100644 index 62ec1ce..0000000 --- a/cmd/api_statuspages.go +++ /dev/null @@ -1,171 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - - "github.com/cronitorio/cronitor-cli/lib" - "github.com/spf13/cobra" -) - -var ( - statuspageNew string - statuspageUpdate string - statuspageDelete bool -) - -var apiStatuspagesCmd = &cobra.Command{ - Use: "statuspages [key]", - Short: "Manage status pages", - Long: ` -Manage Cronitor status pages. - -Status pages turn your Cronitor monitoring data into public (or private) -communication. Your monitors feed directly into status components, creating -a real-time view of your system health. - -Examples: - List all status pages: - $ cronitor api statuspages - - Get a specific status page: - $ cronitor api statuspages - - Create a status page: - $ cronitor api statuspages --new '{"name":"API Status","hosted_subdomain":"api-status"}' - - Update a status page: - $ cronitor api statuspages --update '{"name":"Updated Status Page"}' - - Delete a status page: - $ cronitor api statuspages --delete - - Output as table: - $ cronitor api statuspages --format table -`, - Run: func(cmd *cobra.Command, args []string) { - client := getAPIClient() - key := "" - if len(args) > 0 { - key = args[0] - } - - switch { - case statuspageNew != "": - createStatuspage(client, statuspageNew) - case statuspageUpdate != "": - if key == "" { - fatal("status page key is required for --update", 1) - } - updateStatuspage(client, key, statuspageUpdate) - case statuspageDelete: - if key == "" { - fatal("status page key is required for --delete", 1) - } - deleteStatuspage(client, key) - case key != "": - getStatuspage(client, key) - default: - listStatuspages(client) - } - }, -} - -func init() { - apiCmd.AddCommand(apiStatuspagesCmd) - apiStatuspagesCmd.Flags().StringVar(&statuspageNew, "new", "", "Create status page with JSON data") - apiStatuspagesCmd.Flags().StringVar(&statuspageUpdate, "update", "", "Update status page with JSON data") - apiStatuspagesCmd.Flags().BoolVar(&statuspageDelete, "delete", false, "Delete the status page") -} - -func listStatuspages(client *lib.APIClient) { - params := buildQueryParams() - resp, err := client.GET("/statuspages", params) - if err != nil { - fatal(fmt.Sprintf("Failed to list status pages: %s", err), 1) - } - - outputResponse(resp, []string{"Key", "Name", "Subdomain", "Status", "Environment"}, - func(data []byte) [][]string { - var result struct { - StatusPages []struct { - Key string `json:"key"` - Name string `json:"name"` - HostedSubdomain string `json:"hosted_subdomain"` - Status string `json:"status"` - Environment string `json:"environment"` - } `json:"statuspages"` - } - if err := json.Unmarshal(data, &result); err != nil { - return nil - } - - rows := make([][]string, len(result.StatusPages)) - for i, sp := range result.StatusPages { - rows[i] = []string{sp.Key, sp.Name, sp.HostedSubdomain, sp.Status, sp.Environment} - } - return rows - }) -} - -func getStatuspage(client *lib.APIClient, key string) { - resp, err := client.GET(fmt.Sprintf("/statuspages/%s", key), nil) - if err != nil { - fatal(fmt.Sprintf("Failed to get status page: %s", err), 1) - } - - if resp.IsNotFound() { - fatal(fmt.Sprintf("Status page '%s' could not be found", key), 1) - } - - outputResponse(resp, nil, nil) -} - -func createStatuspage(client *lib.APIClient, jsonData string) { - body := []byte(jsonData) - - var js json.RawMessage - if err := json.Unmarshal(body, &js); err != nil { - fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) - } - - resp, err := client.POST("/statuspages", body, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to create status page: %s", err), 1) - } - - outputResponse(resp, nil, nil) -} - -func updateStatuspage(client *lib.APIClient, key string, jsonData string) { - body := []byte(jsonData) - - var js json.RawMessage - if err := json.Unmarshal(body, &js); err != nil { - fatal(fmt.Sprintf("Invalid JSON: %s", err), 1) - } - - resp, err := client.PUT(fmt.Sprintf("/statuspages/%s", key), body, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to update status page: %s", err), 1) - } - - outputResponse(resp, nil, nil) -} - -func deleteStatuspage(client *lib.APIClient, key string) { - resp, err := client.DELETE(fmt.Sprintf("/statuspages/%s", key), nil, nil) - if err != nil { - fatal(fmt.Sprintf("Failed to delete status page: %s", err), 1) - } - - if resp.IsNotFound() { - fatal(fmt.Sprintf("Status page '%s' could not be found", key), 1) - } - - if resp.IsSuccess() { - fmt.Printf("Status page '%s' deleted\n", key) - } else { - outputResponse(resp, nil, nil) - } -} diff --git a/cmd/environment.go b/cmd/environment.go new file mode 100644 index 0000000..ffb1cfe --- /dev/null +++ b/cmd/environment.go @@ -0,0 +1,278 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var environmentCmd = &cobra.Command{ + Use: "environment", + Aliases: []string{"env"}, + Short: "Manage environments", + Long: `Manage Cronitor environments. + +Examples: + cronitor environment list + cronitor environment get + cronitor environment create --data '{"name":"Production","key":"production"}' + cronitor environment update --data '{"name":"Updated Name"}' + cronitor environment delete `, + Args: func(cmd *cobra.Command, args []string) error { + if len(viper.GetString(varApiKey)) < 10 { + return errors.New("API key required. Run 'cronitor configure' or use --api-key flag") + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +var ( + environmentPage int + environmentFormat string + environmentOutput string + environmentData string + environmentFile string +) + +func init() { + RootCmd.AddCommand(environmentCmd) + environmentCmd.PersistentFlags().IntVar(&environmentPage, "page", 1, "Page number") + environmentCmd.PersistentFlags().StringVar(&environmentFormat, "format", "", "Output format: json, table") + environmentCmd.PersistentFlags().StringVarP(&environmentOutput, "output", "o", "", "Write output to file") +} + +// --- LIST --- +var environmentListCmd = &cobra.Command{ + Use: "list", + Short: "List all environments", + Run: func(cmd *cobra.Command, args []string) { + client := lib.NewAPIClient(dev, log) + params := make(map[string]string) + if environmentPage > 1 { + params["page"] = fmt.Sprintf("%d", environmentPage) + } + + resp, err := client.GET("/environments", params) + if err != nil { + Error(fmt.Sprintf("Failed to list environments: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + var result struct { + Environments []struct { + Key string `json:"key"` + Name string `json:"name"` + } `json:"environments"` + } + if err := json.Unmarshal(resp.Body, &result); err != nil { + Error(fmt.Sprintf("Failed to parse response: %s", err)) + os.Exit(1) + } + + format := environmentFormat + if format == "" { + format = "table" + } + + if format == "json" { + environmentOutputToTarget(FormatJSON(resp.Body)) + return + } + + table := &UITable{ + Headers: []string{"KEY", "NAME"}, + } + + for _, e := range result.Environments { + table.Rows = append(table.Rows, []string{e.Key, e.Name}) + } + + environmentOutputToTarget(table.Render()) + }, +} + +// --- GET --- +var environmentGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a specific environment", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + resp, err := client.GET(fmt.Sprintf("/environments/%s", key), nil) + if err != nil { + Error(fmt.Sprintf("Failed to get environment: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Environment '%s' not found", key)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + environmentOutputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- CREATE --- +var environmentCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new environment", + Run: func(cmd *cobra.Command, args []string) { + body, err := getEnvironmentRequestBody() + if err != nil { + Error(err.Error()) + os.Exit(1) + } + if body == nil { + Error("JSON data required. Use --data or --file") + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + resp, err := client.POST("/environments", body, nil) + if err != nil { + Error(fmt.Sprintf("Failed to create environment: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success("Environment created") + environmentOutputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- UPDATE --- +var environmentUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an environment", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + body, err := getEnvironmentRequestBody() + if err != nil { + Error(err.Error()) + os.Exit(1) + } + if body == nil { + Error("JSON data required. Use --data or --file") + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + resp, err := client.PUT(fmt.Sprintf("/environments/%s", key), body, nil) + if err != nil { + Error(fmt.Sprintf("Failed to update environment: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success(fmt.Sprintf("Environment '%s' updated", key)) + environmentOutputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- DELETE --- +var environmentDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an environment", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + resp, err := client.DELETE(fmt.Sprintf("/environments/%s", key), nil, nil) + if err != nil { + Error(fmt.Sprintf("Failed to delete environment: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Environment '%s' not found", key)) + os.Exit(1) + } + + if resp.IsSuccess() { + Success(fmt.Sprintf("Environment '%s' deleted", key)) + } else { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + }, +} + +func init() { + environmentCmd.AddCommand(environmentListCmd) + environmentCmd.AddCommand(environmentGetCmd) + environmentCmd.AddCommand(environmentCreateCmd) + environmentCmd.AddCommand(environmentUpdateCmd) + environmentCmd.AddCommand(environmentDeleteCmd) + + environmentCreateCmd.Flags().StringVarP(&environmentData, "data", "d", "", "JSON data") + environmentCreateCmd.Flags().StringVarP(&environmentFile, "file", "f", "", "JSON file") + environmentUpdateCmd.Flags().StringVarP(&environmentData, "data", "d", "", "JSON data") + environmentUpdateCmd.Flags().StringVarP(&environmentFile, "file", "f", "", "JSON file") +} + +func getEnvironmentRequestBody() ([]byte, error) { + if environmentData != "" && environmentFile != "" { + return nil, errors.New("cannot specify both --data and --file") + } + + if environmentData != "" { + var js json.RawMessage + if err := json.Unmarshal([]byte(environmentData), &js); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + return []byte(environmentData), nil + } + + if environmentFile != "" { + data, err := os.ReadFile(environmentFile) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + return data, nil + } + + return nil, nil +} + +func environmentOutputToTarget(content string) { + if environmentOutput != "" { + if err := os.WriteFile(environmentOutput, []byte(content+"\n"), 0644); err != nil { + Error(fmt.Sprintf("Failed to write to %s: %s", environmentOutput, err)) + os.Exit(1) + } + Info(fmt.Sprintf("Output written to %s", environmentOutput)) + } else { + fmt.Println(content) + } +} diff --git a/cmd/issue.go b/cmd/issue.go new file mode 100644 index 0000000..1911ee3 --- /dev/null +++ b/cmd/issue.go @@ -0,0 +1,359 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var issueCmd = &cobra.Command{ + Use: "issue", + Short: "Manage issues", + Long: `Manage Cronitor issues and incidents. + +Examples: + cronitor issue list + cronitor issue list --state open + cronitor issue get + cronitor issue create --data '{"monitor":"my-job","summary":"Issue title"}' + cronitor issue update --data '{"state":"resolved"}' + cronitor issue resolve + cronitor issue delete `, + Args: func(cmd *cobra.Command, args []string) error { + if len(viper.GetString(varApiKey)) < 10 { + return errors.New("API key required. Run 'cronitor configure' or use --api-key flag") + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +var ( + issuePage int + issueFormat string + issueOutput string + issueData string + issueFile string + issueState string + issueSeverity string + issueMonitor string +) + +func init() { + RootCmd.AddCommand(issueCmd) + issueCmd.PersistentFlags().IntVar(&issuePage, "page", 1, "Page number") + issueCmd.PersistentFlags().StringVar(&issueFormat, "format", "", "Output format: json, table") + issueCmd.PersistentFlags().StringVarP(&issueOutput, "output", "o", "", "Write output to file") +} + +// --- LIST --- +var issueListCmd = &cobra.Command{ + Use: "list", + Short: "List all issues", + Long: `List all issues in your Cronitor account. + +Examples: + cronitor issue list + cronitor issue list --state open + cronitor issue list --severity high + cronitor issue list --monitor my-job`, + Run: func(cmd *cobra.Command, args []string) { + client := lib.NewAPIClient(dev, log) + params := make(map[string]string) + if issuePage > 1 { + params["page"] = fmt.Sprintf("%d", issuePage) + } + if issueState != "" { + params["state"] = issueState + } + if issueSeverity != "" { + params["severity"] = issueSeverity + } + if issueMonitor != "" { + params["monitor"] = issueMonitor + } + + resp, err := client.GET("/issues", params) + if err != nil { + Error(fmt.Sprintf("Failed to list issues: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + var result struct { + Issues []struct { + Key string `json:"key"` + Summary string `json:"summary"` + Monitor string `json:"monitor"` + State string `json:"state"` + Severity string `json:"severity"` + } `json:"issues"` + } + if err := json.Unmarshal(resp.Body, &result); err != nil { + Error(fmt.Sprintf("Failed to parse response: %s", err)) + os.Exit(1) + } + + format := issueFormat + if format == "" { + format = "table" + } + + if format == "json" { + issueOutputToTarget(FormatJSON(resp.Body)) + return + } + + table := &UITable{ + Headers: []string{"KEY", "SUMMARY", "MONITOR", "STATE", "SEVERITY"}, + } + + for _, issue := range result.Issues { + state := issue.State + if state == "open" { + state = errorStyle.Render("open") + } else if state == "resolved" { + state = successStyle.Render("resolved") + } else { + state = mutedStyle.Render(state) + } + + severity := issue.Severity + if severity == "high" || severity == "critical" { + severity = errorStyle.Render(severity) + } else if severity == "medium" { + severity = warningStyle.Render(severity) + } + + summary := issue.Summary + if len(summary) > 40 { + summary = summary[:37] + "..." + } + + table.Rows = append(table.Rows, []string{issue.Key, summary, issue.Monitor, state, severity}) + } + + issueOutputToTarget(table.Render()) + }, +} + +// --- GET --- +var issueGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a specific issue", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + resp, err := client.GET(fmt.Sprintf("/issues/%s", key), nil) + if err != nil { + Error(fmt.Sprintf("Failed to get issue: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Issue '%s' not found", key)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + issueOutputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- CREATE --- +var issueCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new issue", + Run: func(cmd *cobra.Command, args []string) { + body, err := getIssueRequestBody() + if err != nil { + Error(err.Error()) + os.Exit(1) + } + if body == nil { + Error("JSON data required. Use --data or --file") + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + resp, err := client.POST("/issues", body, nil) + if err != nil { + Error(fmt.Sprintf("Failed to create issue: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success("Issue created") + issueOutputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- UPDATE --- +var issueUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an issue", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + body, err := getIssueRequestBody() + if err != nil { + Error(err.Error()) + os.Exit(1) + } + if body == nil { + Error("JSON data required. Use --data or --file") + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + resp, err := client.PUT(fmt.Sprintf("/issues/%s", key), body, nil) + if err != nil { + Error(fmt.Sprintf("Failed to update issue: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success(fmt.Sprintf("Issue '%s' updated", key)) + issueOutputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- RESOLVE --- +var issueResolveCmd = &cobra.Command{ + Use: "resolve ", + Short: "Resolve an issue", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + body := []byte(`{"state":"resolved"}`) + resp, err := client.PUT(fmt.Sprintf("/issues/%s", key), body, nil) + if err != nil { + Error(fmt.Sprintf("Failed to resolve issue: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Issue '%s' not found", key)) + os.Exit(1) + } + + if resp.IsSuccess() { + Success(fmt.Sprintf("Issue '%s' resolved", key)) + } else { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + }, +} + +// --- DELETE --- +var issueDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an issue", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + resp, err := client.DELETE(fmt.Sprintf("/issues/%s", key), nil, nil) + if err != nil { + Error(fmt.Sprintf("Failed to delete issue: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Issue '%s' not found", key)) + os.Exit(1) + } + + if resp.IsSuccess() { + Success(fmt.Sprintf("Issue '%s' deleted", key)) + } else { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + }, +} + +func init() { + issueCmd.AddCommand(issueListCmd) + issueCmd.AddCommand(issueGetCmd) + issueCmd.AddCommand(issueCreateCmd) + issueCmd.AddCommand(issueUpdateCmd) + issueCmd.AddCommand(issueResolveCmd) + issueCmd.AddCommand(issueDeleteCmd) + + // List filters + issueListCmd.Flags().StringVar(&issueState, "state", "", "Filter by state: open, resolved") + issueListCmd.Flags().StringVar(&issueSeverity, "severity", "", "Filter by severity") + issueListCmd.Flags().StringVar(&issueMonitor, "monitor", "", "Filter by monitor key") + + // Create/Update flags + issueCreateCmd.Flags().StringVarP(&issueData, "data", "d", "", "JSON data") + issueCreateCmd.Flags().StringVarP(&issueFile, "file", "f", "", "JSON file") + issueUpdateCmd.Flags().StringVarP(&issueData, "data", "d", "", "JSON data") + issueUpdateCmd.Flags().StringVarP(&issueFile, "file", "f", "", "JSON file") +} + +func getIssueRequestBody() ([]byte, error) { + if issueData != "" && issueFile != "" { + return nil, errors.New("cannot specify both --data and --file") + } + + if issueData != "" { + var js json.RawMessage + if err := json.Unmarshal([]byte(issueData), &js); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + return []byte(issueData), nil + } + + if issueFile != "" { + data, err := os.ReadFile(issueFile) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + return data, nil + } + + return nil, nil +} + +func issueOutputToTarget(content string) { + if issueOutput != "" { + if err := os.WriteFile(issueOutput, []byte(content+"\n"), 0644); err != nil { + Error(fmt.Sprintf("Failed to write to %s: %s", issueOutput, err)) + os.Exit(1) + } + Info(fmt.Sprintf("Output written to %s", issueOutput)) + } else { + fmt.Println(content) + } +} diff --git a/cmd/monitor.go b/cmd/monitor.go new file mode 100644 index 0000000..43d1290 --- /dev/null +++ b/cmd/monitor.go @@ -0,0 +1,474 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var monitorCmd = &cobra.Command{ + Use: "monitor", + Short: "Manage monitors", + Long: `Manage Cronitor monitors (jobs, checks, heartbeats, sites). + +Examples: + cronitor monitor list + cronitor monitor get + cronitor monitor create --data '{"key":"my-job","type":"job"}' + cronitor monitor update --data '{"name":"New Name"}' + cronitor monitor delete + cronitor monitor pause + cronitor monitor unpause `, + Args: func(cmd *cobra.Command, args []string) error { + if len(viper.GetString(varApiKey)) < 10 { + return errors.New("API key required. Run 'cronitor configure' or use --api-key flag") + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +// Flags +var ( + monitorWithEvents bool + monitorPage int + monitorEnv string + monitorFormat string + monitorOutput string + monitorData string + monitorFile string +) + +func init() { + RootCmd.AddCommand(monitorCmd) + + // Persistent flags for all monitor subcommands + monitorCmd.PersistentFlags().IntVar(&monitorPage, "page", 1, "Page number for paginated results") + monitorCmd.PersistentFlags().StringVar(&monitorEnv, "env", "", "Filter by environment") + monitorCmd.PersistentFlags().StringVar(&monitorFormat, "format", "", "Output format: json, table (default: table for list, json for get)") + monitorCmd.PersistentFlags().StringVarP(&monitorOutput, "output", "o", "", "Write output to file") +} + +// --- LIST --- +var monitorListCmd = &cobra.Command{ + Use: "list", + Short: "List all monitors", + Long: `List all monitors in your Cronitor account. + +Examples: + cronitor monitor list + cronitor monitor list --page 2 + cronitor monitor list --env production + cronitor monitor list --format json`, + Run: func(cmd *cobra.Command, args []string) { + client := lib.NewAPIClient(dev, log) + params := make(map[string]string) + if monitorPage > 1 { + params["page"] = fmt.Sprintf("%d", monitorPage) + } + if monitorEnv != "" { + params["env"] = monitorEnv + } + + resp, err := client.GET("/monitors", params) + if err != nil { + Error(fmt.Sprintf("Failed to list monitors: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + // Parse response + var result struct { + Monitors []struct { + Key string `json:"key"` + Name string `json:"name"` + Type string `json:"type"` + Passing bool `json:"passing"` + Paused bool `json:"paused"` + } `json:"monitors"` + PageInfo struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + TotalCount int `json:"totalMonitorCount"` + } `json:"page_info"` + } + if err := json.Unmarshal(resp.Body, &result); err != nil { + Error(fmt.Sprintf("Failed to parse response: %s", err)) + os.Exit(1) + } + + format := monitorFormat + if format == "" { + format = "table" + } + + if format == "json" { + outputToTarget(FormatJSON(resp.Body)) + return + } + + // Table output + table := &UITable{ + Headers: []string{"KEY", "NAME", "TYPE", "STATUS"}, + } + + for _, m := range result.Monitors { + name := m.Name + if name == "" { + name = m.Key + } + status := successStyle.Render("passing") + if m.Paused { + status = warningStyle.Render("paused") + } else if !m.Passing { + status = errorStyle.Render("failing") + } + table.Rows = append(table.Rows, []string{m.Key, name, m.Type, status}) + } + + output := table.Render() + if result.PageInfo.TotalCount > 0 { + output += mutedStyle.Render(fmt.Sprintf("\nShowing page %d • %d monitors total", + result.PageInfo.Page, result.PageInfo.TotalCount)) + } + outputToTarget(output) + }, +} + +// --- GET --- +var monitorGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a specific monitor", + Long: `Get details for a specific monitor. + +Examples: + cronitor monitor get my-job + cronitor monitor get my-job --with-events`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + params := make(map[string]string) + if monitorWithEvents { + params["withLatestEvents"] = "true" + } + + resp, err := client.GET(fmt.Sprintf("/monitors/%s", key), params) + if err != nil { + Error(fmt.Sprintf("Failed to get monitor: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Monitor '%s' not found", key)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + outputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- CREATE --- +var monitorCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new monitor", + Long: `Create a new monitor. + +Examples: + cronitor monitor create --data '{"key":"my-job","type":"job"}' + cronitor monitor create --file monitor.json + cat monitor.json | cronitor monitor create`, + Run: func(cmd *cobra.Command, args []string) { + body, err := getMonitorRequestBody() + if err != nil { + Error(err.Error()) + os.Exit(1) + } + if body == nil { + Error("JSON data required. Use --data, --file, or pipe JSON to stdin") + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + + // Check if bulk create (array) + var testArray []json.RawMessage + isBulk := json.Unmarshal(body, &testArray) == nil && len(testArray) > 0 + + var resp *lib.APIResponse + if isBulk { + resp, err = client.PUT("/monitors", body, nil) + } else { + resp, err = client.POST("/monitors", body, nil) + } + + if err != nil { + Error(fmt.Sprintf("Failed to create monitor: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success("Monitor created") + outputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- UPDATE --- +var monitorUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an existing monitor", + Long: `Update an existing monitor. + +Examples: + cronitor monitor update my-job --data '{"name":"New Name"}' + cronitor monitor update my-job --file updates.json`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + body, err := getMonitorRequestBody() + if err != nil { + Error(err.Error()) + os.Exit(1) + } + if body == nil { + Error("JSON data required. Use --data or --file") + os.Exit(1) + } + + // Parse and add key + var bodyMap map[string]interface{} + if err := json.Unmarshal(body, &bodyMap); err != nil { + Error(fmt.Sprintf("Invalid JSON: %s", err)) + os.Exit(1) + } + bodyMap["key"] = key + body, _ = json.Marshal(bodyMap) + body = []byte(fmt.Sprintf("[%s]", string(body))) + + client := lib.NewAPIClient(dev, log) + resp, err := client.PUT("/monitors", body, nil) + if err != nil { + Error(fmt.Sprintf("Failed to update monitor: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success(fmt.Sprintf("Monitor '%s' updated", key)) + outputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- DELETE --- +var monitorDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a monitor", + Long: `Delete a monitor. + +Examples: + cronitor monitor delete my-job`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + resp, err := client.DELETE(fmt.Sprintf("/monitors/%s", key), nil, nil) + if err != nil { + Error(fmt.Sprintf("Failed to delete monitor: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Monitor '%s' not found", key)) + os.Exit(1) + } + + if resp.IsSuccess() { + Success(fmt.Sprintf("Monitor '%s' deleted", key)) + } else { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + }, +} + +// --- PAUSE --- +var monitorPauseHours string + +var monitorPauseCmd = &cobra.Command{ + Use: "pause ", + Short: "Pause a monitor", + Long: `Pause a monitor to stop receiving alerts. + +Examples: + cronitor monitor pause my-job # Pause indefinitely + cronitor monitor pause my-job --hours 24 # Pause for 24 hours`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + endpoint := fmt.Sprintf("/monitors/%s/pause", key) + if monitorPauseHours != "" && monitorPauseHours != "0" { + endpoint = fmt.Sprintf("%s/%s", endpoint, monitorPauseHours) + } + + resp, err := client.GET(endpoint, nil) + if err != nil { + Error(fmt.Sprintf("Failed to pause monitor: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Monitor '%s' not found", key)) + os.Exit(1) + } + + if resp.IsSuccess() { + if monitorPauseHours != "" && monitorPauseHours != "0" { + Success(fmt.Sprintf("Monitor '%s' paused for %s hours", key, monitorPauseHours)) + } else { + Success(fmt.Sprintf("Monitor '%s' paused", key)) + } + } else { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + }, +} + +// --- UNPAUSE --- +var monitorUnpauseCmd = &cobra.Command{ + Use: "unpause ", + Short: "Unpause a monitor", + Long: `Unpause a monitor to resume receiving alerts. + +Examples: + cronitor monitor unpause my-job`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + resp, err := client.GET(fmt.Sprintf("/monitors/%s/pause/0", key), nil) + if err != nil { + Error(fmt.Sprintf("Failed to unpause monitor: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Monitor '%s' not found", key)) + os.Exit(1) + } + + if resp.IsSuccess() { + Success(fmt.Sprintf("Monitor '%s' unpaused", key)) + } else { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + }, +} + +func init() { + monitorCmd.AddCommand(monitorListCmd) + monitorCmd.AddCommand(monitorGetCmd) + monitorCmd.AddCommand(monitorCreateCmd) + monitorCmd.AddCommand(monitorUpdateCmd) + monitorCmd.AddCommand(monitorDeleteCmd) + monitorCmd.AddCommand(monitorPauseCmd) + monitorCmd.AddCommand(monitorUnpauseCmd) + + // Get flags + monitorGetCmd.Flags().BoolVar(&monitorWithEvents, "with-events", false, "Include latest events") + + // Create/Update flags + monitorCreateCmd.Flags().StringVarP(&monitorData, "data", "d", "", "JSON data") + monitorCreateCmd.Flags().StringVarP(&monitorFile, "file", "f", "", "JSON file") + monitorUpdateCmd.Flags().StringVarP(&monitorData, "data", "d", "", "JSON data") + monitorUpdateCmd.Flags().StringVarP(&monitorFile, "file", "f", "", "JSON file") + + // Pause flags + monitorPauseCmd.Flags().StringVar(&monitorPauseHours, "hours", "", "Hours to pause (default: indefinite)") +} + +// Helper functions +func getMonitorRequestBody() ([]byte, error) { + if monitorData != "" && monitorFile != "" { + return nil, errors.New("cannot specify both --data and --file") + } + + if monitorData != "" { + var js json.RawMessage + if err := json.Unmarshal([]byte(monitorData), &js); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + return []byte(monitorData), nil + } + + if monitorFile != "" { + data, err := os.ReadFile(monitorFile) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + var js json.RawMessage + if err := json.Unmarshal(data, &js); err != nil { + return nil, fmt.Errorf("invalid JSON in file: %w", err) + } + return data, nil + } + + // Try stdin + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + data, err := os.ReadFile("/dev/stdin") + if err != nil { + return nil, fmt.Errorf("failed to read stdin: %w", err) + } + if len(data) > 0 { + var js json.RawMessage + if err := json.Unmarshal(data, &js); err != nil { + return nil, fmt.Errorf("invalid JSON from stdin: %w", err) + } + return data, nil + } + } + + return nil, nil +} + +func outputToTarget(content string) { + if monitorOutput != "" { + if err := os.WriteFile(monitorOutput, []byte(content+"\n"), 0644); err != nil { + Error(fmt.Sprintf("Failed to write to %s: %s", monitorOutput, err)) + os.Exit(1) + } + Info(fmt.Sprintf("Output written to %s", monitorOutput)) + } else { + fmt.Println(content) + } +} diff --git a/cmd/notification.go b/cmd/notification.go new file mode 100644 index 0000000..8197cdb --- /dev/null +++ b/cmd/notification.go @@ -0,0 +1,282 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var notificationCmd = &cobra.Command{ + Use: "notification", + Aliases: []string{"notifications"}, + Short: "Manage notification lists", + Long: `Manage Cronitor notification lists. + +Examples: + cronitor notification list + cronitor notification get + cronitor notification create --data '{"name":"DevOps Team","email":["team@example.com"]}' + cronitor notification update --data '{"name":"Updated Name"}' + cronitor notification delete `, + Args: func(cmd *cobra.Command, args []string) error { + if len(viper.GetString(varApiKey)) < 10 { + return errors.New("API key required. Run 'cronitor configure' or use --api-key flag") + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +var ( + notificationPage int + notificationFormat string + notificationOutput string + notificationData string + notificationFile string +) + +func init() { + RootCmd.AddCommand(notificationCmd) + notificationCmd.PersistentFlags().IntVar(¬ificationPage, "page", 1, "Page number") + notificationCmd.PersistentFlags().StringVar(¬ificationFormat, "format", "", "Output format: json, table") + notificationCmd.PersistentFlags().StringVarP(¬ificationOutput, "output", "o", "", "Write output to file") +} + +// --- LIST --- +var notificationListCmd = &cobra.Command{ + Use: "list", + Short: "List all notification lists", + Run: func(cmd *cobra.Command, args []string) { + client := lib.NewAPIClient(dev, log) + params := make(map[string]string) + if notificationPage > 1 { + params["page"] = fmt.Sprintf("%d", notificationPage) + } + + resp, err := client.GET("/notification-lists", params) + if err != nil { + Error(fmt.Sprintf("Failed to list notification lists: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + var result struct { + NotificationLists []struct { + Key string `json:"key"` + Name string `json:"name"` + Emails []string `json:"emails"` + Webhooks []string `json:"webhooks"` + } `json:"notification_lists"` + } + if err := json.Unmarshal(resp.Body, &result); err != nil { + Error(fmt.Sprintf("Failed to parse response: %s", err)) + os.Exit(1) + } + + format := notificationFormat + if format == "" { + format = "table" + } + + if format == "json" { + notificationOutputToTarget(FormatJSON(resp.Body)) + return + } + + table := &UITable{ + Headers: []string{"KEY", "NAME", "EMAILS", "WEBHOOKS"}, + } + + for _, n := range result.NotificationLists { + emailCount := fmt.Sprintf("%d", len(n.Emails)) + webhookCount := fmt.Sprintf("%d", len(n.Webhooks)) + table.Rows = append(table.Rows, []string{n.Key, n.Name, emailCount, webhookCount}) + } + + notificationOutputToTarget(table.Render()) + }, +} + +// --- GET --- +var notificationGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a specific notification list", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + resp, err := client.GET(fmt.Sprintf("/notification-lists/%s", key), nil) + if err != nil { + Error(fmt.Sprintf("Failed to get notification list: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Notification list '%s' not found", key)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + notificationOutputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- CREATE --- +var notificationCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new notification list", + Run: func(cmd *cobra.Command, args []string) { + body, err := getNotificationRequestBody() + if err != nil { + Error(err.Error()) + os.Exit(1) + } + if body == nil { + Error("JSON data required. Use --data or --file") + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + resp, err := client.POST("/notification-lists", body, nil) + if err != nil { + Error(fmt.Sprintf("Failed to create notification list: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success("Notification list created") + notificationOutputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- UPDATE --- +var notificationUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a notification list", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + body, err := getNotificationRequestBody() + if err != nil { + Error(err.Error()) + os.Exit(1) + } + if body == nil { + Error("JSON data required. Use --data or --file") + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + resp, err := client.PUT(fmt.Sprintf("/notification-lists/%s", key), body, nil) + if err != nil { + Error(fmt.Sprintf("Failed to update notification list: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success(fmt.Sprintf("Notification list '%s' updated", key)) + notificationOutputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- DELETE --- +var notificationDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a notification list", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + resp, err := client.DELETE(fmt.Sprintf("/notification-lists/%s", key), nil, nil) + if err != nil { + Error(fmt.Sprintf("Failed to delete notification list: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Notification list '%s' not found", key)) + os.Exit(1) + } + + if resp.IsSuccess() { + Success(fmt.Sprintf("Notification list '%s' deleted", key)) + } else { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + }, +} + +func init() { + notificationCmd.AddCommand(notificationListCmd) + notificationCmd.AddCommand(notificationGetCmd) + notificationCmd.AddCommand(notificationCreateCmd) + notificationCmd.AddCommand(notificationUpdateCmd) + notificationCmd.AddCommand(notificationDeleteCmd) + + notificationCreateCmd.Flags().StringVarP(¬ificationData, "data", "d", "", "JSON data") + notificationCreateCmd.Flags().StringVarP(¬ificationFile, "file", "f", "", "JSON file") + notificationUpdateCmd.Flags().StringVarP(¬ificationData, "data", "d", "", "JSON data") + notificationUpdateCmd.Flags().StringVarP(¬ificationFile, "file", "f", "", "JSON file") +} + +func getNotificationRequestBody() ([]byte, error) { + if notificationData != "" && notificationFile != "" { + return nil, errors.New("cannot specify both --data and --file") + } + + if notificationData != "" { + var js json.RawMessage + if err := json.Unmarshal([]byte(notificationData), &js); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + return []byte(notificationData), nil + } + + if notificationFile != "" { + data, err := os.ReadFile(notificationFile) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + return data, nil + } + + return nil, nil +} + +func notificationOutputToTarget(content string) { + if notificationOutput != "" { + if err := os.WriteFile(notificationOutput, []byte(content+"\n"), 0644); err != nil { + Error(fmt.Sprintf("Failed to write to %s: %s", notificationOutput, err)) + os.Exit(1) + } + Info(fmt.Sprintf("Output written to %s", notificationOutput)) + } else { + fmt.Println(content) + } +} diff --git a/cmd/statuspage.go b/cmd/statuspage.go new file mode 100644 index 0000000..e70d6ca --- /dev/null +++ b/cmd/statuspage.go @@ -0,0 +1,283 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var statuspageCmd = &cobra.Command{ + Use: "statuspage", + Short: "Manage status pages", + Long: `Manage Cronitor status pages. + +Examples: + cronitor statuspage list + cronitor statuspage get + cronitor statuspage create --data '{"name":"My Status Page"}' + cronitor statuspage update --data '{"name":"New Name"}' + cronitor statuspage delete `, + Args: func(cmd *cobra.Command, args []string) error { + if len(viper.GetString(varApiKey)) < 10 { + return errors.New("API key required. Run 'cronitor configure' or use --api-key flag") + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +var ( + statuspagePage int + statuspageFormat string + statuspageOutput string + statuspageData string + statuspageFile string +) + +func init() { + RootCmd.AddCommand(statuspageCmd) + statuspageCmd.PersistentFlags().IntVar(&statuspagePage, "page", 1, "Page number") + statuspageCmd.PersistentFlags().StringVar(&statuspageFormat, "format", "", "Output format: json, table") + statuspageCmd.PersistentFlags().StringVarP(&statuspageOutput, "output", "o", "", "Write output to file") +} + +// --- LIST --- +var statuspageListCmd = &cobra.Command{ + Use: "list", + Short: "List all status pages", + Run: func(cmd *cobra.Command, args []string) { + client := lib.NewAPIClient(dev, log) + params := make(map[string]string) + if statuspagePage > 1 { + params["page"] = fmt.Sprintf("%d", statuspagePage) + } + + resp, err := client.GET("/statuspages", params) + if err != nil { + Error(fmt.Sprintf("Failed to list status pages: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + var result struct { + StatusPages []struct { + Key string `json:"key"` + Name string `json:"name"` + Subdomain string `json:"subdomain"` + Status string `json:"status"` + } `json:"statuspages"` + } + if err := json.Unmarshal(resp.Body, &result); err != nil { + Error(fmt.Sprintf("Failed to parse response: %s", err)) + os.Exit(1) + } + + format := statuspageFormat + if format == "" { + format = "table" + } + + if format == "json" { + statuspageOutputToTarget(FormatJSON(resp.Body)) + return + } + + table := &UITable{ + Headers: []string{"KEY", "NAME", "SUBDOMAIN", "STATUS"}, + } + + for _, sp := range result.StatusPages { + status := successStyle.Render(sp.Status) + if sp.Status != "operational" { + status = warningStyle.Render(sp.Status) + } + table.Rows = append(table.Rows, []string{sp.Key, sp.Name, sp.Subdomain, status}) + } + + statuspageOutputToTarget(table.Render()) + }, +} + +// --- GET --- +var statuspageGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a specific status page", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + resp, err := client.GET(fmt.Sprintf("/statuspages/%s", key), nil) + if err != nil { + Error(fmt.Sprintf("Failed to get status page: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Status page '%s' not found", key)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + statuspageOutputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- CREATE --- +var statuspageCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new status page", + Run: func(cmd *cobra.Command, args []string) { + body, err := getStatuspageRequestBody() + if err != nil { + Error(err.Error()) + os.Exit(1) + } + if body == nil { + Error("JSON data required. Use --data or --file") + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + resp, err := client.POST("/statuspages", body, nil) + if err != nil { + Error(fmt.Sprintf("Failed to create status page: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success("Status page created") + statuspageOutputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- UPDATE --- +var statuspageUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a status page", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + body, err := getStatuspageRequestBody() + if err != nil { + Error(err.Error()) + os.Exit(1) + } + if body == nil { + Error("JSON data required. Use --data or --file") + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + resp, err := client.PUT(fmt.Sprintf("/statuspages/%s", key), body, nil) + if err != nil { + Error(fmt.Sprintf("Failed to update status page: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success(fmt.Sprintf("Status page '%s' updated", key)) + statuspageOutputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- DELETE --- +var statuspageDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a status page", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + resp, err := client.DELETE(fmt.Sprintf("/statuspages/%s", key), nil, nil) + if err != nil { + Error(fmt.Sprintf("Failed to delete status page: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Status page '%s' not found", key)) + os.Exit(1) + } + + if resp.IsSuccess() { + Success(fmt.Sprintf("Status page '%s' deleted", key)) + } else { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + }, +} + +func init() { + statuspageCmd.AddCommand(statuspageListCmd) + statuspageCmd.AddCommand(statuspageGetCmd) + statuspageCmd.AddCommand(statuspageCreateCmd) + statuspageCmd.AddCommand(statuspageUpdateCmd) + statuspageCmd.AddCommand(statuspageDeleteCmd) + + statuspageCreateCmd.Flags().StringVarP(&statuspageData, "data", "d", "", "JSON data") + statuspageCreateCmd.Flags().StringVarP(&statuspageFile, "file", "f", "", "JSON file") + statuspageUpdateCmd.Flags().StringVarP(&statuspageData, "data", "d", "", "JSON data") + statuspageUpdateCmd.Flags().StringVarP(&statuspageFile, "file", "f", "", "JSON file") +} + +func getStatuspageRequestBody() ([]byte, error) { + if statuspageData != "" && statuspageFile != "" { + return nil, errors.New("cannot specify both --data and --file") + } + + if statuspageData != "" { + var js json.RawMessage + if err := json.Unmarshal([]byte(statuspageData), &js); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + return []byte(statuspageData), nil + } + + if statuspageFile != "" { + data, err := os.ReadFile(statuspageFile) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + return data, nil + } + + return nil, nil +} + +func statuspageOutputToTarget(content string) { + if statuspageOutput != "" { + if err := os.WriteFile(statuspageOutput, []byte(content+"\n"), 0644); err != nil { + Error(fmt.Sprintf("Failed to write to %s: %s", statuspageOutput, err)) + os.Exit(1) + } + Info(fmt.Sprintf("Output written to %s", statuspageOutput)) + } else { + fmt.Println(content) + } +} diff --git a/cmd/ui.go b/cmd/ui.go new file mode 100644 index 0000000..62cb7ce --- /dev/null +++ b/cmd/ui.go @@ -0,0 +1,235 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Color palette +var ( + primaryColor = lipgloss.Color("#7C3AED") // Purple + successColor = lipgloss.Color("#10B981") // Green + warningColor = lipgloss.Color("#F59E0B") // Amber + errorColor = lipgloss.Color("#EF4444") // Red + mutedColor = lipgloss.Color("#6B7280") // Gray + borderColor = lipgloss.Color("#374151") // Dark gray +) + +// Styles +var ( + // Text styles + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(primaryColor) + + subtitleStyle = lipgloss.NewStyle(). + Foreground(mutedColor) + + successStyle = lipgloss.NewStyle(). + Foreground(successColor) + + errorStyle = lipgloss.NewStyle(). + Foreground(errorColor) + + warningStyle = lipgloss.NewStyle(). + Foreground(warningColor) + + mutedStyle = lipgloss.NewStyle(). + Foreground(mutedColor) + + boldStyle = lipgloss.NewStyle(). + Bold(true) + + // Table styles + tableHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(primaryColor). + Padding(0, 1) + + tableCellStyle = lipgloss.NewStyle(). + Padding(0, 1) + + tableRowStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + BorderForeground(borderColor) + + // Status badges + passingBadge = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(successColor). + Padding(0, 1). + SetString("PASSING") + + failingBadge = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(errorColor). + Padding(0, 1). + SetString("FAILING") + + pausedBadge = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(warningColor). + Padding(0, 1). + SetString("PAUSED") + + // Box styles + infoBox = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(primaryColor). + Padding(0, 1) +) + +// Icons +const ( + iconCheck = "✓" + iconCross = "✗" + iconWarning = "⚠" + iconInfo = "ℹ" + iconArrow = "→" + iconDot = "•" + iconSpinner = "◐" +) + +// UITable represents a styled table +type UITable struct { + Headers []string + Rows [][]string + MaxWidth int +} + +// Render renders the table with beautiful styling +func (t *UITable) Render() string { + if len(t.Rows) == 0 { + return mutedStyle.Render("No results found") + } + + // Calculate column widths + colWidths := make([]int, len(t.Headers)) + for i, h := range t.Headers { + colWidths[i] = len(h) + } + for _, row := range t.Rows { + for i, cell := range row { + if i < len(colWidths) && len(cell) > colWidths[i] { + colWidths[i] = len(cell) + } + } + } + + // Cap column widths + maxColWidth := 40 + for i := range colWidths { + if colWidths[i] > maxColWidth { + colWidths[i] = maxColWidth + } + } + + var sb strings.Builder + + // Render header + var headerCells []string + for i, h := range t.Headers { + cell := tableHeaderStyle.Width(colWidths[i]).Render(h) + headerCells = append(headerCells, cell) + } + sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, headerCells...)) + sb.WriteString("\n") + + // Render rows + for _, row := range t.Rows { + var cells []string + for i, cell := range row { + if i < len(colWidths) { + // Truncate if needed + if len(cell) > colWidths[i] { + cell = cell[:colWidths[i]-1] + "…" + } + styledCell := tableCellStyle.Width(colWidths[i]).Render(cell) + cells = append(cells, styledCell) + } + } + sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, cells...)) + sb.WriteString("\n") + } + + return sb.String() +} + +// StatusBadge returns a styled status badge +func StatusBadge(passing bool, paused bool) string { + if paused { + return pausedBadge.String() + } + if passing { + return passingBadge.String() + } + return failingBadge.String() +} + +// Success prints a success message +func Success(msg string) { + fmt.Println(successStyle.Render(iconCheck + " " + msg)) +} + +// Error prints an error message +func Error(msg string) { + fmt.Println(errorStyle.Render(iconCross + " " + msg)) +} + +// Warning prints a warning message +func Warning(msg string) { + fmt.Println(warningStyle.Render(iconWarning + " " + msg)) +} + +// Info prints an info message +func Info(msg string) { + fmt.Println(mutedStyle.Render(iconInfo + " " + msg)) +} + +// Title prints a title +func Title(msg string) { + fmt.Println(titleStyle.Render(msg)) +} + +// Muted prints muted text +func Muted(msg string) { + fmt.Println(mutedStyle.Render(msg)) +} + +// FormatJSON formats JSON with syntax highlighting +func FormatJSON(data []byte) string { + var prettyJSON bytes.Buffer + if err := json.Indent(&prettyJSON, data, "", " "); err != nil { + return string(data) + } + return prettyJSON.String() +} + +// RenderKeyValue renders a key-value pair +func RenderKeyValue(key, value string) string { + return fmt.Sprintf("%s %s", + mutedStyle.Render(key+":"), + value) +} + +// RenderList renders a list of items +func RenderList(title string, items []string) string { + var sb strings.Builder + sb.WriteString(boldStyle.Render(title)) + sb.WriteString("\n") + for _, item := range items { + sb.WriteString(fmt.Sprintf(" %s %s\n", mutedStyle.Render(iconDot), item)) + } + return sb.String() +} + +// Box wraps content in a styled box +func Box(content string) string { + return infoBox.Render(content) +} diff --git a/tests/test-api.bats b/tests/test-api.bats index 2e5d065..07b4aee 100644 --- a/tests/test-api.bats +++ b/tests/test-api.bats @@ -3,290 +3,274 @@ setup() { SCRIPT_DIR="$(dirname $BATS_TEST_FILENAME)" cd $SCRIPT_DIR - rm -f $CLI_LOGFILE } ################# -# API COMMAND TESTS +# MONITOR COMMAND TESTS ################# -@test "API command shows help" { - ../cronitor api --help | grep -q "Interact with the Cronitor API" +@test "monitor command shows help" { + ../cronitor monitor --help | grep -qi "manage.*monitors" } -@test "API command lists available subcommands" { - ../cronitor api --help | grep -q "monitors" - ../cronitor api --help | grep -q "issues" - ../cronitor api --help | grep -q "statuspages" - ../cronitor api --help | grep -q "metrics" - ../cronitor api --help | grep -q "notifications" - ../cronitor api --help | grep -q "environments" +@test "monitor command lists subcommands" { + ../cronitor monitor --help | grep -q "list" + ../cronitor monitor --help | grep -q "get" + ../cronitor monitor --help | grep -q "create" + ../cronitor monitor --help | grep -q "update" + ../cronitor monitor --help | grep -q "delete" + ../cronitor monitor --help | grep -q "pause" + ../cronitor monitor --help | grep -q "unpause" } -@test "API command requires API key" { - # When no API key is configured, the command should fail - run ../cronitor api monitors 2>&1 - [ "$status" -eq 1 ] +@test "monitor list shows help" { + ../cronitor monitor list --help | grep -q "List all monitors" } -################# -# MONITORS SUBCOMMAND TESTS -################# +@test "monitor list has pagination flag" { + ../cronitor monitor list --help | grep -q "\-\-page" +} + +@test "monitor list has env flag" { + ../cronitor monitor list --help | grep -q "\-\-env" +} + +@test "monitor get requires key" { + run ../cronitor monitor get 2>&1 + [ "$status" -eq 1 ] +} -@test "API monitors shows help" { - ../cronitor api monitors --help | grep -q "Manage Cronitor monitors" +@test "monitor get has --with-events flag" { + ../cronitor monitor get --help | grep -q "\-\-with-events" } -@test "API monitors help shows examples" { - ../cronitor api monitors --help | grep -q "cronitor api monitors" - ../cronitor api monitors --help | grep -q "\-\-new" - ../cronitor api monitors --help | grep -q "\-\-update" - ../cronitor api monitors --help | grep -q "\-\-delete" - ../cronitor api monitors --help | grep -q "\-\-pause" - ../cronitor api monitors --help | grep -q "\-\-unpause" +@test "monitor create has --data flag" { + ../cronitor monitor create --help | grep -q "\-\-data" } -@test "API monitors has --with-events flag" { - ../cronitor api monitors --help | grep -q "\-\-with-events" +@test "monitor create has --file flag" { + ../cronitor monitor create --help | grep -q "\-\-file" } -@test "API monitors update requires key" { - run ../cronitor api monitors --update '{}' -k test-api-key 2>&1 +@test "monitor update requires key" { + run ../cronitor monitor update 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"key is required"* ]] } -@test "API monitors pause requires key" { - run ../cronitor api monitors --pause -k test-api-key 2>&1 +@test "monitor delete requires key" { + run ../cronitor monitor delete 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"key is required"* ]] } -@test "API monitors delete requires key" { - run ../cronitor api monitors --delete -k test-api-key 2>&1 +@test "monitor pause requires key" { + run ../cronitor monitor pause 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"key is required"* ]] } -@test "API monitors new rejects invalid JSON" { - run ../cronitor api monitors --new 'not valid json' -k test-api-key 2>&1 +@test "monitor pause has --hours flag" { + ../cronitor monitor pause --help | grep -q "\-\-hours" +} + +@test "monitor unpause requires key" { + run ../cronitor monitor unpause 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"Invalid JSON"* ]] } ################# -# ISSUES SUBCOMMAND TESTS +# STATUSPAGE COMMAND TESTS ################# -@test "API issues shows help" { - ../cronitor api issues --help | grep -q "Manage Cronitor issues" +@test "statuspage command shows help" { + ../cronitor statuspage --help | grep -qi "manage.*status" } -@test "API issues help shows examples" { - ../cronitor api issues --help | grep -q "cronitor api issues" - ../cronitor api issues --help | grep -q "\-\-new" - ../cronitor api issues --help | grep -q "\-\-update" - ../cronitor api issues --help | grep -q "\-\-delete" - ../cronitor api issues --help | grep -q "\-\-resolve" +@test "statuspage command lists subcommands" { + ../cronitor statuspage --help | grep -q "list" + ../cronitor statuspage --help | grep -q "get" + ../cronitor statuspage --help | grep -q "create" + ../cronitor statuspage --help | grep -q "update" + ../cronitor statuspage --help | grep -q "delete" } -@test "API issues has --state flag" { - ../cronitor api issues --help | grep -q "\-\-state" +@test "statuspage list shows help" { + ../cronitor statuspage list --help | grep -qi "list" } -@test "API issues has --severity flag" { - ../cronitor api issues --help | grep -q "\-\-severity" -} - -@test "API issues update requires key" { - run ../cronitor api issues --update '{}' -k test-api-key 2>&1 +@test "statuspage get requires key" { + run ../cronitor statuspage get 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"key is required"* ]] } -@test "API issues delete requires key" { - run ../cronitor api issues --delete -k test-api-key 2>&1 +@test "statuspage update requires key" { + run ../cronitor statuspage update 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"key is required"* ]] } -@test "API issues new rejects invalid JSON" { - run ../cronitor api issues --new '{broken' -k test-api-key 2>&1 +@test "statuspage delete requires key" { + run ../cronitor statuspage delete 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"Invalid JSON"* ]] } ################# -# STATUSPAGES SUBCOMMAND TESTS +# ISSUE COMMAND TESTS ################# -@test "API statuspages shows help" { - ../cronitor api statuspages --help | grep -q "Manage Cronitor status pages" +@test "issue command shows help" { + ../cronitor issue --help | grep -qi "manage.*issues" } -@test "API statuspages help shows examples" { - ../cronitor api statuspages --help | grep -q "cronitor api statuspages" - ../cronitor api statuspages --help | grep -q "\-\-new" - ../cronitor api statuspages --help | grep -q "\-\-update" - ../cronitor api statuspages --help | grep -q "\-\-delete" +@test "issue command lists subcommands" { + ../cronitor issue --help | grep -q "list" + ../cronitor issue --help | grep -q "get" + ../cronitor issue --help | grep -q "create" + ../cronitor issue --help | grep -q "update" + ../cronitor issue --help | grep -q "resolve" + ../cronitor issue --help | grep -q "delete" } -@test "API statuspages update requires key" { - run ../cronitor api statuspages --update '{}' -k test-api-key 2>&1 - [ "$status" -eq 1 ] - [[ "$output" == *"key is required"* ]] +@test "issue list has --state flag" { + ../cronitor issue list --help | grep -q "\-\-state" } -@test "API statuspages delete requires key" { - run ../cronitor api statuspages --delete -k test-api-key 2>&1 - [ "$status" -eq 1 ] - [[ "$output" == *"key is required"* ]] +@test "issue list has --severity flag" { + ../cronitor issue list --help | grep -q "\-\-severity" } -@test "API statuspages new rejects invalid JSON" { - run ../cronitor api statuspages --new 'invalid' -k test-api-key 2>&1 - [ "$status" -eq 1 ] - [[ "$output" == *"Invalid JSON"* ]] -} - -################# -# METRICS SUBCOMMAND TESTS -################# - -@test "API metrics shows help" { - ../cronitor api metrics --help | grep -q "View monitor metrics" +@test "issue list has --monitor flag" { + ../cronitor issue list --help | grep -q "\-\-monitor" } -@test "API metrics has time range flags" { - ../cronitor api metrics --help | grep -q "\-\-start" - ../cronitor api metrics --help | grep -q "\-\-end" +@test "issue get requires key" { + run ../cronitor issue get 2>&1 + [ "$status" -eq 1 ] } -@test "API metrics has --aggregates flag" { - ../cronitor api metrics --help | grep -q "\-\-aggregates" +@test "issue resolve requires key" { + run ../cronitor issue resolve 2>&1 + [ "$status" -eq 1 ] } -@test "API metrics has --group flag" { - ../cronitor api metrics --help | grep -q "\-\-group" +@test "issue delete requires key" { + run ../cronitor issue delete 2>&1 + [ "$status" -eq 1 ] } ################# -# NOTIFICATIONS SUBCOMMAND TESTS +# NOTIFICATION COMMAND TESTS ################# -@test "API notifications shows help" { - ../cronitor api notifications --help | grep -qi "notification lists" +@test "notification command shows help" { + ../cronitor notification --help | grep -qi "notification" +} + +@test "notification command lists subcommands" { + ../cronitor notification --help | grep -q "list" + ../cronitor notification --help | grep -q "get" + ../cronitor notification --help | grep -q "create" + ../cronitor notification --help | grep -q "update" + ../cronitor notification --help | grep -q "delete" } -@test "API notifications help shows examples" { - ../cronitor api notifications --help | grep -q "cronitor api notifications" - ../cronitor api notifications --help | grep -q "\-\-new" - ../cronitor api notifications --help | grep -q "\-\-update" - ../cronitor api notifications --help | grep -q "\-\-delete" +@test "notification has alias 'notifications'" { + ../cronitor notifications --help | grep -qi "notification" } -@test "API notifications update requires key" { - run ../cronitor api notifications --update '{}' -k test-api-key 2>&1 +@test "notification get requires key" { + run ../cronitor notification get 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"key is required"* ]] } -@test "API notifications delete requires key" { - run ../cronitor api notifications --delete -k test-api-key 2>&1 +@test "notification update requires key" { + run ../cronitor notification update 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"key is required"* ]] } -@test "API notifications new rejects invalid JSON" { - run ../cronitor api notifications --new 'bad json' -k test-api-key 2>&1 +@test "notification delete requires key" { + run ../cronitor notification delete 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"Invalid JSON"* ]] } ################# -# ENVIRONMENTS SUBCOMMAND TESTS +# ENVIRONMENT COMMAND TESTS ################# -@test "API environments shows help" { - ../cronitor api environments --help | grep -qi "environments" +@test "environment command shows help" { + ../cronitor environment --help | grep -qi "environment" } -@test "API environments help shows examples" { - ../cronitor api environments --help | grep -q "cronitor api environments" - ../cronitor api environments --help | grep -q "\-\-new" - ../cronitor api environments --help | grep -q "\-\-update" - ../cronitor api environments --help | grep -q "\-\-delete" +@test "environment command lists subcommands" { + ../cronitor environment --help | grep -q "list" + ../cronitor environment --help | grep -q "get" + ../cronitor environment --help | grep -q "create" + ../cronitor environment --help | grep -q "update" + ../cronitor environment --help | grep -q "delete" } -@test "API environments update requires key" { - run ../cronitor api environments --update '{}' -k test-api-key 2>&1 +@test "environment has alias 'env'" { + ../cronitor env --help | grep -qi "environment" +} + +@test "environment get requires key" { + run ../cronitor environment get 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"key is required"* ]] } -@test "API environments delete requires key" { - run ../cronitor api environments --delete -k test-api-key 2>&1 +@test "environment update requires key" { + run ../cronitor environment update 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"key is required"* ]] } -@test "API environments new rejects invalid JSON" { - run ../cronitor api environments --new 'bad json' -k test-api-key 2>&1 +@test "environment delete requires key" { + run ../cronitor environment delete 2>&1 [ "$status" -eq 1 ] - [[ "$output" == *"Invalid JSON"* ]] } ################# # GLOBAL FLAGS TESTS ################# -@test "API command has --format flag" { - ../cronitor api --help | grep -q "\-\-format" +@test "monitor has --format flag" { + ../cronitor monitor --help | grep -q "\-\-format" } -@test "API command has --page flag" { - ../cronitor api --help | grep -q "\-\-page" +@test "monitor has --output flag" { + ../cronitor monitor --help | grep -q "\-o, \-\-output" } -@test "API command has --output flag" { - ../cronitor api --help | grep -q "\-o, \-\-output" +@test "statuspage has --format flag" { + ../cronitor statuspage --help | grep -q "\-\-format" } -@test "API command has --raw flag" { - ../cronitor api --help | grep -q "\-\-raw" +@test "issue has --format flag" { + ../cronitor issue --help | grep -q "\-\-format" } ################# # INTEGRATION TESTS (SKIPPED BY DEFAULT) ################# -@test "API monitors list integration test" { +@test "monitor list integration test" { skip "Integration test requires valid API key" - # ../cronitor api monitors -k $CRONITOR_API_KEY -} - -@test "API monitors get integration test" { - skip "Integration test requires valid API key and existing monitor" - # ../cronitor api monitors test-monitor -k $CRONITOR_API_KEY + # ../cronitor monitor list -k $CRONITOR_API_KEY } -@test "API monitors create integration test" { +@test "monitor get integration test" { skip "Integration test requires valid API key" - # ../cronitor api monitors --new '{"key":"test-cli-monitor","type":"job"}' -k $CRONITOR_API_KEY + # ../cronitor monitor get test-monitor -k $CRONITOR_API_KEY } -@test "API issues list integration test" { +@test "monitor create integration test" { skip "Integration test requires valid API key" - # ../cronitor api issues -k $CRONITOR_API_KEY + # ../cronitor monitor create --data '{"key":"test-cli-monitor","type":"job"}' -k $CRONITOR_API_KEY } -@test "API statuspages list integration test" { +@test "issue list integration test" { skip "Integration test requires valid API key" - # ../cronitor api statuspages -k $CRONITOR_API_KEY + # ../cronitor issue list -k $CRONITOR_API_KEY } -@test "API metrics integration test" { +@test "statuspage list integration test" { skip "Integration test requires valid API key" - # ../cronitor api metrics -k $CRONITOR_API_KEY + # ../cronitor statuspage list -k $CRONITOR_API_KEY } From 1251ce3567452de416a93b34ee776b5a1e9f0f0a Mon Sep 17 00:00:00 2001 From: August Flanagan Date: Sun, 1 Feb 2026 12:51:01 -0800 Subject: [PATCH 05/25] Add integration tests, shared test utilities, and API docs links - Create internal/testutil package with shared MockAPI server, CaptureStdout helper, and ExecuteCommand helper for command-level testing - Refactor lib/api_client_test.go to use shared testutil package - Add MergePagedJSON and FetchAllPages unit tests in cmd/ui_test.go - Add integration tests in cmd/integration_test.go covering table, JSON, YAML output formats, file output, pagination metadata, and --all flag - Add API documentation URLs to all resource command help text Co-Authored-By: Claude Opus 4.5 --- Plan.md | 449 ++++++++++ cmd/configure.go | 9 + cmd/discover.go | 79 ++ cmd/discover_test.go | 18 + cmd/environment.go | 182 ++-- cmd/environment_test.go | 66 ++ cmd/group.go | 472 ++++++++++ cmd/group_test.go | 191 ++++ cmd/integration_test.go | 368 ++++++++ cmd/issue.go | 382 ++++++-- cmd/issue_test.go | 102 +++ cmd/maintenance.go | 454 ++++++++++ cmd/maintenance_test.go | 66 ++ cmd/metric.go | 358 ++++++++ cmd/metric_test.go | 128 +++ cmd/monitor.go | 412 ++++++++- cmd/monitor_test.go | 69 ++ cmd/notification.go | 222 +++-- cmd/notification_test.go | 67 ++ cmd/ping.go | 123 ++- cmd/root.go | 3 + cmd/site.go | 883 +++++++++++++++++++ cmd/site_test.go | 113 +++ cmd/statuspage.go | 384 ++++++++- cmd/statuspage_test.go | 105 +++ cmd/ui.go | 91 +- cmd/ui_test.go | 218 +++++ go.mod | 3 +- internal/testutil/capture.go | 25 + internal/testutil/command.go | 24 + internal/testutil/mock_api.go | 152 ++++ lib/api_client.go | 12 +- lib/api_client_test.go | 1339 +++++++++++++++++++++++++++++ lib/cronitor.go | 93 +- testdata/aggregates_get.json | 14 + testdata/components_list.json | 18 + testdata/environments_list.json | 18 + testdata/error_responses/400.json | 6 + testdata/error_responses/403.json | 3 + testdata/error_responses/404.json | 3 + testdata/error_responses/429.json | 3 + testdata/error_responses/500.json | 3 + testdata/groups_list.json | 19 + testdata/issues_list.json | 18 + testdata/maintenance_list.json | 20 + testdata/metrics_get.json | 18 + testdata/monitor_get.json | 13 + testdata/monitors_list.json | 33 + testdata/notifications_list.json | 26 + testdata/site_errors_list.json | 11 + testdata/site_query.json | 8 + testdata/sites_list.json | 12 + testdata/statuspages_list.json | 16 + 53 files changed, 7604 insertions(+), 320 deletions(-) create mode 100644 Plan.md create mode 100644 cmd/environment_test.go create mode 100644 cmd/group.go create mode 100644 cmd/group_test.go create mode 100644 cmd/integration_test.go create mode 100644 cmd/issue_test.go create mode 100644 cmd/maintenance.go create mode 100644 cmd/maintenance_test.go create mode 100644 cmd/metric.go create mode 100644 cmd/metric_test.go create mode 100644 cmd/monitor_test.go create mode 100644 cmd/notification_test.go create mode 100644 cmd/site.go create mode 100644 cmd/site_test.go create mode 100644 cmd/statuspage_test.go create mode 100644 cmd/ui_test.go create mode 100644 internal/testutil/capture.go create mode 100644 internal/testutil/command.go create mode 100644 internal/testutil/mock_api.go create mode 100644 lib/api_client_test.go create mode 100644 testdata/aggregates_get.json create mode 100644 testdata/components_list.json create mode 100644 testdata/environments_list.json create mode 100644 testdata/error_responses/400.json create mode 100644 testdata/error_responses/403.json create mode 100644 testdata/error_responses/404.json create mode 100644 testdata/error_responses/429.json create mode 100644 testdata/error_responses/500.json create mode 100644 testdata/groups_list.json create mode 100644 testdata/issues_list.json create mode 100644 testdata/maintenance_list.json create mode 100644 testdata/metrics_get.json create mode 100644 testdata/monitor_get.json create mode 100644 testdata/monitors_list.json create mode 100644 testdata/notifications_list.json create mode 100644 testdata/site_errors_list.json create mode 100644 testdata/site_query.json create mode 100644 testdata/sites_list.json create mode 100644 testdata/statuspages_list.json diff --git a/Plan.md b/Plan.md new file mode 100644 index 0000000..d041bad --- /dev/null +++ b/Plan.md @@ -0,0 +1,449 @@ +# Cronitor CLI — Full API Support Plan + +## Overview + +Add first-class CLI support for the entire Cronitor REST API as top-level resource commands with consistent subcommands (list, get, create, update, delete, plus resource-specific actions). + +## Task Tracking + +When working on a task, prefix it with **`[WORKING]`** to indicate it is actively in progress. When the task is complete, remove the prefix and mark it as done (`[x]`). Only one task should be marked `[WORKING]` at a time. + +**Branch:** `claude/cronitor-api-support-PLatz` +**API Version:** Configurable via `--api-version` flag, `CRONITOR_API_VERSION` env var, or config file (header omitted when unset) +**Base URL:** `https://cronitor.io/api/` + +--- + +## Architecture + +- Each API resource is a top-level cobra command (e.g. `cronitor monitor`, `cronitor group`) +- Subcommands follow CRUD conventions: `list`, `get`, `create`, `update`, `delete` +- Resources with special actions get additional subcommands (e.g. `monitor pause`, `group resume`) +- Shared flags across all resources: `--format` (json/table/yaml), `--output`, `--page` +- API client lives in `lib/cronitor.go` / `lib/api_client.go` with GET/POST/PUT/DELETE helpers +- Table output uses lipgloss styling via shared helpers in `cmd/ui.go` + +--- + +## Completed Work + +### Phase 1: Core Infrastructure [DONE] + +- [x] API client with HTTP Basic Auth (`lib/api_client.go`, `lib/cronitor.go`) +- [x] `Cronitor-Version: 2025-11-28` header sent on all requests +- [x] Shared output formatting: JSON, YAML, table (`--format`, `--output` flags) +- [x] Table rendering with lipgloss styling (`cmd/ui.go`) +- [x] Color palette, status badges, and formatting helpers + +### Phase 2: Resource Commands [DONE] + +All 9 resources implemented with `Run` functions wired to real API calls: + +- [x] **monitor** — list, get, search, create, update, delete, clone, pause, unpause + - Filters: `--type`, `--group`, `--tag`, `--state`, `--search`, `--sort`, `--env` + - File: `cmd/monitor.go` + +- [x] **group** — list, get, create, update, delete, pause, resume + - Filters: `--env`, `--with-status`, `--page-size`, `--sort` + - File: `cmd/group.go` + +- [x] **environment** (alias: `env`) — list, get, create, update, delete + - File: `cmd/environment.go` + +- [x] **notification** (alias: `notifications`) — list, get, create, update, delete + - Supports all channels: email, slack, pagerduty, opsgenie, victorops, microsoft-teams, discord, telegram, gchat, larksuite, webhooks + - File: `cmd/notification.go` + +- [x] **issue** — list, get, create, update, resolve, delete + - Filters: `--state`, `--severity`, `--monitor`, `--group`, `--tag`, `--env`, `--search`, `--time`, `--order-by` + - File: `cmd/issue.go` + +- [x] **maintenance** (alias: `maint`) — list, get, create, update, delete + - Filters: `--past`, `--ongoing`, `--upcoming`, `--statuspage`, `--env`, `--with-monitors` + - File: `cmd/maintenance.go` + +- [x] **statuspage** — list, get, create, update, delete + - Nested: `component list`, `component create`, `component delete` + - Filters: `--with-status`, `--with-components` + - File: `cmd/statuspage.go` + +- [x] **metric** (alias: `metrics`) — get, aggregate + - Filters: `--monitor`, `--group`, `--tag`, `--type`, `--time`, `--start`, `--end`, `--env`, `--region`, `--with-nulls` + - Fields: duration_p10/p50/p90/p99, duration_mean, success_rate, run_count, complete_count, fail_count, tick_count, alert_count + - File: `cmd/metric.go` + +- [x] **site** — list, get, create, update, delete, query, error {list, get} + - Query kinds: aggregation, breakdown, timeseries, search_options, error_groups + - File: `cmd/site.go` + +- [x] **ping** — updated with richer flags: `--run`, `--complete`, `--fail`, `--ok`, `--tick`, `--msg`, `--series`, `--status-code`, `--duration`, `--metric` + - File: `cmd/ping.go` + +### Phase 3: Structural Tests [DONE] + +All resources have test files verifying: +- Command and subcommand hierarchy +- Flag presence and types +- Argument validation +- Aliases +- Help text and examples + +Test files: `cmd/*_test.go` (monitor, environment, issue, notification, statuspage, group, maintenance, metric, site, discover) + +--- + +## Remaining Work + +### Phase 4: Missing Subcommands & Flags [DONE] + +- [x] **Statuspage component update** — `component update` subcommand (`PUT /statuspage_components/:key`) + - Updatable fields: name, description, autopublish + - File: `cmd/statuspage.go` + +- [x] **Issue bulk actions** — `issue bulk` subcommand (`POST /issues/bulk`) + - Actions: delete, change_state, assign_to + - Accepts: `--action`, `--issues` (comma-separated keys), `--state`, `--assign-to` + - File: `cmd/issue.go` + +- [x] **Issue expansion flags** — `--with-statuspage-details`, `--with-monitor-details`, `--with-alert-details`, `--with-component-details` on `issue list` and `issue get` + - These map to query params: `withStatusPageDetails`, `withMonitorDetails`, `withAlertDetails`, `withComponentDetails` + - File: `cmd/issue.go` + +### Phase 5: Testing + +Current state: All 10 resource test files (`cmd/*_test.go`) only verify command structure (subcommands, flags, aliases, argument counts). There is **no HTTP mocking, no behavioral testing, and no output verification**. This phase adds robust API-level testing. + +#### 5a: Test Infrastructure [DONE] + +- [x] **Shared test helpers** — Create `lib/api_test_helpers.go` (or `lib/testutil_test.go`) + - `NewMockAPIServer()` — returns an `httptest.NewServer` that: + - Records incoming requests (method, path, query params, headers, body) for assertion + - Returns configurable JSON responses per route (method + path pattern) + - Supports setting response status codes (200, 400, 403, 404, 429, 500) + - Validates `Authorization` header (HTTP Basic with API key) + - Validates `Cronitor-Version` header presence/absence + - `AssertRequest(t, recorded, expected)` — helper to compare method, path, query params, body fields + - `LoadFixture(name string)` — reads JSON fixture files from `testdata/` directory + - `CaptureOutput(fn func()) string` — captures stdout for output format assertions + +- [x] **Test fixtures** — Create `testdata/` directory with representative API responses + - `testdata/monitors_list.json` — paginated list response with 2-3 monitors + - `testdata/monitor_get.json` — single monitor with all fields populated + - `testdata/groups_list.json`, `testdata/group_get.json` + - `testdata/environments_list.json`, `testdata/environment_get.json` + - `testdata/notifications_list.json`, `testdata/notification_get.json` + - `testdata/issues_list.json`, `testdata/issue_get.json` + - `testdata/maintenance_list.json`, `testdata/maintenance_get.json` + - `testdata/statuspages_list.json`, `testdata/statuspage_get.json` + - `testdata/components_list.json` + - `testdata/metrics_get.json`, `testdata/aggregates_get.json` + - `testdata/sites_list.json`, `testdata/site_get.json` + - `testdata/site_query.json`, `testdata/site_errors_list.json` + - `testdata/error_responses/` — 400, 403, 404, 429, 500 responses + +#### 5b: API Client Tests (`lib/api_client_test.go`) [DONE] + +- [x] **Authentication** — Verify API key is sent as HTTP Basic Auth (username = API key, no password) +- [x] **Cronitor-Version header** — Verify header is sent (currently hardcoded to `2025-11-28`). Version-absent test will be added after Phase 6 makes it configurable. +- [x] **HTTP methods** — Each helper (GET, POST, PUT, DELETE) sends the correct method +- [x] **URL construction** — Base URL + resource path + query params are built correctly +- [x] **Request body** — POST/PUT send correct JSON body from `--data` flag +- [x] **Error handling** — Client returns meaningful errors for: + - 400 Bad Request (validation errors from API) + - 403 Forbidden (invalid API key) + - 404 Not Found (invalid resource key) + - 429 Rate Limited (with Retry-After header) + - 500 Server Error + - Network errors (connection refused, timeout) + - Malformed JSON response + +#### 5c: Per-Resource Request Tests [DONE] + +All per-resource endpoint tests are in `lib/api_client_test.go` using table-driven tests against the mock server. Tests cover correct HTTP method, path, query params, and request body for every endpoint. + +- [x] **Monitor tests** (`cmd/monitor_test.go` — extend existing file) + - `list` — GET /monitors, with each filter flag mapped to correct query param (`--type`→`type`, `--group`→`group`, `--tag`→`tag`, `--state`→`state`, `--search`→`search`, `--sort`→`sort`, `--env`→`env`, `--page`→`page`) + - `get KEY` — GET /monitors/KEY + - `create --data '{...}'` — POST /monitors with JSON body + - `update KEY --data '{...}'` — PUT /monitors with JSON body containing key + - `delete KEY` — DELETE /monitors/KEY + - `delete KEY1 KEY2` — bulk delete via DELETE /monitors with body + - `clone KEY --name NEW` — POST /monitors/clone with correct body + - `pause KEY` — GET /monitors/KEY/pause (no duration) + - `pause KEY --hours 4` — GET /monitors/KEY/pause/4 + - `unpause KEY` — GET /monitors/KEY/pause/0 + - `search QUERY` — GET /api/search?query=QUERY + +- [x] **Group tests** (`cmd/group_test.go` — extend) + - `list` — GET /groups, with filters (`--env`, `--with-status`, `--page-size`, `--sort`) + - `get KEY` — GET /groups/KEY + - `create --data '{...}'` — POST /groups + - `update KEY --data '{...}'` — PUT /groups/KEY + - `delete KEY` — DELETE /groups/KEY + - `pause KEY 4` — GET /groups/KEY/pause/4 + - `resume KEY` — GET /groups/KEY/pause/0 + +- [x] **Environment tests** (`cmd/environment_test.go` — extend) + - `list` — GET /environments + - `get KEY` — GET /environments/KEY + - `create --data '{...}'` — POST /environments + - `update KEY --data '{...}'` — PUT /environments/KEY + - `delete KEY` — DELETE /environments/KEY + +- [x] **Notification tests** (`cmd/notification_test.go` — extend) + - `list` — GET /notifications + - `get KEY` — GET /notifications/KEY + - `create --data '{...}'` — POST /notifications + - `update KEY --data '{...}'` — PUT /notifications/KEY + - `delete KEY` — DELETE /notifications/KEY + +- [x] **Issue tests** (`cmd/issue_test.go` — extend) + - `list` — GET /issues, with all filter flags (`--state`, `--severity`, `--monitor`, `--group`, `--tag`, `--env`, `--search`, `--time`, `--order-by`) + - `get KEY` — GET /issues/KEY + - `create --data '{...}'` — POST /issues + - `update KEY --data '{...}'` — PUT /issues/KEY + - `resolve KEY` — PUT /issues/KEY with state=resolved + - `delete KEY` — DELETE /issues/KEY + - `bulk --action delete --issues KEY1,KEY2` — POST /issues/bulk (after Phase 4) + +- [x] **Maintenance tests** (`cmd/maintenance_test.go` — extend) + - `list` — GET /maintenance_windows, with filters (`--past`, `--ongoing`, `--upcoming`, `--statuspage`, `--env`, `--with-monitors`) + - `get KEY` — GET /maintenance_windows/KEY + - `create --data '{...}'` — POST /maintenance_windows + - `update KEY --data '{...}'` — PUT /maintenance_windows/KEY + - `delete KEY` — DELETE /maintenance_windows/KEY + +- [x] **Statuspage tests** (`cmd/statuspage_test.go` — extend) + - `list` — GET /statuspages, with filters (`--with-status`, `--with-components`) + - `get KEY` — GET /statuspages/KEY + - `create --data '{...}'` — POST /statuspages + - `update KEY --data '{...}'` — PUT /statuspages/KEY + - `delete KEY` — DELETE /statuspages/KEY + - `component list` — GET /statuspage_components + - `component create --data '{...}'` — POST /statuspage_components + - `component update KEY --data '{...}'` — PUT /statuspage_components/KEY (after Phase 4) + - `component delete KEY` — DELETE /statuspage_components/KEY + +- [x] **Metric tests** (`cmd/metric_test.go` — extend) + - `get` — GET /metrics, with filters (`--monitor`, `--group`, `--tag`, `--type`, `--time`, `--start`, `--end`, `--env`, `--region`, `--with-nulls`, `--field`) + - `aggregate` — GET /aggregates, with same filters + +- [x] **Site tests** (`cmd/site_test.go` — extend) + - `list` — GET /sites + - `get KEY` — GET /sites/KEY + - `create --data '{...}'` — POST /sites + - `update KEY --data '{...}'` — PUT /sites/KEY + - `delete KEY` — DELETE /sites/KEY + - `query --site KEY --type aggregation` — POST /sites/query with correct body + - `error list --site KEY` — GET /site_errors?site=KEY + - `error get KEY` — GET /site_errors/KEY + +#### 5d: Response Parsing & Output Tests + +- [x] **JSON output** — `FormatJSON()` tested: pretty-prints valid JSON, returns raw on invalid + +The remaining items require command-level integration tests that execute cobra commands against a mock server and verify stdout/file output. These test the glue between "API returns JSON" and "user sees formatted output." + +**Known limitation:** Commands call `os.Exit(1)` on errors, which kills the test process. Error-path integration tests are deferred. A future improvement would be to refactor commands to return errors instead of calling `os.Exit` directly. + +**Scope note:** These integration tests are intentionally representative, not exhaustive. The goal is to verify each output format works end-to-end for a couple of commands, not to re-test every endpoint (already covered by `lib/api_client_test.go`). + +##### Step 1: Create `internal/testutil/mock_api.go` [DONE] + +- [x] Create `internal/testutil/mock_api.go` with exported `MockAPI`, `NewMockAPI()`, `RecordedRequest`, `On()`, `OnWithHeaders()`, `SetDefault()`, `LastRequest()`, `RequestCount()`, `Reset()` — copied from the existing package-private implementation in `lib/api_client_test.go` + +##### Step 2: Create `internal/testutil/capture.go` [DONE] + +- [x] Create `CaptureStdout(fn func()) string` helper + - Redirects `os.Stdout` to an `os.Pipe()`, runs `fn`, reads the pipe, restores stdout + - Needed because commands use `fmt.Println` directly, not cobra's `cmd.OutOrStdout()` + +##### Step 3: Create `internal/testutil/command.go` [DONE] + +- [x] Create `ExecuteCommand(root *cobra.Command, args ...string) (string, error)` helper + - Calls `root.SetArgs(args)`, wraps `root.Execute()` inside `CaptureStdout`, returns captured output + error + - Also handles setup boilerplate: sets `lib.BaseURLOverride` to the mock server URL and `viper.Set("CRONITOR_API_KEY", "test-key")` + +##### Step 4: Refactor `lib/api_client_test.go` to use shared mock [DONE] + +- [x] Replace the local `MockAPI` / `RecordedRequest` / `NewMockAPI` in `lib/api_client_test.go` with imports from `internal/testutil` + - Verify all existing lib tests still pass after refactor + +##### Step 5: Unit test `MergePagedJSON` [DONE] + +- [x] Add test in `cmd/ui_test.go` (or create it if it doesn't exist) + - Given two page response bodies: `{"items":[{"id":1}]}` and `{"items":[{"id":2}]}` + - Assert `MergePagedJSON(bodies, "items")` returns `[{"id":1},{"id":2}]` + - Test edge cases: empty pages, single page, mismatched keys + +##### Step 6: Unit test `FetchAllPages` [DONE] + +- [x] Add test in `cmd/ui_test.go` using mock server from `internal/testutil` + - Mock returns items on page 1 and 2, empty array on page 3 + - Assert `FetchAllPages` returns 2 bodies (stops at empty page) + - Assert it sends incrementing `page` query param + - Test safety limit behavior (mock always returns items, assert it stops at 200) + +##### Step 7: Integration test — table output [DONE] + +- [x] Add `cmd/integration_test.go` + - Test `monitor list` (default format = table): + - Mock returns `testdata/monitors_list.json` fixture on `GET /monitors` + - Assert output contains column headers: "NAME", "KEY", "TYPE", "STATUS" + - Assert output contains monitor names/keys from the fixture + - Test `issue list --format table`: + - Mock returns `testdata/issues_list.json` fixture + - Assert output contains "NAME", "KEY", "STATE", "SEVERITY" + +##### Step 8: Integration test — JSON output [DONE] + +- [x] Test `monitor list --format json`: + - Mock returns fixture on `GET /monitors` + - Assert output is valid JSON (`json.Valid()`) + - Assert output contains expected monitor keys from the fixture +- [x] Test `monitor get my-job --format json`: + - Mock returns fixture on `GET /monitors/my-job` + - Assert output is valid pretty-printed JSON + +##### Step 9: Integration test — YAML output [DONE] + +- [x] Test `monitor list --format yaml`: + - Mock returns YAML-formatted body when `format=yaml` query param is present + - Assert output is non-empty and matches what the mock returned (passthrough test) + +##### Step 10: Integration test — output to file [DONE] + +- [x] Test `monitor list --format json --output `: + - Execute command with `--output` pointing to `t.TempDir()` file + - Assert file exists, contains valid JSON matching the fixture + - Assert captured stdout contains "Output written to" but NOT the JSON data + +##### Step 11: Integration test — pagination metadata [DONE] + +- [x] Test `monitor list` (table format) with pagination: + - Mock returns fixture with `page_info.totalMonitorCount` > page size + - Assert output contains pagination string (e.g., "Showing page 1") + +##### Step 12: Integration test — `--all` flag [DONE] + +- [x] Test `monitor list --all --format json`: + - Mock returns different items on `GET /monitors?page=1` vs `page=2`, empty on `page=3` + - Assert output is a merged JSON array containing items from both pages + +#### 5e: Error Handling Tests [DONE] + +All error handling tested in `lib/api_client_test.go`: + +- [x] **Invalid API key** — 403 response parses "Invalid API key" from error body +- [x] **Resource not found** — 404 `IsNotFound()` correctly returns true +- [x] **Validation errors** — 400 `ParseError()` extracts messages from `errors[]` array +- [x] **Rate limiting** — 429 response captures `Retry-After` header +- [x] **Server errors** — 500 `ParseError()` returns "Internal server error" +- [x] **Network errors** — Connection refused returns `request failed` error (not panic) +- [x] **Malformed responses** — Invalid JSON handled gracefully by `FormatJSON()` and `ParseError()` +- [x] **Response helpers** — `IsSuccess()` tested for all status code ranges (2xx true, 3xx/4xx/5xx false) + +#### 5f: Configuration & Version Header Tests [DONE] + +All version header tests implemented in `lib/api_client_test.go` after Phase 6 made the header configurable via viper: + +- [x] **No version configured** — `TestVersionHeader_NotSentWhenUnset` and `TestVersionHeader_NotSentAcrossAllMethods` verify no header when `CRONITOR_API_VERSION` is empty +- [x] **Version in config file / env var** — `TestVersionHeader_SentWhenConfigured` and `TestVersionHeader_DifferentVersionValues` verify header sent with correct value via `viper.Set()` +- [x] **All HTTP methods** — `TestVersionHeader_AppliesAcrossAllMethods` verifies header on GET, POST, PUT, DELETE, PATCH +- [x] **Priority order** — `TestVersionHeader_ViperPriority_EnvOverridesConfig` verifies viper precedence (env var overrides config) + +#### 5g: Run & Fix Existing Tests [DONE] + +- [x] **Run all tests** — `go test ./cmd/... ./lib/...` passes (all existing structural tests + all new API client tests) +- [x] **Fix any failures** — No failures found; all tests pass +- [x] **Verify test coverage** — `go test -cover ./cmd/... ./lib/...` shows adequate coverage for new code + +### Phase 6: Polish & Edge Cases [DONE] + +- [x] **Configurable `Cronitor-Version` header** — Remove hardcoded version, make it configurable across the entire CLI + - Removed hardcoded `2025-11-28` from `lib/api_client.go` and `lib/cronitor.go` (both `send()` and `sendWithContentType()`) + - Added `varApiVersion = "CRONITOR_API_VERSION"` to `cmd/root.go` + - Added `--api-version` persistent flag on `RootCmd` (available to all commands) + - Added `ApiVersion` field to `ConfigFile` struct in `cmd/configure.go` + - Configure command reads and displays API version + - Header only sent when `CRONITOR_API_VERSION` is non-empty (via env var, config file, or `--api-version` flag) + - Extended `Monitor.UnmarshalJSON()` to normalize singular `schedule` (string) into `schedules` ([]string) for cross-version compatibility + +- [x] **Consistent error messaging** — Audited all commands for consistent error output + - All API errors use: `Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError()))` + - All network errors use: `Error(fmt.Sprintf("Failed to : %s", err))` + - Added missing `IsNotFound()` checks to: group get/delete, issue update, notification update, maintenance delete, statuspage update/component update/component delete, site delete, monitor update + +- [x] **Pagination helpers** — Added `--all` flag to all list commands + - `FetchAllPages()` and `MergePagedJSON()` helpers in `cmd/ui.go` + - For JSON: merges all pages into a single JSON array + - For table: accumulates rows from all pages, renders once + - Added to: monitor, group, environment, issue, notification, maintenance, statuspage, site + +- [x] **Output to file** — Verified `--output` flag works correctly across all commands + - Fixed group.go: added missing newline in file write, standardized success message to `Info()` + - Fixed bypass issues: routed "no results found" messages through output functions in group.go, maintenance.go, metric.go, site.go + +--- + +## API Reference Quick Map + +| CLI Command | API Endpoint | Methods | +|-------------|-------------|---------| +| `monitor list` | `GET /monitors` | GET | +| `monitor get KEY` | `GET /monitors/:key` | GET | +| `monitor search QUERY` | `GET /api/search` | GET | +| `monitor create` | `POST /monitors` (single), `PUT /monitors` (batch) | POST, PUT | +| `monitor update KEY` | `PUT /monitors` | PUT | +| `monitor delete KEY` | `DELETE /monitors/:key` or `DELETE /monitors` (bulk) | DELETE | +| `monitor clone KEY` | `POST /monitors/clone` | POST | +| `monitor pause KEY` | `GET /monitors/:key/pause[/:hours]` | GET | +| `monitor unpause KEY` | `GET /monitors/:key/pause/0` | GET | +| `group list` | `GET /groups` | GET | +| `group get KEY` | `GET /groups/:key` | GET | +| `group create` | `POST /groups` | POST | +| `group update KEY` | `PUT /groups/:key` | PUT | +| `group delete KEY` | `DELETE /groups/:key` | DELETE | +| `group pause KEY HOURS` | `GET /groups/:key/pause/:hours` | GET | +| `group resume KEY` | `GET /groups/:key/pause/0` | GET | +| `environment list` | `GET /environments` | GET | +| `environment get KEY` | `GET /environments/:key` | GET | +| `environment create` | `POST /environments` | POST | +| `environment update KEY` | `PUT /environments/:key` | PUT | +| `environment delete KEY` | `DELETE /environments/:key` | DELETE | +| `notification list` | `GET /notifications` | GET | +| `notification get KEY` | `GET /notifications/:key` | GET | +| `notification create` | `POST /notifications` | POST | +| `notification update KEY` | `PUT /notifications/:key` | PUT | +| `notification delete KEY` | `DELETE /notifications/:key` | DELETE | +| `issue list` | `GET /issues` | GET | +| `issue get KEY` | `GET /issues/:key` | GET | +| `issue create` | `POST /issues` | POST | +| `issue update KEY` | `PUT /issues/:key` | PUT | +| `issue resolve KEY` | `PUT /issues/:key` (state=resolved) | PUT | +| `issue delete KEY` | `DELETE /issues/:key` | DELETE | +| `issue bulk` | `POST /issues/bulk` | POST | +| `maintenance list` | `GET /maintenance_windows` | GET | +| `maintenance get KEY` | `GET /maintenance_windows/:key` | GET | +| `maintenance create` | `POST /maintenance_windows` | POST | +| `maintenance update KEY` | `PUT /maintenance_windows/:key` | PUT | +| `maintenance delete KEY` | `DELETE /maintenance_windows/:key` | DELETE | +| `statuspage list` | `GET /statuspages` | GET | +| `statuspage get KEY` | `GET /statuspages/:key` | GET | +| `statuspage create` | `POST /statuspages` | POST | +| `statuspage update KEY` | `PUT /statuspages/:key` | PUT | +| `statuspage delete KEY` | `DELETE /statuspages/:key` | DELETE | +| `statuspage component list` | `GET /statuspage_components` | GET | +| `statuspage component create` | `POST /statuspage_components` | POST | +| `statuspage component update KEY` | `PUT /statuspage_components/:key` | PUT | +| `statuspage component delete KEY` | `DELETE /statuspage_components/:key` | DELETE | +| `metric get` | `GET /metrics` | GET | +| `metric aggregate` | `GET /aggregates` | GET | +| `site list` | `GET /sites` | GET | +| `site get KEY` | `GET /sites/:key` | GET | +| `site create` | `POST /sites` | POST | +| `site update KEY` | `PUT /sites/:key` | PUT | +| `site delete KEY` | `DELETE /sites/:key` | DELETE | +| `site query` | `POST /sites/query` | POST | +| `site error list` | `GET /site_errors` | GET | +| `site error get KEY` | `GET /site_errors/:key` | GET | diff --git a/cmd/configure.go b/cmd/configure.go index be7eda1..22440de 100644 --- a/cmd/configure.go +++ b/cmd/configure.go @@ -23,6 +23,7 @@ type ConfigFile struct { AllowedIPs string `json:"CRONITOR_ALLOWED_IPS"` CorsAllowedOrigins string `json:"CRONITOR_CORS_ALLOWED_ORIGINS"` Users string `json:"CRONITOR_USERS"` + ApiVersion string `json:"CRONITOR_API_VERSION,omitempty"` MCPEnabled bool `json:"CRONITOR_MCP_ENABLED,omitempty"` MCPInstances map[string]MCPInstanceConfig `json:"mcp_instances,omitempty"` } @@ -76,6 +77,7 @@ Example setting common exclude text for use with 'cronitor discover': configData.AllowedIPs = viper.GetString(varAllowedIPs) configData.CorsAllowedOrigins = viper.GetString("CRONITOR_CORS_ALLOWED_ORIGINS") configData.Users = viper.GetString(varUsers) + configData.ApiVersion = viper.GetString(varApiVersion) configData.MCPEnabled = viper.GetBool(varMCPEnabled) // Load MCP instances if configured @@ -169,6 +171,13 @@ Example setting common exclude text for use with 'cronitor discover': fmt.Println(configData.Users) } + fmt.Println("\nAPI Version:") + if configData.ApiVersion == "" { + fmt.Println("Not Set (API default)") + } else { + fmt.Println(configData.ApiVersion) + } + fmt.Println("\nMCP Enabled:") fmt.Println(configData.MCPEnabled) diff --git a/cmd/discover.go b/cmd/discover.go index 3057b79..16fd53c 100644 --- a/cmd/discover.go +++ b/cmd/discover.go @@ -1,10 +1,12 @@ package cmd import ( + "encoding/json" "errors" "fmt" "os" "os/user" + "path/filepath" "runtime" "strings" @@ -16,6 +18,7 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" "github.com/spf13/viper" + "gopkg.in/yaml.v3" ) // getJobLabel returns the appropriate term for a job based on the platform @@ -109,6 +112,7 @@ var notificationList string var existingMonitors = ExistingMonitors{} var processingMultipleCrontabs = false var userAbortedSync = false // Set to true when user presses Ctrl+D to abort sync entirely +var syncFile string // Path to YAML/JSON file for bulk monitor import // To deprecate this feature we are hijacking this flag that will trigger removal of auto-discover lines from existing user's crontabs. var noAutoDiscover = true @@ -173,6 +177,12 @@ Example where you perform a dry-run without any crontab modifications: lipgloss.NewStyle().Italic(true).Render("cronitor configure --api-key ")), 1) } + // Handle --file flag for bulk monitor import from YAML/JSON file + if syncFile != "" { + importMonitorsFromFile(syncFile) + return + } + var username string if u, err := user.Current(); err == nil { username = u.Username @@ -275,6 +285,74 @@ func processDirectory(username, directory string) { } } +// importMonitorsFromFile imports monitors from a YAML or JSON file +// YAML files are sent with Content-Type: application/yaml +// JSON files are sent with Content-Type: application/json +func importMonitorsFromFile(filePath string) { + printSuccessText(fmt.Sprintf("Importing monitors from %s...", filePath), false) + + // Read the file + data, err := os.ReadFile(filePath) + if err != nil { + fatal(fmt.Sprintf("Failed to read file: %s", err.Error()), 1) + } + + // Determine content type based on file extension + ext := strings.ToLower(filepath.Ext(filePath)) + var contentType string + + switch ext { + case ".yaml", ".yml": + contentType = "application/yaml" + // Basic validation - try to parse YAML + var yamlData interface{} + if err := yaml.Unmarshal(data, &yamlData); err != nil { + fatal(fmt.Sprintf("Failed to parse YAML: %s", err.Error()), 1) + } + case ".json": + contentType = "application/json" + // Basic validation - try to parse JSON + var jsonData interface{} + if err := json.Unmarshal(data, &jsonData); err != nil { + fatal(fmt.Sprintf("Failed to parse JSON: %s", err.Error()), 1) + } + default: + fatal(fmt.Sprintf("Unsupported file format: %s (use .yaml, .yml, or .json)", ext), 1) + } + + // Send to the API with the appropriate content type + printDoneText("Sending to Cronitor...", false) + + response, err := getCronitorApi().PutRawMonitors(data, contentType) + if err != nil { + fatal(fmt.Sprintf("API error: %s", err.Error()), 1) + } + + // Try to parse response to show results + // Response format may vary based on input format + var result struct { + Monitors []struct { + Key string `json:"key"` + Name string `json:"name"` + } `json:"monitors"` + } + if err := json.Unmarshal(response, &result); err == nil && len(result.Monitors) > 0 { + printDoneText(fmt.Sprintf("Successfully synced %d monitor(s)", len(result.Monitors)), false) + for _, m := range result.Monitors { + name := m.Name + if name == "" { + name = m.Key + } + printSuccessText(fmt.Sprintf(" • %s", name), false) + } + } else { + // For YAML responses or other formats, just show success + printDoneText("Monitors synced successfully", false) + } + + printSuccessText("View your dashboard: https://cronitor.io/app/dashboard", false) +} + func processCrontab(crontab *lib.Crontab) bool { defer printLn() @@ -792,6 +870,7 @@ func init() { discoverCmd.Flags().BoolVar(&noStdoutPassthru, "no-stdout", noStdoutPassthru, "Do not send cron job output to Cronitor when your job completes.") discoverCmd.Flags().StringVar(¬ificationList, "notification-list", notificationList, "Use the provided notification list when creating or updating monitors, or \"default\" list if omitted.") discoverCmd.Flags().BoolVar(&isAutoDiscover, "auto", isAutoDiscover, "Do not use an interactive shell. Write updated crontab to stdout.") + discoverCmd.Flags().StringVar(&syncFile, "file", "", "Path to YAML or JSON file containing monitor definitions for bulk import") discoverCmd.Flags().BoolVar(&isSilent, "silent", isSilent, "") discoverCmd.Flags().MarkHidden("silent") diff --git a/cmd/discover_test.go b/cmd/discover_test.go index 8dd593e..f6e3ff6 100644 --- a/cmd/discover_test.go +++ b/cmd/discover_test.go @@ -201,3 +201,21 @@ func TestCreateDefaultName(t *testing.T) { } } } + +func TestCreateDefaultNameAutoDiscover(t *testing.T) { + line := &lib.Line{ + CommandToRun: "cronitor d3x0c1 cronitor discover --auto /discover/test", + LineNumber: 11, + RunAs: "", + } + crontab := &lib.Crontab{ + Filename: "/discover/test", + } + + defaultName := createDefaultName(line, crontab, "localhost", nil, map[string]bool{}) + + expected := "[localhost] Auto discover /discover/test" + if defaultName != expected { + t.Errorf("Auto discover test failed, got: %s, expected: %s.", defaultName, expected) + } +} diff --git a/cmd/environment.go b/cmd/environment.go index ffb1cfe..0efd6c4 100644 --- a/cmd/environment.go +++ b/cmd/environment.go @@ -17,12 +17,18 @@ var environmentCmd = &cobra.Command{ Short: "Manage environments", Long: `Manage Cronitor environments. +Environments allow you to separate monitors by deployment stage (production, staging, etc.) +and control which environments trigger alerts. + Examples: cronitor environment list - cronitor environment get - cronitor environment create --data '{"name":"Production","key":"production"}' - cronitor environment update --data '{"name":"Updated Name"}' - cronitor environment delete `, + cronitor environment get production + cronitor environment create staging --name "Staging" --no-alerts + cronitor environment create production --name "Production" --with-alerts + cronitor environment update staging --name "QA Environment" + cronitor environment delete old-env + +For full API documentation, see https://cronitor.io/docs/environments-api.md`, Args: func(cmd *cobra.Command, args []string) error { if len(viper.GetString(varApiKey)) < 10 { return errors.New("API key required. Run 'cronitor configure' or use --api-key flag") @@ -35,11 +41,11 @@ Examples: } var ( - environmentPage int - environmentFormat string - environmentOutput string - environmentData string - environmentFile string + environmentPage int + environmentFormat string + environmentOutput string + environmentData string + environmentFetchAll bool ) func init() { @@ -53,6 +59,11 @@ func init() { var environmentListCmd = &cobra.Command{ Use: "list", Short: "List all environments", + Long: `List all environments. + +Examples: + cronitor environment list + cronitor environment list --format json`, Run: func(cmd *cobra.Command, args []string) { client := lib.NewAPIClient(dev, log) params := make(map[string]string) @@ -60,6 +71,48 @@ var environmentListCmd = &cobra.Command{ params["page"] = fmt.Sprintf("%d", environmentPage) } + if environmentFetchAll { + bodies, err := FetchAllPages(client, "/environments", params, "environments") + if err != nil { + Error(fmt.Sprintf("Failed to list environments: %s", err)) + os.Exit(1) + } + if environmentFormat == "json" || environmentFormat == "" { + environmentOutputToTarget(FormatJSON(MergePagedJSON(bodies, "environments"))) + return + } + // Table: accumulate rows from all pages + table := &UITable{ + Headers: []string{"NAME", "KEY", "ALERTS", "MONITORS", "DEFAULT"}, + } + for _, body := range bodies { + var result struct { + Environments []struct { + Key string `json:"key"` + Name string `json:"name"` + WithAlerts bool `json:"with_alerts"` + Default bool `json:"default"` + ActiveMonitors int `json:"active_monitors"` + } `json:"environments"` + } + json.Unmarshal(body, &result) + for _, e := range result.Environments { + alerts := mutedStyle.Render("off") + if e.WithAlerts { + alerts = successStyle.Render("on") + } + isDefault := "" + if e.Default { + isDefault = "yes" + } + monitors := fmt.Sprintf("%d", e.ActiveMonitors) + table.Rows = append(table.Rows, []string{e.Name, e.Key, alerts, monitors, isDefault}) + } + } + environmentOutputToTarget(table.Render()) + return + } + resp, err := client.GET("/environments", params) if err != nil { Error(fmt.Sprintf("Failed to list environments: %s", err)) @@ -73,8 +126,11 @@ var environmentListCmd = &cobra.Command{ var result struct { Environments []struct { - Key string `json:"key"` - Name string `json:"name"` + Key string `json:"key"` + Name string `json:"name"` + WithAlerts bool `json:"with_alerts"` + Default bool `json:"default"` + ActiveMonitors int `json:"active_monitors"` } `json:"environments"` } if err := json.Unmarshal(resp.Body, &result); err != nil { @@ -93,11 +149,20 @@ var environmentListCmd = &cobra.Command{ } table := &UITable{ - Headers: []string{"KEY", "NAME"}, + Headers: []string{"NAME", "KEY", "ALERTS", "MONITORS", "DEFAULT"}, } for _, e := range result.Environments { - table.Rows = append(table.Rows, []string{e.Key, e.Name}) + alerts := mutedStyle.Render("off") + if e.WithAlerts { + alerts = successStyle.Render("on") + } + isDefault := "" + if e.Default { + isDefault = "yes" + } + monitors := fmt.Sprintf("%d", e.ActiveMonitors) + table.Rows = append(table.Rows, []string{e.Name, e.Key, alerts, monitors, isDefault}) } environmentOutputToTarget(table.Render()) @@ -137,19 +202,25 @@ var environmentGetCmd = &cobra.Command{ var environmentCreateCmd = &cobra.Command{ Use: "create", Short: "Create a new environment", + Long: `Create a new environment. + +Examples: + cronitor environment create --data '{"key":"staging","name":"Staging Environment"}' + cronitor environment create --data '{"key":"production","name":"Production","with_alerts":true}'`, Run: func(cmd *cobra.Command, args []string) { - body, err := getEnvironmentRequestBody() - if err != nil { - Error(err.Error()) + if environmentData == "" { + Error("Create data required. Use --data '{...}'") os.Exit(1) } - if body == nil { - Error("JSON data required. Use --data or --file") + + var js json.RawMessage + if err := json.Unmarshal([]byte(environmentData), &js); err != nil { + Error(fmt.Sprintf("Invalid JSON: %s", err)) os.Exit(1) } client := lib.NewAPIClient(dev, log) - resp, err := client.POST("/environments", body, nil) + resp, err := client.POST("/environments", []byte(environmentData), nil) if err != nil { Error(fmt.Sprintf("Failed to create environment: %s", err)) os.Exit(1) @@ -160,8 +231,19 @@ var environmentCreateCmd = &cobra.Command{ os.Exit(1) } - Success("Environment created") - environmentOutputToTarget(FormatJSON(resp.Body)) + var result struct { + Key string `json:"key"` + Name string `json:"name"` + } + if err := json.Unmarshal(resp.Body, &result); err == nil { + Success(fmt.Sprintf("Created environment: %s (key: %s)", result.Name, result.Key)) + } else { + Success("Environment created") + } + + if environmentFormat == "json" { + environmentOutputToTarget(FormatJSON(resp.Body)) + } }, } @@ -169,21 +251,29 @@ var environmentCreateCmd = &cobra.Command{ var environmentUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update an environment", - Args: cobra.ExactArgs(1), + Long: `Update an existing environment. + +Examples: + cronitor environment update staging --data '{"name":"Staging Environment"}' + cronitor environment update production --data '{"with_alerts":true}' + cronitor environment update dev --data '{"with_alerts":false}'`, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { key := args[0] - body, err := getEnvironmentRequestBody() - if err != nil { - Error(err.Error()) + + if environmentData == "" { + Error("Update data required. Use --data '{...}'") os.Exit(1) } - if body == nil { - Error("JSON data required. Use --data or --file") + + var js json.RawMessage + if err := json.Unmarshal([]byte(environmentData), &js); err != nil { + Error(fmt.Sprintf("Invalid JSON: %s", err)) os.Exit(1) } client := lib.NewAPIClient(dev, log) - resp, err := client.PUT(fmt.Sprintf("/environments/%s", key), body, nil) + resp, err := client.PUT(fmt.Sprintf("/environments/%s", key), []byte(environmentData), nil) if err != nil { Error(fmt.Sprintf("Failed to update environment: %s", err)) os.Exit(1) @@ -195,7 +285,9 @@ var environmentUpdateCmd = &cobra.Command{ } Success(fmt.Sprintf("Environment '%s' updated", key)) - environmentOutputToTarget(FormatJSON(resp.Body)) + if environmentFormat == "json" { + environmentOutputToTarget(FormatJSON(resp.Body)) + } }, } @@ -235,34 +327,14 @@ func init() { environmentCmd.AddCommand(environmentUpdateCmd) environmentCmd.AddCommand(environmentDeleteCmd) - environmentCreateCmd.Flags().StringVarP(&environmentData, "data", "d", "", "JSON data") - environmentCreateCmd.Flags().StringVarP(&environmentFile, "file", "f", "", "JSON file") - environmentUpdateCmd.Flags().StringVarP(&environmentData, "data", "d", "", "JSON data") - environmentUpdateCmd.Flags().StringVarP(&environmentFile, "file", "f", "", "JSON file") -} - -func getEnvironmentRequestBody() ([]byte, error) { - if environmentData != "" && environmentFile != "" { - return nil, errors.New("cannot specify both --data and --file") - } + // List flags + environmentListCmd.Flags().BoolVar(&environmentFetchAll, "all", false, "Fetch all pages of results") - if environmentData != "" { - var js json.RawMessage - if err := json.Unmarshal([]byte(environmentData), &js); err != nil { - return nil, fmt.Errorf("invalid JSON: %w", err) - } - return []byte(environmentData), nil - } - - if environmentFile != "" { - data, err := os.ReadFile(environmentFile) - if err != nil { - return nil, fmt.Errorf("failed to read file: %w", err) - } - return data, nil - } + // Create flags + environmentCreateCmd.Flags().StringVarP(&environmentData, "data", "d", "", "JSON payload") - return nil, nil + // Update flags + environmentUpdateCmd.Flags().StringVarP(&environmentData, "data", "d", "", "JSON payload") } func environmentOutputToTarget(content string) { diff --git a/cmd/environment_test.go b/cmd/environment_test.go new file mode 100644 index 0000000..4b7608f --- /dev/null +++ b/cmd/environment_test.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "testing" +) + +func TestEnvironmentCommandStructure(t *testing.T) { + subcommands := []string{"list", "get", "create", "update", "delete"} + + for _, name := range subcommands { + found := false + for _, cmd := range environmentCmd.Commands() { + if cmd.Name() == name { + found = true + break + } + } + if !found { + t.Errorf("Expected subcommand '%s' not found in environment command", name) + } + } +} + +func TestEnvironmentPersistentFlags(t *testing.T) { + flags := []string{"page", "format", "output"} + + for _, flag := range flags { + if environmentCmd.PersistentFlags().Lookup(flag) == nil { + t.Errorf("Expected persistent flag '--%s' not found in environment command", flag) + } + } +} + +func TestEnvironmentCreateCommandFlags(t *testing.T) { + flags := []string{"data"} + + for _, flag := range flags { + if environmentCreateCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in environment create command", flag) + } + } +} + +func TestEnvironmentUpdateCommandFlags(t *testing.T) { + flags := []string{"data"} + + for _, flag := range flags { + if environmentUpdateCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in environment update command", flag) + } + } +} + +func TestEnvironmentCommandAliases(t *testing.T) { + aliases := environmentCmd.Aliases + found := false + for _, alias := range aliases { + if alias == "env" { + found = true + break + } + } + if !found { + t.Error("Expected alias 'env' not found") + } +} diff --git a/cmd/group.go b/cmd/group.go new file mode 100644 index 0000000..75b2cfe --- /dev/null +++ b/cmd/group.go @@ -0,0 +1,472 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" +) + +var ( + groupPage int + groupPageSize int + groupEnv string + groupFormat string + groupOutput string + groupWithStatus bool + groupFetchAll bool + groupSort string + groupData string +) + +var groupCmd = &cobra.Command{ + Use: "group", + Short: "Manage monitor groups", + Long: `Create, list, update, and delete monitor groups. + +For full API documentation, see https://cronitor.io/docs/groups-api.md`, +} + +func init() { + RootCmd.AddCommand(groupCmd) + + // Add subcommands + groupCmd.AddCommand(groupListCmd) + groupCmd.AddCommand(groupGetCmd) + groupCmd.AddCommand(groupCreateCmd) + groupCmd.AddCommand(groupUpdateCmd) + groupCmd.AddCommand(groupDeleteCmd) + groupCmd.AddCommand(groupPauseCmd) + groupCmd.AddCommand(groupResumeCmd) + + // Persistent flags for all group subcommands + groupCmd.PersistentFlags().IntVar(&groupPage, "page", 1, "Page number for paginated results") + groupCmd.PersistentFlags().StringVar(&groupEnv, "env", "", "Filter by environment") + groupCmd.PersistentFlags().StringVar(&groupFormat, "format", "", "Output format: json, table") + groupCmd.PersistentFlags().StringVarP(&groupOutput, "output", "o", "", "Write output to file") +} + +// --- LIST --- +var groupListCmd = &cobra.Command{ + Use: "list", + Short: "List all groups", + Long: `List all monitor groups with optional filtering. + +Examples: + cronitor group list + cronitor group list --page 2 + cronitor group list --page-size 50 + cronitor group list --with-status + cronitor group list --env production`, + Run: func(cmd *cobra.Command, args []string) { + client := lib.NewAPIClient(dev, log) + params := make(map[string]string) + + if groupPage > 1 { + params["page"] = fmt.Sprintf("%d", groupPage) + } + if groupPageSize > 0 { + params["pageSize"] = fmt.Sprintf("%d", groupPageSize) + } + if groupEnv != "" { + params["env"] = groupEnv + } + if groupWithStatus { + params["withStatus"] = "true" + } + + if groupFetchAll { + bodies, err := FetchAllPages(client, "/groups", params, "groups") + if err != nil { + Error(fmt.Sprintf("Failed to list groups: %s", err)) + os.Exit(1) + } + if groupFormat == "json" || groupFormat == "" { + outputGroupToTarget(FormatJSON(MergePagedJSON(bodies, "groups"))) + return + } + // Table: accumulate rows from all pages + table := &UITable{ + Headers: []string{"NAME", "KEY", "MONITORS", "CREATED"}, + } + for _, body := range bodies { + var result struct { + Groups []struct { + Key string `json:"key"` + Name string `json:"name"` + Monitors []string `json:"monitors"` + Created string `json:"created"` + } `json:"groups"` + } + json.Unmarshal(body, &result) + for _, g := range result.Groups { + monitorCount := fmt.Sprintf("%d", len(g.Monitors)) + created := "" + if g.Created != "" { + created = g.Created[:10] + } + table.Rows = append(table.Rows, []string{g.Name, g.Key, monitorCount, created}) + } + } + outputGroupToTarget(table.Render()) + return + } + + resp, err := client.GET("/groups", params) + if err != nil { + Error(fmt.Sprintf("Failed to list groups: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + if groupFormat == "json" || groupFormat == "" { + outputGroupToTarget(FormatJSON(resp.Body)) + return + } + + // Parse for table output + var result struct { + Groups []struct { + Key string `json:"key"` + Name string `json:"name"` + Monitors []string `json:"monitors"` + Created string `json:"created"` + } `json:"groups"` + PageSize int `json:"page_size"` + Page int `json:"page"` + TotalCount int `json:"total_count"` + } + + if err := json.Unmarshal(resp.Body, &result); err != nil { + Error(fmt.Sprintf("Failed to parse response: %s", err)) + os.Exit(1) + } + + if len(result.Groups) == 0 { + outputGroupToTarget(mutedStyle.Render("No groups found")) + return + } + + // Table output + table := &UITable{ + Headers: []string{"NAME", "KEY", "MONITORS", "CREATED"}, + } + for _, g := range result.Groups { + monitorCount := fmt.Sprintf("%d", len(g.Monitors)) + created := "" + if g.Created != "" { + created = g.Created[:10] // Just the date part + } + table.Rows = append(table.Rows, []string{g.Name, g.Key, monitorCount, created}) + } + outputGroupToTarget(table.Render()) + + if result.TotalCount > result.PageSize { + fmt.Printf("\nPage %d of %d (total: %d groups)\n", + result.Page, (result.TotalCount+result.PageSize-1)/result.PageSize, result.TotalCount) + } + }, +} + +// --- GET --- +var groupGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a specific group", + Long: `Retrieve details for a specific group by key. + +Examples: + cronitor group get my-group + cronitor group get my-group --with-status + cronitor group get my-group --format json`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + params := make(map[string]string) + + if groupEnv != "" { + params["env"] = groupEnv + } + if groupWithStatus { + params["withStatus"] = "true" + } + if groupSort != "" { + params["sort"] = groupSort + } + + resp, err := client.GET(fmt.Sprintf("/groups/%s", key), params) + if err != nil { + Error(fmt.Sprintf("Failed to get group: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Group '%s' not found", key)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + if groupFormat == "json" || groupFormat == "" { + outputGroupToTarget(FormatJSON(resp.Body)) + return + } + + // Parse for detailed output + var group struct { + Key string `json:"key"` + Name string `json:"name"` + Monitors []string `json:"monitors"` + Created string `json:"created"` + LatestEvent struct { + Stamp string `json:"stamp"` + State string `json:"state"` + } `json:"latest_event"` + } + + if err := json.Unmarshal(resp.Body, &group); err != nil { + Error(fmt.Sprintf("Failed to parse response: %s", err)) + os.Exit(1) + } + + // Table output for single group + fmt.Printf("Group: %s\n", boldStyle.Render(group.Name)) + fmt.Printf("Key: %s\n", group.Key) + if group.Created != "" { + fmt.Printf("Created: %s\n", group.Created) + } + if group.LatestEvent.Stamp != "" { + fmt.Printf("Latest Event: %s (%s)\n", group.LatestEvent.Stamp, group.LatestEvent.State) + } + if len(group.Monitors) > 0 { + fmt.Printf("\nMonitors (%d):\n", len(group.Monitors)) + for _, m := range group.Monitors { + fmt.Printf(" - %s\n", m) + } + } + }, +} + +// --- CREATE --- +var groupCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new group", + Long: `Create a new monitor group. + +Examples: + cronitor group create --data '{"name":"Production Jobs"}' + cronitor group create --data '{"name":"Production Jobs","key":"prod-jobs","monitors":["job1","job2"]}'`, + Run: func(cmd *cobra.Command, args []string) { + if groupData == "" { + Error("Create data required. Use --data '{...}'") + os.Exit(1) + } + + var js json.RawMessage + if err := json.Unmarshal([]byte(groupData), &js); err != nil { + Error(fmt.Sprintf("Invalid JSON: %s", err)) + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + resp, err := client.POST("/groups", []byte(groupData), nil) + if err != nil { + Error(fmt.Sprintf("Failed to create group: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + var result struct { + Key string `json:"key"` + Name string `json:"name"` + } + if err := json.Unmarshal(resp.Body, &result); err == nil { + Success(fmt.Sprintf("Created group: %s (key: %s)", result.Name, result.Key)) + } else { + Success("Group created successfully") + } + + if groupFormat == "json" { + outputGroupToTarget(FormatJSON(resp.Body)) + } + }, +} + +// --- UPDATE --- +var groupUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an existing group", + Long: `Update an existing monitor group. + +Examples: + cronitor group update my-group --data '{"name":"New Name"}' + cronitor group update my-group --data '{"monitors":["job1","job2","job3"]}'`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + + if groupData == "" { + Error("Update data required. Use --data '{...}'") + os.Exit(1) + } + + var js json.RawMessage + if err := json.Unmarshal([]byte(groupData), &js); err != nil { + Error(fmt.Sprintf("Invalid JSON: %s", err)) + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + resp, err := client.PUT(fmt.Sprintf("/groups/%s", key), []byte(groupData), nil) + if err != nil { + Error(fmt.Sprintf("Failed to update group: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success(fmt.Sprintf("Updated group: %s", key)) + + if groupFormat == "json" { + outputGroupToTarget(FormatJSON(resp.Body)) + } + }, +} + +// --- DELETE --- +var groupDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a group", + Long: `Delete a monitor group. This does not delete the monitors in the group. + +Examples: + cronitor group delete my-group`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + resp, err := client.DELETE(fmt.Sprintf("/groups/%s", key), nil, nil) + if err != nil { + Error(fmt.Sprintf("Failed to delete group: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Group '%s' not found", key)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success(fmt.Sprintf("Deleted group: %s", key)) + }, +} + +// --- PAUSE --- +var groupPauseCmd = &cobra.Command{ + Use: "pause ", + Short: "Pause all monitors in a group", + Long: `Pause all monitors in a group for the specified number of hours. + +Examples: + cronitor group pause my-group 1 # Pause for 1 hour + cronitor group pause my-group 24 # Pause for 24 hours + cronitor group pause my-group 168 # Pause for 1 week`, + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + hours := args[1] + client := lib.NewAPIClient(dev, log) + + resp, err := client.GET(fmt.Sprintf("/groups/%s/pause/%s", key, hours), nil) + if err != nil { + Error(fmt.Sprintf("Failed to pause group: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success(fmt.Sprintf("Paused all monitors in group '%s' for %s hours", key, hours)) + }, +} + +// --- RESUME --- +var groupResumeCmd = &cobra.Command{ + Use: "resume ", + Short: "Resume all monitors in a group", + Long: `Resume all paused monitors in a group. + +Examples: + cronitor group resume my-group`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + // Resume is just pause with 0 hours + resp, err := client.GET(fmt.Sprintf("/groups/%s/pause/0", key), nil) + if err != nil { + Error(fmt.Sprintf("Failed to resume group: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success(fmt.Sprintf("Resumed all monitors in group '%s'", key)) + }, +} + +func init() { + // List command flags + groupListCmd.Flags().IntVar(&groupPageSize, "page-size", 0, "Number of results per page") + groupListCmd.Flags().BoolVar(&groupWithStatus, "with-status", false, "Include status information") + groupListCmd.Flags().BoolVar(&groupFetchAll, "all", false, "Fetch all pages of results") + + // Get command flags + groupGetCmd.Flags().BoolVar(&groupWithStatus, "with-status", false, "Include status information") + groupGetCmd.Flags().StringVar(&groupSort, "sort", "", "Sort order for monitors") + + // Create command flags + groupCreateCmd.Flags().StringVarP(&groupData, "data", "d", "", "JSON payload") + + // Update command flags + groupUpdateCmd.Flags().StringVarP(&groupData, "data", "d", "", "JSON payload") +} + +func outputGroupToTarget(content string) { + if groupOutput != "" { + if err := os.WriteFile(groupOutput, []byte(content+"\n"), 0644); err != nil { + Error(fmt.Sprintf("Failed to write to file: %s", err)) + os.Exit(1) + } + Info(fmt.Sprintf("Output written to %s", groupOutput)) + } else { + fmt.Println(content) + } +} diff --git a/cmd/group_test.go b/cmd/group_test.go new file mode 100644 index 0000000..a1fe0e3 --- /dev/null +++ b/cmd/group_test.go @@ -0,0 +1,191 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestGroupCommandStructure(t *testing.T) { + // Test that group command exists and has expected subcommands + subcommands := []string{"list", "get", "create", "update", "delete", "pause", "resume"} + + for _, name := range subcommands { + found := false + for _, cmd := range groupCmd.Commands() { + if cmd.Name() == name { + found = true + break + } + } + if !found { + t.Errorf("Expected subcommand '%s' not found in group command", name) + } + } +} + +func TestGroupListCommandFlags(t *testing.T) { + flags := []string{"page-size", "with-status"} + + for _, flag := range flags { + if groupListCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in group list command", flag) + } + } +} + +func TestGroupGetCommandFlags(t *testing.T) { + flags := []string{"with-status", "sort"} + + for _, flag := range flags { + if groupGetCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in group get command", flag) + } + } +} + +func TestGroupCreateCommandFlags(t *testing.T) { + flags := []string{"data"} + + for _, flag := range flags { + if groupCreateCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in group create command", flag) + } + } +} + +func TestGroupUpdateCommandFlags(t *testing.T) { + flags := []string{"data"} + + for _, flag := range flags { + if groupUpdateCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in group update command", flag) + } + } +} + +func TestGroupPersistentFlags(t *testing.T) { + flags := []string{"page", "env", "format", "output"} + + for _, flag := range flags { + if groupCmd.PersistentFlags().Lookup(flag) == nil { + t.Errorf("Expected persistent flag '--%s' not found in group command", flag) + } + } +} + +func TestGroupCommandArgs(t *testing.T) { + tests := []struct { + name string + cmd *cobra.Command + expectedArgs cobra.PositionalArgs + }{ + {"get requires 1 arg", groupGetCmd, cobra.ExactArgs(1)}, + {"update requires 1 arg", groupUpdateCmd, cobra.ExactArgs(1)}, + {"delete requires 1 arg", groupDeleteCmd, cobra.ExactArgs(1)}, + {"pause requires 2 args", groupPauseCmd, cobra.ExactArgs(2)}, + {"resume requires 1 arg", groupResumeCmd, cobra.ExactArgs(1)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.cmd.Args == nil { + t.Errorf("%s: Args validator is nil", tt.name) + } + }) + } +} + +func TestGroupHelpContainsExamples(t *testing.T) { + tests := []struct { + name string + cmd *cobra.Command + examples []string + }{ + { + "list has examples", + groupListCmd, + []string{"cronitor group list"}, + }, + { + "get has examples", + groupGetCmd, + []string{"cronitor group get"}, + }, + { + "create has examples", + groupCreateCmd, + []string{"cronitor group create"}, + }, + { + "update has examples", + groupUpdateCmd, + []string{"cronitor group update"}, + }, + { + "delete has examples", + groupDeleteCmd, + []string{"cronitor group delete"}, + }, + { + "pause has examples", + groupPauseCmd, + []string{"cronitor group pause"}, + }, + { + "resume has examples", + groupResumeCmd, + []string{"cronitor group resume"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Get the help output + buf := new(bytes.Buffer) + tt.cmd.SetOut(buf) + tt.cmd.SetErr(buf) + tt.cmd.SetArgs([]string{"--help"}) + tt.cmd.Execute() + + help := buf.String() + // Also check the Long description directly + longDesc := tt.cmd.Long + + for _, example := range tt.examples { + if !strings.Contains(help, example) && !strings.Contains(longDesc, example) { + t.Errorf("%s: expected example '%s' not found in help text", tt.name, example) + } + } + }) + } +} + +func TestGroupListHasPageFlag(t *testing.T) { + // Verify page flag inherited from persistent flags + cmd := groupCmd + flag := cmd.PersistentFlags().Lookup("page") + if flag == nil { + t.Error("Expected --page flag on group command") + } + if flag.DefValue != "1" { + t.Errorf("Expected --page default value to be '1', got '%s'", flag.DefValue) + } +} + +func TestGroupPauseResumeRelationship(t *testing.T) { + // Resume should effectively be pause with 0 hours + // This is a documentation/behavior test + pauseLong := groupPauseCmd.Long + resumeLong := groupResumeCmd.Long + + if !strings.Contains(pauseLong, "hours") { + t.Error("Pause command should mention hours in description") + } + + if !strings.Contains(resumeLong, "Resume") { + t.Error("Resume command should mention resuming in description") + } +} diff --git a/cmd/integration_test.go b/cmd/integration_test.go new file mode 100644 index 0000000..44a61d4 --- /dev/null +++ b/cmd/integration_test.go @@ -0,0 +1,368 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cronitorio/cronitor-cli/internal/testutil" + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/viper" +) + +// setupIntegrationTest configures the test environment to point at a mock server. +// It returns a cleanup function that restores the original state. +func setupIntegrationTest(mockURL string) func() { + oldBaseURL := lib.BaseURLOverride + oldAPIKey := viper.GetString("CRONITOR_API_KEY") + + lib.BaseURLOverride = mockURL + viper.Set("CRONITOR_API_KEY", "test-api-key-1234567890") + viper.Set("CRONITOR_API_VERSION", "") + + return func() { + lib.BaseURLOverride = oldBaseURL + viper.Set("CRONITOR_API_KEY", oldAPIKey) + viper.Set("CRONITOR_API_VERSION", "") + } +} + +// executeCmd runs a command through the root cobra command and captures stdout. +func executeCmd(args ...string) (string, error) { + RootCmd.SetArgs(args) + var execErr error + output := testutil.CaptureStdout(func() { + execErr = RootCmd.Execute() + }) + return output, execErr +} + +// --- Step 7: Table Output Tests --- + +func TestIntegration_MonitorList_TableOutput(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + fixture := testutil.LoadFixture("monitors_list.json") + mock.On("GET", "/monitors", 200, fixture) + + cleanup := setupIntegrationTest(mock.Server.URL) + defer cleanup() + + // Reset flag state + monitorFormat = "" + monitorOutput = "" + monitorFetchAll = false + monitorPage = 1 + + output, err := executeCmd("monitor", "list") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify table headers + for _, header := range []string{"NAME", "KEY", "TYPE", "STATUS"} { + if !strings.Contains(output, header) { + t.Errorf("expected table header %q in output, got:\n%s", header, output) + } + } + + // Verify monitor data from fixture + for _, name := range []string{"Nightly Backup", "Health Check", "Paused Monitor"} { + if !strings.Contains(output, name) { + t.Errorf("expected monitor name %q in output, got:\n%s", name, output) + } + } + for _, key := range []string{"abc123", "def456", "ghi789"} { + if !strings.Contains(output, key) { + t.Errorf("expected monitor key %q in output, got:\n%s", key, output) + } + } +} + +func TestIntegration_IssueList_TableOutput(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + fixture := testutil.LoadFixture("issues_list.json") + mock.On("GET", "/issues", 200, fixture) + + cleanup := setupIntegrationTest(mock.Server.URL) + defer cleanup() + + // Reset flag state + issueFormat = "" + issueOutput = "" + issueFetchAll = false + issuePage = 1 + + output, err := executeCmd("issue", "list", "--format", "table") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify table headers + for _, header := range []string{"NAME", "KEY", "STATE", "SEVERITY"} { + if !strings.Contains(output, header) { + t.Errorf("expected table header %q in output, got:\n%s", header, output) + } + } + + // Verify issue data from fixture + if !strings.Contains(output, "issue-001") { + t.Errorf("expected issue key 'issue-001' in output") + } + if !strings.Contains(output, "issue-002") { + t.Errorf("expected issue key 'issue-002' in output") + } +} + +// --- Step 8: JSON Output Tests --- + +func TestIntegration_MonitorList_JSONOutput(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + fixture := testutil.LoadFixture("monitors_list.json") + mock.On("GET", "/monitors", 200, fixture) + + cleanup := setupIntegrationTest(mock.Server.URL) + defer cleanup() + + monitorFormat = "" + monitorOutput = "" + monitorFetchAll = false + monitorPage = 1 + + output, err := executeCmd("monitor", "list", "--format", "json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + trimmed := strings.TrimSpace(output) + if !json.Valid([]byte(trimmed)) { + t.Errorf("expected valid JSON output, got:\n%s", output) + } + + // Verify it contains expected keys from fixture + if !strings.Contains(trimmed, "abc123") { + t.Error("expected JSON to contain monitor key 'abc123'") + } + if !strings.Contains(trimmed, "Nightly Backup") { + t.Error("expected JSON to contain monitor name 'Nightly Backup'") + } +} + +func TestIntegration_MonitorGet_JSONOutput(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + fixture := testutil.LoadFixture("monitor_get.json") + mock.On("GET", "/monitors/my-job", 200, fixture) + + cleanup := setupIntegrationTest(mock.Server.URL) + defer cleanup() + + monitorFormat = "" + monitorOutput = "" + + output, err := executeCmd("monitor", "get", "my-job") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + trimmed := strings.TrimSpace(output) + if !json.Valid([]byte(trimmed)) { + t.Errorf("expected valid pretty-printed JSON output, got:\n%s", output) + } + + // Should be indented (pretty-printed) + if !strings.Contains(trimmed, "\n") { + t.Error("expected pretty-printed JSON (multi-line)") + } +} + +// --- Step 9: YAML Output Test --- + +func TestIntegration_MonitorList_YAMLOutput(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + yamlBody := "---\nmonitors:\n- key: abc123\n name: Nightly Backup\n" + mock.On("GET", "/monitors", 200, yamlBody) + + cleanup := setupIntegrationTest(mock.Server.URL) + defer cleanup() + + monitorFormat = "" + monitorOutput = "" + monitorFetchAll = false + monitorPage = 1 + + output, err := executeCmd("monitor", "list", "--format", "yaml") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + trimmed := strings.TrimSpace(output) + if trimmed == "" { + t.Error("expected non-empty YAML output") + } + // Should be a passthrough of the mock body + if !strings.Contains(trimmed, "abc123") { + t.Error("expected YAML output to contain monitor key") + } + + // Verify the format=yaml query param was sent + req := mock.LastRequest() + if req.QueryParams.Get("format") != "yaml" { + t.Errorf("expected format=yaml query param, got %q", req.QueryParams.Get("format")) + } +} + +// --- Step 10: Output to File Test --- + +func TestIntegration_MonitorList_OutputToFile(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + fixture := testutil.LoadFixture("monitors_list.json") + mock.On("GET", "/monitors", 200, fixture) + + cleanup := setupIntegrationTest(mock.Server.URL) + defer cleanup() + + tmpDir := t.TempDir() + outFile := filepath.Join(tmpDir, "output.json") + + monitorFormat = "" + monitorOutput = "" + monitorFetchAll = false + monitorPage = 1 + + output, err := executeCmd("monitor", "list", "--format", "json", "--output", outFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // File should exist with valid JSON + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("expected output file to exist: %v", err) + } + trimmedFile := strings.TrimSpace(string(data)) + if !json.Valid([]byte(trimmedFile)) { + t.Errorf("expected file to contain valid JSON, got:\n%s", trimmedFile) + } + if !strings.Contains(trimmedFile, "abc123") { + t.Error("expected file to contain monitor key 'abc123'") + } + + // Stdout should mention file, not contain the JSON data + if !strings.Contains(output, "Output written to") { + t.Errorf("expected stdout to contain 'Output written to', got:\n%s", output) + } + // Stdout should NOT contain the JSON data itself + if strings.Contains(output, `"abc123"`) { + t.Error("expected stdout to NOT contain JSON data when writing to file") + } +} + +// --- Step 11: Pagination Metadata Test --- + +func TestIntegration_MonitorList_PaginationMetadata(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + fixture := testutil.LoadFixture("monitors_list.json") + mock.On("GET", "/monitors", 200, fixture) + + cleanup := setupIntegrationTest(mock.Server.URL) + defer cleanup() + + monitorFormat = "" + monitorOutput = "" + monitorFetchAll = false + monitorPage = 1 + + output, err := executeCmd("monitor", "list") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // The fixture has page_info.totalMonitorCount = 3 + // Should show pagination info + if !strings.Contains(output, "Showing page 1") { + t.Errorf("expected pagination metadata 'Showing page 1' in output, got:\n%s", output) + } + if !strings.Contains(output, "3 monitors total") { + t.Errorf("expected '3 monitors total' in output, got:\n%s", output) + } +} + +// --- Step 12: --all Flag Test --- + +func TestIntegration_MonitorList_AllFlag(t *testing.T) { + // Custom server that returns different items per page + page1 := `{"monitors":[{"key":"mon-1","name":"Monitor 1","type":"job","passing":true,"paused":false}],"page_info":{"page":1,"pageSize":1,"totalMonitorCount":2}}` + page2 := `{"monitors":[{"key":"mon-2","name":"Monitor 2","type":"check","passing":true,"paused":false}],"page_info":{"page":2,"pageSize":1,"totalMonitorCount":2}}` + page3 := `{"monitors":[],"page_info":{"page":3,"pageSize":1,"totalMonitorCount":2}}` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + page := r.URL.Query().Get("page") + switch page { + case "1", "": + w.WriteHeader(200) + fmt.Fprint(w, page1) + case "2": + w.WriteHeader(200) + fmt.Fprint(w, page2) + default: + w.WriteHeader(200) + fmt.Fprint(w, page3) + } + })) + defer server.Close() + + cleanup := setupIntegrationTest(server.URL) + defer cleanup() + + monitorFormat = "" + monitorOutput = "" + monitorFetchAll = false + monitorPage = 1 + + output, err := executeCmd("monitor", "list", "--all", "--format", "json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + trimmed := strings.TrimSpace(output) + if !json.Valid([]byte(trimmed)) { + t.Errorf("expected valid JSON output, got:\n%s", output) + } + + // Should be a merged array with items from both pages + var items []map[string]interface{} + if err := json.Unmarshal([]byte(trimmed), &items); err != nil { + t.Fatalf("expected JSON array, got parse error: %v\noutput:\n%s", err, trimmed) + } + + if len(items) != 2 { + t.Errorf("expected 2 items from merged pages, got %d", len(items)) + } + + // Verify both monitors are present + if !strings.Contains(trimmed, "mon-1") { + t.Error("expected merged output to contain 'mon-1'") + } + if !strings.Contains(trimmed, "mon-2") { + t.Error("expected merged output to contain 'mon-2'") + } +} diff --git a/cmd/issue.go b/cmd/issue.go index 1911ee3..64d37d0 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "strings" "github.com/cronitorio/cronitor-cli/lib" "github.com/spf13/cobra" @@ -16,14 +17,20 @@ var issueCmd = &cobra.Command{ Short: "Manage issues", Long: `Manage Cronitor issues and incidents. +Severity levels: missing_data, operational, maintenance, degraded_performance, minor_outage, outage +States: unresolved, investigating, identified, monitoring, resolved + Examples: cronitor issue list - cronitor issue list --state open + cronitor issue list --state unresolved + cronitor issue list --severity outage --time 24h cronitor issue get - cronitor issue create --data '{"monitor":"my-job","summary":"Issue title"}' - cronitor issue update --data '{"state":"resolved"}' + cronitor issue create "Database connection issues" --severity outage + cronitor issue update --state investigating cronitor issue resolve - cronitor issue delete `, + cronitor issue delete + +For full API documentation, see https://cronitor.io/docs/issues-api.md`, Args: func(cmd *cobra.Command, args []string) error { if len(viper.GetString(varApiKey)) < 10 { return errors.New("API key required. Run 'cronitor configure' or use --api-key flag") @@ -37,18 +44,37 @@ Examples: var ( issuePage int + issuePageSize int issueFormat string issueOutput string issueData string - issueFile string issueState string issueSeverity string issueMonitor string + issueGroup string + issueTag string + issueEnv string + issueSearch string + issueTime string + issueOrderBy string + // Expansion flags + issueWithStatuspageDetails bool + issueWithMonitorDetails bool + issueWithAlertDetails bool + issueWithComponentDetails bool + // Bulk flags + issueBulkAction string + issueBulkIssues string + issueBulkState string + issueBulkAssignTo string + // Pagination flags + issueFetchAll bool ) func init() { RootCmd.AddCommand(issueCmd) issueCmd.PersistentFlags().IntVar(&issuePage, "page", 1, "Page number") + issueCmd.PersistentFlags().IntVar(&issuePageSize, "page-size", 0, "Results per page (max 1000)") issueCmd.PersistentFlags().StringVar(&issueFormat, "format", "", "Output format: json, table") issueCmd.PersistentFlags().StringVarP(&issueOutput, "output", "o", "", "Write output to file") } @@ -61,15 +87,22 @@ var issueListCmd = &cobra.Command{ Examples: cronitor issue list - cronitor issue list --state open - cronitor issue list --severity high - cronitor issue list --monitor my-job`, + cronitor issue list --state unresolved + cronitor issue list --severity outage + cronitor issue list --monitor my-job + cronitor issue list --group production + cronitor issue list --time 24h + cronitor issue list --search "database" + cronitor issue list --order-by -started`, Run: func(cmd *cobra.Command, args []string) { client := lib.NewAPIClient(dev, log) params := make(map[string]string) if issuePage > 1 { params["page"] = fmt.Sprintf("%d", issuePage) } + if issuePageSize > 0 { + params["pageSize"] = fmt.Sprintf("%d", issuePageSize) + } if issueState != "" { params["state"] = issueState } @@ -77,7 +110,79 @@ Examples: params["severity"] = issueSeverity } if issueMonitor != "" { - params["monitor"] = issueMonitor + params["job"] = issueMonitor + } + if issueGroup != "" { + params["group"] = issueGroup + } + if issueTag != "" { + params["tag"] = issueTag + } + if issueEnv != "" { + params["env"] = issueEnv + } + if issueSearch != "" { + params["search"] = issueSearch + } + if issueTime != "" { + params["time"] = issueTime + } + if issueOrderBy != "" { + params["orderBy"] = issueOrderBy + } + if issueWithStatuspageDetails { + params["withStatusPageDetails"] = "true" + } + if issueWithMonitorDetails { + params["withMonitorDetails"] = "true" + } + if issueWithAlertDetails { + params["withAlertDetails"] = "true" + } + if issueWithComponentDetails { + params["withComponentDetails"] = "true" + } + + if issueFetchAll { + bodies, err := FetchAllPages(client, "/issues", params, "issues") + if err != nil { + Error(fmt.Sprintf("Failed to list issues: %s", err)) + os.Exit(1) + } + if issueFormat == "json" || issueFormat == "" { + issueOutputToTarget(FormatJSON(MergePagedJSON(bodies, "issues"))) + return + } + // Table: accumulate rows from all pages + table := &UITable{ + Headers: []string{"NAME", "KEY", "STATE", "SEVERITY", "STARTED"}, + } + for _, body := range bodies { + var result struct { + Issues []struct { + Key string `json:"key"` + Name string `json:"name"` + State string `json:"state"` + Severity string `json:"severity"` + Started string `json:"started"` + } `json:"issues"` + } + json.Unmarshal(body, &result) + for _, issue := range result.Issues { + state := issue.State + switch state { + case "resolved": + state = successStyle.Render(state) + case "unresolved": + state = errorStyle.Render(state) + default: + state = warningStyle.Render(state) + } + table.Rows = append(table.Rows, []string{issue.Name, issue.Key, state, issue.Severity, issue.Started}) + } + } + issueOutputToTarget(table.Render()) + return } resp, err := client.GET("/issues", params) @@ -94,10 +199,10 @@ Examples: var result struct { Issues []struct { Key string `json:"key"` - Summary string `json:"summary"` - Monitor string `json:"monitor"` + Name string `json:"name"` State string `json:"state"` Severity string `json:"severity"` + Started string `json:"started"` } `json:"issues"` } if err := json.Unmarshal(resp.Body, &result); err != nil { @@ -116,32 +221,43 @@ Examples: } table := &UITable{ - Headers: []string{"KEY", "SUMMARY", "MONITOR", "STATE", "SEVERITY"}, + Headers: []string{"NAME", "KEY", "STATE", "SEVERITY", "STARTED"}, } for _, issue := range result.Issues { state := issue.State - if state == "open" { - state = errorStyle.Render("open") - } else if state == "resolved" { - state = successStyle.Render("resolved") - } else { + switch state { + case "unresolved": + state = errorStyle.Render("unresolved") + case "investigating", "identified": + state = warningStyle.Render(state) + case "monitoring": state = mutedStyle.Render(state) + case "resolved": + state = successStyle.Render("resolved") } severity := issue.Severity - if severity == "high" || severity == "critical" { + switch severity { + case "outage", "minor_outage": severity = errorStyle.Render(severity) - } else if severity == "medium" { + case "degraded_performance": severity = warningStyle.Render(severity) + case "maintenance": + severity = mutedStyle.Render(severity) + } + + name := issue.Name + if len(name) > 40 { + name = name[:37] + "..." } - summary := issue.Summary - if len(summary) > 40 { - summary = summary[:37] + "..." + started := "" + if issue.Started != "" && len(issue.Started) >= 10 { + started = issue.Started[:10] } - table.Rows = append(table.Rows, []string{issue.Key, summary, issue.Monitor, state, severity}) + table.Rows = append(table.Rows, []string{name, issue.Key, state, severity, started}) } issueOutputToTarget(table.Render()) @@ -157,7 +273,21 @@ var issueGetCmd = &cobra.Command{ key := args[0] client := lib.NewAPIClient(dev, log) - resp, err := client.GET(fmt.Sprintf("/issues/%s", key), nil) + params := make(map[string]string) + if issueWithStatuspageDetails { + params["withStatusPageDetails"] = "true" + } + if issueWithMonitorDetails { + params["withMonitorDetails"] = "true" + } + if issueWithAlertDetails { + params["withAlertDetails"] = "true" + } + if issueWithComponentDetails { + params["withComponentDetails"] = "true" + } + + resp, err := client.GET(fmt.Sprintf("/issues/%s", key), params) if err != nil { Error(fmt.Sprintf("Failed to get issue: %s", err)) os.Exit(1) @@ -181,19 +311,27 @@ var issueGetCmd = &cobra.Command{ var issueCreateCmd = &cobra.Command{ Use: "create", Short: "Create a new issue", + Long: `Create a new issue. + +Severity levels: missing_data, operational, maintenance, degraded_performance, minor_outage, outage + +Examples: + cronitor issue create --data '{"name":"Database connection issues","severity":"outage"}' + cronitor issue create --data '{"name":"Scheduled maintenance","severity":"maintenance","state":"monitoring"}'`, Run: func(cmd *cobra.Command, args []string) { - body, err := getIssueRequestBody() - if err != nil { - Error(err.Error()) + if issueData == "" { + Error("Create data required. Use --data '{...}'") os.Exit(1) } - if body == nil { - Error("JSON data required. Use --data or --file") + + var js json.RawMessage + if err := json.Unmarshal([]byte(issueData), &js); err != nil { + Error(fmt.Sprintf("Invalid JSON: %s", err)) os.Exit(1) } client := lib.NewAPIClient(dev, log) - resp, err := client.POST("/issues", body, nil) + resp, err := client.POST("/issues", []byte(issueData), nil) if err != nil { Error(fmt.Sprintf("Failed to create issue: %s", err)) os.Exit(1) @@ -204,8 +342,19 @@ var issueCreateCmd = &cobra.Command{ os.Exit(1) } - Success("Issue created") - issueOutputToTarget(FormatJSON(resp.Body)) + var result struct { + Key string `json:"key"` + Name string `json:"name"` + } + if err := json.Unmarshal(resp.Body, &result); err == nil { + Success(fmt.Sprintf("Created issue: %s (key: %s)", result.Name, result.Key)) + } else { + Success("Issue created") + } + + if issueFormat == "json" { + issueOutputToTarget(FormatJSON(resp.Body)) + } }, } @@ -213,26 +362,38 @@ var issueCreateCmd = &cobra.Command{ var issueUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update an issue", - Args: cobra.ExactArgs(1), + Long: `Update an existing issue. + +Examples: + cronitor issue update my-issue --data '{"state":"investigating"}' + cronitor issue update my-issue --data '{"severity":"outage"}'`, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { key := args[0] - body, err := getIssueRequestBody() - if err != nil { - Error(err.Error()) + + if issueData == "" { + Error("Update data required. Use --data '{...}'") os.Exit(1) } - if body == nil { - Error("JSON data required. Use --data or --file") + + var js json.RawMessage + if err := json.Unmarshal([]byte(issueData), &js); err != nil { + Error(fmt.Sprintf("Invalid JSON: %s", err)) os.Exit(1) } client := lib.NewAPIClient(dev, log) - resp, err := client.PUT(fmt.Sprintf("/issues/%s", key), body, nil) + resp, err := client.PUT(fmt.Sprintf("/issues/%s", key), []byte(issueData), nil) if err != nil { Error(fmt.Sprintf("Failed to update issue: %s", err)) os.Exit(1) } + if resp.IsNotFound() { + Error(fmt.Sprintf("Issue '%s' not found", key)) + os.Exit(1) + } + if !resp.IsSuccess() { Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) os.Exit(1) @@ -302,6 +463,83 @@ var issueDeleteCmd = &cobra.Command{ }, } +// --- BULK --- +var issueBulkCmd = &cobra.Command{ + Use: "bulk", + Short: "Perform bulk actions on issues", + Long: `Perform bulk actions on multiple issues at once. + +Actions: delete, change_state, assign_to + +Examples: + cronitor issue bulk --action delete --issues KEY1,KEY2,KEY3 + cronitor issue bulk --action change_state --issues KEY1,KEY2 --state resolved + cronitor issue bulk --action assign_to --issues KEY1,KEY2 --assign-to user@example.com`, + Run: func(cmd *cobra.Command, args []string) { + if issueBulkAction == "" { + Error("Action required. Use --action (delete, change_state, assign_to)") + os.Exit(1) + } + if issueBulkIssues == "" { + Error("Issues required. Use --issues KEY1,KEY2,KEY3") + os.Exit(1) + } + + issues := strings.Split(issueBulkIssues, ",") + for i := range issues { + issues[i] = strings.TrimSpace(issues[i]) + } + + body := map[string]interface{}{ + "action": issueBulkAction, + "issues": issues, + } + + switch issueBulkAction { + case "change_state": + if issueBulkState == "" { + Error("State required for change_state action. Use --state") + os.Exit(1) + } + body["state"] = issueBulkState + case "assign_to": + if issueBulkAssignTo == "" { + Error("Assignee required for assign_to action. Use --assign-to") + os.Exit(1) + } + body["assign_to"] = issueBulkAssignTo + case "delete": + // No extra fields needed + default: + Error(fmt.Sprintf("Unknown action '%s'. Use: delete, change_state, assign_to", issueBulkAction)) + os.Exit(1) + } + + jsonBody, err := json.Marshal(body) + if err != nil { + Error(fmt.Sprintf("Failed to encode request: %s", err)) + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + resp, err := client.POST("/issues/bulk", jsonBody, nil) + if err != nil { + Error(fmt.Sprintf("Failed to perform bulk action: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success(fmt.Sprintf("Bulk %s completed for %d issues", issueBulkAction, len(issues))) + if issueFormat == "json" { + issueOutputToTarget(FormatJSON(resp.Body)) + } + }, +} + func init() { issueCmd.AddCommand(issueListCmd) issueCmd.AddCommand(issueGetCmd) @@ -309,41 +547,43 @@ func init() { issueCmd.AddCommand(issueUpdateCmd) issueCmd.AddCommand(issueResolveCmd) issueCmd.AddCommand(issueDeleteCmd) + issueCmd.AddCommand(issueBulkCmd) // List filters - issueListCmd.Flags().StringVar(&issueState, "state", "", "Filter by state: open, resolved") - issueListCmd.Flags().StringVar(&issueSeverity, "severity", "", "Filter by severity") + issueListCmd.Flags().StringVar(&issueState, "state", "", "Filter by state: unresolved, investigating, identified, monitoring, resolved") + issueListCmd.Flags().StringVar(&issueSeverity, "severity", "", "Filter by severity: outage, minor_outage, degraded_performance, maintenance, operational, missing_data") issueListCmd.Flags().StringVar(&issueMonitor, "monitor", "", "Filter by monitor key") - - // Create/Update flags - issueCreateCmd.Flags().StringVarP(&issueData, "data", "d", "", "JSON data") - issueCreateCmd.Flags().StringVarP(&issueFile, "file", "f", "", "JSON file") - issueUpdateCmd.Flags().StringVarP(&issueData, "data", "d", "", "JSON data") - issueUpdateCmd.Flags().StringVarP(&issueFile, "file", "f", "", "JSON file") -} - -func getIssueRequestBody() ([]byte, error) { - if issueData != "" && issueFile != "" { - return nil, errors.New("cannot specify both --data and --file") - } - - if issueData != "" { - var js json.RawMessage - if err := json.Unmarshal([]byte(issueData), &js); err != nil { - return nil, fmt.Errorf("invalid JSON: %w", err) - } - return []byte(issueData), nil - } - - if issueFile != "" { - data, err := os.ReadFile(issueFile) - if err != nil { - return nil, fmt.Errorf("failed to read file: %w", err) - } - return data, nil - } - - return nil, nil + issueListCmd.Flags().StringVar(&issueGroup, "group", "", "Filter by group key") + issueListCmd.Flags().StringVar(&issueTag, "tag", "", "Filter by monitor tag") + issueListCmd.Flags().StringVar(&issueEnv, "env", "", "Filter by environment key") + issueListCmd.Flags().StringVar(&issueSearch, "search", "", "Search issue/monitor names and keys") + issueListCmd.Flags().StringVar(&issueTime, "time", "", "Time range: 24h, 7d, 30d") + issueListCmd.Flags().StringVar(&issueOrderBy, "order-by", "", "Sort: started, -started, relevance, -relevance") + + // List expansion flags + issueListCmd.Flags().BoolVar(&issueWithStatuspageDetails, "with-statuspage-details", false, "Include status page details") + issueListCmd.Flags().BoolVar(&issueWithMonitorDetails, "with-monitor-details", false, "Include monitor details") + issueListCmd.Flags().BoolVar(&issueWithAlertDetails, "with-alert-details", false, "Include alert details") + issueListCmd.Flags().BoolVar(&issueWithComponentDetails, "with-component-details", false, "Include component details") + issueListCmd.Flags().BoolVar(&issueFetchAll, "all", false, "Fetch all pages of results") + + // Get expansion flags + issueGetCmd.Flags().BoolVar(&issueWithStatuspageDetails, "with-statuspage-details", false, "Include status page details") + issueGetCmd.Flags().BoolVar(&issueWithMonitorDetails, "with-monitor-details", false, "Include monitor details") + issueGetCmd.Flags().BoolVar(&issueWithAlertDetails, "with-alert-details", false, "Include alert details") + issueGetCmd.Flags().BoolVar(&issueWithComponentDetails, "with-component-details", false, "Include component details") + + // Create flags + issueCreateCmd.Flags().StringVarP(&issueData, "data", "d", "", "JSON payload") + + // Update flags + issueUpdateCmd.Flags().StringVarP(&issueData, "data", "d", "", "JSON payload") + + // Bulk flags + issueBulkCmd.Flags().StringVar(&issueBulkAction, "action", "", "Bulk action: delete, change_state, assign_to") + issueBulkCmd.Flags().StringVar(&issueBulkIssues, "issues", "", "Comma-separated issue keys") + issueBulkCmd.Flags().StringVar(&issueBulkState, "state", "", "New state (for change_state action)") + issueBulkCmd.Flags().StringVar(&issueBulkAssignTo, "assign-to", "", "Assignee (for assign_to action)") } func issueOutputToTarget(content string) { diff --git a/cmd/issue_test.go b/cmd/issue_test.go new file mode 100644 index 0000000..86a1e4d --- /dev/null +++ b/cmd/issue_test.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "testing" +) + +func TestIssueCommandStructure(t *testing.T) { + subcommands := []string{"list", "get", "create", "update", "resolve", "delete", "bulk"} + + for _, name := range subcommands { + found := false + for _, cmd := range issueCmd.Commands() { + if cmd.Name() == name { + found = true + break + } + } + if !found { + t.Errorf("Expected subcommand '%s' not found in issue command", name) + } + } +} + +func TestIssuePersistentFlags(t *testing.T) { + flags := []string{"page", "page-size", "format", "output"} + + for _, flag := range flags { + if issueCmd.PersistentFlags().Lookup(flag) == nil { + t.Errorf("Expected persistent flag '--%s' not found in issue command", flag) + } + } +} + +func TestIssueListCommandFlags(t *testing.T) { + flags := []string{"state", "severity", "monitor", "group", "tag", "env", "search", "time", "order-by"} + + for _, flag := range flags { + if issueListCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in issue list command", flag) + } + } +} + +func TestIssueCreateCommandFlags(t *testing.T) { + flags := []string{"data"} + + for _, flag := range flags { + if issueCreateCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in issue create command", flag) + } + } +} + +func TestIssueUpdateCommandFlags(t *testing.T) { + flags := []string{"data"} + + for _, flag := range flags { + if issueUpdateCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in issue update command", flag) + } + } +} + +func TestIssueResolveExists(t *testing.T) { + // Resolve is a convenience command that sets state to resolved + if issueResolveCmd == nil { + t.Error("issueResolveCmd should exist") + } + if issueResolveCmd.Args == nil { + t.Error("issueResolveCmd should require args") + } +} + +func TestIssueListExpansionFlags(t *testing.T) { + flags := []string{"with-statuspage-details", "with-monitor-details", "with-alert-details", "with-component-details"} + + for _, flag := range flags { + if issueListCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in issue list command", flag) + } + } +} + +func TestIssueGetExpansionFlags(t *testing.T) { + flags := []string{"with-statuspage-details", "with-monitor-details", "with-alert-details", "with-component-details"} + + for _, flag := range flags { + if issueGetCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in issue get command", flag) + } + } +} + +func TestIssueBulkCommandFlags(t *testing.T) { + flags := []string{"action", "issues", "state", "assign-to"} + + for _, flag := range flags { + if issueBulkCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in issue bulk command", flag) + } + } +} diff --git a/cmd/maintenance.go b/cmd/maintenance.go new file mode 100644 index 0000000..179b0d6 --- /dev/null +++ b/cmd/maintenance.go @@ -0,0 +1,454 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" +) + +var ( + maintenancePage int + maintenanceFormat string + maintenanceOutput string + maintenancePast bool + maintenanceOngoing bool + maintenanceUpcoming bool + maintenanceStatuspage string + maintenanceEnv string + maintenanceWithMonitors bool + // Create flags + maintenanceName string + maintenanceDesc string + maintenanceStart string + maintenanceEnd string + maintenanceMonitors string + maintenanceGroups string + maintenanceStatuspages string + maintenanceAllMonitors bool + // Data flags + maintenanceData string + maintenanceFile string + maintenanceFetchAll bool +) + +var maintenanceCmd = &cobra.Command{ + Use: "maintenance", + Aliases: []string{"maint"}, + Short: "Manage maintenance windows", + Long: `Manage maintenance windows. + +Maintenance windows suppress alerts for monitors during scheduled maintenance periods. + +Examples: + cronitor maintenance list + cronitor maintenance list --ongoing + cronitor maintenance list --upcoming + cronitor maintenance get + cronitor maintenance create "Deploy v2.0" --start "2024-01-15T02:00:00Z" --end "2024-01-15T04:00:00Z" + cronitor maintenance create "DB Migration" --start "2024-01-20T00:00:00Z" --end "2024-01-20T02:00:00Z" --monitors "db-job,db-check" + cronitor maintenance delete + +For full API documentation, see https://cronitor.io/docs/maintenance-windows-api.md`, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + RootCmd.AddCommand(maintenanceCmd) + maintenanceCmd.PersistentFlags().IntVar(&maintenancePage, "page", 1, "Page number") + maintenanceCmd.PersistentFlags().StringVar(&maintenanceFormat, "format", "", "Output format: json, table") + maintenanceCmd.PersistentFlags().StringVarP(&maintenanceOutput, "output", "o", "", "Write output to file") +} + +// --- LIST --- +var maintenanceListCmd = &cobra.Command{ + Use: "list", + Short: "List maintenance windows", + Long: `List maintenance windows. + +Examples: + cronitor maintenance list + cronitor maintenance list --ongoing + cronitor maintenance list --upcoming + cronitor maintenance list --past + cronitor maintenance list --statuspage my-page + cronitor maintenance list --env production`, + Run: func(cmd *cobra.Command, args []string) { + client := lib.NewAPIClient(dev, log) + params := make(map[string]string) + + if maintenancePage > 1 { + params["page"] = fmt.Sprintf("%d", maintenancePage) + } + if maintenancePast { + params["past"] = "true" + } + if maintenanceOngoing { + params["ongoing"] = "true" + } + if maintenanceUpcoming { + params["upcoming"] = "true" + } + if maintenanceStatuspage != "" { + params["statuspage"] = maintenanceStatuspage + } + if maintenanceEnv != "" { + params["env"] = maintenanceEnv + } + if maintenanceWithMonitors { + params["withAllAffectedMonitors"] = "true" + } + + if maintenanceFetchAll { + bodies, err := FetchAllPages(client, "/maintenance_windows", params, "maintenance_windows") + if err != nil { + Error(fmt.Sprintf("Failed to list maintenance windows: %s", err)) + os.Exit(1) + } + if maintenanceFormat == "json" || maintenanceFormat == "" { + maintenanceOutputToTarget(FormatJSON(MergePagedJSON(bodies, "maintenance_windows"))) + return + } + // Table: accumulate rows from all pages + table := &UITable{ + Headers: []string{"NAME", "KEY", "START", "END", "STATE"}, + } + for _, body := range bodies { + var result struct { + Windows []struct { + Key string `json:"key"` + Name string `json:"name"` + Start string `json:"start"` + End string `json:"end"` + State string `json:"state"` + Duration int `json:"duration"` + } `json:"maintenance_windows"` + } + json.Unmarshal(body, &result) + for _, w := range result.Windows { + state := w.State + switch state { + case "ongoing": + state = warningStyle.Render("ongoing") + case "upcoming": + state = mutedStyle.Render("upcoming") + case "past": + state = successStyle.Render("completed") + } + start := "" + if len(w.Start) >= 16 { + start = w.Start[:16] + } + end := "" + if len(w.End) >= 16 { + end = w.End[:16] + } + table.Rows = append(table.Rows, []string{w.Name, w.Key, start, end, state}) + } + } + maintenanceOutputToTarget(table.Render()) + return + } + + resp, err := client.GET("/maintenance_windows", params) + if err != nil { + Error(fmt.Sprintf("Failed to list maintenance windows: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + if maintenanceFormat == "json" { + maintenanceOutputToTarget(FormatJSON(resp.Body)) + return + } + + var result struct { + Windows []struct { + Key string `json:"key"` + Name string `json:"name"` + Start string `json:"start"` + End string `json:"end"` + State string `json:"state"` + Duration int `json:"duration"` + } `json:"maintenance_windows"` + } + if err := json.Unmarshal(resp.Body, &result); err != nil { + Error(fmt.Sprintf("Failed to parse response: %s", err)) + os.Exit(1) + } + + if len(result.Windows) == 0 { + maintenanceOutputToTarget(mutedStyle.Render("No maintenance windows found")) + return + } + + table := &UITable{ + Headers: []string{"NAME", "KEY", "START", "END", "STATE"}, + } + + for _, w := range result.Windows { + state := w.State + switch state { + case "ongoing": + state = warningStyle.Render("ongoing") + case "upcoming": + state = mutedStyle.Render("upcoming") + case "past": + state = successStyle.Render("completed") + } + + start := "" + if len(w.Start) >= 16 { + start = w.Start[:16] + } + end := "" + if len(w.End) >= 16 { + end = w.End[:16] + } + + table.Rows = append(table.Rows, []string{w.Name, w.Key, start, end, state}) + } + + maintenanceOutputToTarget(table.Render()) + }, +} + +// --- GET --- +var maintenanceGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a maintenance window", + Long: `Get details of a specific maintenance window. + +Examples: + cronitor maintenance get + cronitor maintenance get --with-monitors`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + params := make(map[string]string) + + if maintenanceWithMonitors { + params["withAllAffectedMonitors"] = "true" + } + + resp, err := client.GET(fmt.Sprintf("/maintenance_windows/%s", key), params) + if err != nil { + Error(fmt.Sprintf("Failed to get maintenance window: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Maintenance window '%s' not found", key)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + maintenanceOutputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- CREATE --- +var maintenanceCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a maintenance window", + Long: `Create a new maintenance window. + +Times should be in ISO 8601 format (e.g., "2024-01-15T02:00:00Z"). + +Examples: + cronitor maintenance create --data '{"name":"Deploy v2.0","start":"2024-01-15T02:00:00Z","end":"2024-01-15T04:00:00Z"}' + cronitor maintenance create --data '{"name":"DB Migration","start":"2024-01-20T00:00:00Z","end":"2024-01-20T02:00:00Z","monitors":["db-job","db-check"]}'`, + Run: func(cmd *cobra.Command, args []string) { + if maintenanceData == "" { + Error("Create data required. Use --data '{...}'") + os.Exit(1) + } + + var js json.RawMessage + if err := json.Unmarshal([]byte(maintenanceData), &js); err != nil { + Error(fmt.Sprintf("Invalid JSON: %s", err)) + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + resp, err := client.POST("/maintenance_windows", []byte(maintenanceData), nil) + if err != nil { + Error(fmt.Sprintf("Failed to create maintenance window: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + var result struct { + Key string `json:"key"` + Name string `json:"name"` + } + if err := json.Unmarshal(resp.Body, &result); err == nil { + Success(fmt.Sprintf("Created maintenance window: %s (key: %s)", result.Name, result.Key)) + } else { + Success("Maintenance window created") + } + + if maintenanceFormat == "json" { + maintenanceOutputToTarget(FormatJSON(resp.Body)) + } + }, +} + +// --- UPDATE --- +var maintenanceUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a maintenance window", + Long: `Update an existing maintenance window. + +Use --data to provide a JSON payload with the fields to update. + +Examples: + cronitor maintenance update my-window --data '{"name":"New Name"}' + cronitor maintenance update my-window --data '{"start":"2024-01-15T03:00:00Z","end":"2024-01-15T05:00:00Z"}' + cronitor maintenance update my-window --file update.json`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + + body, err := getMaintenanceRequestBody() + if err != nil { + Error(err.Error()) + os.Exit(1) + } + + if body == nil { + Error("Update data required. Use --data or --file") + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + resp, err := client.PUT(fmt.Sprintf("/maintenance_windows/%s", key), body, nil) + if err != nil { + Error(fmt.Sprintf("Failed to update maintenance window: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Maintenance window '%s' not found", key)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success(fmt.Sprintf("Maintenance window '%s' updated", key)) + if maintenanceFormat == "json" { + maintenanceOutputToTarget(FormatJSON(resp.Body)) + } + }, +} + +// --- DELETE --- +var maintenanceDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a maintenance window", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + resp, err := client.DELETE(fmt.Sprintf("/maintenance_windows/%s", key), nil, nil) + if err != nil { + Error(fmt.Sprintf("Failed to delete maintenance window: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Maintenance window '%s' not found", key)) + os.Exit(1) + } + + if resp.IsSuccess() { + Success(fmt.Sprintf("Maintenance window '%s' deleted", key)) + } else { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + }, +} + +func init() { + maintenanceCmd.AddCommand(maintenanceListCmd) + maintenanceCmd.AddCommand(maintenanceGetCmd) + maintenanceCmd.AddCommand(maintenanceCreateCmd) + maintenanceCmd.AddCommand(maintenanceUpdateCmd) + maintenanceCmd.AddCommand(maintenanceDeleteCmd) + + // List flags + maintenanceListCmd.Flags().BoolVar(&maintenancePast, "past", false, "Include past windows") + maintenanceListCmd.Flags().BoolVar(&maintenanceOngoing, "ongoing", false, "Show only ongoing windows") + maintenanceListCmd.Flags().BoolVar(&maintenanceUpcoming, "upcoming", false, "Show only upcoming windows") + maintenanceListCmd.Flags().StringVar(&maintenanceStatuspage, "statuspage", "", "Filter by status page key") + maintenanceListCmd.Flags().StringVar(&maintenanceEnv, "env", "", "Filter by environment") + maintenanceListCmd.Flags().BoolVar(&maintenanceWithMonitors, "with-monitors", false, "Include affected monitor details") + maintenanceListCmd.Flags().BoolVar(&maintenanceFetchAll, "all", false, "Fetch all pages of results") + + // Get flags + maintenanceGetCmd.Flags().BoolVar(&maintenanceWithMonitors, "with-monitors", false, "Include affected monitor details") + + // Create flags + maintenanceCreateCmd.Flags().StringVarP(&maintenanceData, "data", "d", "", "JSON payload") + + // Update flags + maintenanceUpdateCmd.Flags().StringVarP(&maintenanceData, "data", "d", "", "JSON payload") +} + +func splitAndTrimMaint(s string) []string { + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +func getMaintenanceRequestBody() ([]byte, error) { + if maintenanceData != "" { + var js json.RawMessage + if err := json.Unmarshal([]byte(maintenanceData), &js); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + return []byte(maintenanceData), nil + } + return nil, nil +} + +func maintenanceOutputToTarget(content string) { + if maintenanceOutput != "" { + if err := os.WriteFile(maintenanceOutput, []byte(content+"\n"), 0644); err != nil { + Error(fmt.Sprintf("Failed to write to %s: %s", maintenanceOutput, err)) + os.Exit(1) + } + Info(fmt.Sprintf("Output written to %s", maintenanceOutput)) + } else { + fmt.Println(content) + } +} diff --git a/cmd/maintenance_test.go b/cmd/maintenance_test.go new file mode 100644 index 0000000..5a122c4 --- /dev/null +++ b/cmd/maintenance_test.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "testing" +) + +func TestMaintenanceCommandStructure(t *testing.T) { + subcommands := []string{"list", "get", "create", "delete"} + + for _, name := range subcommands { + found := false + for _, cmd := range maintenanceCmd.Commands() { + if cmd.Name() == name { + found = true + break + } + } + if !found { + t.Errorf("Expected subcommand '%s' not found in maintenance command", name) + } + } +} + +func TestMaintenancePersistentFlags(t *testing.T) { + flags := []string{"page", "format", "output"} + + for _, flag := range flags { + if maintenanceCmd.PersistentFlags().Lookup(flag) == nil { + t.Errorf("Expected persistent flag '--%s' not found in maintenance command", flag) + } + } +} + +func TestMaintenanceListCommandFlags(t *testing.T) { + flags := []string{"past", "ongoing", "upcoming", "statuspage", "env", "with-monitors"} + + for _, flag := range flags { + if maintenanceListCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in maintenance list command", flag) + } + } +} + +func TestMaintenanceCreateCommandFlags(t *testing.T) { + flags := []string{"data"} + + for _, flag := range flags { + if maintenanceCreateCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in maintenance create command", flag) + } + } +} + +func TestMaintenanceCommandAliases(t *testing.T) { + aliases := maintenanceCmd.Aliases + found := false + for _, alias := range aliases { + if alias == "maint" { + found = true + break + } + } + if !found { + t.Error("Expected alias 'maint' not found") + } +} diff --git a/cmd/metric.go b/cmd/metric.go new file mode 100644 index 0000000..f07ce7a --- /dev/null +++ b/cmd/metric.go @@ -0,0 +1,358 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" +) + +var ( + metricFormat string + metricOutput string + metricMonitors string + metricGroups string + metricTags string + metricTypes string + metricTime string + metricStart int64 + metricEnd int64 + metricEnv string + metricRegions string + metricFields string + metricWithNulls bool +) + +var metricCmd = &cobra.Command{ + Use: "metric", + Aliases: []string{"metrics"}, + Short: "Query monitor metrics and aggregates", + Long: `Query performance metrics and aggregated data for monitors. + +Metrics provides time-series data points while aggregates provide summarized statistics. + +Time ranges: + 1h, 6h, 12h, 24h, 3d, 7d, 14d, 30d, 90d, 180d, 365d + +Available fields: + Performance: duration_p10, duration_p50, duration_p90, duration_p99, duration_mean, success_rate + Counts: run_count, complete_count, fail_count, tick_count, alert_count + Checks: checks_healthy_count, checks_triggered_count, checks_failed_count + +Examples: + cronitor metric get --monitor my-job --field duration_p50,success_rate + cronitor metric get --group production --time 7d --field run_count,fail_count + cronitor metric aggregate --monitor my-job --time 30d + cronitor metric aggregate --tag critical --env production + +For full API documentation, see https://cronitor.io/docs/metrics-api.md`, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + RootCmd.AddCommand(metricCmd) + metricCmd.PersistentFlags().StringVar(&metricFormat, "format", "", "Output format: json, table") + metricCmd.PersistentFlags().StringVarP(&metricOutput, "output", "o", "", "Write output to file") +} + +// --- GET (time-series metrics) --- +var metricGetCmd = &cobra.Command{ + Use: "get", + Short: "Get time-series metrics", + Long: `Get time-series metrics data for monitors. + +You must specify at least one --field parameter. + +Examples: + cronitor metric get --monitor my-job --field duration_p50 + cronitor metric get --monitor my-job --field duration_p50,duration_p90,success_rate --time 7d + cronitor metric get --group production --field run_count,fail_count + cronitor metric get --tag critical --time 30d --field success_rate`, + Run: func(cmd *cobra.Command, args []string) { + if metricFields == "" { + Error("At least one --field is required") + Info("Available fields: duration_p10, duration_p50, duration_p90, duration_p99, duration_mean, success_rate, run_count, complete_count, fail_count, tick_count, alert_count") + os.Exit(1) + } + + if metricMonitors == "" && metricGroups == "" && metricTags == "" { + Error("At least one of --monitor, --group, or --tag is required") + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + params := buildMetricParams() + + // Add fields + params["field"] = metricFields + + resp, err := client.GET("/metrics", params) + if err != nil { + Error(fmt.Sprintf("Failed to get metrics: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + if metricFormat == "json" || metricFormat == "" { + metricOutputToTarget(FormatJSON(resp.Body)) + return + } + + // Parse and display as table + var result struct { + Monitors map[string]map[string][]map[string]interface{} `json:"monitors"` + } + if err := json.Unmarshal(resp.Body, &result); err != nil { + Error(fmt.Sprintf("Failed to parse response: %s", err)) + os.Exit(1) + } + + if len(result.Monitors) == 0 { + metricOutputToTarget(mutedStyle.Render("No metrics found")) + return + } + + // Build table with dynamic columns based on fields + fields := splitAndTrimMetric(metricFields) + headers := []string{"MONITOR", "ENV", "TIMESTAMP"} + headers = append(headers, fields...) + + table := &UITable{ + Headers: headers, + } + + for monitorKey, envData := range result.Monitors { + for envKey, dataPoints := range envData { + for _, dp := range dataPoints { + row := []string{monitorKey, envKey} + if stamp, ok := dp["stamp"].(float64); ok { + row = append(row, fmt.Sprintf("%.0f", stamp)) + } else { + row = append(row, "-") + } + for _, f := range fields { + if val, ok := dp[f]; ok { + row = append(row, formatMetricValue(val)) + } else { + row = append(row, "-") + } + } + table.Rows = append(table.Rows, row) + } + } + } + + metricOutputToTarget(table.Render()) + }, +} + +// --- AGGREGATE --- +var metricAggregateCmd = &cobra.Command{ + Use: "aggregate", + Aliases: []string{"agg"}, + Short: "Get aggregated metrics", + Long: `Get aggregated statistics for monitors. + +Returns summarized metrics like mean duration, success rate, total runs, and uptime. + +Examples: + cronitor metric aggregate --monitor my-job + cronitor metric aggregate --monitor my-job --time 30d + cronitor metric aggregate --group production --env production + cronitor metric aggregate --tag critical`, + Run: func(cmd *cobra.Command, args []string) { + if metricMonitors == "" && metricGroups == "" && metricTags == "" { + Error("At least one of --monitor, --group, or --tag is required") + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + params := buildMetricParams() + + resp, err := client.GET("/aggregates", params) + if err != nil { + Error(fmt.Sprintf("Failed to get aggregates: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + if metricFormat == "json" || metricFormat == "" { + metricOutputToTarget(FormatJSON(resp.Body)) + return + } + + // Parse and display as table + var result struct { + Monitors map[string]map[string]map[string]interface{} `json:"monitors"` + } + if err := json.Unmarshal(resp.Body, &result); err != nil { + Error(fmt.Sprintf("Failed to parse response: %s", err)) + os.Exit(1) + } + + if len(result.Monitors) == 0 { + metricOutputToTarget(mutedStyle.Render("No aggregates found")) + return + } + + table := &UITable{ + Headers: []string{"MONITOR", "ENV", "SUCCESS RATE", "MEAN DURATION", "P50", "P90", "RUNS", "FAILURES"}, + } + + for monitorKey, envData := range result.Monitors { + for envKey, agg := range envData { + row := []string{monitorKey, envKey} + + if sr, ok := agg["success_rate"]; ok { + row = append(row, formatMetricValue(sr)+"%") + } else { + row = append(row, "-") + } + if dm, ok := agg["duration_mean"]; ok { + row = append(row, formatMetricValue(dm)+"ms") + } else { + row = append(row, "-") + } + if p50, ok := agg["duration_p50"]; ok { + row = append(row, formatMetricValue(p50)+"ms") + } else { + row = append(row, "-") + } + if p90, ok := agg["duration_p90"]; ok { + row = append(row, formatMetricValue(p90)+"ms") + } else { + row = append(row, "-") + } + if runs, ok := agg["total_runs"]; ok { + row = append(row, formatMetricValue(runs)) + } else { + row = append(row, "-") + } + if fails, ok := agg["total_failures"]; ok { + row = append(row, formatMetricValue(fails)) + } else { + row = append(row, "-") + } + + table.Rows = append(table.Rows, row) + } + } + + metricOutputToTarget(table.Render()) + }, +} + +func init() { + metricCmd.AddCommand(metricGetCmd) + metricCmd.AddCommand(metricAggregateCmd) + + // Shared flags for both commands + for _, cmd := range []*cobra.Command{metricGetCmd, metricAggregateCmd} { + cmd.Flags().StringVar(&metricMonitors, "monitor", "", "Monitor keys (comma-separated)") + cmd.Flags().StringVar(&metricGroups, "group", "", "Group keys (comma-separated)") + cmd.Flags().StringVar(&metricTags, "tag", "", "Tag names (comma-separated)") + cmd.Flags().StringVar(&metricTypes, "type", "", "Monitor types: job, check, event, heartbeat (comma-separated)") + cmd.Flags().StringVar(&metricTime, "time", "24h", "Time range: 1h, 6h, 12h, 24h, 3d, 7d, 14d, 30d, 90d, 180d, 365d") + cmd.Flags().Int64Var(&metricStart, "start", 0, "Custom start time (Unix timestamp)") + cmd.Flags().Int64Var(&metricEnd, "end", 0, "Custom end time (Unix timestamp)") + cmd.Flags().StringVar(&metricEnv, "env", "", "Environment key") + cmd.Flags().StringVar(&metricRegions, "region", "", "Regions (comma-separated)") + cmd.Flags().BoolVar(&metricWithNulls, "with-nulls", false, "Include null values for missing data points") + } + + // Field flag only for get command + metricGetCmd.Flags().StringVar(&metricFields, "field", "", "Metric fields to return (comma-separated, required)") +} + +func buildMetricParams() map[string]string { + params := make(map[string]string) + + // Note: For multiple values, pass comma-separated to the CLI + // The API accepts repeated params but our client uses map[string]string + // so we pass the first value for each. Use comma-separated for multiple. + if metricMonitors != "" { + params["monitor"] = metricMonitors + } + if metricGroups != "" { + params["group"] = metricGroups + } + if metricTags != "" { + params["tag"] = metricTags + } + if metricTypes != "" { + params["type"] = metricTypes + } + if metricStart > 0 { + params["start"] = fmt.Sprintf("%d", metricStart) + } + if metricEnd > 0 { + params["end"] = fmt.Sprintf("%d", metricEnd) + } + if metricStart == 0 && metricEnd == 0 && metricTime != "" { + params["time"] = metricTime + } + if metricEnv != "" { + params["env"] = metricEnv + } + if metricRegions != "" { + params["region"] = metricRegions + } + if metricWithNulls { + params["withNulls"] = "true" + } + + return params +} + +func splitAndTrimMetric(s string) []string { + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +func formatMetricValue(v interface{}) string { + switch val := v.(type) { + case float64: + if val == float64(int(val)) { + return fmt.Sprintf("%.0f", val) + } + return fmt.Sprintf("%.2f", val) + case int: + return fmt.Sprintf("%d", val) + case nil: + return "-" + default: + return fmt.Sprintf("%v", val) + } +} + +func metricOutputToTarget(content string) { + if metricOutput != "" { + if err := os.WriteFile(metricOutput, []byte(content+"\n"), 0644); err != nil { + Error(fmt.Sprintf("Failed to write to %s: %s", metricOutput, err)) + os.Exit(1) + } + Info(fmt.Sprintf("Output written to %s", metricOutput)) + } else { + fmt.Println(content) + } +} diff --git a/cmd/metric_test.go b/cmd/metric_test.go new file mode 100644 index 0000000..c9eaee0 --- /dev/null +++ b/cmd/metric_test.go @@ -0,0 +1,128 @@ +package cmd + +import ( + "testing" +) + +func TestMetricCommandStructure(t *testing.T) { + subcommands := []string{"get", "aggregate"} + + for _, name := range subcommands { + found := false + for _, cmd := range metricCmd.Commands() { + if cmd.Name() == name { + found = true + break + } + } + if !found { + t.Errorf("Expected subcommand '%s' not found in metric command", name) + } + } +} + +func TestMetricCommandAliases(t *testing.T) { + aliases := metricCmd.Aliases + found := false + for _, alias := range aliases { + if alias == "metrics" { + found = true + break + } + } + if !found { + t.Error("Expected alias 'metrics' not found") + } +} + +func TestMetricAggregateCommandAliases(t *testing.T) { + aliases := metricAggregateCmd.Aliases + found := false + for _, alias := range aliases { + if alias == "agg" { + found = true + break + } + } + if !found { + t.Error("Expected alias 'agg' not found for aggregate command") + } +} + +func TestMetricPersistentFlags(t *testing.T) { + flags := []string{"format", "output"} + + for _, flag := range flags { + if metricCmd.PersistentFlags().Lookup(flag) == nil { + t.Errorf("Expected persistent flag '--%s' not found in metric command", flag) + } + } +} + +func TestMetricGetCommandFlags(t *testing.T) { + flags := []string{"monitor", "group", "tag", "type", "time", "start", "end", "env", "region", "with-nulls", "field"} + + for _, flag := range flags { + if metricGetCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in metric get command", flag) + } + } +} + +func TestMetricAggregateCommandFlags(t *testing.T) { + flags := []string{"monitor", "group", "tag", "type", "time", "start", "end", "env", "region", "with-nulls"} + + for _, flag := range flags { + if metricAggregateCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in metric aggregate command", flag) + } + } +} + +func TestFormatMetricValue(t *testing.T) { + tests := []struct { + input interface{} + expected string + }{ + {float64(100), "100"}, + {float64(99.5), "99.50"}, + {float64(0), "0"}, + {nil, "-"}, + {42, "42"}, + {"test", "test"}, + } + + for _, test := range tests { + result := formatMetricValue(test.input) + if result != test.expected { + t.Errorf("formatMetricValue(%v) = %s, expected %s", test.input, result, test.expected) + } + } +} + +func TestSplitAndTrimMetric(t *testing.T) { + tests := []struct { + input string + expected []string + }{ + {"a,b,c", []string{"a", "b", "c"}}, + {"a, b, c", []string{"a", "b", "c"}}, + {" a , b , c ", []string{"a", "b", "c"}}, + {"single", []string{"single"}}, + {"", []string{}}, + {"a,,b", []string{"a", "b"}}, + } + + for _, test := range tests { + result := splitAndTrimMetric(test.input) + if len(result) != len(test.expected) { + t.Errorf("splitAndTrimMetric(%q) returned %d items, expected %d", test.input, len(result), len(test.expected)) + continue + } + for i, v := range result { + if v != test.expected[i] { + t.Errorf("splitAndTrimMetric(%q)[%d] = %q, expected %q", test.input, i, v, test.expected[i]) + } + } + } +} diff --git a/cmd/monitor.go b/cmd/monitor.go index 43d1290..b411c43 100644 --- a/cmd/monitor.go +++ b/cmd/monitor.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "strings" "github.com/cronitorio/cronitor-cli/lib" "github.com/spf13/cobra" @@ -18,12 +19,18 @@ var monitorCmd = &cobra.Command{ Examples: cronitor monitor list + cronitor monitor list --type job --state failing cronitor monitor get cronitor monitor create --data '{"key":"my-job","type":"job"}' cronitor monitor update --data '{"name":"New Name"}' cronitor monitor delete + cronitor monitor delete key1 key2 key3 cronitor monitor pause - cronitor monitor unpause `, + cronitor monitor unpause + cronitor monitor clone --name "Cloned Monitor" + cronitor monitor search "backup" + +For full API documentation, see https://cronitor.io/docs/monitors-api.md`, Args: func(cmd *cobra.Command, args []string) error { if len(viper.GetString(varApiKey)) < 10 { return errors.New("API key required. Run 'cronitor configure' or use --api-key flag") @@ -37,13 +44,23 @@ Examples: // Flags var ( - monitorWithEvents bool - monitorPage int - monitorEnv string - monitorFormat string - monitorOutput string - monitorData string - monitorFile string + monitorWithEvents bool + monitorWithInvocations bool + monitorPage int + monitorPageSize int + monitorEnv string + monitorFormat string + monitorOutput string + monitorData string + monitorFile string + monitorFetchAll bool + // List filters + monitorType []string + monitorGroup string + monitorTag []string + monitorState []string + monitorSearch string + monitorSort string ) func init() { @@ -52,7 +69,7 @@ func init() { // Persistent flags for all monitor subcommands monitorCmd.PersistentFlags().IntVar(&monitorPage, "page", 1, "Page number for paginated results") monitorCmd.PersistentFlags().StringVar(&monitorEnv, "env", "", "Filter by environment") - monitorCmd.PersistentFlags().StringVar(&monitorFormat, "format", "", "Output format: json, table (default: table for list, json for get)") + monitorCmd.PersistentFlags().StringVar(&monitorFormat, "format", "", "Output format: json, table, yaml") monitorCmd.PersistentFlags().StringVarP(&monitorOutput, "output", "o", "", "Write output to file") } @@ -64,18 +81,112 @@ var monitorListCmd = &cobra.Command{ Examples: cronitor monitor list - cronitor monitor list --page 2 - cronitor monitor list --env production - cronitor monitor list --format json`, + cronitor monitor list --type job + cronitor monitor list --type job --type check + cronitor monitor list --group production + cronitor monitor list --tag critical --tag database + cronitor monitor list --state failing + cronitor monitor list --state failing --state paused + cronitor monitor list --search backup + cronitor monitor list --sort name + cronitor monitor list --sort -created + cronitor monitor list --page-size 100 + cronitor monitor list --format yaml`, Run: func(cmd *cobra.Command, args []string) { client := lib.NewAPIClient(dev, log) params := make(map[string]string) + if monitorPage > 1 { params["page"] = fmt.Sprintf("%d", monitorPage) } + if monitorPageSize > 0 { + params["pageSize"] = fmt.Sprintf("%d", monitorPageSize) + } if monitorEnv != "" { params["env"] = monitorEnv } + if monitorGroup != "" { + params["group"] = monitorGroup + } + if monitorSearch != "" { + params["search"] = monitorSearch + } + if monitorSort != "" { + params["sort"] = monitorSort + } + + // Handle array params by joining with comma (API may need multiple params) + if len(monitorType) > 0 { + params["type"] = strings.Join(monitorType, ",") + } + if len(monitorTag) > 0 { + params["tag"] = strings.Join(monitorTag, ",") + } + if len(monitorState) > 0 { + params["state"] = strings.Join(monitorState, ",") + } + if monitorWithEvents { + params["withEvents"] = "true" + } + if monitorWithInvocations { + params["withInvocations"] = "true" + } + + // Check for YAML format + format := monitorFormat + if format == "yaml" { + params["format"] = "yaml" + } + + if monitorFetchAll { + bodies, err := FetchAllPages(client, "/monitors", params, "monitors") + if err != nil { + Error(fmt.Sprintf("Failed to list monitors: %s", err)) + os.Exit(1) + } + if format == "json" || format == "" { + outputToTarget(FormatJSON(MergePagedJSON(bodies, "monitors"))) + return + } + if format == "yaml" { + for _, body := range bodies { + outputToTarget(string(body)) + } + return + } + // Table format: parse all pages and accumulate rows + table := &UITable{ + Headers: []string{"NAME", "KEY", "TYPE", "STATUS"}, + } + for _, body := range bodies { + var result struct { + Monitors []struct { + Key string `json:"key"` + Name string `json:"name"` + Type string `json:"type"` + Passing bool `json:"passing"` + Paused bool `json:"paused"` + } `json:"monitors"` + } + json.Unmarshal(body, &result) + for _, m := range result.Monitors { + name := m.Name + if name == "" { + name = m.Key + } + status := successStyle.Render("passing") + if m.Paused { + status = warningStyle.Render("paused") + } else if !m.Passing { + status = errorStyle.Render("failing") + } + table.Rows = append(table.Rows, []string{name, m.Key, m.Type, status}) + } + } + output := table.Render() + outputToTarget(output) + return + } resp, err := client.GET("/monitors", params) if err != nil { @@ -88,6 +199,12 @@ Examples: os.Exit(1) } + // YAML format - output directly + if format == "yaml" { + outputToTarget(string(resp.Body)) + return + } + // Parse response var result struct { Monitors []struct { @@ -96,6 +213,7 @@ Examples: Type string `json:"type"` Passing bool `json:"passing"` Paused bool `json:"paused"` + Group string `json:"group"` } `json:"monitors"` PageInfo struct { Page int `json:"page"` @@ -108,7 +226,6 @@ Examples: os.Exit(1) } - format := monitorFormat if format == "" { format = "table" } @@ -120,7 +237,7 @@ Examples: // Table output table := &UITable{ - Headers: []string{"KEY", "NAME", "TYPE", "STATUS"}, + Headers: []string{"NAME", "KEY", "TYPE", "STATUS"}, } for _, m := range result.Monitors { @@ -134,7 +251,7 @@ Examples: } else if !m.Passing { status = errorStyle.Render("failing") } - table.Rows = append(table.Rows, []string{m.Key, name, m.Type, status}) + table.Rows = append(table.Rows, []string{name, m.Key, m.Type, status}) } output := table.Render() @@ -146,6 +263,99 @@ Examples: }, } +// --- SEARCH --- +var monitorSearchCmd = &cobra.Command{ + Use: "search ", + Short: "Search monitors", + Long: `Search monitors using advanced query syntax. + +Supported search scopes (use quotes when using colons): + job: Search job-type monitors (e.g., "job:backup") + check: Search check-type monitors + heartbeat: Search heartbeat-type monitors + group: Search by group name (e.g., "group:production") + tag: Search by tag (e.g., "tag:critical") + ungrouped: Find monitors without a group (no value needed) + +Examples: + cronitor monitor search backup # Simple text search + cronitor monitor search "job:backup" # Search job monitors for "backup" + cronitor monitor search "group:production" # Search monitors in "production" group + cronitor monitor search "tag:critical" # Search monitors with "critical" tag + cronitor monitor search "ungrouped:" # Find all ungrouped monitors + cronitor monitor search backup --format yaml # Output results as YAML`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + query := args[0] + client := lib.NewAPIClient(dev, log) + + params := map[string]string{"query": query} + if monitorPage > 1 { + params["page"] = fmt.Sprintf("%d", monitorPage) + } + + format := monitorFormat + if format == "yaml" { + params["format"] = "yaml" + } + + resp, err := client.GET("/search", params) + if err != nil { + Error(fmt.Sprintf("Failed to search: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + // YAML format - output directly + if format == "yaml" { + outputToTarget(string(resp.Body)) + return + } + + if format == "json" || format == "" { + outputToTarget(FormatJSON(resp.Body)) + return + } + + // Parse for table output + var result struct { + Monitors []struct { + Key string `json:"key"` + Name string `json:"name"` + Type string `json:"type"` + Passing bool `json:"passing"` + Paused bool `json:"paused"` + } `json:"monitors"` + } + if err := json.Unmarshal(resp.Body, &result); err != nil { + outputToTarget(FormatJSON(resp.Body)) + return + } + + table := &UITable{ + Headers: []string{"NAME", "KEY", "TYPE", "STATUS"}, + } + for _, m := range result.Monitors { + name := m.Name + if name == "" { + name = m.Key + } + status := successStyle.Render("passing") + if m.Paused { + status = warningStyle.Render("paused") + } else if !m.Passing { + status = errorStyle.Render("failing") + } + table.Rows = append(table.Rows, []string{name, m.Key, m.Type, status}) + } + outputToTarget(table.Render()) + }, +} + // --- GET --- var monitorGetCmd = &cobra.Command{ Use: "get ", @@ -154,7 +364,8 @@ var monitorGetCmd = &cobra.Command{ Examples: cronitor monitor get my-job - cronitor monitor get my-job --with-events`, + cronitor monitor get my-job --with-events + cronitor monitor get my-job --with-invocations`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { key := args[0] @@ -162,7 +373,10 @@ Examples: params := make(map[string]string) if monitorWithEvents { - params["withLatestEvents"] = "true" + params["withEvents"] = "true" + } + if monitorWithInvocations { + params["withInvocations"] = "true" } resp, err := client.GET(fmt.Sprintf("/monitors/%s", key), params) @@ -193,7 +407,9 @@ var monitorCreateCmd = &cobra.Command{ Examples: cronitor monitor create --data '{"key":"my-job","type":"job"}' + cronitor monitor create --data '{"key":"my-job","type":"job","schedule":"0 0 * * *"}' cronitor monitor create --file monitor.json + cronitor monitor create --file monitors.yaml cat monitor.json | cronitor monitor create`, Run: func(cmd *cobra.Command, args []string) { body, err := getMonitorRequestBody() @@ -202,18 +418,28 @@ Examples: os.Exit(1) } if body == nil { - Error("JSON data required. Use --data, --file, or pipe JSON to stdin") + Error("JSON/YAML data required. Use --data, --file, or pipe to stdin") os.Exit(1) } client := lib.NewAPIClient(dev, log) - // Check if bulk create (array) + // Check if bulk create (array) or YAML var testArray []json.RawMessage isBulk := json.Unmarshal(body, &testArray) == nil && len(testArray) > 0 + // Check if YAML (starts with jobs:, checks:, heartbeats:, or sites:) + bodyStr := strings.TrimSpace(string(body)) + isYAML := strings.HasPrefix(bodyStr, "jobs:") || + strings.HasPrefix(bodyStr, "checks:") || + strings.HasPrefix(bodyStr, "heartbeats:") || + strings.HasPrefix(bodyStr, "sites:") + var resp *lib.APIResponse - if isBulk { + if isYAML { + headers := map[string]string{"Content-Type": "application/yaml"} + resp, err = client.PUT("/monitors", body, headers) + } else if isBulk { resp, err = client.PUT("/monitors", body, nil) } else { resp, err = client.POST("/monitors", body, nil) @@ -242,6 +468,7 @@ var monitorUpdateCmd = &cobra.Command{ Examples: cronitor monitor update my-job --data '{"name":"New Name"}' + cronitor monitor update my-job --data '{"schedule":"0 0 * * *","assertions":["metric.duration < 5min"]}' cronitor monitor update my-job --file updates.json`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { @@ -273,6 +500,11 @@ Examples: os.Exit(1) } + if resp.IsNotFound() { + Error(fmt.Sprintf("Monitor '%s' not found", key)) + os.Exit(1) + } + if !resp.IsSuccess() { Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) os.Exit(1) @@ -285,20 +517,95 @@ Examples: // --- DELETE --- var monitorDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a monitor", - Long: `Delete a monitor. + Use: "delete [keys...]", + Short: "Delete one or more monitors", + Long: `Delete one or more monitors. + +Examples: + cronitor monitor delete my-job + cronitor monitor delete job1 job2 job3`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := lib.NewAPIClient(dev, log) + + if len(args) == 1 { + // Single delete + key := args[0] + resp, err := client.DELETE(fmt.Sprintf("/monitors/%s", key), nil, nil) + if err != nil { + Error(fmt.Sprintf("Failed to delete monitor: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Monitor '%s' not found", key)) + os.Exit(1) + } + + if resp.IsSuccess() { + Success(fmt.Sprintf("Monitor '%s' deleted", key)) + } else { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + } else { + // Bulk delete + body := map[string][]string{"monitors": args} + bodyJSON, _ := json.Marshal(body) + + resp, err := client.DELETE("/monitors", bodyJSON, nil) + if err != nil { + Error(fmt.Sprintf("Failed to delete monitors: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + var result struct { + DeletedCount int `json:"deleted_count"` + RequestedCount int `json:"requested_count"` + Errors struct { + Missing []string `json:"missing"` + } `json:"errors"` + } + json.Unmarshal(resp.Body, &result) + + Success(fmt.Sprintf("Deleted %d of %d monitors", result.DeletedCount, result.RequestedCount)) + if len(result.Errors.Missing) > 0 { + Warning(fmt.Sprintf("Not found: %s", strings.Join(result.Errors.Missing, ", "))) + } + } + }, +} + +// --- CLONE --- +var monitorCloneName string + +var monitorCloneCmd = &cobra.Command{ + Use: "clone ", + Short: "Clone an existing monitor", + Long: `Create a copy of an existing monitor. Examples: - cronitor monitor delete my-job`, + cronitor monitor clone my-job + cronitor monitor clone my-job --name "My Job Copy"`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { key := args[0] client := lib.NewAPIClient(dev, log) - resp, err := client.DELETE(fmt.Sprintf("/monitors/%s", key), nil, nil) + body := map[string]string{"key": key} + if monitorCloneName != "" { + body["name"] = monitorCloneName + } + bodyJSON, _ := json.Marshal(body) + + resp, err := client.POST("/monitors/clone", bodyJSON, nil) if err != nil { - Error(fmt.Sprintf("Failed to delete monitor: %s", err)) + Error(fmt.Sprintf("Failed to clone monitor: %s", err)) os.Exit(1) } @@ -307,12 +614,19 @@ Examples: os.Exit(1) } - if resp.IsSuccess() { - Success(fmt.Sprintf("Monitor '%s' deleted", key)) - } else { + if !resp.IsSuccess() { Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) os.Exit(1) } + + var result struct { + Key string `json:"key"` + Name string `json:"name"` + } + json.Unmarshal(resp.Body, &result) + + Success(fmt.Sprintf("Monitor cloned as '%s'", result.Key)) + outputToTarget(FormatJSON(resp.Body)) }, } @@ -324,9 +638,13 @@ var monitorPauseCmd = &cobra.Command{ Short: "Pause a monitor", Long: `Pause a monitor to stop receiving alerts. +For job, heartbeat & site monitors: telemetry is still recorded but no alerts are sent. +For check monitors: outbound requests stop entirely. + Examples: cronitor monitor pause my-job # Pause indefinitely - cronitor monitor pause my-job --hours 24 # Pause for 24 hours`, + cronitor monitor pause my-job --hours 24 # Pause for 24 hours + cronitor monitor pause my-job --hours 2 # Pause for 2 hours`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { key := args[0] @@ -396,22 +714,40 @@ Examples: func init() { monitorCmd.AddCommand(monitorListCmd) + monitorCmd.AddCommand(monitorSearchCmd) monitorCmd.AddCommand(monitorGetCmd) monitorCmd.AddCommand(monitorCreateCmd) monitorCmd.AddCommand(monitorUpdateCmd) monitorCmd.AddCommand(monitorDeleteCmd) + monitorCmd.AddCommand(monitorCloneCmd) monitorCmd.AddCommand(monitorPauseCmd) monitorCmd.AddCommand(monitorUnpauseCmd) + // List filters + monitorListCmd.Flags().StringArrayVar(&monitorType, "type", nil, "Filter by type: job, check, heartbeat, site (can specify multiple)") + monitorListCmd.Flags().StringVar(&monitorGroup, "group", "", "Filter by group key") + monitorListCmd.Flags().StringArrayVar(&monitorTag, "tag", nil, "Filter by tag (can specify multiple)") + monitorListCmd.Flags().StringArrayVar(&monitorState, "state", nil, "Filter by state: passing, failing, paused (can specify multiple)") + monitorListCmd.Flags().StringVar(&monitorSearch, "search", "", "Search across monitor names and keys") + monitorListCmd.Flags().IntVar(&monitorPageSize, "page-size", 0, "Number of results per page (default 50)") + monitorListCmd.Flags().StringVar(&monitorSort, "sort", "", "Sort order: created, -created, name, -name") + monitorListCmd.Flags().BoolVar(&monitorWithEvents, "with-events", false, "Include latest events for each monitor") + monitorListCmd.Flags().BoolVar(&monitorWithInvocations, "with-invocations", false, "Include recent invocations for each monitor") + monitorListCmd.Flags().BoolVar(&monitorFetchAll, "all", false, "Fetch all pages of results") + // Get flags monitorGetCmd.Flags().BoolVar(&monitorWithEvents, "with-events", false, "Include latest events") + monitorGetCmd.Flags().BoolVar(&monitorWithInvocations, "with-invocations", false, "Include recent invocations") // Create/Update flags - monitorCreateCmd.Flags().StringVarP(&monitorData, "data", "d", "", "JSON data") - monitorCreateCmd.Flags().StringVarP(&monitorFile, "file", "f", "", "JSON file") + monitorCreateCmd.Flags().StringVarP(&monitorData, "data", "d", "", "JSON or YAML data") + monitorCreateCmd.Flags().StringVarP(&monitorFile, "file", "f", "", "JSON or YAML file") monitorUpdateCmd.Flags().StringVarP(&monitorData, "data", "d", "", "JSON data") monitorUpdateCmd.Flags().StringVarP(&monitorFile, "file", "f", "", "JSON file") + // Clone flags + monitorCloneCmd.Flags().StringVar(&monitorCloneName, "name", "", "Name for the cloned monitor") + // Pause flags monitorPauseCmd.Flags().StringVar(&monitorPauseHours, "hours", "", "Hours to pause (default: indefinite)") } @@ -423,9 +759,11 @@ func getMonitorRequestBody() ([]byte, error) { } if monitorData != "" { + // Try JSON first var js json.RawMessage if err := json.Unmarshal([]byte(monitorData), &js); err != nil { - return nil, fmt.Errorf("invalid JSON: %w", err) + // Might be YAML, return as-is + return []byte(monitorData), nil } return []byte(monitorData), nil } @@ -435,10 +773,6 @@ func getMonitorRequestBody() ([]byte, error) { if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } - var js json.RawMessage - if err := json.Unmarshal(data, &js); err != nil { - return nil, fmt.Errorf("invalid JSON in file: %w", err) - } return data, nil } @@ -450,10 +784,6 @@ func getMonitorRequestBody() ([]byte, error) { return nil, fmt.Errorf("failed to read stdin: %w", err) } if len(data) > 0 { - var js json.RawMessage - if err := json.Unmarshal(data, &js); err != nil { - return nil, fmt.Errorf("invalid JSON from stdin: %w", err) - } return data, nil } } diff --git a/cmd/monitor_test.go b/cmd/monitor_test.go new file mode 100644 index 0000000..216750d --- /dev/null +++ b/cmd/monitor_test.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "testing" +) + +func TestMonitorCommandStructure(t *testing.T) { + subcommands := []string{"list", "get", "create", "update", "delete", "search", "clone"} + + for _, name := range subcommands { + found := false + for _, cmd := range monitorCmd.Commands() { + if cmd.Name() == name { + found = true + break + } + } + if !found { + t.Errorf("Expected subcommand '%s' not found in monitor command", name) + } + } +} + +func TestMonitorListCommandFlags(t *testing.T) { + flags := []string{"type", "group", "tag", "state", "search", "page-size", "sort"} + + for _, flag := range flags { + if monitorListCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in monitor list command", flag) + } + } +} + +func TestMonitorGetCommandFlags(t *testing.T) { + flags := []string{"with-events", "with-invocations"} + + for _, flag := range flags { + if monitorGetCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in monitor get command", flag) + } + } +} + +func TestMonitorPersistentFlags(t *testing.T) { + flags := []string{"page", "env", "format", "output"} + + for _, flag := range flags { + if monitorCmd.PersistentFlags().Lookup(flag) == nil { + t.Errorf("Expected persistent flag '--%s' not found in monitor command", flag) + } + } +} + +func TestMonitorDeleteSupportsMultipleArgs(t *testing.T) { + // Delete should accept 1 or more arguments for bulk delete + if monitorDeleteCmd.Args == nil { + t.Error("monitor delete command should have Args validator") + } +} + +func TestMonitorCloneCommandFlags(t *testing.T) { + flags := []string{"name"} + + for _, flag := range flags { + if monitorCloneCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in monitor clone command", flag) + } + } +} diff --git a/cmd/notification.go b/cmd/notification.go index 8197cdb..e66a0c4 100644 --- a/cmd/notification.go +++ b/cmd/notification.go @@ -17,12 +17,19 @@ var notificationCmd = &cobra.Command{ Short: "Manage notification lists", Long: `Manage Cronitor notification lists. +Notification lists define where alerts are sent when monitors fail or recover. +Supported channels: email, slack, pagerduty, opsgenie, victorops, microsoft-teams, +discord, telegram, gchat, larksuite, webhooks, and SMS (phones). + Examples: cronitor notification list - cronitor notification get - cronitor notification create --data '{"name":"DevOps Team","email":["team@example.com"]}' - cronitor notification update --data '{"name":"Updated Name"}' - cronitor notification delete `, + cronitor notification get default + cronitor notification create "DevOps Team" --emails "dev@example.com,ops@example.com" + cronitor notification create "Slack Alerts" --slack "#alerts" + cronitor notification update my-list --name "New Name" + cronitor notification delete old-list + +For full API documentation, see https://cronitor.io/docs/notifications-api.md`, Args: func(cmd *cobra.Command, args []string) error { if len(viper.GetString(varApiKey)) < 10 { return errors.New("API key required. Run 'cronitor configure' or use --api-key flag") @@ -35,16 +42,18 @@ Examples: } var ( - notificationPage int - notificationFormat string - notificationOutput string - notificationData string - notificationFile string + notificationPage int + notificationPageSize int + notificationFormat string + notificationOutput string + notificationData string + notificationFetchAll bool ) func init() { RootCmd.AddCommand(notificationCmd) notificationCmd.PersistentFlags().IntVar(¬ificationPage, "page", 1, "Page number") + notificationCmd.PersistentFlags().IntVar(¬ificationPageSize, "page-size", 0, "Number of results per page") notificationCmd.PersistentFlags().StringVar(¬ificationFormat, "format", "", "Output format: json, table") notificationCmd.PersistentFlags().StringVarP(¬ificationOutput, "output", "o", "", "Write output to file") } @@ -53,14 +62,64 @@ func init() { var notificationListCmd = &cobra.Command{ Use: "list", Short: "List all notification lists", + Long: `List all notification lists. + +Examples: + cronitor notification list + cronitor notification list --page 2 + cronitor notification list --page-size 100 + cronitor notification list --format json`, Run: func(cmd *cobra.Command, args []string) { client := lib.NewAPIClient(dev, log) params := make(map[string]string) if notificationPage > 1 { params["page"] = fmt.Sprintf("%d", notificationPage) } + if notificationPageSize > 0 { + params["pageSize"] = fmt.Sprintf("%d", notificationPageSize) + } + + if notificationFetchAll { + bodies, err := FetchAllPages(client, "/notifications", params, "templates") + if err != nil { + Error(fmt.Sprintf("Failed to list notification lists: %s", err)) + os.Exit(1) + } + if notificationFormat == "json" || notificationFormat == "" { + notificationOutputToTarget(FormatJSON(MergePagedJSON(bodies, "templates"))) + return + } + // Table: accumulate rows from all pages + table := &UITable{ + Headers: []string{"NAME", "KEY", "EMAILS", "SLACK", "MONITORS"}, + } + for _, body := range bodies { + var result struct { + Templates []struct { + Key string `json:"key"` + Name string `json:"name"` + Notifications struct { + Emails []string `json:"emails"` + Slack []string `json:"slack"` + Webhooks []string `json:"webhooks"` + Phones []string `json:"phones"` + } `json:"notifications"` + Monitors []string `json:"monitors"` + } `json:"templates"` + } + json.Unmarshal(body, &result) + for _, n := range result.Templates { + emailCount := fmt.Sprintf("%d", len(n.Notifications.Emails)) + slackCount := fmt.Sprintf("%d", len(n.Notifications.Slack)) + monitorCount := fmt.Sprintf("%d", len(n.Monitors)) + table.Rows = append(table.Rows, []string{n.Name, n.Key, emailCount, slackCount, monitorCount}) + } + } + notificationOutputToTarget(table.Render()) + return + } - resp, err := client.GET("/notification-lists", params) + resp, err := client.GET("/notifications", params) if err != nil { Error(fmt.Sprintf("Failed to list notification lists: %s", err)) os.Exit(1) @@ -72,12 +131,17 @@ var notificationListCmd = &cobra.Command{ } var result struct { - NotificationLists []struct { - Key string `json:"key"` - Name string `json:"name"` - Emails []string `json:"emails"` - Webhooks []string `json:"webhooks"` - } `json:"notification_lists"` + Templates []struct { + Key string `json:"key"` + Name string `json:"name"` + Notifications struct { + Emails []string `json:"emails"` + Slack []string `json:"slack"` + Webhooks []string `json:"webhooks"` + Phones []string `json:"phones"` + } `json:"notifications"` + Monitors []string `json:"monitors"` + } `json:"templates"` } if err := json.Unmarshal(resp.Body, &result); err != nil { Error(fmt.Sprintf("Failed to parse response: %s", err)) @@ -95,13 +159,14 @@ var notificationListCmd = &cobra.Command{ } table := &UITable{ - Headers: []string{"KEY", "NAME", "EMAILS", "WEBHOOKS"}, + Headers: []string{"NAME", "KEY", "EMAILS", "SLACK", "MONITORS"}, } - for _, n := range result.NotificationLists { - emailCount := fmt.Sprintf("%d", len(n.Emails)) - webhookCount := fmt.Sprintf("%d", len(n.Webhooks)) - table.Rows = append(table.Rows, []string{n.Key, n.Name, emailCount, webhookCount}) + for _, n := range result.Templates { + emailCount := fmt.Sprintf("%d", len(n.Notifications.Emails)) + slackCount := fmt.Sprintf("%d", len(n.Notifications.Slack)) + monitorCount := fmt.Sprintf("%d", len(n.Monitors)) + table.Rows = append(table.Rows, []string{n.Name, n.Key, emailCount, slackCount, monitorCount}) } notificationOutputToTarget(table.Render()) @@ -112,12 +177,18 @@ var notificationListCmd = &cobra.Command{ var notificationGetCmd = &cobra.Command{ Use: "get ", Short: "Get a specific notification list", - Args: cobra.ExactArgs(1), + Long: `Get details for a specific notification list. + +Examples: + cronitor notification get default + cronitor notification get devops-team + cronitor notification get my-list --format json`, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { key := args[0] client := lib.NewAPIClient(dev, log) - resp, err := client.GET(fmt.Sprintf("/notification-lists/%s", key), nil) + resp, err := client.GET(fmt.Sprintf("/notifications/%s", key), nil) if err != nil { Error(fmt.Sprintf("Failed to get notification list: %s", err)) os.Exit(1) @@ -141,19 +212,25 @@ var notificationGetCmd = &cobra.Command{ var notificationCreateCmd = &cobra.Command{ Use: "create", Short: "Create a new notification list", + Long: `Create a new notification list. + +Examples: + cronitor notification create --data '{"name":"DevOps Team","notifications":{"emails":["dev@example.com"]}}' + cronitor notification create --data '{"name":"Slack Alerts","notifications":{"slack":["#alerts"]}}'`, Run: func(cmd *cobra.Command, args []string) { - body, err := getNotificationRequestBody() - if err != nil { - Error(err.Error()) + if notificationData == "" { + Error("Create data required. Use --data '{...}'") os.Exit(1) } - if body == nil { - Error("JSON data required. Use --data or --file") + + var js json.RawMessage + if err := json.Unmarshal([]byte(notificationData), &js); err != nil { + Error(fmt.Sprintf("Invalid JSON: %s", err)) os.Exit(1) } client := lib.NewAPIClient(dev, log) - resp, err := client.POST("/notification-lists", body, nil) + resp, err := client.POST("/notifications", []byte(notificationData), nil) if err != nil { Error(fmt.Sprintf("Failed to create notification list: %s", err)) os.Exit(1) @@ -164,8 +241,19 @@ var notificationCreateCmd = &cobra.Command{ os.Exit(1) } - Success("Notification list created") - notificationOutputToTarget(FormatJSON(resp.Body)) + var result struct { + Key string `json:"key"` + Name string `json:"name"` + } + if err := json.Unmarshal(resp.Body, &result); err == nil { + Success(fmt.Sprintf("Created notification list: %s (key: %s)", result.Name, result.Key)) + } else { + Success("Notification list created") + } + + if notificationFormat == "json" { + notificationOutputToTarget(FormatJSON(resp.Body)) + } }, } @@ -173,33 +261,47 @@ var notificationCreateCmd = &cobra.Command{ var notificationUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update a notification list", - Args: cobra.ExactArgs(1), + Long: `Update an existing notification list. + +Examples: + cronitor notification update my-list --data '{"name":"New Name"}' + cronitor notification update my-list --data '{"notifications":{"emails":["new@example.com"]}}'`, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { key := args[0] - body, err := getNotificationRequestBody() - if err != nil { - Error(err.Error()) + + if notificationData == "" { + Error("Update data required. Use --data '{...}'") os.Exit(1) } - if body == nil { - Error("JSON data required. Use --data or --file") + + var js json.RawMessage + if err := json.Unmarshal([]byte(notificationData), &js); err != nil { + Error(fmt.Sprintf("Invalid JSON: %s", err)) os.Exit(1) } client := lib.NewAPIClient(dev, log) - resp, err := client.PUT(fmt.Sprintf("/notification-lists/%s", key), body, nil) + resp, err := client.PUT(fmt.Sprintf("/notifications/%s", key), []byte(notificationData), nil) if err != nil { Error(fmt.Sprintf("Failed to update notification list: %s", err)) os.Exit(1) } + if resp.IsNotFound() { + Error(fmt.Sprintf("Notification '%s' not found", key)) + os.Exit(1) + } + if !resp.IsSuccess() { Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) os.Exit(1) } Success(fmt.Sprintf("Notification list '%s' updated", key)) - notificationOutputToTarget(FormatJSON(resp.Body)) + if notificationFormat == "json" { + notificationOutputToTarget(FormatJSON(resp.Body)) + } }, } @@ -207,12 +309,18 @@ var notificationUpdateCmd = &cobra.Command{ var notificationDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a notification list", - Args: cobra.ExactArgs(1), + Long: `Delete a notification list. + +Note: The default notification list cannot be deleted. + +Examples: + cronitor notification delete old-list`, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { key := args[0] client := lib.NewAPIClient(dev, log) - resp, err := client.DELETE(fmt.Sprintf("/notification-lists/%s", key), nil, nil) + resp, err := client.DELETE(fmt.Sprintf("/notifications/%s", key), nil, nil) if err != nil { Error(fmt.Sprintf("Failed to delete notification list: %s", err)) os.Exit(1) @@ -239,34 +347,14 @@ func init() { notificationCmd.AddCommand(notificationUpdateCmd) notificationCmd.AddCommand(notificationDeleteCmd) - notificationCreateCmd.Flags().StringVarP(¬ificationData, "data", "d", "", "JSON data") - notificationCreateCmd.Flags().StringVarP(¬ificationFile, "file", "f", "", "JSON file") - notificationUpdateCmd.Flags().StringVarP(¬ificationData, "data", "d", "", "JSON data") - notificationUpdateCmd.Flags().StringVarP(¬ificationFile, "file", "f", "", "JSON file") -} + // List flags + notificationListCmd.Flags().BoolVar(¬ificationFetchAll, "all", false, "Fetch all pages of results") -func getNotificationRequestBody() ([]byte, error) { - if notificationData != "" && notificationFile != "" { - return nil, errors.New("cannot specify both --data and --file") - } - - if notificationData != "" { - var js json.RawMessage - if err := json.Unmarshal([]byte(notificationData), &js); err != nil { - return nil, fmt.Errorf("invalid JSON: %w", err) - } - return []byte(notificationData), nil - } - - if notificationFile != "" { - data, err := os.ReadFile(notificationFile) - if err != nil { - return nil, fmt.Errorf("failed to read file: %w", err) - } - return data, nil - } + // Create command flags + notificationCreateCmd.Flags().StringVarP(¬ificationData, "data", "d", "", "JSON payload") - return nil, nil + // Update command flags + notificationUpdateCmd.Flags().StringVarP(¬ificationData, "data", "d", "", "JSON payload") } func notificationOutputToTarget(content string) { diff --git a/cmd/notification_test.go b/cmd/notification_test.go new file mode 100644 index 0000000..eae54ca --- /dev/null +++ b/cmd/notification_test.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "testing" +) + +func TestNotificationCommandStructure(t *testing.T) { + subcommands := []string{"list", "get", "create", "update", "delete"} + + for _, name := range subcommands { + found := false + for _, cmd := range notificationCmd.Commands() { + if cmd.Name() == name { + found = true + break + } + } + if !found { + t.Errorf("Expected subcommand '%s' not found in notification command", name) + } + } +} + +func TestNotificationPersistentFlags(t *testing.T) { + flags := []string{"page", "page-size", "format", "output"} + + for _, flag := range flags { + if notificationCmd.PersistentFlags().Lookup(flag) == nil { + t.Errorf("Expected persistent flag '--%s' not found in notification command", flag) + } + } +} + +func TestNotificationCreateCommandFlags(t *testing.T) { + flags := []string{"data"} + + for _, flag := range flags { + if notificationCreateCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in notification create command", flag) + } + } +} + +func TestNotificationUpdateCommandFlags(t *testing.T) { + flags := []string{"data"} + + for _, flag := range flags { + if notificationUpdateCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in notification update command", flag) + } + } +} + +func TestNotificationCommandAliases(t *testing.T) { + aliases := notificationCmd.Aliases + found := false + for _, alias := range aliases { + if alias == "notifications" { + found = true + break + } + } + if !found { + t.Error("Expected alias 'notifications' not found") + } +} + diff --git a/cmd/ping.go b/cmd/ping.go index 68eedd1..10ad13b 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -2,45 +2,72 @@ package cmd import ( "errors" - "github.com/spf13/cobra" + "strconv" + "strings" "sync" + + "github.com/spf13/cobra" ) var run bool var complete bool var fail bool var tick bool +var ok bool var msg string var series string +var pingStatusCode int +var pingDuration float64 +var pingMetrics string var pingCmd = &cobra.Command{ Use: "ping ", Short: "Send a telemetry ping to Cronitor", - Long: ` -Ping the specified monitor to report current status. + Long: `Send telemetry events to Cronitor monitors. + +States: + --run Job has started running + --complete Job completed successfully + --fail Job failed + --ok Manually reset monitor to healthy state + --tick Send a heartbeat (for heartbeat monitors) + +Metrics (for --complete or --fail): + count: Event count + duration: Duration in seconds + error_count: Error count + +Examples: + Report job started: + cronitor ping d3x0c1 --run -Example: - Notify Cronitor that your job has started to run - $ cronitor ping d3x0c1 --run + Report job completed with duration: + cronitor ping d3x0c1 --complete --duration 45.2 -Example with a custom hostname: - $ cronitor ping d3x0c1 --run --hostname "custom-name" - If no hostname is provided, the system hostname is used. + Report failure with exit code and message: + cronitor ping d3x0c1 --fail --status-code 1 --msg "Connection refused" -Example with a custom message: - $ cronitor ping d3x0c1 --fail -msg "Error: Job was not successful" + Send custom metrics: + cronitor ping d3x0c1 --complete --metric "count:processed=100,error_count:failed=2" -Example when using authenticated ping requests: - $ cronitor ping d3x0c1 --complete --ping-api-key 9134e94e13a098dbaca57c2df2f2c06f + Correlate run/complete events: + cronitor ping d3x0c1 --run --series "job-123" + cronitor ping d3x0c1 --complete --series "job-123" --duration 30.5 - `, + Reset monitor to healthy: + cronitor ping d3x0c1 --ok + + Send heartbeat: + cronitor ping d3x0c1 --tick + +For full API documentation, see https://cronitor.io/docs/telemetry-api.md`, Args: func(cmd *cobra.Command, args []string) error { if len(args) < 1 { return errors.New("a unique monitor key is required") } if len(getEndpointFromFlag()) == 0 { - return errors.New("an endpoint flag is required") + return errors.New("a state flag is required (--run, --complete, --fail, --ok, or --tick)") } return nil @@ -48,13 +75,28 @@ Example when using authenticated ping requests: Run: func(cmd *cobra.Command, args []string) { var wg sync.WaitGroup - uniqueIdentifier := args[0] - wg.Add(1) - var schedule = "" + // Parse duration if provided + var duration *float64 + if cmd.Flags().Changed("duration") { + duration = &pingDuration + } + + // Parse status code if provided + var exitCode *int + if cmd.Flags().Changed("status-code") { + exitCode = &pingStatusCode + } + + // Parse metrics if provided + var metrics map[string]int + if pingMetrics != "" { + metrics = parseMetrics(pingMetrics) + } - go sendPing(getEndpointFromFlag(), uniqueIdentifier, msg, series, makeStamp(), nil, nil, nil, schedule, &wg) + wg.Add(1) + go sendPing(getEndpointFromFlag(), uniqueIdentifier, msg, series, makeStamp(), duration, exitCode, metrics, "", &wg) wg.Wait() }, } @@ -68,17 +110,50 @@ func getEndpointFromFlag() string { return "run" } else if tick { return "tick" + } else if ok { + return "ok" } return "" } +// parseMetrics parses metric strings like "count:processed=100,error_count:failed=2" +func parseMetrics(metricStr string) map[string]int { + metrics := make(map[string]int) + parts := strings.Split(metricStr, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + // Format: type:name=value (e.g., count:processed=100) + eqIdx := strings.LastIndex(part, "=") + if eqIdx == -1 { + continue + } + key := strings.TrimSpace(part[:eqIdx]) + valStr := strings.TrimSpace(part[eqIdx+1:]) + if val, err := strconv.Atoi(valStr); err == nil { + metrics[key] = val + } + } + return metrics +} + func init() { RootCmd.AddCommand(pingCmd) - pingCmd.Flags().BoolVar(&run, "run", false, "Report job is running") - pingCmd.Flags().BoolVar(&complete, "complete", false, "Report job completion") - pingCmd.Flags().BoolVar(&fail, "fail", false, "Report job failure") + + // State flags + pingCmd.Flags().BoolVar(&run, "run", false, "Report job started") + pingCmd.Flags().BoolVar(&complete, "complete", false, "Report job completed successfully") + pingCmd.Flags().BoolVar(&fail, "fail", false, "Report job failed") + pingCmd.Flags().BoolVar(&ok, "ok", false, "Manually reset monitor to healthy state") pingCmd.Flags().BoolVar(&tick, "tick", false, "Send a heartbeat") - pingCmd.Flags().StringVar(&msg, "msg", "", "Optional message to send with ping") - pingCmd.Flags().StringVar(&series, "series", "", "Optional unique user-supplied ID to collate related pings") + + // Data flags + pingCmd.Flags().StringVar(&msg, "msg", "", "Message to include (max 2000 chars)") + pingCmd.Flags().StringVar(&series, "series", "", "Unique ID to correlate run/complete events") + pingCmd.Flags().IntVar(&pingStatusCode, "status-code", 0, "Exit/status code") + pingCmd.Flags().Float64Var(&pingDuration, "duration", 0, "Execution duration in seconds") + pingCmd.Flags().StringVar(&pingMetrics, "metric", "", "Custom metrics: type:name=value (comma-separated)") } diff --git a/cmd/root.go b/cmd/root.go index 0f0dd7c..21cf7a4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -68,6 +68,7 @@ var varDashUsername = "CRONITOR_DASH_USER" var varDashPassword = "CRONITOR_DASH_PASS" var varAllowedIPs = "CRONITOR_ALLOWED_IPS" var varUsers = "CRONITOR_USERS" +var varApiVersion = "CRONITOR_API_VERSION" func init() { userAgent = fmt.Sprintf("CronitorCLI/%s", Version) @@ -85,6 +86,7 @@ func init() { RootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", verbose, "Verbose output") RootCmd.PersistentFlags().StringVarP(&users, "users", "u", users, "Comma-separated list of users whose crontabs to include (default: current user only)") + RootCmd.PersistentFlags().String("api-version", "", "Cronitor API version (e.g. 2025-11-28)") RootCmd.PersistentFlags().BoolVar(&dev, "use-dev", dev, "Dev mode") RootCmd.PersistentFlags().MarkHidden("use-dev") @@ -95,6 +97,7 @@ func init() { viper.BindPFlag(varLog, RootCmd.PersistentFlags().Lookup("log")) viper.BindPFlag(varPingApiKey, RootCmd.PersistentFlags().Lookup("ping-api-key")) viper.BindPFlag(varConfig, RootCmd.PersistentFlags().Lookup("config")) + viper.BindPFlag(varApiVersion, RootCmd.PersistentFlags().Lookup("api-version")) viper.BindPFlag(varDashUsername, RootCmd.PersistentFlags().Lookup("dash-username")) viper.BindPFlag(varDashPassword, RootCmd.PersistentFlags().Lookup("dash-password")) viper.BindPFlag(varUsers, RootCmd.PersistentFlags().Lookup("users")) diff --git a/cmd/site.go b/cmd/site.go new file mode 100644 index 0000000..fc9a016 --- /dev/null +++ b/cmd/site.go @@ -0,0 +1,883 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" +) + +var ( + sitePage int + sitePageSize int + siteFormat string + siteOutput string + siteWithSnippet bool + siteData string + siteFetchAll bool + // Create/Update flags + siteName string + siteWebVitals bool + siteErrors bool + siteSampling int + siteFilterLocal bool + siteFilterBots bool + // Query flags + siteQueryType string + siteQuerySite string + siteQueryTime string + siteQueryStart string + siteQueryEnd string + siteQueryMetrics string + siteQueryDims string + siteQueryGroupBy string + siteQueryFilters string + siteQueryOrderBy string + siteQueryTimezone string + siteQueryBucket string + siteQueryCompare bool +) + +var siteCmd = &cobra.Command{ + Use: "site", + Short: "Manage RUM sites", + Long: `Manage Real User Monitoring (RUM) sites. + +Sites collect web performance metrics, Core Web Vitals, and JavaScript errors +from your web applications. + +Examples: + cronitor site list + cronitor site get my-site + cronitor site get my-site --with-snippet + cronitor site create "My Website" + cronitor site update my-site --sampling 50 + cronitor site delete my-site + + cronitor site errors --site my-site + cronitor site query --site my-site --type aggregation --metric session_count + cronitor site query --site my-site --type breakdown --metric lcp_p50 --group-by country_code + cronitor site query --site my-site --type timeseries --metric session_count --bucket hour + +For full API documentation, see https://cronitor.io/docs/sites-api.md`, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + RootCmd.AddCommand(siteCmd) + siteCmd.PersistentFlags().IntVar(&sitePage, "page", 1, "Page number") + siteCmd.PersistentFlags().IntVar(&sitePageSize, "page-size", 0, "Results per page") + siteCmd.PersistentFlags().StringVar(&siteFormat, "format", "", "Output format: json, table") + siteCmd.PersistentFlags().StringVarP(&siteOutput, "output", "o", "", "Write output to file") +} + +// --- LIST --- +var siteListCmd = &cobra.Command{ + Use: "list", + Short: "List all RUM sites", + Long: `List all Real User Monitoring sites. + +Examples: + cronitor site list + cronitor site list --page-size 100`, + Run: func(cmd *cobra.Command, args []string) { + client := lib.NewAPIClient(dev, log) + params := make(map[string]string) + + if sitePage > 1 { + params["page"] = fmt.Sprintf("%d", sitePage) + } + if sitePageSize > 0 { + params["pageSize"] = fmt.Sprintf("%d", sitePageSize) + } + + if siteFetchAll { + bodies, err := FetchAllPages(client, "/sites", params, "sites") + if err != nil { + Error(fmt.Sprintf("Failed to list sites: %s", err)) + os.Exit(1) + } + if siteFormat == "json" || siteFormat == "" { + siteOutputToTarget(FormatJSON(MergePagedJSON(bodies, "sites"))) + return + } + // Table: accumulate rows from all pages + table := &UITable{ + Headers: []string{"NAME", "KEY", "WEB VITALS", "ERRORS", "SAMPLING"}, + } + for _, body := range bodies { + var result struct { + Sites []struct { + Key string `json:"key"` + Name string `json:"name"` + ClientKey string `json:"client_key"` + WebVitalsEnabled bool `json:"webvitals_enabled"` + ErrorsEnabled bool `json:"errors_enabled"` + Sampling int `json:"sampling"` + } `json:"sites"` + } + json.Unmarshal(body, &result) + for _, s := range result.Sites { + webVitals := "off" + if s.WebVitalsEnabled { + webVitals = successStyle.Render("on") + } + errors := "off" + if s.ErrorsEnabled { + errors = successStyle.Render("on") + } + sampling := fmt.Sprintf("%d%%", s.Sampling) + table.Rows = append(table.Rows, []string{s.Name, s.Key, webVitals, errors, sampling}) + } + } + siteOutputToTarget(table.Render()) + return + } + + resp, err := client.GET("/sites", params) + if err != nil { + Error(fmt.Sprintf("Failed to list sites: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + if siteFormat == "json" { + siteOutputToTarget(FormatJSON(resp.Body)) + return + } + + var result struct { + Sites []struct { + Key string `json:"key"` + Name string `json:"name"` + ClientKey string `json:"client_key"` + WebVitalsEnabled bool `json:"webvitals_enabled"` + ErrorsEnabled bool `json:"errors_enabled"` + Sampling int `json:"sampling"` + } `json:"sites"` + } + if err := json.Unmarshal(resp.Body, &result); err != nil { + Error(fmt.Sprintf("Failed to parse response: %s", err)) + os.Exit(1) + } + + if len(result.Sites) == 0 { + siteOutputToTarget(mutedStyle.Render("No sites found")) + return + } + + table := &UITable{ + Headers: []string{"NAME", "KEY", "WEB VITALS", "ERRORS", "SAMPLING"}, + } + + for _, s := range result.Sites { + webVitals := "off" + if s.WebVitalsEnabled { + webVitals = successStyle.Render("on") + } + errors := "off" + if s.ErrorsEnabled { + errors = successStyle.Render("on") + } + sampling := fmt.Sprintf("%d%%", s.Sampling) + table.Rows = append(table.Rows, []string{s.Name, s.Key, webVitals, errors, sampling}) + } + + siteOutputToTarget(table.Render()) + }, +} + +// --- GET --- +var siteGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a RUM site", + Long: `Get details of a specific RUM site. + +Examples: + cronitor site get my-site + cronitor site get my-site --with-snippet`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + params := make(map[string]string) + + if siteWithSnippet { + params["withSnippet"] = "true" + } + + resp, err := client.GET(fmt.Sprintf("/sites/%s", key), params) + if err != nil { + Error(fmt.Sprintf("Failed to get site: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Site '%s' not found", key)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + siteOutputToTarget(FormatJSON(resp.Body)) + }, +} + +// --- CREATE --- +var siteCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a RUM site", + Long: `Create a new Real User Monitoring site. + +Examples: + cronitor site create --data '{"name":"My Website"}' + cronitor site create --data '{"name":"My App","sampling":50}'`, + Run: func(cmd *cobra.Command, args []string) { + if siteData == "" { + Error("Create data required. Use --data '{...}'") + os.Exit(1) + } + + var js json.RawMessage + if err := json.Unmarshal([]byte(siteData), &js); err != nil { + Error(fmt.Sprintf("Invalid JSON: %s", err)) + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + resp, err := client.POST("/sites", []byte(siteData), nil) + if err != nil { + Error(fmt.Sprintf("Failed to create site: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + var result struct { + Key string `json:"key"` + Name string `json:"name"` + ClientKey string `json:"client_key"` + } + if err := json.Unmarshal(resp.Body, &result); err == nil { + Success(fmt.Sprintf("Created site: %s (key: %s)", result.Name, result.Key)) + Info(fmt.Sprintf("Client key for browser: %s", result.ClientKey)) + } else { + Success("Site created") + } + + if siteFormat == "json" { + siteOutputToTarget(FormatJSON(resp.Body)) + } + }, +} + +// --- UPDATE --- +var siteUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a RUM site", + Long: `Update settings for a RUM site. + +Examples: + cronitor site update my-site --data '{"name":"New Name"}' + cronitor site update my-site --data '{"sampling":50}' + cronitor site update my-site --data '{"webvitals_enabled":false}'`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + + if siteData == "" { + Error("Update data required. Use --data '{...}'") + os.Exit(1) + } + + var js json.RawMessage + if err := json.Unmarshal([]byte(siteData), &js); err != nil { + Error(fmt.Sprintf("Invalid JSON: %s", err)) + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + resp, err := client.PUT(fmt.Sprintf("/sites/%s", key), []byte(siteData), nil) + if err != nil { + Error(fmt.Sprintf("Failed to update site: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Site '%s' not found", key)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success(fmt.Sprintf("Site '%s' updated", key)) + if siteFormat == "json" { + siteOutputToTarget(FormatJSON(resp.Body)) + } + }, +} + +// --- DELETE --- +var siteDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a RUM site", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + resp, err := client.DELETE(fmt.Sprintf("/sites/%s", key), nil, nil) + if err != nil { + Error(fmt.Sprintf("Failed to delete site: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Site '%s' not found", key)) + os.Exit(1) + } + + if resp.IsSuccess() { + Success(fmt.Sprintf("Site '%s' deleted", key)) + } else { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + }, +} + +// --- QUERY --- +var siteQueryCmd = &cobra.Command{ + Use: "query", + Short: "Query RUM analytics data", + Long: `Query Real User Monitoring analytics data. + +Query types: + aggregation - Aggregate metrics over time range + breakdown - Group metrics by dimension + timeseries - Metrics over time with buckets + error_groups - Grouped JavaScript error patterns + +Available metrics: + session_count, pageview_count, bounce_rate + page_load_p50, page_load_p75, page_load_p90, page_load_p99 + lcp_p50, lcp_p75, lcp_p90, lcp_p99 (Largest Contentful Paint) + fid_p50, fid_p75, fid_p90, fid_p99 (First Input Delay) + cls_p50, cls_p75, cls_p90, cls_p99 (Cumulative Layout Shift) + ttfb_p50, ttfb_p75, ttfb_p90, ttfb_p99 (Time to First Byte) + +Dimensions for breakdown/filtering: + country_code, city_name, path, hostname, device_type + browser, operating_system, referrer_hostname + utm_source, utm_medium, utm_campaign, connection_type + +Time ranges: 1h, 6h, 12h, 24h, 3d, 7d, 14d, 30d, 90d +Time buckets: minute, hour, day, week, month + +Examples: + cronitor site query --site my-site --type aggregation --metric session_count,lcp_p50 + cronitor site query --site my-site --type breakdown --metric session_count --group-by country_code + cronitor site query --site my-site --type timeseries --metric pageview_count --bucket hour --time 7d + cronitor site query --site my-site --type breakdown --metric lcp_p50 --group-by browser --filter "device_type:eq:desktop" + cronitor site query --site my-site --type error_groups --time 24h`, + Run: func(cmd *cobra.Command, args []string) { + if siteQuerySite == "" { + Error("--site is required") + os.Exit(1) + } + if siteQueryType == "" { + Error("--type is required (aggregation, breakdown, timeseries, error_groups)") + os.Exit(1) + } + + payload := map[string]interface{}{ + "site": siteQuerySite, + "type": siteQueryType, + } + + // Time range + if siteQueryTime != "" { + payload["time"] = siteQueryTime + } else { + payload["time"] = "24h" + } + if siteQueryStart != "" { + payload["start"] = siteQueryStart + } + if siteQueryEnd != "" { + payload["end"] = siteQueryEnd + } + if siteQueryTimezone != "" { + payload["timezone"] = siteQueryTimezone + } + + // Metrics + if siteQueryMetrics != "" { + payload["metrics"] = splitAndTrimSite(siteQueryMetrics) + } + + // Dimensions (for breakdown) + if siteQueryGroupBy != "" { + payload["dimensions"] = splitAndTrimSite(siteQueryGroupBy) + } + + // Time bucket (for timeseries) + if siteQueryBucket != "" { + payload["time_bucket"] = siteQueryBucket + } + + // Filters + if siteQueryFilters != "" { + filters := parseFilters(siteQueryFilters) + if len(filters) > 0 { + payload["filters"] = filters + } + } + + // Order by + if siteQueryOrderBy != "" { + payload["order_by"] = splitAndTrimSite(siteQueryOrderBy) + } + + // Compare + if siteQueryCompare { + payload["compare"] = "previous_time_range" + } + + // Pagination + if sitePage > 1 { + payload["page"] = sitePage + } + if sitePageSize > 0 { + payload["page_size"] = sitePageSize + } + + body, _ := json.Marshal(payload) + client := lib.NewAPIClient(dev, log) + resp, err := client.POST("/sites/query", body, nil) + if err != nil { + Error(fmt.Sprintf("Failed to query site: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + // For query results, JSON is the default since structure varies by query type + if siteFormat == "table" { + renderQueryTable(resp.Body, siteQueryType) + } else { + siteOutputToTarget(FormatJSON(resp.Body)) + } + }, +} + +// --- ERRORS (parent command) --- +var siteErrorsCmd = &cobra.Command{ + Use: "error", + Aliases: []string{"errors"}, + Short: "Manage JavaScript errors", + Long: `Manage JavaScript errors collected from RUM sites. + +For grouped error analytics, use: cronitor site query --type error_groups + +Examples: + cronitor site error list --site my-site + cronitor site error get `, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +// --- ERROR LIST --- +var siteErrorListCmd = &cobra.Command{ + Use: "list", + Short: "List JavaScript errors", + Long: `List JavaScript errors collected from RUM sites. + +Examples: + cronitor site error list --site my-site + cronitor site error list --site my-site --page-size 100`, + Run: func(cmd *cobra.Command, args []string) { + client := lib.NewAPIClient(dev, log) + params := make(map[string]string) + + if sitePage > 1 { + params["page"] = fmt.Sprintf("%d", sitePage) + } + if sitePageSize > 0 { + params["pageSize"] = fmt.Sprintf("%d", sitePageSize) + } + + siteKey, _ := cmd.Flags().GetString("site") + if siteKey != "" { + params["site"] = siteKey + } + + resp, err := client.GET("/site_errors", params) + if err != nil { + Error(fmt.Sprintf("Failed to list errors: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + if siteFormat == "json" { + siteOutputToTarget(FormatJSON(resp.Body)) + return + } + + var result struct { + Errors []struct { + Key string `json:"key"` + Message string `json:"message"` + ErrorType string `json:"error_type"` + Filename string `json:"filename"` + Count int `json:"count"` + } `json:"site_errors"` + } + if err := json.Unmarshal(resp.Body, &result); err != nil { + Error(fmt.Sprintf("Failed to parse response: %s", err)) + os.Exit(1) + } + + if len(result.Errors) == 0 { + siteOutputToTarget(mutedStyle.Render("No errors found")) + return + } + + table := &UITable{ + Headers: []string{"KEY", "TYPE", "MESSAGE", "FILE", "COUNT"}, + } + + for _, e := range result.Errors { + msg := e.Message + if len(msg) > 40 { + msg = msg[:37] + "..." + } + filename := e.Filename + if len(filename) > 30 { + filename = "..." + filename[len(filename)-27:] + } + table.Rows = append(table.Rows, []string{e.Key, e.ErrorType, msg, filename, fmt.Sprintf("%d", e.Count)}) + } + + siteOutputToTarget(table.Render()) + }, +} + +// --- ERROR GET --- +var siteErrorGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get error details", + Long: `Get detailed information about a specific JavaScript error. + +Examples: + cronitor site error get abc123`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + resp, err := client.GET(fmt.Sprintf("/site_errors/%s", key), nil) + if err != nil { + Error(fmt.Sprintf("Failed to get error: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Error '%s' not found", key)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + siteOutputToTarget(FormatJSON(resp.Body)) + }, +} + +func init() { + siteCmd.AddCommand(siteListCmd) + siteCmd.AddCommand(siteGetCmd) + siteCmd.AddCommand(siteCreateCmd) + siteCmd.AddCommand(siteUpdateCmd) + siteCmd.AddCommand(siteDeleteCmd) + siteCmd.AddCommand(siteQueryCmd) + siteCmd.AddCommand(siteErrorsCmd) + + // List flags + siteListCmd.Flags().BoolVar(&siteFetchAll, "all", false, "Fetch all pages of results") + + // Get flags + siteGetCmd.Flags().BoolVar(&siteWithSnippet, "with-snippet", false, "Include JavaScript installation snippet") + + // Create flags + siteCreateCmd.Flags().StringVarP(&siteData, "data", "d", "", "JSON payload") + + // Update flags + siteUpdateCmd.Flags().StringVarP(&siteData, "data", "d", "", "JSON payload") + + // Query flags + siteQueryCmd.Flags().StringVar(&siteQuerySite, "site", "", "Site key (required)") + siteQueryCmd.Flags().StringVar(&siteQueryType, "type", "", "Query type: aggregation, breakdown, timeseries, error_groups") + siteQueryCmd.Flags().StringVar(&siteQueryTime, "time", "24h", "Time range: 1h, 6h, 12h, 24h, 3d, 7d, 14d, 30d, 90d") + siteQueryCmd.Flags().StringVar(&siteQueryStart, "start", "", "Custom start time (ISO 8601)") + siteQueryCmd.Flags().StringVar(&siteQueryEnd, "end", "", "Custom end time (ISO 8601)") + siteQueryCmd.Flags().StringVar(&siteQueryMetrics, "metric", "", "Metrics to return (comma-separated)") + siteQueryCmd.Flags().StringVar(&siteQueryGroupBy, "group-by", "", "Dimensions to group by (comma-separated)") + siteQueryCmd.Flags().StringVar(&siteQueryFilters, "filter", "", "Filters: dim:op:value (comma-separated)") + siteQueryCmd.Flags().StringVar(&siteQueryOrderBy, "order-by", "", "Sort fields (prefix - for desc)") + siteQueryCmd.Flags().StringVar(&siteQueryTimezone, "timezone", "", "Timezone (IANA format)") + siteQueryCmd.Flags().StringVar(&siteQueryBucket, "bucket", "", "Time bucket: minute, hour, day, week, month") + siteQueryCmd.Flags().BoolVar(&siteQueryCompare, "compare", false, "Compare with previous time range") + + // Error subcommands + siteErrorsCmd.AddCommand(siteErrorListCmd) + siteErrorsCmd.AddCommand(siteErrorGetCmd) + + // Error list flags + siteErrorListCmd.Flags().String("site", "", "Filter by site key") +} + +func siteOutputToTarget(content string) { + if siteOutput != "" { + if err := os.WriteFile(siteOutput, []byte(content+"\n"), 0644); err != nil { + Error(fmt.Sprintf("Failed to write to %s: %s", siteOutput, err)) + os.Exit(1) + } + Info(fmt.Sprintf("Output written to %s", siteOutput)) + } else { + fmt.Println(content) + } +} + +func splitAndTrimSite(s string) []string { + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +// parseFilters parses filter strings in format "dimension:operator:value" +// e.g., "device_type:eq:desktop,country_code:eq:US" +func parseFilters(filterStr string) []map[string]string { + filters := []map[string]string{} + for _, f := range splitAndTrimSite(filterStr) { + parts := strings.SplitN(f, ":", 3) + if len(parts) == 3 { + filters = append(filters, map[string]string{ + "dimension": parts[0], + "operator": parts[1], + "value": parts[2], + }) + } + } + return filters +} + +// renderQueryTable renders query results as a table based on query type +func renderQueryTable(body []byte, queryType string) { + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + siteOutputToTarget(FormatJSON(body)) + return + } + + switch queryType { + case "aggregation": + renderAggregationTable(result) + case "breakdown": + renderBreakdownTable(result) + case "timeseries": + renderTimeseriesTable(result) + case "error_groups": + renderErrorGroupsTable(result) + default: + siteOutputToTarget(FormatJSON(body)) + } +} + +func renderAggregationTable(result map[string]interface{}) { + data, ok := result["data"].(map[string]interface{}) + if !ok { + fmt.Println("No data") + return + } + + table := &UITable{ + Headers: []string{"METRIC", "VALUE"}, + } + + for k, v := range data { + table.Rows = append(table.Rows, []string{k, formatSiteValue(v)}) + } + + siteOutputToTarget(table.Render()) +} + +func renderBreakdownTable(result map[string]interface{}) { + data, ok := result["data"].([]interface{}) + if !ok || len(data) == 0 { + fmt.Println("No data") + return + } + + // Get headers from first row + firstRow, ok := data[0].(map[string]interface{}) + if !ok { + fmt.Println("Invalid data format") + return + } + + headers := []string{} + for k := range firstRow { + headers = append(headers, strings.ToUpper(k)) + } + + table := &UITable{ + Headers: headers, + } + + for _, item := range data { + row, ok := item.(map[string]interface{}) + if !ok { + continue + } + values := []string{} + for _, h := range headers { + key := strings.ToLower(h) + values = append(values, formatSiteValue(row[key])) + } + table.Rows = append(table.Rows, values) + } + + siteOutputToTarget(table.Render()) +} + +func renderTimeseriesTable(result map[string]interface{}) { + data, ok := result["data"].([]interface{}) + if !ok || len(data) == 0 { + fmt.Println("No data") + return + } + + // Build headers from first row + firstRow, ok := data[0].(map[string]interface{}) + if !ok { + fmt.Println("Invalid data format") + return + } + + headers := []string{"TIMESTAMP"} + for k := range firstRow { + if k != "timestamp" && k != "time" { + headers = append(headers, strings.ToUpper(k)) + } + } + + table := &UITable{ + Headers: headers, + } + + for _, item := range data { + row, ok := item.(map[string]interface{}) + if !ok { + continue + } + values := []string{} + if ts, ok := row["timestamp"]; ok { + values = append(values, formatSiteValue(ts)) + } else if ts, ok := row["time"]; ok { + values = append(values, formatSiteValue(ts)) + } else { + values = append(values, "-") + } + for _, h := range headers[1:] { + key := strings.ToLower(h) + values = append(values, formatSiteValue(row[key])) + } + table.Rows = append(table.Rows, values) + } + + siteOutputToTarget(table.Render()) +} + +func renderErrorGroupsTable(result map[string]interface{}) { + data, ok := result["data"].([]interface{}) + if !ok || len(data) == 0 { + fmt.Println("No error groups found") + return + } + + table := &UITable{ + Headers: []string{"MESSAGE", "TYPE", "COUNT", "FIRST SEEN", "LAST SEEN"}, + } + + for _, item := range data { + row, ok := item.(map[string]interface{}) + if !ok { + continue + } + msg := formatSiteValue(row["message"]) + if len(msg) > 50 { + msg = msg[:47] + "..." + } + table.Rows = append(table.Rows, []string{ + msg, + formatSiteValue(row["error_type"]), + formatSiteValue(row["count"]), + formatSiteValue(row["first_seen"]), + formatSiteValue(row["last_seen"]), + }) + } + + siteOutputToTarget(table.Render()) +} + +func formatSiteValue(v interface{}) string { + if v == nil { + return "-" + } + switch val := v.(type) { + case float64: + if val == float64(int(val)) { + return fmt.Sprintf("%.0f", val) + } + return fmt.Sprintf("%.2f", val) + case string: + return val + default: + return fmt.Sprintf("%v", val) + } +} diff --git a/cmd/site_test.go b/cmd/site_test.go new file mode 100644 index 0000000..f278cdb --- /dev/null +++ b/cmd/site_test.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "testing" +) + +func TestSiteCommandStructure(t *testing.T) { + subcommands := []string{"list", "get", "create", "update", "delete", "query", "error"} + + for _, name := range subcommands { + found := false + for _, cmd := range siteCmd.Commands() { + if cmd.Name() == name { + found = true + break + } + } + if !found { + t.Errorf("Expected subcommand '%s' not found in site command", name) + } + } +} + +func TestSitePersistentFlags(t *testing.T) { + flags := []string{"page", "page-size", "format", "output"} + + for _, flag := range flags { + if siteCmd.PersistentFlags().Lookup(flag) == nil { + t.Errorf("Expected persistent flag '--%s' not found in site command", flag) + } + } +} + +func TestSiteGetCommandFlags(t *testing.T) { + flags := []string{"with-snippet"} + + for _, flag := range flags { + if siteGetCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in site get command", flag) + } + } +} + +func TestSiteCreateCommandFlags(t *testing.T) { + flags := []string{"data"} + + for _, flag := range flags { + if siteCreateCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in site create command", flag) + } + } +} + +func TestSiteUpdateCommandFlags(t *testing.T) { + flags := []string{"data"} + + for _, flag := range flags { + if siteUpdateCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in site update command", flag) + } + } +} + +func TestSiteQueryCommandFlags(t *testing.T) { + flags := []string{"site", "type", "time", "start", "end", "metric", "group-by", "filter", "order-by", "timezone", "bucket", "compare"} + + for _, flag := range flags { + if siteQueryCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in site query command", flag) + } + } +} + +func TestSiteErrorCommandStructure(t *testing.T) { + subcommands := []string{"list", "get"} + + for _, name := range subcommands { + found := false + for _, cmd := range siteErrorsCmd.Commands() { + if cmd.Name() == name { + found = true + break + } + } + if !found { + t.Errorf("Expected subcommand '%s' not found in site error command", name) + } + } +} + +func TestSiteErrorCommandAliases(t *testing.T) { + aliases := siteErrorsCmd.Aliases + found := false + for _, alias := range aliases { + if alias == "errors" { + found = true + break + } + } + if !found { + t.Error("Expected alias 'errors' not found for error command") + } +} + +func TestSiteErrorListCommandFlags(t *testing.T) { + flags := []string{"site"} + + for _, flag := range flags { + if siteErrorListCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in site error list command", flag) + } + } +} diff --git a/cmd/statuspage.go b/cmd/statuspage.go index e70d6ca..4773c69 100644 --- a/cmd/statuspage.go +++ b/cmd/statuspage.go @@ -14,14 +14,24 @@ import ( var statuspageCmd = &cobra.Command{ Use: "statuspage", Short: "Manage status pages", - Long: `Manage Cronitor status pages. + Long: `Manage Cronitor status pages and their components. + +Status pages display the health of your monitors to your users. +Components are individual items on a status page (linked to monitors or groups). Examples: cronitor statuspage list - cronitor statuspage get - cronitor statuspage create --data '{"name":"My Status Page"}' - cronitor statuspage update --data '{"name":"New Name"}' - cronitor statuspage delete `, + cronitor statuspage list --with-status + cronitor statuspage get my-page --with-components + cronitor statuspage create "My Status Page" --subdomain my-status + cronitor statuspage delete + + cronitor statuspage component list --statuspage my-page + cronitor statuspage component create --statuspage my-page --monitor api-health + cronitor statuspage component update --data '{"name":"New Name"}' + cronitor statuspage component delete + +For full API documentation, see https://cronitor.io/docs/statuspages-api.md`, Args: func(cmd *cobra.Command, args []string) error { if len(viper.GetString(varApiKey)) < 10 { return errors.New("API key required. Run 'cronitor configure' or use --api-key flag") @@ -34,11 +44,16 @@ Examples: } var ( - statuspagePage int - statuspageFormat string - statuspageOutput string - statuspageData string - statuspageFile string + statuspagePage int + statuspageFormat string + statuspageOutput string + statuspageData string + statuspageWithStatus bool + statuspageWithComponents bool + statuspageFetchAll bool + // Component flags + componentStatuspage string + componentData string ) func init() { @@ -52,12 +67,60 @@ func init() { var statuspageListCmd = &cobra.Command{ Use: "list", Short: "List all status pages", + Long: `List all status pages. + +Examples: + cronitor statuspage list + cronitor statuspage list --with-status + cronitor statuspage list --with-components`, Run: func(cmd *cobra.Command, args []string) { client := lib.NewAPIClient(dev, log) params := make(map[string]string) if statuspagePage > 1 { params["page"] = fmt.Sprintf("%d", statuspagePage) } + if statuspageWithStatus { + params["withStatus"] = "true" + } + if statuspageWithComponents { + params["withComponents"] = "true" + } + + if statuspageFetchAll { + bodies, err := FetchAllPages(client, "/statuspages", params, "statuspages") + if err != nil { + Error(fmt.Sprintf("Failed to list status pages: %s", err)) + os.Exit(1) + } + if statuspageFormat == "json" || statuspageFormat == "" { + statuspageOutputToTarget(FormatJSON(MergePagedJSON(bodies, "statuspages"))) + return + } + // Table: accumulate rows from all pages + table := &UITable{ + Headers: []string{"NAME", "KEY", "SUBDOMAIN", "STATUS"}, + } + for _, body := range bodies { + var result struct { + StatusPages []struct { + Key string `json:"key"` + Name string `json:"name"` + Subdomain string `json:"subdomain"` + Status string `json:"status"` + } `json:"statuspages"` + } + json.Unmarshal(body, &result) + for _, sp := range result.StatusPages { + status := successStyle.Render(sp.Status) + if sp.Status != "operational" { + status = warningStyle.Render(sp.Status) + } + table.Rows = append(table.Rows, []string{sp.Name, sp.Key, sp.Subdomain, status}) + } + } + statuspageOutputToTarget(table.Render()) + return + } resp, err := client.GET("/statuspages", params) if err != nil { @@ -94,7 +157,7 @@ var statuspageListCmd = &cobra.Command{ } table := &UITable{ - Headers: []string{"KEY", "NAME", "SUBDOMAIN", "STATUS"}, + Headers: []string{"NAME", "KEY", "SUBDOMAIN", "STATUS"}, } for _, sp := range result.StatusPages { @@ -102,7 +165,7 @@ var statuspageListCmd = &cobra.Command{ if sp.Status != "operational" { status = warningStyle.Render(sp.Status) } - table.Rows = append(table.Rows, []string{sp.Key, sp.Name, sp.Subdomain, status}) + table.Rows = append(table.Rows, []string{sp.Name, sp.Key, sp.Subdomain, status}) } statuspageOutputToTarget(table.Render()) @@ -142,19 +205,25 @@ var statuspageGetCmd = &cobra.Command{ var statuspageCreateCmd = &cobra.Command{ Use: "create", Short: "Create a new status page", + Long: `Create a new status page. + +Examples: + cronitor statuspage create --data '{"name":"My Status Page","subdomain":"my-status"}' + cronitor statuspage create --data '{"name":"Internal Status","subdomain":"internal","access":"private"}'`, Run: func(cmd *cobra.Command, args []string) { - body, err := getStatuspageRequestBody() - if err != nil { - Error(err.Error()) + if statuspageData == "" { + Error("Create data required. Use --data '{...}'") os.Exit(1) } - if body == nil { - Error("JSON data required. Use --data or --file") + + var js json.RawMessage + if err := json.Unmarshal([]byte(statuspageData), &js); err != nil { + Error(fmt.Sprintf("Invalid JSON: %s", err)) os.Exit(1) } client := lib.NewAPIClient(dev, log) - resp, err := client.POST("/statuspages", body, nil) + resp, err := client.POST("/statuspages", []byte(statuspageData), nil) if err != nil { Error(fmt.Sprintf("Failed to create status page: %s", err)) os.Exit(1) @@ -165,7 +234,16 @@ var statuspageCreateCmd = &cobra.Command{ os.Exit(1) } - Success("Status page created") + var result struct { + Key string `json:"key"` + Name string `json:"name"` + } + if err := json.Unmarshal(resp.Body, &result); err == nil { + Success(fmt.Sprintf("Created status page: %s (key: %s)", result.Name, result.Key)) + } else { + Success("Status page created") + } + statuspageOutputToTarget(FormatJSON(resp.Body)) }, } @@ -174,26 +252,38 @@ var statuspageCreateCmd = &cobra.Command{ var statuspageUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update a status page", - Args: cobra.ExactArgs(1), + Long: `Update an existing status page. + +Examples: + cronitor statuspage update my-page --data '{"name":"New Name"}' + cronitor statuspage update my-page --data '{"access":"private"}'`, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { key := args[0] - body, err := getStatuspageRequestBody() - if err != nil { - Error(err.Error()) + + if statuspageData == "" { + Error("Update data required. Use --data '{...}'") os.Exit(1) } - if body == nil { - Error("JSON data required. Use --data or --file") + + var js json.RawMessage + if err := json.Unmarshal([]byte(statuspageData), &js); err != nil { + Error(fmt.Sprintf("Invalid JSON: %s", err)) os.Exit(1) } client := lib.NewAPIClient(dev, log) - resp, err := client.PUT(fmt.Sprintf("/statuspages/%s", key), body, nil) + resp, err := client.PUT(fmt.Sprintf("/statuspages/%s", key), []byte(statuspageData), nil) if err != nil { Error(fmt.Sprintf("Failed to update status page: %s", err)) os.Exit(1) } + if resp.IsNotFound() { + Error(fmt.Sprintf("Status page '%s' not found", key)) + os.Exit(1) + } + if !resp.IsSuccess() { Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) os.Exit(1) @@ -239,35 +329,239 @@ func init() { statuspageCmd.AddCommand(statuspageCreateCmd) statuspageCmd.AddCommand(statuspageUpdateCmd) statuspageCmd.AddCommand(statuspageDeleteCmd) + statuspageCmd.AddCommand(componentCmd) + + // List flags + statuspageListCmd.Flags().BoolVar(&statuspageWithStatus, "with-status", false, "Include current status") + statuspageListCmd.Flags().BoolVar(&statuspageWithComponents, "with-components", false, "Include component details") + statuspageListCmd.Flags().BoolVar(&statuspageFetchAll, "all", false, "Fetch all pages of results") + + // Get flags + statuspageGetCmd.Flags().BoolVar(&statuspageWithStatus, "with-status", false, "Include current status") + statuspageGetCmd.Flags().BoolVar(&statuspageWithComponents, "with-components", false, "Include component details") - statuspageCreateCmd.Flags().StringVarP(&statuspageData, "data", "d", "", "JSON data") - statuspageCreateCmd.Flags().StringVarP(&statuspageFile, "file", "f", "", "JSON file") - statuspageUpdateCmd.Flags().StringVarP(&statuspageData, "data", "d", "", "JSON data") - statuspageUpdateCmd.Flags().StringVarP(&statuspageFile, "file", "f", "", "JSON file") + // Create flags + statuspageCreateCmd.Flags().StringVarP(&statuspageData, "data", "d", "", "JSON payload") + + // Update flags + statuspageUpdateCmd.Flags().StringVarP(&statuspageData, "data", "d", "", "JSON payload") } -func getStatuspageRequestBody() ([]byte, error) { - if statuspageData != "" && statuspageFile != "" { - return nil, errors.New("cannot specify both --data and --file") - } +// --- COMPONENT COMMANDS --- +var componentCmd = &cobra.Command{ + Use: "component", + Short: "Manage status page components", + Long: `Manage components on status pages. + +Components represent individual services/monitors displayed on a status page. + +Examples: + cronitor statuspage component list --statuspage my-page + cronitor statuspage component create --statuspage my-page --monitor api-health + cronitor statuspage component update --data '{"name":"New Name"}' + cronitor statuspage component delete `, +} + +var componentListCmd = &cobra.Command{ + Use: "list", + Short: "List components", + Long: `List status page components. + +Examples: + cronitor statuspage component list --statuspage my-page + cronitor statuspage component list --statuspage my-page --with-status`, + Run: func(cmd *cobra.Command, args []string) { + client := lib.NewAPIClient(dev, log) + params := make(map[string]string) + + if componentStatuspage != "" { + params["statuspage"] = componentStatuspage + } + if statuspageWithStatus { + params["withStatus"] = "true" + } + + resp, err := client.GET("/statuspage_components", params) + if err != nil { + Error(fmt.Sprintf("Failed to list components: %s", err)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + if statuspageFormat == "json" { + statuspageOutputToTarget(FormatJSON(resp.Body)) + return + } + + var result struct { + Components []struct { + Key string `json:"key"` + Name string `json:"name"` + Type string `json:"type"` + Statuspage string `json:"statuspage"` + Autopub bool `json:"autopublish"` + } `json:"statuspage_components"` + } + if err := json.Unmarshal(resp.Body, &result); err != nil { + Error(fmt.Sprintf("Failed to parse response: %s", err)) + os.Exit(1) + } + + table := &UITable{ + Headers: []string{"NAME", "KEY", "TYPE", "STATUSPAGE", "AUTOPUBLISH"}, + } + + for _, c := range result.Components { + autopub := "no" + if c.Autopub { + autopub = "yes" + } + table.Rows = append(table.Rows, []string{c.Name, c.Key, c.Type, c.Statuspage, autopub}) + } + + statuspageOutputToTarget(table.Render()) + }, +} + +var componentCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a component", + Long: `Create a new status page component. + +Examples: + cronitor statuspage component create --data '{"statuspage":"my-page","monitor":"api-health"}' + cronitor statuspage component create --data '{"statuspage":"my-page","group":"production","name":"Production"}'`, + Run: func(cmd *cobra.Command, args []string) { + if componentData == "" { + Error("Create data required. Use --data '{...}'") + os.Exit(1) + } - if statuspageData != "" { var js json.RawMessage - if err := json.Unmarshal([]byte(statuspageData), &js); err != nil { - return nil, fmt.Errorf("invalid JSON: %w", err) + if err := json.Unmarshal([]byte(componentData), &js); err != nil { + Error(fmt.Sprintf("Invalid JSON: %s", err)) + os.Exit(1) } - return []byte(statuspageData), nil - } - if statuspageFile != "" { - data, err := os.ReadFile(statuspageFile) + client := lib.NewAPIClient(dev, log) + resp, err := client.POST("/statuspage_components", []byte(componentData), nil) if err != nil { - return nil, fmt.Errorf("failed to read file: %w", err) + Error(fmt.Sprintf("Failed to create component: %s", err)) + os.Exit(1) } - return data, nil - } - return nil, nil + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + var result struct { + Key string `json:"key"` + Name string `json:"name"` + } + if err := json.Unmarshal(resp.Body, &result); err == nil { + Success(fmt.Sprintf("Created component: %s (key: %s)", result.Name, result.Key)) + } else { + Success("Component created") + } + }, +} + +var componentUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a component", + Long: `Update an existing status page component. + +Updatable fields: name, description, autopublish. + +Examples: + cronitor statuspage component update my-comp --data '{"name":"New Name"}' + cronitor statuspage component update my-comp --data '{"autopublish":false}' + cronitor statuspage component update my-comp --data '{"description":"Updated description"}'`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + + if componentData == "" { + Error("Update data required. Use --data '{...}'") + os.Exit(1) + } + + var js json.RawMessage + if err := json.Unmarshal([]byte(componentData), &js); err != nil { + Error(fmt.Sprintf("Invalid JSON: %s", err)) + os.Exit(1) + } + + client := lib.NewAPIClient(dev, log) + resp, err := client.PUT(fmt.Sprintf("/statuspage_components/%s", key), []byte(componentData), nil) + if err != nil { + Error(fmt.Sprintf("Failed to update component: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Component '%s' not found", key)) + os.Exit(1) + } + + if !resp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + + Success(fmt.Sprintf("Component '%s' updated", key)) + statuspageOutputToTarget(FormatJSON(resp.Body)) + }, +} + +var componentDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a component", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + client := lib.NewAPIClient(dev, log) + + resp, err := client.DELETE(fmt.Sprintf("/statuspage_components/%s", key), nil, nil) + if err != nil { + Error(fmt.Sprintf("Failed to delete component: %s", err)) + os.Exit(1) + } + + if resp.IsNotFound() { + Error(fmt.Sprintf("Component '%s' not found", key)) + os.Exit(1) + } + + if resp.IsSuccess() { + Success(fmt.Sprintf("Component '%s' deleted", key)) + } else { + Error(fmt.Sprintf("API Error (%d): %s", resp.StatusCode, resp.ParseError())) + os.Exit(1) + } + }, +} + +func init() { + componentCmd.AddCommand(componentListCmd) + componentCmd.AddCommand(componentCreateCmd) + componentCmd.AddCommand(componentUpdateCmd) + componentCmd.AddCommand(componentDeleteCmd) + + // Component list flags + componentListCmd.Flags().StringVar(&componentStatuspage, "statuspage", "", "Filter by status page key") + componentListCmd.Flags().BoolVar(&statuspageWithStatus, "with-status", false, "Include status information") + + // Component create flags + componentCreateCmd.Flags().StringVarP(&componentData, "data", "d", "", "JSON payload") + + // Component update flags + componentUpdateCmd.Flags().StringVarP(&componentData, "data", "d", "", "JSON payload") } func statuspageOutputToTarget(content string) { diff --git a/cmd/statuspage_test.go b/cmd/statuspage_test.go new file mode 100644 index 0000000..8e64d94 --- /dev/null +++ b/cmd/statuspage_test.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "testing" +) + +func TestStatuspageCommandStructure(t *testing.T) { + subcommands := []string{"list", "get", "create", "update", "delete", "component"} + + for _, name := range subcommands { + found := false + for _, cmd := range statuspageCmd.Commands() { + if cmd.Name() == name { + found = true + break + } + } + if !found { + t.Errorf("Expected subcommand '%s' not found in statuspage command", name) + } + } +} + +func TestStatuspagePersistentFlags(t *testing.T) { + flags := []string{"page", "format", "output"} + + for _, flag := range flags { + if statuspageCmd.PersistentFlags().Lookup(flag) == nil { + t.Errorf("Expected persistent flag '--%s' not found in statuspage command", flag) + } + } +} + +func TestStatuspageListCommandFlags(t *testing.T) { + flags := []string{"with-status", "with-components"} + + for _, flag := range flags { + if statuspageListCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in statuspage list command", flag) + } + } +} + +func TestStatuspageCreateCommandFlags(t *testing.T) { + flags := []string{"data"} + + for _, flag := range flags { + if statuspageCreateCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in statuspage create command", flag) + } + } +} + +func TestComponentCommandStructure(t *testing.T) { + subcommands := []string{"list", "create", "update", "delete"} + + for _, name := range subcommands { + found := false + for _, cmd := range componentCmd.Commands() { + if cmd.Name() == name { + found = true + break + } + } + if !found { + t.Errorf("Expected subcommand '%s' not found in component command", name) + } + } +} + +func TestComponentListCommandFlags(t *testing.T) { + flags := []string{"statuspage", "with-status"} + + for _, flag := range flags { + if componentListCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in component list command", flag) + } + } +} + +func TestComponentCreateCommandFlags(t *testing.T) { + flags := []string{"data"} + + for _, flag := range flags { + if componentCreateCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in component create command", flag) + } + } +} + +func TestComponentUpdateCommandFlags(t *testing.T) { + flags := []string{"data"} + + for _, flag := range flags { + if componentUpdateCmd.Flags().Lookup(flag) == nil { + t.Errorf("Expected flag '--%s' not found in component update command", flag) + } + } +} + +func TestComponentUpdateRequiresArgs(t *testing.T) { + if componentUpdateCmd.Args == nil { + t.Error("componentUpdateCmd should require args") + } +} diff --git a/cmd/ui.go b/cmd/ui.go index 62cb7ce..5f6a9db 100644 --- a/cmd/ui.go +++ b/cmd/ui.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/cronitorio/cronitor-cli/lib" ) // Color palette @@ -109,15 +110,16 @@ func (t *UITable) Render() string { return mutedStyle.Render("No results found") } - // Calculate column widths + // Calculate column widths using visual width (handles ANSI codes) colWidths := make([]int, len(t.Headers)) for i, h := range t.Headers { - colWidths[i] = len(h) + colWidths[i] = lipgloss.Width(h) } for _, row := range t.Rows { for i, cell := range row { - if i < len(colWidths) && len(cell) > colWidths[i] { - colWidths[i] = len(cell) + cellWidth := lipgloss.Width(cell) + if i < len(colWidths) && cellWidth > colWidths[i] { + colWidths[i] = cellWidth } } } @@ -132,10 +134,10 @@ func (t *UITable) Render() string { var sb strings.Builder - // Render header + // Render header (add 2 for padding on each side) var headerCells []string for i, h := range t.Headers { - cell := tableHeaderStyle.Width(colWidths[i]).Render(h) + cell := tableHeaderStyle.Width(colWidths[i] + 2).Render(h) headerCells = append(headerCells, cell) } sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, headerCells...)) @@ -146,11 +148,16 @@ func (t *UITable) Render() string { var cells []string for i, cell := range row { if i < len(colWidths) { - // Truncate if needed - if len(cell) > colWidths[i] { - cell = cell[:colWidths[i]-1] + "…" + // Truncate if needed (only for plain text cells) + cellWidth := lipgloss.Width(cell) + if cellWidth > colWidths[i] { + // Simple truncation for cells without ANSI codes + if cellWidth == len(cell) { + cell = cell[:colWidths[i]-1] + "…" + } + // For styled cells, just let them overflow slightly } - styledCell := tableCellStyle.Width(colWidths[i]).Render(cell) + styledCell := tableCellStyle.Width(colWidths[i] + 2).Render(cell) cells = append(cells, styledCell) } } @@ -211,6 +218,70 @@ func FormatJSON(data []byte) string { return prettyJSON.String() } +// FetchAllPages fetches all pages from a paginated API endpoint. +// It returns all response bodies as a slice, stopping when a page returns +// an empty items array (identified by itemsKey in the JSON response). +func FetchAllPages(client *lib.APIClient, endpoint string, params map[string]string, itemsKey string) ([][]byte, error) { + var bodies [][]byte + page := 1 + for { + p := make(map[string]string) + for k, v := range params { + p[k] = v + } + p["page"] = fmt.Sprintf("%d", page) + + resp, err := client.GET(endpoint, p) + if err != nil { + return bodies, err + } + if !resp.IsSuccess() { + return nil, fmt.Errorf("API Error (%d): %s", resp.StatusCode, resp.ParseError()) + } + bodies = append(bodies, resp.Body) + + // Check if there are items in this page + var raw map[string]json.RawMessage + if err := json.Unmarshal(resp.Body, &raw); err != nil { + break + } + if items, ok := raw[itemsKey]; ok { + var arr []json.RawMessage + if err := json.Unmarshal(items, &arr); err != nil || len(arr) == 0 { + break + } + } else { + break + } + + page++ + if page > 200 { // safety limit + break + } + } + return bodies, nil +} + +// MergePagedJSON merges multiple paginated API responses into a single JSON array. +// It extracts items from each page using the specified key and combines them. +func MergePagedJSON(responses [][]byte, key string) []byte { + var allItems []json.RawMessage + for _, body := range responses { + var page map[string]json.RawMessage + if err := json.Unmarshal(body, &page); err != nil { + continue + } + if items, ok := page[key]; ok { + var arr []json.RawMessage + if err := json.Unmarshal(items, &arr); err == nil { + allItems = append(allItems, arr...) + } + } + } + result, _ := json.MarshalIndent(allItems, "", " ") + return result +} + // RenderKeyValue renders a key-value pair func RenderKeyValue(key, value string) string { return fmt.Sprintf("%s %s", diff --git a/cmd/ui_test.go b/cmd/ui_test.go new file mode 100644 index 0000000..a40d156 --- /dev/null +++ b/cmd/ui_test.go @@ -0,0 +1,218 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/viper" +) + +// --------------------------------------------------------------------------- +// MergePagedJSON +// --------------------------------------------------------------------------- + +func TestMergePagedJSON_TwoPages(t *testing.T) { + pages := [][]byte{ + []byte(`{"items":[{"id":1}]}`), + []byte(`{"items":[{"id":2}]}`), + } + + result := MergePagedJSON(pages, "items") + + var items []map[string]interface{} + if err := json.Unmarshal(result, &items); err != nil { + t.Fatalf("failed to unmarshal result: %v", err) + } + + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + + if items[0]["id"].(float64) != 1 { + t.Errorf("expected first item id=1, got %v", items[0]["id"]) + } + if items[1]["id"].(float64) != 2 { + t.Errorf("expected second item id=2, got %v", items[1]["id"]) + } +} + +func TestMergePagedJSON_EmptyPages(t *testing.T) { + result := MergePagedJSON([][]byte{}, "items") + + // json.MarshalIndent of nil slice produces "null" + if string(result) != "null" { + t.Errorf("expected null for empty pages, got %s", string(result)) + } +} + +func TestMergePagedJSON_SinglePage(t *testing.T) { + pages := [][]byte{ + []byte(`{"items":[{"id":1},{"id":2}]}`), + } + + result := MergePagedJSON(pages, "items") + + var items []map[string]interface{} + if err := json.Unmarshal(result, &items); err != nil { + t.Fatalf("failed to unmarshal result: %v", err) + } + + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } +} + +func TestMergePagedJSON_MismatchedKey(t *testing.T) { + pages := [][]byte{ + []byte(`{"monitors":[{"id":1}]}`), + []byte(`{"monitors":[{"id":2}]}`), + } + + result := MergePagedJSON(pages, "items") + + // Key "items" does not exist, so no items are collected → nil slice → "null" + if string(result) != "null" { + t.Errorf("expected null for mismatched key, got %s", string(result)) + } +} + +// --------------------------------------------------------------------------- +// FetchAllPages +// --------------------------------------------------------------------------- + +func TestFetchAllPages_StopsOnEmptyItems(t *testing.T) { + viper.Set("CRONITOR_API_KEY", "test-key") + defer viper.Set("CRONITOR_API_KEY", "") + + var requestCount atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount.Add(1) + page := r.URL.Query().Get("page") + var body string + switch page { + case "", "1": + body = `{"items":[{"id":1}]}` + case "2": + body = `{"items":[{"id":2}]}` + default: + body = `{"items":[]}` + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte(body)) + })) + defer server.Close() + + client := &lib.APIClient{ + BaseURL: server.URL, + ApiKey: "test-key", + UserAgent: "test", + } + + pages, err := FetchAllPages(client, "/monitors", nil, "items") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Pages 1, 2, and 3 are fetched; page 3 is the empty one that stops iteration. + // The empty page body is still included in the returned slice. + if len(pages) != 3 { + t.Fatalf("expected 3 pages, got %d", len(pages)) + } + + // Verify that request count matches: pages 1, 2, 3 + if int(requestCount.Load()) != 3 { + t.Errorf("expected 3 requests, got %d", requestCount.Load()) + } +} + +func TestFetchAllPages_PageQueryParamsIncrement(t *testing.T) { + viper.Set("CRONITOR_API_KEY", "test-key") + defer viper.Set("CRONITOR_API_KEY", "") + + var receivedPages []string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + receivedPages = append(receivedPages, page) + + var body string + switch page { + case "", "1": + body = `{"items":[{"id":1}]}` + case "2": + body = `{"items":[{"id":2}]}` + default: + body = `{"items":[]}` + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte(body)) + })) + defer server.Close() + + client := &lib.APIClient{ + BaseURL: server.URL, + ApiKey: "test-key", + UserAgent: "test", + } + + _, err := FetchAllPages(client, "/monitors", nil, "items") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Expect page params: "1", "2", "3" + if len(receivedPages) != 3 { + t.Fatalf("expected 3 page requests, got %d: %v", len(receivedPages), receivedPages) + } + + for i, expected := range []string{"1", "2", "3"} { + if receivedPages[i] != expected { + t.Errorf("request %d: expected page=%s, got page=%s", i, expected, receivedPages[i]) + } + } +} + +func TestFetchAllPages_SafetyLimitAt200(t *testing.T) { + viper.Set("CRONITOR_API_KEY", "test-key") + defer viper.Set("CRONITOR_API_KEY", "") + + var requestCount atomic.Int32 + + // Server that always returns non-empty items + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount.Add(1) + page := r.URL.Query().Get("page") + body := fmt.Sprintf(`{"items":[{"id":%s}]}`, page) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte(body)) + })) + defer server.Close() + + client := &lib.APIClient{ + BaseURL: server.URL, + ApiKey: "test-key", + UserAgent: "test", + } + + pages, err := FetchAllPages(client, "/monitors", nil, "items") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(pages) != 200 { + t.Errorf("expected safety limit of 200 pages, got %d", len(pages)) + } + + if int(requestCount.Load()) != 200 { + t.Errorf("expected 200 requests, got %d", requestCount.Load()) + } +} diff --git a/go.mod b/go.mod index b9a5880..1162f5c 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,9 @@ require ( github.com/charmbracelet/lipgloss v1.0.0 github.com/mark3labs/mcp-go v0.32.0 github.com/pkg/errors v0.8.1 + github.com/rickb777/date v1.14.2 golang.org/x/time v0.11.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -49,7 +51,6 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/pelletier/go-toml v1.9.4 // indirect - github.com/rickb777/date v1.14.2 // indirect github.com/rickb777/plural v1.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect diff --git a/internal/testutil/capture.go b/internal/testutil/capture.go new file mode 100644 index 0000000..db7ca42 --- /dev/null +++ b/internal/testutil/capture.go @@ -0,0 +1,25 @@ +package testutil + +import ( + "bytes" + "io" + "os" +) + +// CaptureStdout captures everything written to os.Stdout while fn executes. +func CaptureStdout(fn func()) string { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + os.Stdout = old + + var buf bytes.Buffer + io.Copy(&buf, r) + r.Close() + + return buf.String() +} diff --git a/internal/testutil/command.go b/internal/testutil/command.go new file mode 100644 index 0000000..c09fa24 --- /dev/null +++ b/internal/testutil/command.go @@ -0,0 +1,24 @@ +package testutil + +import ( + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// ExecuteCommand runs a cobra command with the given args against a mock server, +// capturing stdout and returning the output along with any error. +// It sets up lib.BaseURLOverride and a test API key automatically. +func ExecuteCommand(root *cobra.Command, mockServerURL string, args ...string) (string, error) { + lib.BaseURLOverride = mockServerURL + viper.Set("CRONITOR_API_KEY", "test-api-key-1234567890") + + root.SetArgs(args) + + var execErr error + output := CaptureStdout(func() { + execErr = root.Execute() + }) + + return output, execErr +} diff --git a/internal/testutil/mock_api.go b/internal/testutil/mock_api.go new file mode 100644 index 0000000..4aa18e0 --- /dev/null +++ b/internal/testutil/mock_api.go @@ -0,0 +1,152 @@ +package testutil + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "runtime" + "sync" +) + +// RecordedRequest captures details of an incoming HTTP request for assertion. +type RecordedRequest struct { + Method string + Path string + QueryParams url.Values + Headers http.Header + Body string +} + +// MockAPI is a test HTTP server that records requests and returns configurable responses. +type MockAPI struct { + Server *httptest.Server + mu sync.Mutex + Requests []RecordedRequest + routes map[string]mockResponse + defaultStatus int + defaultBody string +} + +type mockResponse struct { + status int + body string + headers map[string]string +} + +// NewMockAPI creates a new mock API server. +func NewMockAPI() *MockAPI { + m := &MockAPI{ + routes: make(map[string]mockResponse), + defaultStatus: 200, + defaultBody: `{}`, + } + + m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + bodyBytes, _ := io.ReadAll(r.Body) + defer r.Body.Close() + + m.mu.Lock() + m.Requests = append(m.Requests, RecordedRequest{ + Method: r.Method, + Path: r.URL.Path, + QueryParams: r.URL.Query(), + Headers: r.Header.Clone(), + Body: string(bodyBytes), + }) + m.mu.Unlock() + + // Find matching route + key := r.Method + " " + r.URL.Path + if resp, ok := m.routes[key]; ok { + for k, v := range resp.headers { + w.Header().Set(k, v) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.status) + w.Write([]byte(resp.body)) + return + } + + // Try wildcard match (METHOD *) + wildcardKey := r.Method + " *" + if resp, ok := m.routes[wildcardKey]; ok { + for k, v := range resp.headers { + w.Header().Set(k, v) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.status) + w.Write([]byte(resp.body)) + return + } + + // Default response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(m.defaultStatus) + w.Write([]byte(m.defaultBody)) + })) + + return m +} + +// On registers a response for a specific method + path. +func (m *MockAPI) On(method, path string, status int, body string) { + m.routes[method+" "+path] = mockResponse{status: status, body: body} +} + +// OnWithHeaders registers a response with custom headers. +func (m *MockAPI) OnWithHeaders(method, path string, status int, body string, headers map[string]string) { + m.routes[method+" "+path] = mockResponse{status: status, body: body, headers: headers} +} + +// SetDefault sets the default response for unmatched routes. +func (m *MockAPI) SetDefault(status int, body string) { + m.defaultStatus = status + m.defaultBody = body +} + +// LastRequest returns the most recent recorded request. +func (m *MockAPI) LastRequest() RecordedRequest { + m.mu.Lock() + defer m.mu.Unlock() + if len(m.Requests) == 0 { + return RecordedRequest{} + } + return m.Requests[len(m.Requests)-1] +} + +// RequestCount returns the number of recorded requests. +func (m *MockAPI) RequestCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.Requests) +} + +// Reset clears all recorded requests. +func (m *MockAPI) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.Requests = nil +} + +// Close shuts down the mock server. +func (m *MockAPI) Close() { + m.Server.Close() +} + +// TestdataDir returns the path to the testdata directory at the project root. +func TestdataDir() string { + _, filename, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(filename), "..", "..", "testdata") +} + +// LoadFixture reads a JSON fixture file from testdata/. +func LoadFixture(name string) string { + data, err := os.ReadFile(filepath.Join(TestdataDir(), name)) + if err != nil { + panic("failed to load fixture " + name + ": " + err.Error()) + } + return string(data) +} diff --git a/lib/api_client.go b/lib/api_client.go index b2baf32..47cc77f 100644 --- a/lib/api_client.go +++ b/lib/api_client.go @@ -13,6 +13,10 @@ import ( "github.com/spf13/viper" ) +// BaseURLOverride allows tests to point NewAPIClient at a mock server. +// When non-empty, NewAPIClient uses this instead of the default base URL. +var BaseURLOverride string + // APIClient provides a generic interface for Cronitor API operations type APIClient struct { BaseURL string @@ -40,7 +44,9 @@ type PaginatedResponse struct { // NewAPIClient creates a new API client with the given configuration func NewAPIClient(isDev bool, logger func(string)) *APIClient { baseURL := "https://cronitor.io/api" - if isDev { + if BaseURLOverride != "" { + baseURL = BaseURLOverride + } else if isDev { baseURL = "http://dev.cronitor.io/api" } @@ -92,7 +98,9 @@ func (c *APIClient) Request(method, endpoint string, body []byte, queryParams ma // Set headers req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", c.UserAgent) - req.Header.Set("Cronitor-Version", "2025-11-28") + if apiVersion := viper.GetString("CRONITOR_API_VERSION"); apiVersion != "" { + req.Header.Set("Cronitor-Version", apiVersion) + } client := &http.Client{ Timeout: 120 * time.Second, diff --git a/lib/api_client_test.go b/lib/api_client_test.go new file mode 100644 index 0000000..3884b87 --- /dev/null +++ b/lib/api_client_test.go @@ -0,0 +1,1339 @@ +package lib_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/cronitorio/cronitor-cli/internal/testutil" + "github.com/cronitorio/cronitor-cli/lib" + "github.com/spf13/viper" +) + +// --- API Client Unit Tests --- + +func newTestClient(serverURL string) *lib.APIClient { + return &lib.APIClient{ + BaseURL: serverURL, + ApiKey: "test-api-key-1234567890", + UserAgent: "CronitorCLI/test", + IsDev: false, + Logger: nil, + } +} + +func TestAPIClient_GET(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + fixture := testutil.LoadFixture("monitors_list.json") + mock.On("GET", "/monitors", 200, fixture) + + client := newTestClient(mock.Server.URL) + resp, err := client.GET("/monitors", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.StatusCode != 200 { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + req := mock.LastRequest() + if req.Method != "GET" { + t.Errorf("expected GET, got %s", req.Method) + } + if req.Path != "/monitors" { + t.Errorf("expected /monitors, got %s", req.Path) + } +} + +func TestAPIClient_GET_WithQueryParams(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + mock.On("GET", "/monitors", 200, `{"monitors":[]}`) + + client := newTestClient(mock.Server.URL) + params := map[string]string{ + "page": "2", + "type": "job", + "env": "production", + "search": "backup", + } + _, err := client.GET("/monitors", params) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + for key, expected := range params { + got := req.QueryParams.Get(key) + if got != expected { + t.Errorf("query param %s: expected %q, got %q", key, expected, got) + } + } +} + +func TestAPIClient_GET_EmptyQueryParamsOmitted(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + mock.On("GET", "/monitors", 200, `{"monitors":[]}`) + + client := newTestClient(mock.Server.URL) + params := map[string]string{ + "page": "1", + "type": "", + "env": "", + } + _, err := client.GET("/monitors", params) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + if req.QueryParams.Get("page") != "1" { + t.Error("expected page=1") + } + // Empty values should not be sent + if req.QueryParams.Get("type") != "" { + t.Error("expected empty type param to be omitted") + } + if req.QueryParams.Get("env") != "" { + t.Error("expected empty env param to be omitted") + } +} + +func TestAPIClient_POST(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + mock.On("POST", "/monitors", 201, `{"key":"new-mon","name":"New Monitor"}`) + + client := newTestClient(mock.Server.URL) + body := []byte(`{"key":"new-mon","name":"New Monitor","type":"job"}`) + resp, err := client.POST("/monitors", body, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.StatusCode != 201 { + t.Errorf("expected status 201, got %d", resp.StatusCode) + } + + req := mock.LastRequest() + if req.Method != "POST" { + t.Errorf("expected POST, got %s", req.Method) + } + if req.Body != string(body) { + t.Errorf("expected body %q, got %q", string(body), req.Body) + } +} + +func TestAPIClient_PUT(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + mock.On("PUT", "/groups/prod", 200, `{"key":"prod","name":"Updated"}`) + + client := newTestClient(mock.Server.URL) + body := []byte(`{"name":"Updated"}`) + resp, err := client.PUT("/groups/prod", body, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.StatusCode != 200 { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + req := mock.LastRequest() + if req.Method != "PUT" { + t.Errorf("expected PUT, got %s", req.Method) + } + if req.Path != "/groups/prod" { + t.Errorf("expected /groups/prod, got %s", req.Path) + } +} + +func TestAPIClient_DELETE(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + mock.On("DELETE", "/monitors/abc123", 204, "") + + client := newTestClient(mock.Server.URL) + resp, err := client.DELETE("/monitors/abc123", nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.StatusCode != 204 { + t.Errorf("expected status 204, got %d", resp.StatusCode) + } + + req := mock.LastRequest() + if req.Method != "DELETE" { + t.Errorf("expected DELETE, got %s", req.Method) + } +} + +func TestAPIClient_DELETE_WithBody(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + mock.On("DELETE", "/monitors", 200, `{"deleted_count":3}`) + + client := newTestClient(mock.Server.URL) + body := []byte(`{"monitors":["a","b","c"]}`) + _, err := client.DELETE("/monitors", body, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + if req.Body != string(body) { + t.Errorf("expected body %q, got %q", string(body), req.Body) + } +} + +func TestAPIClient_Authentication(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + mock.On("GET", "/monitors", 200, `{}`) + + client := newTestClient(mock.Server.URL) + client.ApiKey = "my-secret-key" + + // Need to bypass viper for this test - set the key directly + // The Request method reads from viper first, then falls back to client.ApiKey + _, err := client.GET("/monitors", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + authHeader := req.Headers.Get("Authorization") + if authHeader == "" { + t.Error("expected Authorization header to be set") + } + if !strings.HasPrefix(authHeader, "Basic ") { + t.Errorf("expected Basic auth, got %q", authHeader) + } +} + +func TestAPIClient_Headers(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + mock.On("GET", "/monitors", 200, `{}`) + + // Ensure no version is configured + viper.Set("CRONITOR_API_VERSION", "") + defer viper.Set("CRONITOR_API_VERSION", "") + + client := newTestClient(mock.Server.URL) + _, err := client.GET("/monitors", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + + // Content-Type + if ct := req.Headers.Get("Content-Type"); ct != "application/json" { + t.Errorf("expected Content-Type application/json, got %q", ct) + } + + // User-Agent + if ua := req.Headers.Get("User-Agent"); ua != "CronitorCLI/test" { + t.Errorf("expected User-Agent CronitorCLI/test, got %q", ua) + } + + // Cronitor-Version should NOT be sent when no version configured + if cv := req.Headers.Get("Cronitor-Version"); cv != "" { + t.Errorf("expected no Cronitor-Version header when unset, got %q", cv) + } +} + +func TestAPIClient_URLConstruction(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + mock.On("GET", "/monitors/abc123", 200, `{}`) + + client := newTestClient(mock.Server.URL) + _, err := client.GET("/monitors/abc123", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + if req.Path != "/monitors/abc123" { + t.Errorf("expected /monitors/abc123, got %s", req.Path) + } +} + +// --- Error Response Tests --- + +func TestAPIClient_400_BadRequest(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + errBody := testutil.LoadFixture("error_responses/400.json") + mock.On("POST", "/monitors", 400, errBody) + + client := newTestClient(mock.Server.URL) + resp, err := client.POST("/monitors", []byte(`{}`), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.StatusCode != 400 { + t.Errorf("expected 400, got %d", resp.StatusCode) + } + if resp.IsSuccess() { + t.Error("expected IsSuccess() to be false") + } + + parsed := resp.ParseError() + if !strings.Contains(parsed, "name is required") { + t.Errorf("expected error message to contain 'name is required', got %q", parsed) + } +} + +func TestAPIClient_403_Forbidden(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + errBody := testutil.LoadFixture("error_responses/403.json") + mock.On("GET", "/monitors", 403, errBody) + + client := newTestClient(mock.Server.URL) + resp, err := client.GET("/monitors", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.StatusCode != 403 { + t.Errorf("expected 403, got %d", resp.StatusCode) + } + + parsed := resp.ParseError() + if !strings.Contains(parsed, "Invalid API key") { + t.Errorf("expected 'Invalid API key', got %q", parsed) + } +} + +func TestAPIClient_404_NotFound(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + errBody := testutil.LoadFixture("error_responses/404.json") + mock.On("GET", "/monitors/nonexistent", 404, errBody) + + client := newTestClient(mock.Server.URL) + resp, err := client.GET("/monitors/nonexistent", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !resp.IsNotFound() { + t.Error("expected IsNotFound() to be true") + } +} + +func TestAPIClient_429_RateLimit(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + errBody := testutil.LoadFixture("error_responses/429.json") + mock.OnWithHeaders("GET", "/monitors", 429, errBody, map[string]string{ + "Retry-After": "30", + }) + + client := newTestClient(mock.Server.URL) + resp, err := client.GET("/monitors", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.StatusCode != 429 { + t.Errorf("expected 429, got %d", resp.StatusCode) + } + if resp.Headers.Get("Retry-After") != "30" { + t.Errorf("expected Retry-After: 30, got %q", resp.Headers.Get("Retry-After")) + } +} + +func TestAPIClient_500_ServerError(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + errBody := testutil.LoadFixture("error_responses/500.json") + mock.On("GET", "/monitors", 500, errBody) + + client := newTestClient(mock.Server.URL) + resp, err := client.GET("/monitors", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.StatusCode != 500 { + t.Errorf("expected 500, got %d", resp.StatusCode) + } + if resp.IsSuccess() { + t.Error("expected IsSuccess() to be false") + } + + parsed := resp.ParseError() + if !strings.Contains(parsed, "Internal server error") { + t.Errorf("expected 'Internal server error', got %q", parsed) + } +} + +func TestAPIClient_NetworkError(t *testing.T) { + // Create a server and immediately close it to simulate connection refused + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + serverURL := server.URL + server.Close() + + client := newTestClient(serverURL) + _, err := client.GET("/monitors", nil) + if err == nil { + t.Error("expected network error, got nil") + } + if !strings.Contains(err.Error(), "request failed") { + t.Errorf("expected 'request failed' in error, got %q", err.Error()) + } +} + +func TestAPIClient_MalformedJSON(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + mock.On("GET", "/monitors", 200, `{not valid json`) + + client := newTestClient(mock.Server.URL) + resp, err := client.GET("/monitors", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // The client should return the raw body, not crash + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + // FormatJSON should handle this gracefully + formatted := resp.FormatJSON() + if formatted != `{not valid json` { + t.Errorf("expected raw body on invalid JSON, got %q", formatted) + } +} + +// --- Response Helper Tests --- + +func TestAPIResponse_IsSuccess(t *testing.T) { + tests := []struct { + code int + expect bool + }{ + {200, true}, + {201, true}, + {204, true}, + {299, true}, + {300, false}, + {400, false}, + {404, false}, + {500, false}, + } + + for _, tt := range tests { + resp := &lib.APIResponse{StatusCode: tt.code} + if resp.IsSuccess() != tt.expect { + t.Errorf("IsSuccess() for %d: expected %v", tt.code, tt.expect) + } + } +} + +func TestAPIResponse_IsNotFound(t *testing.T) { + tests := []struct { + code int + expect bool + }{ + {404, true}, + {200, false}, + {403, false}, + {500, false}, + } + + for _, tt := range tests { + resp := &lib.APIResponse{StatusCode: tt.code} + if resp.IsNotFound() != tt.expect { + t.Errorf("IsNotFound() for %d: expected %v", tt.code, tt.expect) + } + } +} + +func TestAPIResponse_FormatJSON(t *testing.T) { + resp := &lib.APIResponse{ + Body: []byte(`{"key":"abc","name":"Test"}`), + } + formatted := resp.FormatJSON() + if !strings.Contains(formatted, " ") { + t.Error("expected pretty-printed JSON with indentation") + } + + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(formatted), &parsed); err != nil { + t.Errorf("formatted JSON should be valid: %v", err) + } +} + +func TestAPIResponse_ParseError_ErrorField(t *testing.T) { + resp := &lib.APIResponse{ + Body: []byte(`{"error":"Invalid API key"}`), + } + if msg := resp.ParseError(); msg != "Invalid API key" { + t.Errorf("expected 'Invalid API key', got %q", msg) + } +} + +func TestAPIResponse_ParseError_MessageField(t *testing.T) { + resp := &lib.APIResponse{ + Body: []byte(`{"message":"Rate limit exceeded"}`), + } + if msg := resp.ParseError(); msg != "Rate limit exceeded" { + t.Errorf("expected 'Rate limit exceeded', got %q", msg) + } +} + +func TestAPIResponse_ParseError_ErrorsArray(t *testing.T) { + resp := &lib.APIResponse{ + Body: []byte(`{"errors":[{"message":"name is required"},{"message":"type is invalid"}]}`), + } + msg := resp.ParseError() + if !strings.Contains(msg, "name is required") { + t.Errorf("expected 'name is required' in %q", msg) + } + if !strings.Contains(msg, "type is invalid") { + t.Errorf("expected 'type is invalid' in %q", msg) + } +} + +func TestAPIResponse_ParseError_RawFallback(t *testing.T) { + resp := &lib.APIResponse{ + Body: []byte(`not json at all`), + } + if msg := resp.ParseError(); msg != "not json at all" { + t.Errorf("expected raw body as fallback, got %q", msg) + } +} + +// --- BaseURLOverride Tests --- + +func TestNewAPIClient_DefaultURL(t *testing.T) { + old := lib.BaseURLOverride + lib.BaseURLOverride = "" + defer func() { lib.BaseURLOverride = old }() + + client := lib.NewAPIClient(false, nil) + if client.BaseURL != "https://cronitor.io/api" { + t.Errorf("expected default URL, got %s", client.BaseURL) + } +} + +func TestNewAPIClient_DevURL(t *testing.T) { + old := lib.BaseURLOverride + lib.BaseURLOverride = "" + defer func() { lib.BaseURLOverride = old }() + + client := lib.NewAPIClient(true, nil) + if client.BaseURL != "http://dev.cronitor.io/api" { + t.Errorf("expected dev URL, got %s", client.BaseURL) + } +} + +func TestNewAPIClient_OverrideURL(t *testing.T) { + old := lib.BaseURLOverride + lib.BaseURLOverride = "http://localhost:9999/api" + defer func() { lib.BaseURLOverride = old }() + + // Override should take priority over both dev and prod + client := lib.NewAPIClient(false, nil) + if client.BaseURL != "http://localhost:9999/api" { + t.Errorf("expected override URL, got %s", client.BaseURL) + } + + clientDev := lib.NewAPIClient(true, nil) + if clientDev.BaseURL != "http://localhost:9999/api" { + t.Errorf("expected override URL even with isDev=true, got %s", clientDev.BaseURL) + } +} + +// --- PaginatedResponse Tests --- + +func TestPaginatedResponse_Parse(t *testing.T) { + body := `{"page":2,"page_size":50,"total_count":150,"data":[{"key":"abc"}]}` + var paginated lib.PaginatedResponse + if err := json.Unmarshal([]byte(body), &paginated); err != nil { + t.Fatalf("failed to parse: %v", err) + } + + if paginated.Page != 2 { + t.Errorf("expected page 2, got %d", paginated.Page) + } + if paginated.PageSize != 50 { + t.Errorf("expected page_size 50, got %d", paginated.PageSize) + } + if paginated.TotalCount != 150 { + t.Errorf("expected total_count 150, got %d", paginated.TotalCount) + } +} + +// --- Integration-style tests: full request/response cycle per resource endpoint --- + +func TestAPIClient_MonitorEndpoints(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + client := newTestClient(mock.Server.URL) + + tests := []struct { + name string + do func() (*lib.APIResponse, error) + method string + path string + wantBody string + }{ + { + name: "list monitors", + do: func() (*lib.APIResponse, error) { return client.GET("/monitors", nil) }, + method: "GET", + path: "/monitors", + }, + { + name: "get monitor", + do: func() (*lib.APIResponse, error) { return client.GET("/monitors/abc123", nil) }, + method: "GET", + path: "/monitors/abc123", + }, + { + name: "create monitor", + do: func() (*lib.APIResponse, error) { + return client.POST("/monitors", []byte(`{"key":"new","type":"job"}`), nil) + }, + method: "POST", + path: "/monitors", + wantBody: `{"key":"new","type":"job"}`, + }, + { + name: "update monitor (PUT batch)", + do: func() (*lib.APIResponse, error) { + return client.PUT("/monitors", []byte(`[{"key":"abc","name":"Updated"}]`), nil) + }, + method: "PUT", + path: "/monitors", + wantBody: `[{"key":"abc","name":"Updated"}]`, + }, + { + name: "delete monitor", + do: func() (*lib.APIResponse, error) { return client.DELETE("/monitors/abc123", nil, nil) }, + method: "DELETE", + path: "/monitors/abc123", + }, + { + name: "clone monitor", + do: func() (*lib.APIResponse, error) { + return client.POST("/monitors/clone", []byte(`{"key":"abc123"}`), nil) + }, + method: "POST", + path: "/monitors/clone", + wantBody: `{"key":"abc123"}`, + }, + { + name: "pause monitor", + do: func() (*lib.APIResponse, error) { return client.GET("/monitors/abc123/pause", nil) }, + method: "GET", + path: "/monitors/abc123/pause", + }, + { + name: "pause monitor with hours", + do: func() (*lib.APIResponse, error) { return client.GET("/monitors/abc123/pause/4", nil) }, + method: "GET", + path: "/monitors/abc123/pause/4", + }, + { + name: "unpause monitor", + do: func() (*lib.APIResponse, error) { return client.GET("/monitors/abc123/pause/0", nil) }, + method: "GET", + path: "/monitors/abc123/pause/0", + }, + { + name: "search monitors", + do: func() (*lib.APIResponse, error) { + return client.GET("/search", map[string]string{"query": "backup"}) + }, + method: "GET", + path: "/search", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock.Reset() + _, err := tt.do() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + if req.Method != tt.method { + t.Errorf("expected method %s, got %s", tt.method, req.Method) + } + if req.Path != tt.path { + t.Errorf("expected path %s, got %s", tt.path, req.Path) + } + if tt.wantBody != "" && req.Body != tt.wantBody { + t.Errorf("expected body %q, got %q", tt.wantBody, req.Body) + } + }) + } +} + +func TestAPIClient_GroupEndpoints(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + client := newTestClient(mock.Server.URL) + + tests := []struct { + name string + do func() (*lib.APIResponse, error) + method string + path string + }{ + {"list groups", func() (*lib.APIResponse, error) { return client.GET("/groups", nil) }, "GET", "/groups"}, + {"get group", func() (*lib.APIResponse, error) { return client.GET("/groups/prod", nil) }, "GET", "/groups/prod"}, + {"create group", func() (*lib.APIResponse, error) { + return client.POST("/groups", []byte(`{"name":"New"}`), nil) + }, "POST", "/groups"}, + {"update group", func() (*lib.APIResponse, error) { + return client.PUT("/groups/prod", []byte(`{"name":"Updated"}`), nil) + }, "PUT", "/groups/prod"}, + {"delete group", func() (*lib.APIResponse, error) { return client.DELETE("/groups/prod", nil, nil) }, "DELETE", "/groups/prod"}, + {"pause group", func() (*lib.APIResponse, error) { return client.GET("/groups/prod/pause/4", nil) }, "GET", "/groups/prod/pause/4"}, + {"resume group", func() (*lib.APIResponse, error) { return client.GET("/groups/prod/pause/0", nil) }, "GET", "/groups/prod/pause/0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock.Reset() + _, err := tt.do() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := mock.LastRequest() + if req.Method != tt.method { + t.Errorf("expected %s, got %s", tt.method, req.Method) + } + if req.Path != tt.path { + t.Errorf("expected %s, got %s", tt.path, req.Path) + } + }) + } +} + +func TestAPIClient_EnvironmentEndpoints(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + client := newTestClient(mock.Server.URL) + + tests := []struct { + name string + do func() (*lib.APIResponse, error) + method string + path string + }{ + {"list", func() (*lib.APIResponse, error) { return client.GET("/environments", nil) }, "GET", "/environments"}, + {"get", func() (*lib.APIResponse, error) { return client.GET("/environments/prod", nil) }, "GET", "/environments/prod"}, + {"create", func() (*lib.APIResponse, error) { + return client.POST("/environments", []byte(`{"key":"staging"}`), nil) + }, "POST", "/environments"}, + {"update", func() (*lib.APIResponse, error) { + return client.PUT("/environments/staging", []byte(`{"name":"QA"}`), nil) + }, "PUT", "/environments/staging"}, + {"delete", func() (*lib.APIResponse, error) { return client.DELETE("/environments/old", nil, nil) }, "DELETE", "/environments/old"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock.Reset() + _, err := tt.do() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := mock.LastRequest() + if req.Method != tt.method { + t.Errorf("expected %s, got %s", tt.method, req.Method) + } + if req.Path != tt.path { + t.Errorf("expected %s, got %s", tt.path, req.Path) + } + }) + } +} + +func TestAPIClient_NotificationEndpoints(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + client := newTestClient(mock.Server.URL) + + tests := []struct { + name string + do func() (*lib.APIResponse, error) + method string + path string + }{ + {"list", func() (*lib.APIResponse, error) { return client.GET("/notifications", nil) }, "GET", "/notifications"}, + {"get", func() (*lib.APIResponse, error) { return client.GET("/notifications/default", nil) }, "GET", "/notifications/default"}, + {"create", func() (*lib.APIResponse, error) { + return client.POST("/notifications", []byte(`{"name":"DevOps"}`), nil) + }, "POST", "/notifications"}, + {"update", func() (*lib.APIResponse, error) { + return client.PUT("/notifications/devops", []byte(`{"name":"Updated"}`), nil) + }, "PUT", "/notifications/devops"}, + {"delete", func() (*lib.APIResponse, error) { return client.DELETE("/notifications/old", nil, nil) }, "DELETE", "/notifications/old"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock.Reset() + _, err := tt.do() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := mock.LastRequest() + if req.Method != tt.method { + t.Errorf("expected %s, got %s", tt.method, req.Method) + } + if req.Path != tt.path { + t.Errorf("expected %s, got %s", tt.path, req.Path) + } + }) + } +} + +func TestAPIClient_IssueEndpoints(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + client := newTestClient(mock.Server.URL) + + tests := []struct { + name string + do func() (*lib.APIResponse, error) + method string + path string + wantBody string + }{ + {"list", func() (*lib.APIResponse, error) { return client.GET("/issues", nil) }, "GET", "/issues", ""}, + {"get", func() (*lib.APIResponse, error) { return client.GET("/issues/issue-001", nil) }, "GET", "/issues/issue-001", ""}, + {"create", func() (*lib.APIResponse, error) { + return client.POST("/issues", []byte(`{"name":"DB issue","severity":"outage"}`), nil) + }, "POST", "/issues", `{"name":"DB issue","severity":"outage"}`}, + {"update", func() (*lib.APIResponse, error) { + return client.PUT("/issues/issue-001", []byte(`{"state":"investigating"}`), nil) + }, "PUT", "/issues/issue-001", `{"state":"investigating"}`}, + {"resolve", func() (*lib.APIResponse, error) { + return client.PUT("/issues/issue-001", []byte(`{"state":"resolved"}`), nil) + }, "PUT", "/issues/issue-001", `{"state":"resolved"}`}, + {"delete", func() (*lib.APIResponse, error) { return client.DELETE("/issues/issue-001", nil, nil) }, "DELETE", "/issues/issue-001", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock.Reset() + _, err := tt.do() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := mock.LastRequest() + if req.Method != tt.method { + t.Errorf("expected %s, got %s", tt.method, req.Method) + } + if req.Path != tt.path { + t.Errorf("expected %s, got %s", tt.path, req.Path) + } + if tt.wantBody != "" && req.Body != tt.wantBody { + t.Errorf("expected body %q, got %q", tt.wantBody, req.Body) + } + }) + } +} + +func TestAPIClient_MaintenanceEndpoints(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + client := newTestClient(mock.Server.URL) + + tests := []struct { + name string + do func() (*lib.APIResponse, error) + method string + path string + }{ + {"list", func() (*lib.APIResponse, error) { return client.GET("/maintenance_windows", nil) }, "GET", "/maintenance_windows"}, + {"get", func() (*lib.APIResponse, error) { return client.GET("/maintenance_windows/maint-001", nil) }, "GET", "/maintenance_windows/maint-001"}, + {"create", func() (*lib.APIResponse, error) { + return client.POST("/maintenance_windows", []byte(`{"name":"Deploy"}`), nil) + }, "POST", "/maintenance_windows"}, + {"update", func() (*lib.APIResponse, error) { + return client.PUT("/maintenance_windows/maint-001", []byte(`{"name":"Updated"}`), nil) + }, "PUT", "/maintenance_windows/maint-001"}, + {"delete", func() (*lib.APIResponse, error) { + return client.DELETE("/maintenance_windows/maint-001", nil, nil) + }, "DELETE", "/maintenance_windows/maint-001"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock.Reset() + _, err := tt.do() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := mock.LastRequest() + if req.Method != tt.method { + t.Errorf("expected %s, got %s", tt.method, req.Method) + } + if req.Path != tt.path { + t.Errorf("expected %s, got %s", tt.path, req.Path) + } + }) + } +} + +func TestAPIClient_StatuspageEndpoints(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + client := newTestClient(mock.Server.URL) + + tests := []struct { + name string + do func() (*lib.APIResponse, error) + method string + path string + }{ + {"list statuspages", func() (*lib.APIResponse, error) { return client.GET("/statuspages", nil) }, "GET", "/statuspages"}, + {"get statuspage", func() (*lib.APIResponse, error) { return client.GET("/statuspages/main", nil) }, "GET", "/statuspages/main"}, + {"create statuspage", func() (*lib.APIResponse, error) { + return client.POST("/statuspages", []byte(`{"name":"Main"}`), nil) + }, "POST", "/statuspages"}, + {"update statuspage", func() (*lib.APIResponse, error) { + return client.PUT("/statuspages/main", []byte(`{"name":"Updated"}`), nil) + }, "PUT", "/statuspages/main"}, + {"delete statuspage", func() (*lib.APIResponse, error) { return client.DELETE("/statuspages/main", nil, nil) }, "DELETE", "/statuspages/main"}, + {"list components", func() (*lib.APIResponse, error) { return client.GET("/statuspage_components", nil) }, "GET", "/statuspage_components"}, + {"create component", func() (*lib.APIResponse, error) { + return client.POST("/statuspage_components", []byte(`{"statuspage":"main"}`), nil) + }, "POST", "/statuspage_components"}, + {"delete component", func() (*lib.APIResponse, error) { + return client.DELETE("/statuspage_components/comp-001", nil, nil) + }, "DELETE", "/statuspage_components/comp-001"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock.Reset() + _, err := tt.do() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := mock.LastRequest() + if req.Method != tt.method { + t.Errorf("expected %s, got %s", tt.method, req.Method) + } + if req.Path != tt.path { + t.Errorf("expected %s, got %s", tt.path, req.Path) + } + }) + } +} + +func TestAPIClient_MetricEndpoints(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + client := newTestClient(mock.Server.URL) + + t.Run("get metrics with params", func(t *testing.T) { + mock.Reset() + params := map[string]string{ + "monitor": "abc123", + "field": "duration_p50,success_rate", + "time": "7d", + "env": "production", + "region": "us-east-1", + "withNulls": "true", + } + _, err := client.GET("/metrics", params) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := mock.LastRequest() + if req.Path != "/metrics" { + t.Errorf("expected /metrics, got %s", req.Path) + } + for k, v := range params { + if req.QueryParams.Get(k) != v { + t.Errorf("param %s: expected %q, got %q", k, v, req.QueryParams.Get(k)) + } + } + }) + + t.Run("get aggregates", func(t *testing.T) { + mock.Reset() + _, err := client.GET("/aggregates", map[string]string{"monitor": "abc123"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := mock.LastRequest() + if req.Path != "/aggregates" { + t.Errorf("expected /aggregates, got %s", req.Path) + } + }) +} + +func TestAPIClient_SiteEndpoints(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + client := newTestClient(mock.Server.URL) + + tests := []struct { + name string + do func() (*lib.APIResponse, error) + method string + path string + }{ + {"list sites", func() (*lib.APIResponse, error) { return client.GET("/sites", nil) }, "GET", "/sites"}, + {"get site", func() (*lib.APIResponse, error) { return client.GET("/sites/my-site", nil) }, "GET", "/sites/my-site"}, + {"create site", func() (*lib.APIResponse, error) { + return client.POST("/sites", []byte(`{"name":"My Site"}`), nil) + }, "POST", "/sites"}, + {"update site", func() (*lib.APIResponse, error) { + return client.PUT("/sites/my-site", []byte(`{"name":"Updated"}`), nil) + }, "PUT", "/sites/my-site"}, + {"delete site", func() (*lib.APIResponse, error) { return client.DELETE("/sites/my-site", nil, nil) }, "DELETE", "/sites/my-site"}, + {"query site", func() (*lib.APIResponse, error) { + return client.POST("/sites/query", []byte(`{"site":"my-site","type":"aggregation"}`), nil) + }, "POST", "/sites/query"}, + {"list site errors", func() (*lib.APIResponse, error) { + return client.GET("/site_errors", map[string]string{"site": "my-site"}) + }, "GET", "/site_errors"}, + {"get site error", func() (*lib.APIResponse, error) { return client.GET("/site_errors/err-001", nil) }, "GET", "/site_errors/err-001"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock.Reset() + _, err := tt.do() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req := mock.LastRequest() + if req.Method != tt.method { + t.Errorf("expected %s, got %s", tt.method, req.Method) + } + if req.Path != tt.path { + t.Errorf("expected %s, got %s", tt.path, req.Path) + } + }) + } +} + +// --- Filter/Query Param Tests --- + +func TestAPIClient_MonitorListFilters(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + client := newTestClient(mock.Server.URL) + + params := map[string]string{ + "type": "job,check", + "group": "production", + "tag": "critical,database", + "state": "failing", + "search": "backup", + "sort": "-created", + "env": "production", + "page": "2", + "pageSize": "100", + } + + _, err := client.GET("/monitors", params) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + for k, v := range params { + if req.QueryParams.Get(k) != v { + t.Errorf("param %s: expected %q, got %q", k, v, req.QueryParams.Get(k)) + } + } +} + +func TestAPIClient_IssueListFilters(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + client := newTestClient(mock.Server.URL) + + params := map[string]string{ + "state": "unresolved", + "severity": "outage", + "job": "my-job", + "group": "production", + "tag": "critical", + "env": "production", + "search": "database", + "time": "24h", + "orderBy": "-started", + } + + _, err := client.GET("/issues", params) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + for k, v := range params { + if req.QueryParams.Get(k) != v { + t.Errorf("param %s: expected %q, got %q", k, v, req.QueryParams.Get(k)) + } + } +} + +func TestAPIClient_MaintenanceListFilters(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + client := newTestClient(mock.Server.URL) + + params := map[string]string{ + "past": "true", + "ongoing": "true", + "upcoming": "true", + "statuspage": "main", + "env": "production", + "withAllAffectedMonitors": "true", + } + + _, err := client.GET("/maintenance_windows", params) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + for k, v := range params { + if req.QueryParams.Get(k) != v { + t.Errorf("param %s: expected %q, got %q", k, v, req.QueryParams.Get(k)) + } + } +} + +func TestAPIClient_StatuspageListFilters(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + client := newTestClient(mock.Server.URL) + + params := map[string]string{ + "withStatus": "true", + "withComponents": "true", + } + + _, err := client.GET("/statuspages", params) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + for k, v := range params { + if req.QueryParams.Get(k) != v { + t.Errorf("param %s: expected %q, got %q", k, v, req.QueryParams.Get(k)) + } + } +} + +// --- Phase 5f: Configuration & Version Header Tests --- + +func TestVersionHeader_NotSentWhenUnset(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + mock.On("GET", "/monitors", 200, `{}`) + + viper.Set("CRONITOR_API_VERSION", "") + defer viper.Set("CRONITOR_API_VERSION", "") + + client := newTestClient(mock.Server.URL) + _, err := client.GET("/monitors", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + if cv := req.Headers.Get("Cronitor-Version"); cv != "" { + t.Errorf("expected no Cronitor-Version header when unset, got %q", cv) + } +} + +func TestVersionHeader_SentWhenConfigured(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + mock.On("GET", "/monitors", 200, `{}`) + + viper.Set("CRONITOR_API_VERSION", "2025-11-28") + defer viper.Set("CRONITOR_API_VERSION", "") + + client := newTestClient(mock.Server.URL) + _, err := client.GET("/monitors", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + if cv := req.Headers.Get("Cronitor-Version"); cv != "2025-11-28" { + t.Errorf("expected Cronitor-Version 2025-11-28, got %q", cv) + } +} + +func TestVersionHeader_DifferentVersionValues(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + mock.On("GET", "/monitors", 200, `{}`) + + versions := []string{"2020-10-01", "2025-11-28", "2026-01-01"} + for _, version := range versions { + t.Run(version, func(t *testing.T) { + mock.Reset() + viper.Set("CRONITOR_API_VERSION", version) + defer viper.Set("CRONITOR_API_VERSION", "") + + client := newTestClient(mock.Server.URL) + _, err := client.GET("/monitors", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + if cv := req.Headers.Get("Cronitor-Version"); cv != version { + t.Errorf("expected Cronitor-Version %q, got %q", version, cv) + } + }) + } +} + +func TestVersionHeader_AppliesAcrossAllMethods(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + mock.SetDefault(200, `{}`) + + viper.Set("CRONITOR_API_VERSION", "2025-11-28") + defer viper.Set("CRONITOR_API_VERSION", "") + + client := newTestClient(mock.Server.URL) + + methods := []struct { + name string + do func() (*lib.APIResponse, error) + }{ + {"GET", func() (*lib.APIResponse, error) { return client.GET("/test", nil) }}, + {"POST", func() (*lib.APIResponse, error) { return client.POST("/test", []byte(`{}`), nil) }}, + {"PUT", func() (*lib.APIResponse, error) { return client.PUT("/test", []byte(`{}`), nil) }}, + {"DELETE", func() (*lib.APIResponse, error) { return client.DELETE("/test", nil, nil) }}, + {"PATCH", func() (*lib.APIResponse, error) { return client.PATCH("/test", []byte(`{}`), nil) }}, + } + + for _, m := range methods { + t.Run(m.name, func(t *testing.T) { + mock.Reset() + _, err := m.do() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + if cv := req.Headers.Get("Cronitor-Version"); cv != "2025-11-28" { + t.Errorf("%s: expected Cronitor-Version 2025-11-28, got %q", m.name, cv) + } + }) + } +} + +func TestVersionHeader_NotSentAcrossAllMethods(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + mock.SetDefault(200, `{}`) + + viper.Set("CRONITOR_API_VERSION", "") + defer viper.Set("CRONITOR_API_VERSION", "") + + client := newTestClient(mock.Server.URL) + + methods := []struct { + name string + do func() (*lib.APIResponse, error) + }{ + {"GET", func() (*lib.APIResponse, error) { return client.GET("/test", nil) }}, + {"POST", func() (*lib.APIResponse, error) { return client.POST("/test", []byte(`{}`), nil) }}, + {"PUT", func() (*lib.APIResponse, error) { return client.PUT("/test", []byte(`{}`), nil) }}, + {"DELETE", func() (*lib.APIResponse, error) { return client.DELETE("/test", nil, nil) }}, + } + + for _, m := range methods { + t.Run(m.name, func(t *testing.T) { + mock.Reset() + _, err := m.do() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + if cv := req.Headers.Get("Cronitor-Version"); cv != "" { + t.Errorf("%s: expected no Cronitor-Version header, got %q", m.name, cv) + } + }) + } +} + +func TestVersionHeader_ViperPriority_EnvOverridesConfig(t *testing.T) { + mock := testutil.NewMockAPI() + defer mock.Close() + + mock.On("GET", "/monitors", 200, `{}`) + + // Simulate config file value + viper.Set("CRONITOR_API_VERSION", "2020-10-01") + defer viper.Set("CRONITOR_API_VERSION", "") + + // Env var should override (viper.AutomaticEnv handles this in production; + // in tests we simulate by setting the viper key directly) + viper.Set("CRONITOR_API_VERSION", "2025-11-28") + + client := newTestClient(mock.Server.URL) + _, err := client.GET("/monitors", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := mock.LastRequest() + if cv := req.Headers.Get("Cronitor-Version"); cv != "2025-11-28" { + t.Errorf("expected override version 2025-11-28, got %q", cv) + } +} diff --git a/lib/cronitor.go b/lib/cronitor.go index 67694d1..5005938 100644 --- a/lib/cronitor.go +++ b/lib/cronitor.go @@ -69,13 +69,18 @@ type Monitor struct { NoStdoutPassthru bool `json:"-"` } -// UnmarshalJSON implements custom unmarshaling for the Monitor struct +// UnmarshalJSON implements custom unmarshaling for the Monitor struct. +// Handles flexible parsing for: +// - notify: can be a string, []string, or []interface{} depending on API version +// - schedule/schedules: older API versions return singular "schedule" (string), +// newer versions return "schedules" ([]string). This normalizes both into Schedules. func (m *Monitor) UnmarshalJSON(data []byte) error { - // Create an auxiliary struct to handle the raw notify field + // Create an auxiliary struct to handle the raw notify and schedule fields type AuxMonitor Monitor aux := &struct { *AuxMonitor - Notify interface{} `json:"notify,omitempty"` + Notify interface{} `json:"notify,omitempty"` + Schedule interface{} `json:"schedule,omitempty"` }{ AuxMonitor: (*AuxMonitor)(m), } @@ -102,6 +107,26 @@ func (m *Monitor) UnmarshalJSON(data []byte) error { } } + // Handle the schedule field: normalize singular "schedule" into "schedules" []string. + // If "schedules" was already populated by the standard unmarshal, leave it alone. + if m.Schedules == nil && aux.Schedule != nil { + switch v := aux.Schedule.(type) { + case string: + if v != "" { + s := []string{v} + m.Schedules = &s + } + case []interface{}: + s := make([]string, 0, len(v)) + for _, item := range v { + if str, ok := item.(string); ok { + s = append(s, str) + } + } + m.Schedules = &s + } + } + return nil } @@ -188,6 +213,26 @@ func (api CronitorApi) PutMonitors(monitors map[string]*Monitor) (map[string]*Mo return monitors, nil } +// PutRawMonitors sends raw payload to the monitors API endpoint +// Used for bulk import from YAML/JSON files +// contentType should be "application/json" or "application/yaml" +func (api CronitorApi) PutRawMonitors(payload []byte, contentType string) ([]byte, error) { + url := api.Url() + + api.Logger("\nRequest:") + api.Logger(string(payload) + "\n") + + response, err, _ := api.sendWithContentType("PUT", url, string(payload), contentType) + if err != nil { + return nil, errors.New(fmt.Sprintf("Request to %s failed: %s", url, err)) + } + + api.Logger("\nResponse:") + api.Logger(string(response) + "\n") + + return response, nil +} + func (api CronitorApi) GetMonitors() ([]Monitor, error) { url := api.Url() page := 1 @@ -276,7 +321,47 @@ func (api CronitorApi) send(method string, url string, body string) ([]byte, err } request.Header.Add("User-Agent", api.UserAgent) - request.Header.Add("Cronitor-Version", "2025-11-28") + if apiVersion := viper.GetString("CRONITOR_API_VERSION"); apiVersion != "" { + request.Header.Add("Cronitor-Version", apiVersion) + } + request.ContentLength = int64(len(body)) + response, err := client.Do(request) + if err != nil { + return nil, err, 0 + } + + defer response.Body.Close() + contents, err := ioutil.ReadAll(response.Body) + if err != nil { + raven.CaptureErrorAndWait(err, nil) + return nil, err, 0 + } + + return contents, nil, response.StatusCode +} + +func (api CronitorApi) sendWithContentType(method string, url string, body string, contentType string) ([]byte, error, int) { + client := &http.Client{ + Timeout: 120 * time.Second, + } + request, err := http.NewRequest(method, url, strings.NewReader(body)) + if err != nil { + return nil, err, 0 + } + + // Always fetch the latest API key from viper to pick up settings changes + currentApiKey := viper.GetString("CRONITOR_API_KEY") + if currentApiKey == "" { + // Fallback to the API key stored in the struct if viper doesn't have it + currentApiKey = api.ApiKey + } + request.SetBasicAuth(currentApiKey, "") + + request.Header.Add("Content-Type", contentType) + request.Header.Add("User-Agent", api.UserAgent) + if apiVersion := viper.GetString("CRONITOR_API_VERSION"); apiVersion != "" { + request.Header.Add("Cronitor-Version", apiVersion) + } request.ContentLength = int64(len(body)) response, err := client.Do(request) if err != nil { diff --git a/testdata/aggregates_get.json b/testdata/aggregates_get.json new file mode 100644 index 0000000..4b1c49b --- /dev/null +++ b/testdata/aggregates_get.json @@ -0,0 +1,14 @@ +{ + "monitors": { + "abc123": { + "production": { + "success_rate": 99.5, + "duration_mean": 1180.25, + "duration_p50": 1100.0, + "duration_p90": 2500.0, + "total_runs": 720, + "total_failures": 4 + } + } + } +} diff --git a/testdata/components_list.json b/testdata/components_list.json new file mode 100644 index 0000000..8c099b3 --- /dev/null +++ b/testdata/components_list.json @@ -0,0 +1,18 @@ +{ + "statuspage_components": [ + { + "key": "comp-001", + "name": "API", + "type": "monitor", + "statuspage": "main-status", + "autopublish": true + }, + { + "key": "comp-002", + "name": "Production Services", + "type": "group", + "statuspage": "main-status", + "autopublish": false + } + ] +} diff --git a/testdata/environments_list.json b/testdata/environments_list.json new file mode 100644 index 0000000..ebf8b43 --- /dev/null +++ b/testdata/environments_list.json @@ -0,0 +1,18 @@ +{ + "environments": [ + { + "key": "production", + "name": "Production", + "with_alerts": true, + "default": true, + "active_monitors": 42 + }, + { + "key": "staging", + "name": "Staging", + "with_alerts": false, + "default": false, + "active_monitors": 10 + } + ] +} diff --git a/testdata/error_responses/400.json b/testdata/error_responses/400.json new file mode 100644 index 0000000..e39d9da --- /dev/null +++ b/testdata/error_responses/400.json @@ -0,0 +1,6 @@ +{ + "errors": [ + {"message": "name is required"}, + {"message": "type must be one of: job, check, heartbeat, site"} + ] +} diff --git a/testdata/error_responses/403.json b/testdata/error_responses/403.json new file mode 100644 index 0000000..fb55251 --- /dev/null +++ b/testdata/error_responses/403.json @@ -0,0 +1,3 @@ +{ + "error": "Invalid API key" +} diff --git a/testdata/error_responses/404.json b/testdata/error_responses/404.json new file mode 100644 index 0000000..c7bba48 --- /dev/null +++ b/testdata/error_responses/404.json @@ -0,0 +1,3 @@ +{ + "error": "Not found" +} diff --git a/testdata/error_responses/429.json b/testdata/error_responses/429.json new file mode 100644 index 0000000..2959340 --- /dev/null +++ b/testdata/error_responses/429.json @@ -0,0 +1,3 @@ +{ + "error": "Rate limit exceeded" +} diff --git a/testdata/error_responses/500.json b/testdata/error_responses/500.json new file mode 100644 index 0000000..1cf1359 --- /dev/null +++ b/testdata/error_responses/500.json @@ -0,0 +1,3 @@ +{ + "error": "Internal server error" +} diff --git a/testdata/groups_list.json b/testdata/groups_list.json new file mode 100644 index 0000000..3307da5 --- /dev/null +++ b/testdata/groups_list.json @@ -0,0 +1,19 @@ +{ + "groups": [ + { + "key": "production", + "name": "Production Jobs", + "monitors": ["abc123", "def456"], + "created": "2024-01-10T00:00:00Z" + }, + { + "key": "staging", + "name": "Staging", + "monitors": ["ghi789"], + "created": "2024-02-01T00:00:00Z" + } + ], + "page_size": 50, + "page": 1, + "total_count": 2 +} diff --git a/testdata/issues_list.json b/testdata/issues_list.json new file mode 100644 index 0000000..26e73b2 --- /dev/null +++ b/testdata/issues_list.json @@ -0,0 +1,18 @@ +{ + "issues": [ + { + "key": "issue-001", + "name": "Database connection timeout", + "state": "unresolved", + "severity": "outage", + "started": "2024-03-15T08:30:00Z" + }, + { + "key": "issue-002", + "name": "High latency on API", + "state": "investigating", + "severity": "degraded_performance", + "started": "2024-03-14T12:00:00Z" + } + ] +} diff --git a/testdata/maintenance_list.json b/testdata/maintenance_list.json new file mode 100644 index 0000000..5f92d3a --- /dev/null +++ b/testdata/maintenance_list.json @@ -0,0 +1,20 @@ +{ + "maintenance_windows": [ + { + "key": "maint-001", + "name": "Deploy v2.0", + "start": "2024-03-20T02:00:00Z", + "end": "2024-03-20T04:00:00Z", + "state": "upcoming", + "duration": 120 + }, + { + "key": "maint-002", + "name": "DB Migration", + "start": "2024-03-15T00:00:00Z", + "end": "2024-03-15T02:00:00Z", + "state": "past", + "duration": 120 + } + ] +} diff --git a/testdata/metrics_get.json b/testdata/metrics_get.json new file mode 100644 index 0000000..aaa588a --- /dev/null +++ b/testdata/metrics_get.json @@ -0,0 +1,18 @@ +{ + "monitors": { + "abc123": { + "production": [ + { + "stamp": 1710500000, + "duration_p50": 1200.5, + "success_rate": 99.8 + }, + { + "stamp": 1710503600, + "duration_p50": 1150.0, + "success_rate": 100.0 + } + ] + } + } +} diff --git a/testdata/monitor_get.json b/testdata/monitor_get.json new file mode 100644 index 0000000..dde7d9f --- /dev/null +++ b/testdata/monitor_get.json @@ -0,0 +1,13 @@ +{ + "key": "abc123", + "name": "Nightly Backup", + "type": "job", + "passing": true, + "paused": false, + "schedule": "0 0 * * *", + "group": "production", + "tags": ["critical", "database"], + "assertions": ["metric.duration < 5min"], + "notify": ["default"], + "created": "2024-01-15T10:00:00Z" +} diff --git a/testdata/monitors_list.json b/testdata/monitors_list.json new file mode 100644 index 0000000..d42219e --- /dev/null +++ b/testdata/monitors_list.json @@ -0,0 +1,33 @@ +{ + "monitors": [ + { + "key": "abc123", + "name": "Nightly Backup", + "type": "job", + "passing": true, + "paused": false, + "group": "production" + }, + { + "key": "def456", + "name": "Health Check", + "type": "check", + "passing": false, + "paused": false, + "group": "" + }, + { + "key": "ghi789", + "name": "Paused Monitor", + "type": "heartbeat", + "passing": true, + "paused": true, + "group": "staging" + } + ], + "page_info": { + "page": 1, + "pageSize": 50, + "totalMonitorCount": 3 + } +} diff --git a/testdata/notifications_list.json b/testdata/notifications_list.json new file mode 100644 index 0000000..62962bd --- /dev/null +++ b/testdata/notifications_list.json @@ -0,0 +1,26 @@ +{ + "templates": [ + { + "key": "default", + "name": "Default", + "notifications": { + "emails": ["admin@example.com"], + "slack": ["#alerts"], + "webhooks": [], + "phones": [] + }, + "monitors": ["abc123", "def456"] + }, + { + "key": "devops", + "name": "DevOps Team", + "notifications": { + "emails": ["dev@example.com", "ops@example.com"], + "slack": [], + "webhooks": ["https://hooks.example.com/alert"], + "phones": [] + }, + "monitors": ["ghi789"] + } + ] +} diff --git a/testdata/site_errors_list.json b/testdata/site_errors_list.json new file mode 100644 index 0000000..a834017 --- /dev/null +++ b/testdata/site_errors_list.json @@ -0,0 +1,11 @@ +{ + "site_errors": [ + { + "key": "err-001", + "message": "Uncaught TypeError: Cannot read properties of null", + "error_type": "TypeError", + "filename": "https://example.com/assets/app.js", + "count": 42 + } + ] +} diff --git a/testdata/site_query.json b/testdata/site_query.json new file mode 100644 index 0000000..e824a13 --- /dev/null +++ b/testdata/site_query.json @@ -0,0 +1,8 @@ +{ + "data": { + "session_count": 1500, + "pageview_count": 4200, + "bounce_rate": 35.2, + "lcp_p50": 1800 + } +} diff --git a/testdata/sites_list.json b/testdata/sites_list.json new file mode 100644 index 0000000..0e96f28 --- /dev/null +++ b/testdata/sites_list.json @@ -0,0 +1,12 @@ +{ + "sites": [ + { + "key": "my-site", + "name": "My Website", + "client_key": "ck_abc123", + "webvitals_enabled": true, + "errors_enabled": true, + "sampling": 100 + } + ] +} diff --git a/testdata/statuspages_list.json b/testdata/statuspages_list.json new file mode 100644 index 0000000..8536a49 --- /dev/null +++ b/testdata/statuspages_list.json @@ -0,0 +1,16 @@ +{ + "statuspages": [ + { + "key": "main-status", + "name": "Main Status Page", + "subdomain": "status", + "status": "operational" + }, + { + "key": "internal", + "name": "Internal Status", + "subdomain": "internal-status", + "status": "degraded_performance" + } + ] +} From 72120baccd62f513a8aa017c199bf685fa6c2dc9 Mon Sep 17 00:00:00 2001 From: August Flanagan Date: Sun, 1 Feb 2026 12:52:24 -0800 Subject: [PATCH 06/25] Add Go test step to CI workflow Run `go test ./...` on both Linux and Windows before the BATS integration tests. All Go tests use mock servers and need no secrets. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/tests.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1943df6..80132f8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -48,6 +48,10 @@ jobs: shell: bash run: go build -o cronitor main.go + - name: Run Go tests + shell: bash + run: go test ./... + - name: Run tests working-directory: tests shell: bash @@ -93,6 +97,9 @@ jobs: - name: Build binary run: go build -o cronitor main.go + - name: Run Go tests + run: go test ./... + - name: Run tests working-directory: tests env: From c8a2b4f3cd3eae6f3b4475509df404c1a6a2952c Mon Sep 17 00:00:00 2001 From: August Flanagan Date: Sun, 1 Feb 2026 13:22:11 -0800 Subject: [PATCH 07/25] Fix update commands and list table parsing for production API compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Inject key into PUT request body for all update commands (environment, group, issue, notification, maintenance, site, statuspage, component) - Fix issue resolve to fetch current issue before PUT (API requires all fields) - Fix JSON response key mismatches: issue, maintenance, site, statuspage, and component list commands used wrong keys (e.g. "issues" vs "data") - Fix statuspage list subdomain field ("subdomain" → "hosted_subdomain") - Update test fixtures to match actual API response structure Co-Authored-By: Claude Opus 4.5 --- cmd/environment.go | 8 ++++--- cmd/group.go | 8 ++++--- cmd/issue.go | 38 ++++++++++++++++++++++++---------- cmd/maintenance.go | 15 ++++++++++---- cmd/notification.go | 8 ++++--- cmd/site.go | 18 +++++++++------- cmd/statuspage.go | 30 +++++++++++++++------------ testdata/components_list.json | 2 +- testdata/issues_list.json | 2 +- testdata/maintenance_list.json | 2 +- testdata/site_errors_list.json | 2 +- testdata/sites_list.json | 2 +- testdata/statuspages_list.json | 2 +- 13 files changed, 86 insertions(+), 51 deletions(-) diff --git a/cmd/environment.go b/cmd/environment.go index 0efd6c4..40a8ed3 100644 --- a/cmd/environment.go +++ b/cmd/environment.go @@ -266,14 +266,16 @@ Examples: os.Exit(1) } - var js json.RawMessage - if err := json.Unmarshal([]byte(environmentData), &js); err != nil { + var bodyMap map[string]interface{} + if err := json.Unmarshal([]byte(environmentData), &bodyMap); err != nil { Error(fmt.Sprintf("Invalid JSON: %s", err)) os.Exit(1) } + bodyMap["key"] = key + body, _ := json.Marshal(bodyMap) client := lib.NewAPIClient(dev, log) - resp, err := client.PUT(fmt.Sprintf("/environments/%s", key), []byte(environmentData), nil) + resp, err := client.PUT(fmt.Sprintf("/environments/%s", key), body, nil) if err != nil { Error(fmt.Sprintf("Failed to update environment: %s", err)) os.Exit(1) diff --git a/cmd/group.go b/cmd/group.go index 75b2cfe..77bd051 100644 --- a/cmd/group.go +++ b/cmd/group.go @@ -323,14 +323,16 @@ Examples: os.Exit(1) } - var js json.RawMessage - if err := json.Unmarshal([]byte(groupData), &js); err != nil { + var bodyMap map[string]interface{} + if err := json.Unmarshal([]byte(groupData), &bodyMap); err != nil { Error(fmt.Sprintf("Invalid JSON: %s", err)) os.Exit(1) } + bodyMap["key"] = key + body, _ := json.Marshal(bodyMap) client := lib.NewAPIClient(dev, log) - resp, err := client.PUT(fmt.Sprintf("/groups/%s", key), []byte(groupData), nil) + resp, err := client.PUT(fmt.Sprintf("/groups/%s", key), body, nil) if err != nil { Error(fmt.Sprintf("Failed to update group: %s", err)) os.Exit(1) diff --git a/cmd/issue.go b/cmd/issue.go index 64d37d0..fc994af 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -144,13 +144,13 @@ Examples: } if issueFetchAll { - bodies, err := FetchAllPages(client, "/issues", params, "issues") + bodies, err := FetchAllPages(client, "/issues", params, "data") if err != nil { Error(fmt.Sprintf("Failed to list issues: %s", err)) os.Exit(1) } if issueFormat == "json" || issueFormat == "" { - issueOutputToTarget(FormatJSON(MergePagedJSON(bodies, "issues"))) + issueOutputToTarget(FormatJSON(MergePagedJSON(bodies, "data"))) return } // Table: accumulate rows from all pages @@ -165,7 +165,7 @@ Examples: State string `json:"state"` Severity string `json:"severity"` Started string `json:"started"` - } `json:"issues"` + } `json:"data"` } json.Unmarshal(body, &result) for _, issue := range result.Issues { @@ -203,7 +203,7 @@ Examples: State string `json:"state"` Severity string `json:"severity"` Started string `json:"started"` - } `json:"issues"` + } `json:"data"` } if err := json.Unmarshal(resp.Body, &result); err != nil { Error(fmt.Sprintf("Failed to parse response: %s", err)) @@ -376,14 +376,16 @@ Examples: os.Exit(1) } - var js json.RawMessage - if err := json.Unmarshal([]byte(issueData), &js); err != nil { + var bodyMap map[string]interface{} + if err := json.Unmarshal([]byte(issueData), &bodyMap); err != nil { Error(fmt.Sprintf("Invalid JSON: %s", err)) os.Exit(1) } + bodyMap["key"] = key + body, _ := json.Marshal(bodyMap) client := lib.NewAPIClient(dev, log) - resp, err := client.PUT(fmt.Sprintf("/issues/%s", key), []byte(issueData), nil) + resp, err := client.PUT(fmt.Sprintf("/issues/%s", key), body, nil) if err != nil { Error(fmt.Sprintf("Failed to update issue: %s", err)) os.Exit(1) @@ -413,17 +415,31 @@ var issueResolveCmd = &cobra.Command{ key := args[0] client := lib.NewAPIClient(dev, log) - body := []byte(`{"state":"resolved"}`) - resp, err := client.PUT(fmt.Sprintf("/issues/%s", key), body, nil) + // Fetch current issue to get required fields + getResp, err := client.GET(fmt.Sprintf("/issues/%s", key), nil) if err != nil { Error(fmt.Sprintf("Failed to resolve issue: %s", err)) os.Exit(1) } - - if resp.IsNotFound() { + if getResp.IsNotFound() { Error(fmt.Sprintf("Issue '%s' not found", key)) os.Exit(1) } + if !getResp.IsSuccess() { + Error(fmt.Sprintf("API Error (%d): %s", getResp.StatusCode, getResp.ParseError())) + os.Exit(1) + } + + var current map[string]interface{} + json.Unmarshal(getResp.Body, ¤t) + current["state"] = "resolved" + body, _ := json.Marshal(current) + + resp, err := client.PUT(fmt.Sprintf("/issues/%s", key), body, nil) + if err != nil { + Error(fmt.Sprintf("Failed to resolve issue: %s", err)) + os.Exit(1) + } if resp.IsSuccess() { Success(fmt.Sprintf("Issue '%s' resolved", key)) diff --git a/cmd/maintenance.go b/cmd/maintenance.go index 179b0d6..af5d1c2 100644 --- a/cmd/maintenance.go +++ b/cmd/maintenance.go @@ -105,13 +105,13 @@ Examples: } if maintenanceFetchAll { - bodies, err := FetchAllPages(client, "/maintenance_windows", params, "maintenance_windows") + bodies, err := FetchAllPages(client, "/maintenance_windows", params, "data") if err != nil { Error(fmt.Sprintf("Failed to list maintenance windows: %s", err)) os.Exit(1) } if maintenanceFormat == "json" || maintenanceFormat == "" { - maintenanceOutputToTarget(FormatJSON(MergePagedJSON(bodies, "maintenance_windows"))) + maintenanceOutputToTarget(FormatJSON(MergePagedJSON(bodies, "data"))) return } // Table: accumulate rows from all pages @@ -127,7 +127,7 @@ Examples: End string `json:"end"` State string `json:"state"` Duration int `json:"duration"` - } `json:"maintenance_windows"` + } `json:"data"` } json.Unmarshal(body, &result) for _, w := range result.Windows { @@ -179,7 +179,7 @@ Examples: End string `json:"end"` State string `json:"state"` Duration int `json:"duration"` - } `json:"maintenance_windows"` + } `json:"data"` } if err := json.Unmarshal(resp.Body, &result); err != nil { Error(fmt.Sprintf("Failed to parse response: %s", err)) @@ -339,6 +339,13 @@ Examples: os.Exit(1) } + // Inject key into body + var bodyMap map[string]interface{} + if err := json.Unmarshal(body, &bodyMap); err == nil { + bodyMap["key"] = key + body, _ = json.Marshal(bodyMap) + } + client := lib.NewAPIClient(dev, log) resp, err := client.PUT(fmt.Sprintf("/maintenance_windows/%s", key), body, nil) if err != nil { diff --git a/cmd/notification.go b/cmd/notification.go index e66a0c4..6408c1b 100644 --- a/cmd/notification.go +++ b/cmd/notification.go @@ -275,14 +275,16 @@ Examples: os.Exit(1) } - var js json.RawMessage - if err := json.Unmarshal([]byte(notificationData), &js); err != nil { + var bodyMap map[string]interface{} + if err := json.Unmarshal([]byte(notificationData), &bodyMap); err != nil { Error(fmt.Sprintf("Invalid JSON: %s", err)) os.Exit(1) } + bodyMap["key"] = key + body, _ := json.Marshal(bodyMap) client := lib.NewAPIClient(dev, log) - resp, err := client.PUT(fmt.Sprintf("/notifications/%s", key), []byte(notificationData), nil) + resp, err := client.PUT(fmt.Sprintf("/notifications/%s", key), body, nil) if err != nil { Error(fmt.Sprintf("Failed to update notification list: %s", err)) os.Exit(1) diff --git a/cmd/site.go b/cmd/site.go index fc9a016..f396f32 100644 --- a/cmd/site.go +++ b/cmd/site.go @@ -97,13 +97,13 @@ Examples: } if siteFetchAll { - bodies, err := FetchAllPages(client, "/sites", params, "sites") + bodies, err := FetchAllPages(client, "/sites", params, "data") if err != nil { Error(fmt.Sprintf("Failed to list sites: %s", err)) os.Exit(1) } if siteFormat == "json" || siteFormat == "" { - siteOutputToTarget(FormatJSON(MergePagedJSON(bodies, "sites"))) + siteOutputToTarget(FormatJSON(MergePagedJSON(bodies, "data"))) return } // Table: accumulate rows from all pages @@ -119,7 +119,7 @@ Examples: WebVitalsEnabled bool `json:"webvitals_enabled"` ErrorsEnabled bool `json:"errors_enabled"` Sampling int `json:"sampling"` - } `json:"sites"` + } `json:"data"` } json.Unmarshal(body, &result) for _, s := range result.Sites { @@ -163,7 +163,7 @@ Examples: WebVitalsEnabled bool `json:"webvitals_enabled"` ErrorsEnabled bool `json:"errors_enabled"` Sampling int `json:"sampling"` - } `json:"sites"` + } `json:"data"` } if err := json.Unmarshal(resp.Body, &result); err != nil { Error(fmt.Sprintf("Failed to parse response: %s", err)) @@ -305,14 +305,16 @@ Examples: os.Exit(1) } - var js json.RawMessage - if err := json.Unmarshal([]byte(siteData), &js); err != nil { + var bodyMap map[string]interface{} + if err := json.Unmarshal([]byte(siteData), &bodyMap); err != nil { Error(fmt.Sprintf("Invalid JSON: %s", err)) os.Exit(1) } + bodyMap["key"] = key + body, _ := json.Marshal(bodyMap) client := lib.NewAPIClient(dev, log) - resp, err := client.PUT(fmt.Sprintf("/sites/%s", key), []byte(siteData), nil) + resp, err := client.PUT(fmt.Sprintf("/sites/%s", key), body, nil) if err != nil { Error(fmt.Sprintf("Failed to update site: %s", err)) os.Exit(1) @@ -557,7 +559,7 @@ Examples: ErrorType string `json:"error_type"` Filename string `json:"filename"` Count int `json:"count"` - } `json:"site_errors"` + } `json:"data"` } if err := json.Unmarshal(resp.Body, &result); err != nil { Error(fmt.Sprintf("Failed to parse response: %s", err)) diff --git a/cmd/statuspage.go b/cmd/statuspage.go index 4773c69..1d4a83e 100644 --- a/cmd/statuspage.go +++ b/cmd/statuspage.go @@ -87,13 +87,13 @@ Examples: } if statuspageFetchAll { - bodies, err := FetchAllPages(client, "/statuspages", params, "statuspages") + bodies, err := FetchAllPages(client, "/statuspages", params, "data") if err != nil { Error(fmt.Sprintf("Failed to list status pages: %s", err)) os.Exit(1) } if statuspageFormat == "json" || statuspageFormat == "" { - statuspageOutputToTarget(FormatJSON(MergePagedJSON(bodies, "statuspages"))) + statuspageOutputToTarget(FormatJSON(MergePagedJSON(bodies, "data"))) return } // Table: accumulate rows from all pages @@ -105,9 +105,9 @@ Examples: StatusPages []struct { Key string `json:"key"` Name string `json:"name"` - Subdomain string `json:"subdomain"` + Subdomain string `json:"hosted_subdomain"` Status string `json:"status"` - } `json:"statuspages"` + } `json:"data"` } json.Unmarshal(body, &result) for _, sp := range result.StatusPages { @@ -137,9 +137,9 @@ Examples: StatusPages []struct { Key string `json:"key"` Name string `json:"name"` - Subdomain string `json:"subdomain"` + Subdomain string `json:"hosted_subdomain"` Status string `json:"status"` - } `json:"statuspages"` + } `json:"data"` } if err := json.Unmarshal(resp.Body, &result); err != nil { Error(fmt.Sprintf("Failed to parse response: %s", err)) @@ -266,14 +266,16 @@ Examples: os.Exit(1) } - var js json.RawMessage - if err := json.Unmarshal([]byte(statuspageData), &js); err != nil { + var bodyMap map[string]interface{} + if err := json.Unmarshal([]byte(statuspageData), &bodyMap); err != nil { Error(fmt.Sprintf("Invalid JSON: %s", err)) os.Exit(1) } + bodyMap["key"] = key + body, _ := json.Marshal(bodyMap) client := lib.NewAPIClient(dev, log) - resp, err := client.PUT(fmt.Sprintf("/statuspages/%s", key), []byte(statuspageData), nil) + resp, err := client.PUT(fmt.Sprintf("/statuspages/%s", key), body, nil) if err != nil { Error(fmt.Sprintf("Failed to update status page: %s", err)) os.Exit(1) @@ -404,7 +406,7 @@ Examples: Type string `json:"type"` Statuspage string `json:"statuspage"` Autopub bool `json:"autopublish"` - } `json:"statuspage_components"` + } `json:"data"` } if err := json.Unmarshal(resp.Body, &result); err != nil { Error(fmt.Sprintf("Failed to parse response: %s", err)) @@ -491,14 +493,16 @@ Examples: os.Exit(1) } - var js json.RawMessage - if err := json.Unmarshal([]byte(componentData), &js); err != nil { + var bodyMap map[string]interface{} + if err := json.Unmarshal([]byte(componentData), &bodyMap); err != nil { Error(fmt.Sprintf("Invalid JSON: %s", err)) os.Exit(1) } + bodyMap["key"] = key + body, _ := json.Marshal(bodyMap) client := lib.NewAPIClient(dev, log) - resp, err := client.PUT(fmt.Sprintf("/statuspage_components/%s", key), []byte(componentData), nil) + resp, err := client.PUT(fmt.Sprintf("/statuspage_components/%s", key), body, nil) if err != nil { Error(fmt.Sprintf("Failed to update component: %s", err)) os.Exit(1) diff --git a/testdata/components_list.json b/testdata/components_list.json index 8c099b3..78d4c5d 100644 --- a/testdata/components_list.json +++ b/testdata/components_list.json @@ -1,5 +1,5 @@ { - "statuspage_components": [ + "data": [ { "key": "comp-001", "name": "API", diff --git a/testdata/issues_list.json b/testdata/issues_list.json index 26e73b2..b744128 100644 --- a/testdata/issues_list.json +++ b/testdata/issues_list.json @@ -1,5 +1,5 @@ { - "issues": [ + "data": [ { "key": "issue-001", "name": "Database connection timeout", diff --git a/testdata/maintenance_list.json b/testdata/maintenance_list.json index 5f92d3a..a4171e0 100644 --- a/testdata/maintenance_list.json +++ b/testdata/maintenance_list.json @@ -1,5 +1,5 @@ { - "maintenance_windows": [ + "data": [ { "key": "maint-001", "name": "Deploy v2.0", diff --git a/testdata/site_errors_list.json b/testdata/site_errors_list.json index a834017..abf3ff7 100644 --- a/testdata/site_errors_list.json +++ b/testdata/site_errors_list.json @@ -1,5 +1,5 @@ { - "site_errors": [ + "data": [ { "key": "err-001", "message": "Uncaught TypeError: Cannot read properties of null", diff --git a/testdata/sites_list.json b/testdata/sites_list.json index 0e96f28..e0ea1b9 100644 --- a/testdata/sites_list.json +++ b/testdata/sites_list.json @@ -1,5 +1,5 @@ { - "sites": [ + "data": [ { "key": "my-site", "name": "My Website", diff --git a/testdata/statuspages_list.json b/testdata/statuspages_list.json index 8536a49..26a4040 100644 --- a/testdata/statuspages_list.json +++ b/testdata/statuspages_list.json @@ -1,5 +1,5 @@ { - "statuspages": [ + "data": [ { "key": "main-status", "name": "Main Status Page", From 25184d761680a8e5aa95fe914f860fcb73a4cdd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Feb 2026 23:34:54 +0000 Subject: [PATCH 08/25] README - Document API resource commands https://claude.ai/code/session_01UDueW9A6SuxugCcsbAMfdB --- README.md | 118 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 85 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index d8b0a04..36c77e6 100644 --- a/README.md +++ b/README.md @@ -18,41 +18,93 @@ For the latest installation details, see https://cronitor.io/docs/using-cronitor ## Usage ``` -CronitorCLI version 31.4 - -Command line tools for Cronitor.io. See https://cronitor.io/docs/using-cronitor-cli for details. - -Usage: - cronitor [command] - -Available Commands: - completion generate the autocompletion script for the specified shell - configure Save configuration variables to the config file - dash Start the web dashboard - exec Execute a command with monitoring - help Help about any command - list Search for and list all cron jobs - ping Send a telemetry ping to Cronitor - shell Run commands from a cron-like shell - signup Sign up for a Cronitor account - status View monitor status - sync Add monitoring to new cron jobs and sync changes to existing jobs - update Update to the latest version - -Flags: - -k, --api-key string Cronitor API Key - -c, --config string Config file - --env string Cronitor Environment - -h, --help help for cronitor - -n, --hostname string A unique identifier for this host (default: system hostname) - -l, --log string Write debug logs to supplied file - -p, --ping-api-key string Ping API Key - -u, --users string Comma-separated list of users whose crontabs to include (default: current user only) - -v, --verbose Verbose output - -Use "cronitor [command] --help" for more information about a command. +cronitor [command] ``` +### Cron Management +| Command | Description | +|---------|-------------| +| `cronitor sync` | Sync cron jobs to Cronitor | +| `cronitor exec ` | Run a command with monitoring | +| `cronitor list` | List all cron jobs | +| `cronitor status` | View monitor status | +| `cronitor dash` | Start the web dashboard | + +### API Resources + +Manage Cronitor resources directly from the command line. Each resource supports `list`, `get`, `create`, `update`, and `delete` subcommands. + +#### Monitors + +```bash +cronitor monitor list # List all monitors +cronitor monitor list --page 2 --env production # Paginate, filter by env +cronitor monitor get # Get a monitor +cronitor monitor get --with-events # Include latest events +cronitor monitor create --data '{"key":"my-job","type":"job"}' +cronitor monitor update --data '{"name":"New Name"}' +cronitor monitor delete +cronitor monitor pause # Pause indefinitely +cronitor monitor pause --hours 24 # Pause for 24 hours +cronitor monitor unpause +``` + +#### Status Pages + +```bash +cronitor statuspage list +cronitor statuspage get +cronitor statuspage create --data '{"name":"My Status Page"}' +cronitor statuspage update --data '{"name":"Updated"}' +cronitor statuspage delete +``` + +#### Issues + +```bash +cronitor issue list # List all issues +cronitor issue list --state open --severity high # Filter by state/severity +cronitor issue list --monitor my-job # Filter by monitor +cronitor issue get +cronitor issue create --data '{"monitor":"my-job","summary":"Issue title"}' +cronitor issue update --data '{"state":"resolved"}' +cronitor issue resolve # Shorthand for resolving +cronitor issue delete +``` + +#### Notifications + +```bash +cronitor notification list +cronitor notification get +cronitor notification create --data '{"name":"DevOps","emails":["team@co.com"]}' +cronitor notification update --data '{"name":"Updated"}' +cronitor notification delete +``` + +#### Environments + +```bash +cronitor environment list +cronitor environment get +cronitor environment create --data '{"name":"Production","key":"production"}' +cronitor environment update --data '{"name":"Updated"}' +cronitor environment delete +``` + +**Aliases:** `cronitor env` → `environment`, `cronitor notifications` → `notification` + +### Common Flags + +| Flag | Description | +|------|-------------| +| `--format json\|table` | Output format (default: `table` for list, `json` for get) | +| `-o, --output ` | Write output to a file | +| `--page ` | Page number for paginated results | +| `-d, --data ` | JSON data for create/update | +| `-f, --file ` | Read JSON data from a file | +| `-k, --api-key ` | Cronitor API key | + ## Crontab Guru Dashboard The Cronitor CLI bundles the [Crontab Guru Dashboard](https://crontab.guru/dashboard.html), a self‑hosted web UI to manage your cron jobs, including a one‑click “run now” and "suspend", a local console for testing jobs, and a built in MCP server for configuring jobs and checking the health/status of existing ones. From 15ad30fdbd090666f7a00f0d711d9857e0f82b31 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Feb 2026 23:36:45 +0000 Subject: [PATCH 09/25] README - Update API docs to reflect enhanced commands Document search, clone, bulk delete, YAML support, components, bulk issue actions, and additional filter flags. https://claude.ai/code/session_01UDueW9A6SuxugCcsbAMfdB --- README.md | 66 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 36c77e6..c86aca8 100644 --- a/README.md +++ b/README.md @@ -32,20 +32,26 @@ cronitor [command] ### API Resources -Manage Cronitor resources directly from the command line. Each resource supports `list`, `get`, `create`, `update`, and `delete` subcommands. +Manage Cronitor resources directly from the command line. #### Monitors ```bash -cronitor monitor list # List all monitors -cronitor monitor list --page 2 --env production # Paginate, filter by env -cronitor monitor get # Get a monitor -cronitor monitor get --with-events # Include latest events -cronitor monitor create --data '{"key":"my-job","type":"job"}' -cronitor monitor update --data '{"name":"New Name"}' -cronitor monitor delete -cronitor monitor pause # Pause indefinitely -cronitor monitor pause --hours 24 # Pause for 24 hours +cronitor monitor list # List all monitors +cronitor monitor list --type job --state failing # Filter by type and state +cronitor monitor list --tag critical --env production # Filter by tag and environment +cronitor monitor list --all # Fetch all pages +cronitor monitor search "backup" # Search monitors +cronitor monitor get # Get monitor details +cronitor monitor get --with-events # Include latest events +cronitor monitor create -d '{"key":"my-job","type":"job"}' +cronitor monitor create --file monitors.yaml # Create from YAML +cronitor monitor update -d '{"name":"New Name"}' +cronitor monitor delete # Delete one +cronitor monitor delete key1 key2 key3 # Delete many +cronitor monitor clone --name "Copy" # Clone a monitor +cronitor monitor pause # Pause indefinitely +cronitor monitor pause --hours 24 # Pause for 24 hours cronitor monitor unpause ``` @@ -53,23 +59,32 @@ cronitor monitor unpause ```bash cronitor statuspage list -cronitor statuspage get -cronitor statuspage create --data '{"name":"My Status Page"}' -cronitor statuspage update --data '{"name":"Updated"}' +cronitor statuspage list --with-status # Include current status +cronitor statuspage get --with-components # Include components +cronitor statuspage create -d '{"name":"My Status Page","subdomain":"my-status"}' +cronitor statuspage update -d '{"name":"Updated"}' cronitor statuspage delete + +# Components (nested under statuspage) +cronitor statuspage component list --statuspage my-page +cronitor statuspage component create -d '{"statuspage":"my-page","monitor":"api-health"}' +cronitor statuspage component update -d '{"name":"New Name"}' +cronitor statuspage component delete ``` #### Issues ```bash -cronitor issue list # List all issues -cronitor issue list --state open --severity high # Filter by state/severity -cronitor issue list --monitor my-job # Filter by monitor +cronitor issue list # List all issues +cronitor issue list --state unresolved --severity outage # Filter +cronitor issue list --monitor my-job --time 24h # By monitor, time range +cronitor issue list --search "database" # Search issues cronitor issue get -cronitor issue create --data '{"monitor":"my-job","summary":"Issue title"}' -cronitor issue update --data '{"state":"resolved"}' -cronitor issue resolve # Shorthand for resolving +cronitor issue create -d '{"name":"DB issues","severity":"outage"}' +cronitor issue update -d '{"state":"investigating"}' +cronitor issue resolve # Shorthand for resolving cronitor issue delete +cronitor issue bulk --action delete --issues KEY1,KEY2 # Bulk actions ``` #### Notifications @@ -77,8 +92,8 @@ cronitor issue delete ```bash cronitor notification list cronitor notification get -cronitor notification create --data '{"name":"DevOps","emails":["team@co.com"]}' -cronitor notification update --data '{"name":"Updated"}' +cronitor notification create -d '{"name":"DevOps","notifications":{"emails":["team@co.com"]}}' +cronitor notification update -d '{"name":"Updated"}' cronitor notification delete ``` @@ -87,8 +102,8 @@ cronitor notification delete ```bash cronitor environment list cronitor environment get -cronitor environment create --data '{"name":"Production","key":"production"}' -cronitor environment update --data '{"name":"Updated"}' +cronitor environment create -d '{"key":"staging","name":"Staging"}' +cronitor environment update -d '{"name":"Updated"}' cronitor environment delete ``` @@ -98,11 +113,12 @@ cronitor environment delete | Flag | Description | |------|-------------| -| `--format json\|table` | Output format (default: `table` for list, `json` for get) | +| `--format json\|table\|yaml` | Output format (default: `table` for list, `json` for get) | | `-o, --output ` | Write output to a file | | `--page ` | Page number for paginated results | +| `--all` | Fetch all pages of results | | `-d, --data ` | JSON data for create/update | -| `-f, --file ` | Read JSON data from a file | +| `-f, --file ` | Read JSON or YAML from a file | | `-k, --api-key ` | Cronitor API key | ## Crontab Guru Dashboard From 9b9b6f6c567e2e0b84fe2fe7605d0d7bebd5b0ea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Feb 2026 00:09:56 +0000 Subject: [PATCH 10/25] Remove --all flag from issues, notifications, environments, and statuspages The --all flag could result in fetching hundreds of pages for high-volume resources like issues. Keep --all only on monitors where accounts typically have ~500 monitors max (~18 pages). Add integration tests for monitor list --all that run when CRONITOR_API_KEY is available on CI. https://claude.ai/code/session_01UDueW9A6SuxugCcsbAMfdB --- README.md | 2 +- cmd/environment.go | 46 --------------------------------------------- cmd/issue.go | 45 -------------------------------------------- cmd/notification.go | 44 ------------------------------------------- cmd/statuspage.go | 38 ------------------------------------- tests/test-api.bats | 41 +++++++++++++++++++++++++--------------- 6 files changed, 27 insertions(+), 189 deletions(-) diff --git a/README.md b/README.md index c86aca8..09d4dbf 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ cronitor environment delete | `--format json\|table\|yaml` | Output format (default: `table` for list, `json` for get) | | `-o, --output ` | Write output to a file | | `--page ` | Page number for paginated results | -| `--all` | Fetch all pages of results | +| `--all` | Fetch all pages of results (monitors only) | | `-d, --data ` | JSON data for create/update | | `-f, --file ` | Read JSON or YAML from a file | | `-k, --api-key ` | Cronitor API key | diff --git a/cmd/environment.go b/cmd/environment.go index 40a8ed3..a4059a3 100644 --- a/cmd/environment.go +++ b/cmd/environment.go @@ -45,7 +45,6 @@ var ( environmentFormat string environmentOutput string environmentData string - environmentFetchAll bool ) func init() { @@ -71,48 +70,6 @@ Examples: params["page"] = fmt.Sprintf("%d", environmentPage) } - if environmentFetchAll { - bodies, err := FetchAllPages(client, "/environments", params, "environments") - if err != nil { - Error(fmt.Sprintf("Failed to list environments: %s", err)) - os.Exit(1) - } - if environmentFormat == "json" || environmentFormat == "" { - environmentOutputToTarget(FormatJSON(MergePagedJSON(bodies, "environments"))) - return - } - // Table: accumulate rows from all pages - table := &UITable{ - Headers: []string{"NAME", "KEY", "ALERTS", "MONITORS", "DEFAULT"}, - } - for _, body := range bodies { - var result struct { - Environments []struct { - Key string `json:"key"` - Name string `json:"name"` - WithAlerts bool `json:"with_alerts"` - Default bool `json:"default"` - ActiveMonitors int `json:"active_monitors"` - } `json:"environments"` - } - json.Unmarshal(body, &result) - for _, e := range result.Environments { - alerts := mutedStyle.Render("off") - if e.WithAlerts { - alerts = successStyle.Render("on") - } - isDefault := "" - if e.Default { - isDefault = "yes" - } - monitors := fmt.Sprintf("%d", e.ActiveMonitors) - table.Rows = append(table.Rows, []string{e.Name, e.Key, alerts, monitors, isDefault}) - } - } - environmentOutputToTarget(table.Render()) - return - } - resp, err := client.GET("/environments", params) if err != nil { Error(fmt.Sprintf("Failed to list environments: %s", err)) @@ -329,9 +286,6 @@ func init() { environmentCmd.AddCommand(environmentUpdateCmd) environmentCmd.AddCommand(environmentDeleteCmd) - // List flags - environmentListCmd.Flags().BoolVar(&environmentFetchAll, "all", false, "Fetch all pages of results") - // Create flags environmentCreateCmd.Flags().StringVarP(&environmentData, "data", "d", "", "JSON payload") diff --git a/cmd/issue.go b/cmd/issue.go index fc994af..b8cd220 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -67,8 +67,6 @@ var ( issueBulkIssues string issueBulkState string issueBulkAssignTo string - // Pagination flags - issueFetchAll bool ) func init() { @@ -143,48 +141,6 @@ Examples: params["withComponentDetails"] = "true" } - if issueFetchAll { - bodies, err := FetchAllPages(client, "/issues", params, "data") - if err != nil { - Error(fmt.Sprintf("Failed to list issues: %s", err)) - os.Exit(1) - } - if issueFormat == "json" || issueFormat == "" { - issueOutputToTarget(FormatJSON(MergePagedJSON(bodies, "data"))) - return - } - // Table: accumulate rows from all pages - table := &UITable{ - Headers: []string{"NAME", "KEY", "STATE", "SEVERITY", "STARTED"}, - } - for _, body := range bodies { - var result struct { - Issues []struct { - Key string `json:"key"` - Name string `json:"name"` - State string `json:"state"` - Severity string `json:"severity"` - Started string `json:"started"` - } `json:"data"` - } - json.Unmarshal(body, &result) - for _, issue := range result.Issues { - state := issue.State - switch state { - case "resolved": - state = successStyle.Render(state) - case "unresolved": - state = errorStyle.Render(state) - default: - state = warningStyle.Render(state) - } - table.Rows = append(table.Rows, []string{issue.Name, issue.Key, state, issue.Severity, issue.Started}) - } - } - issueOutputToTarget(table.Render()) - return - } - resp, err := client.GET("/issues", params) if err != nil { Error(fmt.Sprintf("Failed to list issues: %s", err)) @@ -581,7 +537,6 @@ func init() { issueListCmd.Flags().BoolVar(&issueWithMonitorDetails, "with-monitor-details", false, "Include monitor details") issueListCmd.Flags().BoolVar(&issueWithAlertDetails, "with-alert-details", false, "Include alert details") issueListCmd.Flags().BoolVar(&issueWithComponentDetails, "with-component-details", false, "Include component details") - issueListCmd.Flags().BoolVar(&issueFetchAll, "all", false, "Fetch all pages of results") // Get expansion flags issueGetCmd.Flags().BoolVar(&issueWithStatuspageDetails, "with-statuspage-details", false, "Include status page details") diff --git a/cmd/notification.go b/cmd/notification.go index 6408c1b..9f43669 100644 --- a/cmd/notification.go +++ b/cmd/notification.go @@ -47,7 +47,6 @@ var ( notificationFormat string notificationOutput string notificationData string - notificationFetchAll bool ) func init() { @@ -79,46 +78,6 @@ Examples: params["pageSize"] = fmt.Sprintf("%d", notificationPageSize) } - if notificationFetchAll { - bodies, err := FetchAllPages(client, "/notifications", params, "templates") - if err != nil { - Error(fmt.Sprintf("Failed to list notification lists: %s", err)) - os.Exit(1) - } - if notificationFormat == "json" || notificationFormat == "" { - notificationOutputToTarget(FormatJSON(MergePagedJSON(bodies, "templates"))) - return - } - // Table: accumulate rows from all pages - table := &UITable{ - Headers: []string{"NAME", "KEY", "EMAILS", "SLACK", "MONITORS"}, - } - for _, body := range bodies { - var result struct { - Templates []struct { - Key string `json:"key"` - Name string `json:"name"` - Notifications struct { - Emails []string `json:"emails"` - Slack []string `json:"slack"` - Webhooks []string `json:"webhooks"` - Phones []string `json:"phones"` - } `json:"notifications"` - Monitors []string `json:"monitors"` - } `json:"templates"` - } - json.Unmarshal(body, &result) - for _, n := range result.Templates { - emailCount := fmt.Sprintf("%d", len(n.Notifications.Emails)) - slackCount := fmt.Sprintf("%d", len(n.Notifications.Slack)) - monitorCount := fmt.Sprintf("%d", len(n.Monitors)) - table.Rows = append(table.Rows, []string{n.Name, n.Key, emailCount, slackCount, monitorCount}) - } - } - notificationOutputToTarget(table.Render()) - return - } - resp, err := client.GET("/notifications", params) if err != nil { Error(fmt.Sprintf("Failed to list notification lists: %s", err)) @@ -349,9 +308,6 @@ func init() { notificationCmd.AddCommand(notificationUpdateCmd) notificationCmd.AddCommand(notificationDeleteCmd) - // List flags - notificationListCmd.Flags().BoolVar(¬ificationFetchAll, "all", false, "Fetch all pages of results") - // Create command flags notificationCreateCmd.Flags().StringVarP(¬ificationData, "data", "d", "", "JSON payload") diff --git a/cmd/statuspage.go b/cmd/statuspage.go index 1d4a83e..3d2aa4f 100644 --- a/cmd/statuspage.go +++ b/cmd/statuspage.go @@ -50,7 +50,6 @@ var ( statuspageData string statuspageWithStatus bool statuspageWithComponents bool - statuspageFetchAll bool // Component flags componentStatuspage string componentData string @@ -86,42 +85,6 @@ Examples: params["withComponents"] = "true" } - if statuspageFetchAll { - bodies, err := FetchAllPages(client, "/statuspages", params, "data") - if err != nil { - Error(fmt.Sprintf("Failed to list status pages: %s", err)) - os.Exit(1) - } - if statuspageFormat == "json" || statuspageFormat == "" { - statuspageOutputToTarget(FormatJSON(MergePagedJSON(bodies, "data"))) - return - } - // Table: accumulate rows from all pages - table := &UITable{ - Headers: []string{"NAME", "KEY", "SUBDOMAIN", "STATUS"}, - } - for _, body := range bodies { - var result struct { - StatusPages []struct { - Key string `json:"key"` - Name string `json:"name"` - Subdomain string `json:"hosted_subdomain"` - Status string `json:"status"` - } `json:"data"` - } - json.Unmarshal(body, &result) - for _, sp := range result.StatusPages { - status := successStyle.Render(sp.Status) - if sp.Status != "operational" { - status = warningStyle.Render(sp.Status) - } - table.Rows = append(table.Rows, []string{sp.Name, sp.Key, sp.Subdomain, status}) - } - } - statuspageOutputToTarget(table.Render()) - return - } - resp, err := client.GET("/statuspages", params) if err != nil { Error(fmt.Sprintf("Failed to list status pages: %s", err)) @@ -336,7 +299,6 @@ func init() { // List flags statuspageListCmd.Flags().BoolVar(&statuspageWithStatus, "with-status", false, "Include current status") statuspageListCmd.Flags().BoolVar(&statuspageWithComponents, "with-components", false, "Include component details") - statuspageListCmd.Flags().BoolVar(&statuspageFetchAll, "all", false, "Fetch all pages of results") // Get flags statuspageGetCmd.Flags().BoolVar(&statuspageWithStatus, "with-status", false, "Include current status") diff --git a/tests/test-api.bats b/tests/test-api.bats index 07b4aee..9e50ee5 100644 --- a/tests/test-api.bats +++ b/tests/test-api.bats @@ -250,27 +250,38 @@ setup() { # INTEGRATION TESTS (SKIPPED BY DEFAULT) ################# -@test "monitor list integration test" { - skip "Integration test requires valid API key" - # ../cronitor monitor list -k $CRONITOR_API_KEY +@test "monitor list returns results" { + if [ -z "$CRONITOR_API_KEY" ]; then skip "Requires CRONITOR_API_KEY"; fi + run ../cronitor monitor list --format json + [ "$status" -eq 0 ] } -@test "monitor get integration test" { - skip "Integration test requires valid API key" - # ../cronitor monitor get test-monitor -k $CRONITOR_API_KEY +@test "monitor list --all fetches all pages" { + if [ -z "$CRONITOR_API_KEY" ]; then skip "Requires CRONITOR_API_KEY"; fi + run ../cronitor monitor list --all --format json + [ "$status" -eq 0 ] + # Verify we got a JSON array with monitors + echo "$output" | head -1 | grep -q '^\[' } -@test "monitor create integration test" { - skip "Integration test requires valid API key" - # ../cronitor monitor create --data '{"key":"test-cli-monitor","type":"job"}' -k $CRONITOR_API_KEY +@test "monitor list --all returns more results than single page" { + if [ -z "$CRONITOR_API_KEY" ]; then skip "Requires CRONITOR_API_KEY"; fi + single_page=$(../cronitor monitor list --format json) + all_pages=$(../cronitor monitor list --all --format json) + # Count items: all pages should have >= single page + single_count=$(echo "$single_page" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d) if isinstance(d,list) else len(d.get('monitors',d.get('data',[]))))" 2>/dev/null || echo "0") + all_count=$(echo "$all_pages" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0") + [ "$all_count" -ge "$single_count" ] } -@test "issue list integration test" { - skip "Integration test requires valid API key" - # ../cronitor issue list -k $CRONITOR_API_KEY +@test "issue list returns results" { + if [ -z "$CRONITOR_API_KEY" ]; then skip "Requires CRONITOR_API_KEY"; fi + run ../cronitor issue list --format json + [ "$status" -eq 0 ] } -@test "statuspage list integration test" { - skip "Integration test requires valid API key" - # ../cronitor statuspage list -k $CRONITOR_API_KEY +@test "statuspage list returns results" { + if [ -z "$CRONITOR_API_KEY" ]; then skip "Requires CRONITOR_API_KEY"; fi + run ../cronitor statuspage list --format json + [ "$status" -eq 0 ] } From 803b30d70884142c01ac7e57bb8d2dc763e797e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Feb 2026 01:11:56 +0000 Subject: [PATCH 11/25] Remove --all flag from monitors and clean up integration tests The --all flag is not the right approach for any resource. Users can paginate explicitly with --page. Replace --all example with --format yaml to highlight YAML config export capability. https://claude.ai/code/session_01UDueW9A6SuxugCcsbAMfdB --- README.md | 3 +-- cmd/monitor.go | 52 --------------------------------------------- tests/test-api.bats | 24 +++------------------ 3 files changed, 4 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 09d4dbf..51e3ffa 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Manage Cronitor resources directly from the command line. cronitor monitor list # List all monitors cronitor monitor list --type job --state failing # Filter by type and state cronitor monitor list --tag critical --env production # Filter by tag and environment -cronitor monitor list --all # Fetch all pages +cronitor monitor list --format yaml # Export as YAML config cronitor monitor search "backup" # Search monitors cronitor monitor get # Get monitor details cronitor monitor get --with-events # Include latest events @@ -116,7 +116,6 @@ cronitor environment delete | `--format json\|table\|yaml` | Output format (default: `table` for list, `json` for get) | | `-o, --output ` | Write output to a file | | `--page ` | Page number for paginated results | -| `--all` | Fetch all pages of results (monitors only) | | `-d, --data ` | JSON data for create/update | | `-f, --file ` | Read JSON or YAML from a file | | `-k, --api-key ` | Cronitor API key | diff --git a/cmd/monitor.go b/cmd/monitor.go index b411c43..85a6932 100644 --- a/cmd/monitor.go +++ b/cmd/monitor.go @@ -53,7 +53,6 @@ var ( monitorOutput string monitorData string monitorFile string - monitorFetchAll bool // List filters monitorType []string monitorGroup string @@ -138,56 +137,6 @@ Examples: params["format"] = "yaml" } - if monitorFetchAll { - bodies, err := FetchAllPages(client, "/monitors", params, "monitors") - if err != nil { - Error(fmt.Sprintf("Failed to list monitors: %s", err)) - os.Exit(1) - } - if format == "json" || format == "" { - outputToTarget(FormatJSON(MergePagedJSON(bodies, "monitors"))) - return - } - if format == "yaml" { - for _, body := range bodies { - outputToTarget(string(body)) - } - return - } - // Table format: parse all pages and accumulate rows - table := &UITable{ - Headers: []string{"NAME", "KEY", "TYPE", "STATUS"}, - } - for _, body := range bodies { - var result struct { - Monitors []struct { - Key string `json:"key"` - Name string `json:"name"` - Type string `json:"type"` - Passing bool `json:"passing"` - Paused bool `json:"paused"` - } `json:"monitors"` - } - json.Unmarshal(body, &result) - for _, m := range result.Monitors { - name := m.Name - if name == "" { - name = m.Key - } - status := successStyle.Render("passing") - if m.Paused { - status = warningStyle.Render("paused") - } else if !m.Passing { - status = errorStyle.Render("failing") - } - table.Rows = append(table.Rows, []string{name, m.Key, m.Type, status}) - } - } - output := table.Render() - outputToTarget(output) - return - } - resp, err := client.GET("/monitors", params) if err != nil { Error(fmt.Sprintf("Failed to list monitors: %s", err)) @@ -733,7 +682,6 @@ func init() { monitorListCmd.Flags().StringVar(&monitorSort, "sort", "", "Sort order: created, -created, name, -name") monitorListCmd.Flags().BoolVar(&monitorWithEvents, "with-events", false, "Include latest events for each monitor") monitorListCmd.Flags().BoolVar(&monitorWithInvocations, "with-invocations", false, "Include recent invocations for each monitor") - monitorListCmd.Flags().BoolVar(&monitorFetchAll, "all", false, "Fetch all pages of results") // Get flags monitorGetCmd.Flags().BoolVar(&monitorWithEvents, "with-events", false, "Include latest events") diff --git a/tests/test-api.bats b/tests/test-api.bats index 9e50ee5..52ffb44 100644 --- a/tests/test-api.bats +++ b/tests/test-api.bats @@ -250,37 +250,19 @@ setup() { # INTEGRATION TESTS (SKIPPED BY DEFAULT) ################# -@test "monitor list returns results" { +@test "monitor list integration test" { if [ -z "$CRONITOR_API_KEY" ]; then skip "Requires CRONITOR_API_KEY"; fi run ../cronitor monitor list --format json [ "$status" -eq 0 ] } -@test "monitor list --all fetches all pages" { - if [ -z "$CRONITOR_API_KEY" ]; then skip "Requires CRONITOR_API_KEY"; fi - run ../cronitor monitor list --all --format json - [ "$status" -eq 0 ] - # Verify we got a JSON array with monitors - echo "$output" | head -1 | grep -q '^\[' -} - -@test "monitor list --all returns more results than single page" { - if [ -z "$CRONITOR_API_KEY" ]; then skip "Requires CRONITOR_API_KEY"; fi - single_page=$(../cronitor monitor list --format json) - all_pages=$(../cronitor monitor list --all --format json) - # Count items: all pages should have >= single page - single_count=$(echo "$single_page" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d) if isinstance(d,list) else len(d.get('monitors',d.get('data',[]))))" 2>/dev/null || echo "0") - all_count=$(echo "$all_pages" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0") - [ "$all_count" -ge "$single_count" ] -} - -@test "issue list returns results" { +@test "issue list integration test" { if [ -z "$CRONITOR_API_KEY" ]; then skip "Requires CRONITOR_API_KEY"; fi run ../cronitor issue list --format json [ "$status" -eq 0 ] } -@test "statuspage list returns results" { +@test "statuspage list integration test" { if [ -z "$CRONITOR_API_KEY" ]; then skip "Requires CRONITOR_API_KEY"; fi run ../cronitor statuspage list --format json [ "$status" -eq 0 ] From 92d4f950d29f0ca411e1a9e37cdea9898a3bcb46 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 19:17:07 +0000 Subject: [PATCH 12/25] Fix three failing Windows test issues - convertWeekOfMonth: Replace map iteration with ordered slices to ensure deterministic output order (Go maps iterate randomly) - TimeTrigger: Add description "Runs once at