diff --git a/README.md b/README.md index c7ecc64..73783a1 100644 --- a/README.md +++ b/README.md @@ -48,339 +48,63 @@ Global flags that apply to all commands: ## Usage -### Deploy Command +Use `hb --help` to see detailed usage information for any command. -Report a deployment to Honeybadger. **Requires**: `--api-key` or `HONEYBADGER_API_KEY` (project API key) +### Reporting API Commands -```bash -hb deploy --environment production --repository github.com/org/repo --revision abc123 --user johndoe -``` +These commands use `--api-key` or `HONEYBADGER_API_KEY` (project API key): -**Required:** +| Command | Description | +|---------|-------------| +| `hb deploy` | Report a deployment to Honeybadger | +| `hb agent` | Start a metrics reporting agent that sends system metrics to Insights | +| `hb run` | Run a command and report its status to a check-in | +| `hb check-in` | Report a check-in without running a command | - * `-e, --environment` - Environment being deployed to (e.g., `production`) +### Data API Commands -**Optional:** +These commands use `--auth-token` or `HONEYBADGER_AUTH_TOKEN` (personal auth token): - * `-r, --repository` - Repository being deployed (e.g., `github.com/org/repo`) - * `-v, --revision` - Revision or commit SHA being deployed - * `-u, --user` - Local username of the person deploying +| Command | Description | +|---------|-------------| +| `hb accounts` | Manage Honeybadger accounts and team members | +| `hb check-ins` | Manage check-ins for cron job and scheduled task monitoring | +| `hb comments` | Manage comments on faults | +| `hb deployments` | View and manage deployment history | +| `hb environments` | Manage project environments | +| `hb faults` | View and manage faults (errors) in your projects | +| `hb insights` | Execute BadgerQL queries against your Insights data | +| `hb projects` | Manage Honeybadger projects | +| `hb statuspages` | Manage public status pages | +| `hb teams` | Manage teams and team memberships | +| `hb uptime` | Manage uptime monitoring checks | -### Run Command - -Run a command and report its status to Honeybadger's check-in API. This wraps your command, captures its output and execution time, and reports the results. **Requires**: `--api-key` or `HONEYBADGER_API_KEY` when using `--slug` +### Examples ```bash -# Using check-in ID (no API key required) -hb run --id XyZZy -- /usr/local/bin/backup.sh - -# Using slug (requires API key) -hb run --slug daily-backup -- pg_dump -U postgres mydb > backup.sql -``` - -Note: Shell operators such as ">" are interpreted by your shell before hb runs, -so redirection works as usual. If you need more complex shell features, wrap -them in a shell script and invoke that script with "hb run". - -**Required (one of):** +# Report a deployment +hb deploy --environment production --revision abc123 - * `-i, --id` - Check-in ID to report - * `-s, --slug` - Check-in slug to report (requires API key) +# Start the metrics agent +hb agent --interval 60 -The command will: - * Execute your command and stream its output in real-time - * Capture stdout, stderr, duration, and exit code - * Report success or error status to Honeybadger - * Exit with the same exit code as your command - -See https://docs.honeybadger.io/api/reporting-check-ins/ for more information. - -### Check-in Command - -Report a simple check-in to Honeybadger without running a command. Use this when you want to signal that a task completed successfully from your own scripts. **Requires**: `--api-key` or `HONEYBADGER_API_KEY` when using `--slug` - -```bash -# Using check-in ID (no API key required) -hb check-in --id XyZZy +# Run a command and report to a check-in +hb run --id XyZZy -- /usr/local/bin/backup.sh -# Using slug (requires API key) +# Report a check-in without running a command hb check-in --slug daily-backup -``` - -**Required (one of):** - * `-i, --id` - Check-in ID to report - * `-s, --slug` - Check-in slug to report (requires API key) - -See https://docs.honeybadger.io/api/reporting-check-ins/ for more information. - -### Agent Command - -Start a metrics reporting agent that collects and sends system metrics to Honeybadger Insights. **Requires**: `--api-key` or `HONEYBADGER_API_KEY` (project API key) - -```bash -hb agent -``` - -The agent collects and reports the following metrics: - * CPU usage and load averages - * Memory usage (total, used, free, available) - * Disk usage for all mounted filesystems - -**Optional:** - - * `--interval` - Reporting interval in seconds (default: `60`) - -### Projects Command - -Manage Honeybadger projects. **Requires**: `--auth-token` or `HONEYBADGER_AUTH_TOKEN` (personal auth token) - -```bash # List all projects hb projects list -# List projects by account ID -hb projects list --account-id 12345 - -# Get project details -hb projects get --id 12345 - -# Create a new project (using inline JSON) -hb projects create --account-id 12345 --cli-input-json '{"project": {"name": "My Project"}}' - -# Create a new project (using a JSON file) -hb projects create --account-id 12345 --cli-input-json file://project.json - -# Update a project (using inline JSON) -hb projects update --id 12345 --cli-input-json '{"project": {"name": "Updated Name", "resolve_errors_on_deploy": true}}' - -# Update a project (using a JSON file) -hb projects update --id 12345 --cli-input-json file://updates.json - -# Delete a project -hb projects delete --id 12345 - -# Get occurrence counts for all projects -hb projects occurrences --period day --environment production - -# Get occurrence counts for a specific project -hb projects occurrences --id 12345 --period hour - -# Get integrations for a project -hb projects integrations --id 12345 - -# Get report data for a project -hb projects reports --id 12345 --type notices_per_day --start 2024-01-01T00:00:00Z --stop 2024-01-31T23:59:59Z -``` - -#### list - -**Optional:** - - * `--account-id` - Filter projects by account ID - * `-o, --output` - Output format: `table` or `json` (default: `table`) - -#### get - -**Required:** - - * `--id` - Project ID - -**Optional:** - - * `-o, --output` - Output format: `text` or `json` (default: `text`) - -#### create - -**Required:** - - * `--account-id` - Account ID to create the project in - * `--cli-input-json` - JSON payload (inline string or `file://path`) - -**Optional:** - - * `-o, --output` - Output format: `text` or `json` (default: `text`) - -JSON payload format: -```json -{ - "project": { - "name": "My Project", - "resolve_errors_on_deploy": true, - "disable_public_links": false, - "language": "ruby", - "user_url": "https://myapp.com/users/[user_id]", - "source_url": "https://github.com/myorg/myrepo/blob/main/[filename]#L[line]", - "purge_days": 90, - "user_search_field": "user_id" - } -} -``` - -#### update - -**Required:** - - * `--id` - Project ID - * `--cli-input-json` - JSON payload (inline string or `file://path`) - -JSON payload format (all fields optional): -```json -{ - "project": { - "name": "Updated Name", - "resolve_errors_on_deploy": false, - "purge_days": 120 - } -} -``` - -#### delete - -**Required:** - - * `--id` - Project ID - -#### occurrences - -**Optional:** - - * `--id` - Project ID (if omitted, shows data for all projects) - * `--period` - Time period: `hour`, `day`, `week`, or `month` (default: `day`) - * `--environment` - Filter by environment name - * `-o, --output` - Output format: `table` or `json` (default: `table`) - -#### integrations - -**Required:** - - * `--id` - Project ID - -**Optional:** - - * `-o, --output` - Output format: `table` or `json` (default: `table`) - -#### reports - -**Required:** - - * `--id` - Project ID - * `--type` - Report type: `notices_by_class`, `notices_by_location`, `notices_by_user`, or `notices_per_day` - -**Optional:** - - * `--start` - Start time in RFC3339 format (e.g., `2024-01-01T00:00:00Z`) - * `--stop` - Stop time in RFC3339 format (e.g., `2024-01-31T23:59:59Z`) - * `--environment` - Filter by environment name - * `-o, --output` - Output format: `table` or `json` (default: `table`) - -See https://docs.honeybadger.io/api/projects/ for more information. - -### Faults Command - -View and manage faults (errors) in your Honeybadger projects. **Requires**: `--auth-token` or `HONEYBADGER_AUTH_TOKEN` (personal auth token) +# Query Insights data +hb insights query --project-id 12345 --query "fields @ts, @preview | sort @ts" -```bash # List faults for a project hb faults list --project-id 12345 - -# List with filtering -hb faults list --project-id 12345 --query "class:RuntimeError" --order recent --limit 10 - -# Get fault details -hb faults get --project-id 12345 --id 67890 - -# List notices for a fault -hb faults notices --project-id 12345 --id 67890 - -# Get fault counts -hb faults counts --project-id 12345 - -# List users affected by a fault -hb faults affected-users --project-id 12345 --id 67890 ``` -**Note:** All faults commands require `--project-id` (Project ID) - -#### list - -**Optional:** - - * `-q, --query` - Search query string - * `--order` - Sort order: `recent` or `frequent` - * `--limit` - Maximum number of results (max: 25) - * `-o, --output` - Output format: `table` or `json` (default: `table`) - -#### get - -**Required:** - - * `--id` - Fault ID - -**Optional:** - - * `-o, --output` - Output format: `text` or `json` (default: `text`) - -#### notices - -**Required:** - - * `--id` - Fault ID - -**Optional:** - - * `--limit` - Maximum number of results (max: 25) - * `-o, --output` - Output format: `table` or `json` (default: `table`) - -#### counts - -**Optional:** - - * `-o, --output` - Output format: `text` or `json` (default: `text`) - -#### affected-users - -**Required:** - - * `--id` - Fault ID - -**Optional:** - - * `-q, --query` - Search query to filter users - * `-o, --output` - Output format: `table` or `json` (default: `table`) - -See https://docs.honeybadger.io/api/faults/ for more information. - -### Insights Command - -Execute BadgerQL queries against your Honeybadger Insights data. **Requires**: `--auth-token` or `HONEYBADGER_AUTH_TOKEN` (personal auth token) - -```bash -# Basic query for timestamps and previews -hb insights query --project-id 12345 --query "fields @ts, @preview | sort @ts" - -# Query with timezone -hb insights query --project-id 12345 --query "fields @ts, @preview | sort @ts" --timezone "America/New_York" - -# Query at a specific timestamp -hb insights query --project-id 12345 --query "fields @ts, @preview | sort @ts" --ts "PT1H" - -# Output as JSON -hb insights query --project-id 12345 --query "fields @ts, @preview | sort @ts" --output json -``` - -**Required:** - - * `--project-id` - Project ID - * `-q, --query` - BadgerQL query to execute - -**Optional:** - - * `--ts` - Timestamp range for the query (e.g., `PT1H`) - * `--timezone` - Timezone for the query (e.g., `America/New_York`) - * `-o, --output` - Output format: `table` or `json` (default: `table`) - -See https://docs.honeybadger.io/api/insights/#query-insights-data for more information. +See the [Honeybadger CLI documentation](https://docs.honeybadger.io/resources/cli/) for more information. ## Development diff --git a/cmd/accounts.go b/cmd/accounts.go new file mode 100644 index 0000000..ea328db --- /dev/null +++ b/cmd/accounts.go @@ -0,0 +1,757 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + hbapi "github.com/honeybadger-io/api-go" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + accountsOutputFormat string + accountID string + accountUserID int + accountUserRole string + accountInvitationID int + accountCLIInputJSON string +) + +// accountsCmd represents the accounts command +var accountsCmd = &cobra.Command{ + Use: "accounts", + Short: "Manage Honeybadger accounts", + GroupID: GroupDataAPI, + Long: `View and manage your Honeybadger accounts, users, and invitations.`, +} + +// accountsListCmd represents the accounts list command +var accountsListCmd = &cobra.Command{ + Use: "list", + Short: "List all accounts", + Long: `List all accounts accessible with your auth token.`, + RunE: func(_ *cobra.Command, _ []string) error { + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + accounts, err := client.Accounts.List(ctx) + if err != nil { + return fmt.Errorf("failed to list accounts: %w", err) + } + + switch accountsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(accounts, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tNAME\tEMAIL") + for _, account := range accounts { + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", + account.ID, + account.Name, + account.Email) + } + _ = w.Flush() + } + + return nil + }, +} + +// accountsGetCmd represents the accounts get command +var accountsGetCmd = &cobra.Command{ + Use: "get", + Short: "Get an account by ID", + Long: `Get detailed information about a specific account including quota and API stats.`, + RunE: func(_ *cobra.Command, _ []string) error { + if accountID == "" { + return fmt.Errorf("account ID is required. Set it using --id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + account, err := client.Accounts.Get(ctx, accountID) + if err != nil { + return fmt.Errorf("failed to get account: %w", err) + } + + switch accountsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(account, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Account Details:\n") + fmt.Printf(" ID: %s\n", account.ID) + fmt.Printf(" Name: %s\n", account.Name) + fmt.Printf(" Email: %s\n", account.Email) + if account.Active != nil { + fmt.Printf(" Active: %v\n", *account.Active) + } + if account.Parked != nil { + fmt.Printf(" Parked: %v\n", *account.Parked) + } + if account.QuotaConsumed != nil { + fmt.Printf(" Quota Consumed: %.2f%%\n", *account.QuotaConsumed) + } + } + + return nil + }, +} + +// accountsUsersListCmd represents the accounts users list command +var accountsUsersListCmd = &cobra.Command{ + Use: "list", + Short: "List users for an account", + Long: `List all users associated with an account.`, + RunE: func(_ *cobra.Command, _ []string) error { + if accountID == "" { + return fmt.Errorf("account ID is required. Set it using --account-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + users, err := client.Accounts.ListUsers(ctx, accountID) + if err != nil { + return fmt.Errorf("failed to list users: %w", err) + } + + switch accountsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(users, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tNAME\tEMAIL\tROLE") + for _, user := range users { + _, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", + user.ID, + user.Name, + user.Email, + user.Role) + } + _ = w.Flush() + } + + return nil + }, +} + +// accountsUsersGetCmd represents the accounts users get command +var accountsUsersGetCmd = &cobra.Command{ + Use: "get", + Short: "Get a user by ID", + Long: `Get detailed information about a specific user in an account.`, + RunE: func(_ *cobra.Command, _ []string) error { + if accountID == "" { + return fmt.Errorf("account ID is required. Set it using --account-id flag") + } + if accountUserID == 0 { + return fmt.Errorf("user ID is required. Set it using --user-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + user, err := client.Accounts.GetUser(ctx, accountID, accountUserID) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + switch accountsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(user, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("User Details:\n") + fmt.Printf(" ID: %d\n", user.ID) + fmt.Printf(" Name: %s\n", user.Name) + fmt.Printf(" Email: %s\n", user.Email) + fmt.Printf(" Role: %s\n", user.Role) + } + + return nil + }, +} + +// accountsUsersUpdateCmd represents the accounts users update command +var accountsUsersUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update a user's role", + Long: `Update a user's role in an account. Valid roles: Member, Billing, Admin, Owner.`, + RunE: func(_ *cobra.Command, _ []string) error { + if accountID == "" { + return fmt.Errorf("account ID is required. Set it using --account-id flag") + } + if accountUserID == 0 { + return fmt.Errorf("user ID is required. Set it using --user-id flag") + } + if accountUserRole == "" { + return fmt.Errorf("role is required. Set it using --role flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + if err := client.Accounts.UpdateUser( + ctx, + accountID, + accountUserID, + accountUserRole, + ); err != nil { + return fmt.Errorf("failed to update user: %w", err) + } + + user, err := client.Accounts.GetUser(ctx, accountID, accountUserID) + if err != nil { + return fmt.Errorf("failed to fetch updated user: %w", err) + } + + switch accountsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(user, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("User updated successfully!\n") + fmt.Printf(" ID: %d\n", user.ID) + fmt.Printf(" Name: %s\n", user.Name) + fmt.Printf(" Role: %s\n", user.Role) + } + + return nil + }, +} + +// accountsUsersRemoveCmd represents the accounts users remove command +var accountsUsersRemoveCmd = &cobra.Command{ + Use: "remove", + Short: "Remove a user from an account", + Long: `Remove a user from an account. This action cannot be undone.`, + RunE: func(_ *cobra.Command, _ []string) error { + if accountID == "" { + return fmt.Errorf("account ID is required. Set it using --account-id flag") + } + if accountUserID == 0 { + return fmt.Errorf("user ID is required. Set it using --user-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + err := client.Accounts.RemoveUser(ctx, accountID, accountUserID) + if err != nil { + return fmt.Errorf("failed to remove user: %w", err) + } + + fmt.Println("User removed successfully") + return nil + }, +} + +// accountsUsersCmd is the parent command for user operations +var accountsUsersCmd = &cobra.Command{ + Use: "users", + Short: "Manage account users", + Long: `View and manage users in your Honeybadger account.`, +} + +// accountsInvitationsListCmd represents the accounts invitations list command +var accountsInvitationsListCmd = &cobra.Command{ + Use: "list", + Short: "List invitations for an account", + Long: `List all pending invitations for an account.`, + RunE: func(_ *cobra.Command, _ []string) error { + if accountID == "" { + return fmt.Errorf("account ID is required. Set it using --account-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + invitations, err := client.Accounts.ListInvitations(ctx, accountID) + if err != nil { + return fmt.Errorf("failed to list invitations: %w", err) + } + + switch accountsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(invitations, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tEMAIL\tROLE\tCREATED\tACCEPTED") + for _, inv := range invitations { + accepted := "No" + if inv.AcceptedAt != nil { + accepted = inv.AcceptedAt.Format("2006-01-02 15:04") + } + _, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", + inv.ID, + inv.Email, + inv.Role, + inv.CreatedAt.Format("2006-01-02 15:04"), + accepted) + } + _ = w.Flush() + } + + return nil + }, +} + +// accountsInvitationsGetCmd represents the accounts invitations get command +var accountsInvitationsGetCmd = &cobra.Command{ + Use: "get", + Short: "Get an invitation by ID", + Long: `Get detailed information about a specific invitation.`, + RunE: func(_ *cobra.Command, _ []string) error { + if accountID == "" { + return fmt.Errorf("account ID is required. Set it using --account-id flag") + } + if accountInvitationID == 0 { + return fmt.Errorf("invitation ID is required. Set it using --invitation-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + invitation, err := client.Accounts.GetInvitation(ctx, accountID, accountInvitationID) + if err != nil { + return fmt.Errorf("failed to get invitation: %w", err) + } + + switch accountsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(invitation, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Invitation Details:\n") + fmt.Printf(" ID: %d\n", invitation.ID) + fmt.Printf(" Email: %s\n", invitation.Email) + fmt.Printf(" Role: %s\n", invitation.Role) + fmt.Printf(" Token: %s\n", invitation.Token) + fmt.Printf(" Created: %s\n", invitation.CreatedAt.Format("2006-01-02 15:04:05")) + if invitation.AcceptedAt != nil { + fmt.Printf(" Accepted: %s\n", invitation.AcceptedAt.Format("2006-01-02 15:04:05")) + } + if len(invitation.TeamIDs) > 0 { + fmt.Printf(" Team IDs: %v\n", invitation.TeamIDs) + } + } + + return nil + }, +} + +// accountsInvitationsCreateCmd represents the accounts invitations create command +var accountsInvitationsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new invitation", + Long: `Create a new invitation to join an account. + +The --cli-input-json flag accepts either a JSON string or a file path prefixed with 'file://'. + +Example JSON payload: +{ + "invitation": { + "email": "user@example.com", + "role": "Member", + "team_ids": [123, 456] + } +}`, + RunE: func(_ *cobra.Command, _ []string) error { + if accountID == "" { + return fmt.Errorf("account ID is required. Set it using --account-id flag") + } + if accountCLIInputJSON == "" { + return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + jsonData, err := readJSONInput(accountCLIInputJSON) + if err != nil { + return fmt.Errorf("failed to read JSON input: %w", err) + } + + var payload struct { + Invitation hbapi.AccountInvitationParams `json:"invitation"` + } + if err := json.Unmarshal(jsonData, &payload); err != nil { + return fmt.Errorf("failed to parse JSON payload: %w", err) + } + + ctx := context.Background() + invitation, err := client.Accounts.CreateInvitation(ctx, accountID, payload.Invitation) + if err != nil { + return fmt.Errorf("failed to create invitation: %w", err) + } + + switch accountsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(invitation, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Invitation created successfully!\n") + fmt.Printf(" ID: %d\n", invitation.ID) + fmt.Printf(" Email: %s\n", invitation.Email) + fmt.Printf(" Role: %s\n", invitation.Role) + fmt.Printf(" Token: %s\n", invitation.Token) + } + + return nil + }, +} + +// accountsInvitationsUpdateCmd represents the accounts invitations update command +var accountsInvitationsUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update an invitation", + Long: `Update an existing invitation. + +The --cli-input-json flag accepts either a JSON string or a file path prefixed with 'file://'. + +Example JSON payload: +{ + "invitation": { + "role": "Admin", + "team_ids": [123] + } +}`, + RunE: func(_ *cobra.Command, _ []string) error { + if accountID == "" { + return fmt.Errorf("account ID is required. Set it using --account-id flag") + } + if accountInvitationID == 0 { + return fmt.Errorf("invitation ID is required. Set it using --invitation-id flag") + } + if accountCLIInputJSON == "" { + return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + jsonData, err := readJSONInput(accountCLIInputJSON) + if err != nil { + return fmt.Errorf("failed to read JSON input: %w", err) + } + + var payload struct { + Invitation hbapi.AccountInvitationParams `json:"invitation"` + } + if err := json.Unmarshal(jsonData, &payload); err != nil { + return fmt.Errorf("failed to parse JSON payload: %w", err) + } + + ctx := context.Background() + if err := client.Accounts.UpdateInvitation( + ctx, + accountID, + accountInvitationID, + payload.Invitation, + ); err != nil { + return fmt.Errorf("failed to update invitation: %w", err) + } + + invitation, err := client.Accounts.GetInvitation(ctx, accountID, accountInvitationID) + if err != nil { + return fmt.Errorf("failed to fetch updated invitation: %w", err) + } + + switch accountsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(invitation, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Invitation updated successfully!\n") + fmt.Printf(" ID: %d\n", invitation.ID) + fmt.Printf(" Email: %s\n", invitation.Email) + fmt.Printf(" Role: %s\n", invitation.Role) + } + + return nil + }, +} + +// accountsInvitationsDeleteCmd represents the accounts invitations delete command +var accountsInvitationsDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete an invitation", + Long: `Delete a pending invitation. This action cannot be undone.`, + RunE: func(_ *cobra.Command, _ []string) error { + if accountID == "" { + return fmt.Errorf("account ID is required. Set it using --account-id flag") + } + if accountInvitationID == 0 { + return fmt.Errorf("invitation ID is required. Set it using --invitation-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + err := client.Accounts.DeleteInvitation(ctx, accountID, accountInvitationID) + if err != nil { + return fmt.Errorf("failed to delete invitation: %w", err) + } + + fmt.Println("Invitation deleted successfully") + return nil + }, +} + +// accountsInvitationsCmd is the parent command for invitation operations +var accountsInvitationsCmd = &cobra.Command{ + Use: "invitations", + Short: "Manage account invitations", + Long: `View and manage invitations to your Honeybadger account.`, +} + +func init() { + rootCmd.AddCommand(accountsCmd) + + // Add subcommands + accountsCmd.AddCommand(accountsListCmd) + accountsCmd.AddCommand(accountsGetCmd) + accountsCmd.AddCommand(accountsUsersCmd) + accountsCmd.AddCommand(accountsInvitationsCmd) + + // Users subcommands + accountsUsersCmd.AddCommand(accountsUsersListCmd) + accountsUsersCmd.AddCommand(accountsUsersGetCmd) + accountsUsersCmd.AddCommand(accountsUsersUpdateCmd) + accountsUsersCmd.AddCommand(accountsUsersRemoveCmd) + + // Invitations subcommands + accountsInvitationsCmd.AddCommand(accountsInvitationsListCmd) + accountsInvitationsCmd.AddCommand(accountsInvitationsGetCmd) + accountsInvitationsCmd.AddCommand(accountsInvitationsCreateCmd) + accountsInvitationsCmd.AddCommand(accountsInvitationsUpdateCmd) + accountsInvitationsCmd.AddCommand(accountsInvitationsDeleteCmd) + + // Flags for list command + accountsListCmd.Flags(). + StringVarP(&accountsOutputFormat, "output", "o", "table", "Output format (table or json)") + + // Flags for get command + accountsGetCmd.Flags().StringVar(&accountID, "id", "", "Account ID") + accountsGetCmd.Flags(). + StringVarP(&accountsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = accountsGetCmd.MarkFlagRequired("id") + + // Common account ID flag for users subcommands + accountsUsersCmd.PersistentFlags().StringVar(&accountID, "account-id", "", "Account ID") + + // Flags for users list + accountsUsersListCmd.Flags(). + StringVarP(&accountsOutputFormat, "output", "o", "table", "Output format (table or json)") + + // Flags for users get + accountsUsersGetCmd.Flags().IntVar(&accountUserID, "user-id", 0, "User ID") + accountsUsersGetCmd.Flags(). + StringVarP(&accountsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = accountsUsersGetCmd.MarkFlagRequired("user-id") + + // Flags for users update + accountsUsersUpdateCmd.Flags().IntVar(&accountUserID, "user-id", 0, "User ID") + accountsUsersUpdateCmd.Flags(). + StringVar(&accountUserRole, "role", "", "New role (Member, Billing, Admin, Owner)") + accountsUsersUpdateCmd.Flags(). + StringVarP(&accountsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = accountsUsersUpdateCmd.MarkFlagRequired("user-id") + _ = accountsUsersUpdateCmd.MarkFlagRequired("role") + + // Flags for users remove + accountsUsersRemoveCmd.Flags().IntVar(&accountUserID, "user-id", 0, "User ID") + _ = accountsUsersRemoveCmd.MarkFlagRequired("user-id") + + // Common account ID flag for invitations subcommands + accountsInvitationsCmd.PersistentFlags().StringVar(&accountID, "account-id", "", "Account ID") + + // Flags for invitations list + accountsInvitationsListCmd.Flags(). + StringVarP(&accountsOutputFormat, "output", "o", "table", "Output format (table or json)") + + // Flags for invitations get + accountsInvitationsGetCmd.Flags(). + IntVar(&accountInvitationID, "invitation-id", 0, "Invitation ID") + accountsInvitationsGetCmd.Flags(). + StringVarP(&accountsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = accountsInvitationsGetCmd.MarkFlagRequired("invitation-id") + + // Flags for invitations create + accountsInvitationsCreateCmd.Flags(). + StringVar(&accountCLIInputJSON, "cli-input-json", "", "JSON payload (string or file://path)") + accountsInvitationsCreateCmd.Flags(). + StringVarP(&accountsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = accountsInvitationsCreateCmd.MarkFlagRequired("cli-input-json") + + // Flags for invitations update + accountsInvitationsUpdateCmd.Flags(). + IntVar(&accountInvitationID, "invitation-id", 0, "Invitation ID") + accountsInvitationsUpdateCmd.Flags(). + StringVar(&accountCLIInputJSON, "cli-input-json", "", "JSON payload (string or file://path)") + accountsInvitationsUpdateCmd.Flags(). + StringVarP(&accountsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = accountsInvitationsUpdateCmd.MarkFlagRequired("invitation-id") + _ = accountsInvitationsUpdateCmd.MarkFlagRequired("cli-input-json") + + // Flags for invitations delete + accountsInvitationsDeleteCmd.Flags(). + IntVar(&accountInvitationID, "invitation-id", 0, "Invitation ID") + _ = accountsInvitationsDeleteCmd.MarkFlagRequired("invitation-id") +} diff --git a/cmd/accounts_test.go b/cmd/accounts_test.go new file mode 100644 index 0000000..a7d41dc --- /dev/null +++ b/cmd/accounts_test.go @@ -0,0 +1,384 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestAccountsListCommand(t *testing.T) { + tests := []struct { + name string + authToken string + serverStatus int + serverBody string + expectedError bool + errorContains string + }{ + { + name: "successful list", + authToken: "test-token", + serverStatus: http.StatusOK, + serverBody: `{ + "results": [ + {"id": "abc123", "email": "test@example.com", "name": "Test Account"} + ] + }`, + expectedError: false, + }, + { + name: "missing auth token", + authToken: "", + expectedError: true, + errorContains: "auth token is required", + }, + { + name: "unauthorized", + authToken: "invalid-token", + serverStatus: http.StatusUnauthorized, + serverBody: `{"error": "Unauthorized"}`, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create test server only if we expect to make requests + var serverURL string + if tt.authToken != "" { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "/v2/accounts", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.serverStatus) + _, _ = w.Write([]byte(tt.serverBody)) + }), + ) + defer server.Close() + serverURL = server.URL + } else { + serverURL = "http://localhost:9999" // Won't be called + } + + // Reset viper and set config + viper.Reset() + viper.Set("endpoint", serverURL) + if tt.authToken != "" { + viper.Set("auth_token", tt.authToken) + } + + // Reset command state + accountsOutputFormat = "table" + + // Execute command + err := accountsListCmd.RunE(accountsListCmd, []string{}) + + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestAccountsGetCommand(t *testing.T) { + tests := []struct { + name string + accountIDValue string + authToken string + serverStatus int + serverBody string + expectedError bool + errorContains string + }{ + { + name: "successful get", + accountIDValue: "abc123", + authToken: "test-token", + serverStatus: http.StatusOK, + serverBody: `{ + "id": "abc123", + "email": "test@example.com", + "name": "Test Account" + }`, + expectedError: false, + }, + { + name: "missing account ID", + accountIDValue: "", + authToken: "test-token", + expectedError: true, + errorContains: "account ID is required", + }, + { + name: "missing auth token", + accountIDValue: "abc123", + authToken: "", + expectedError: true, + errorContains: "auth token is required", + }, + { + name: "not found", + accountIDValue: "notfound", + authToken: "test-token", + serverStatus: http.StatusNotFound, + serverBody: `{"error": "Not found"}`, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var serverURL string + if tt.authToken != "" && tt.accountIDValue != "" { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Contains(t, r.URL.Path, "/v2/accounts/") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.serverStatus) + _, _ = w.Write([]byte(tt.serverBody)) + }), + ) + defer server.Close() + serverURL = server.URL + } else { + serverURL = "http://localhost:9999" + } + + viper.Reset() + viper.Set("endpoint", serverURL) + if tt.authToken != "" { + viper.Set("auth_token", tt.authToken) + } + + // Set global state that the command reads + accountID = tt.accountIDValue + accountsOutputFormat = "text" + + err := accountsGetCmd.RunE(accountsGetCmd, []string{}) + + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestAccountsOutputFormat(t *testing.T) { + mockResponse := `{ + "results": [ + {"id": "abc123", "email": "test@example.com", "name": "Test Account"} + ] + }` + + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(mockResponse)) + }), + ) + defer server.Close() + + tests := []struct { + name string + format string + }{ + {name: "table format", format: "table"}, + {name: "json format", format: "json"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + viper.Reset() + viper.Set("endpoint", server.URL) + viper.Set("auth_token", "test-token") + + accountsOutputFormat = tt.format + + err := accountsListCmd.RunE(accountsListCmd, []string{}) + assert.NoError(t, err) + }) + } +} + +func TestAccountsUsersListCommand(t *testing.T) { + tests := []struct { + name string + accountIDValue string + authToken string + serverStatus int + serverBody string + expectedError bool + errorContains string + }{ + { + name: "successful list users", + accountIDValue: "abc123", + authToken: "test-token", + serverStatus: http.StatusOK, + serverBody: `{ + "results": [{"id": 1, "name": "User 1", "email": "user1@example.com", "role": "Owner"}], + "links": {"self": "/v2/accounts/abc123/account_users"} + }`, + expectedError: false, + }, + { + name: "missing account ID", + accountIDValue: "", + authToken: "test-token", + expectedError: true, + errorContains: "account ID is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var serverURL string + if tt.authToken != "" && tt.accountIDValue != "" { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.serverStatus) + _, _ = w.Write([]byte(tt.serverBody)) + }), + ) + defer server.Close() + serverURL = server.URL + } else { + serverURL = "http://localhost:9999" + } + + viper.Reset() + viper.Set("endpoint", serverURL) + if tt.authToken != "" { + viper.Set("auth_token", tt.authToken) + } + + accountID = tt.accountIDValue + accountsOutputFormat = "table" + + err := accountsUsersListCmd.RunE(accountsUsersListCmd, []string{}) + + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestAccountsInvitationsCreateCommand(t *testing.T) { + tests := []struct { + name string + accountIDValue string + authToken string + cliInputJSON string + serverStatus int + serverBody string + expectedError bool + errorContains string + }{ + { + name: "successful create", + accountIDValue: "abc123", + authToken: "test-token", + cliInputJSON: `{"invitation": {"email": "new@example.com", "role": "Member"}}`, + serverStatus: http.StatusCreated, + serverBody: `{ + "id": 1, + "email": "new@example.com", + "role": "Member", + "token": "invite-token", + "created_at": "2024-01-01T00:00:00Z" + }`, + expectedError: false, + }, + { + name: "missing account ID", + accountIDValue: "", + authToken: "test-token", + cliInputJSON: `{"invitation": {"email": "new@example.com"}}`, + expectedError: true, + errorContains: "account ID is required", + }, + { + name: "missing JSON payload", + accountIDValue: "abc123", + authToken: "test-token", + cliInputJSON: "", + expectedError: true, + errorContains: "JSON payload is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var serverURL string + if tt.authToken != "" && tt.accountIDValue != "" && tt.cliInputJSON != "" { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + + // Verify JSON payload + var payload map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&payload) + assert.NoError(t, err) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.serverStatus) + _, _ = w.Write([]byte(tt.serverBody)) + }), + ) + defer server.Close() + serverURL = server.URL + } else { + serverURL = "http://localhost:9999" + } + + viper.Reset() + viper.Set("endpoint", serverURL) + if tt.authToken != "" { + viper.Set("auth_token", tt.authToken) + } + + accountID = tt.accountIDValue + accountCLIInputJSON = tt.cliInputJSON + accountsOutputFormat = "text" + + err := accountsInvitationsCreateCmd.RunE(accountsInvitationsCreateCmd, []string{}) + + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/cmd/agent.go b/cmd/agent.go index 5c31556..3df77e3 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -59,8 +59,9 @@ type diskPayload struct { // agentCmd represents the agent command var agentCmd = &cobra.Command{ - Use: "agent", - Short: "Start a metrics reporting agent", + Use: "agent", + Short: "Start a metrics reporting agent", + GroupID: GroupReportingAPI, Long: `Start a persistent process that periodically reports host metrics to Honeybadger's Insights API. This command collects and reports system metrics such as CPU usage, memory usage, disk usage, and load averages. Metrics are aggregated and reported at a configurable interval (default: 60 seconds).`, @@ -109,9 +110,10 @@ func sendMetric(payload interface{}) error { return fmt.Errorf("error marshaling metrics: %w", err) } + apiEndpoint := viper.GetString("endpoint") req, err := http.NewRequest( "POST", - fmt.Sprintf("%s/v1/events", endpoint), + fmt.Sprintf("%s/v1/events", apiEndpoint), strings.NewReader(string(jsonData)+"\n"), ) if err != nil { diff --git a/cmd/agent_test.go b/cmd/agent_test.go index bf34e05..e711bb1 100644 --- a/cmd/agent_test.go +++ b/cmd/agent_test.go @@ -74,6 +74,7 @@ func TestMetricsCollection(t *testing.T) { // Set invalid endpoint endpoint = "http://invalid-endpoint" + viper.Set("endpoint", endpoint) hostname, err := os.Hostname() require.NoError(t, err) @@ -111,6 +112,7 @@ func TestMetricsCollection(t *testing.T) { // Configure viper viper.Set("api_key", "test-api-key") endpoint = server.URL + viper.Set("endpoint", endpoint) // Set up a short interval for testing interval = 1 // 1 second diff --git a/cmd/checkins.go b/cmd/checkins.go new file mode 100644 index 0000000..990c910 --- /dev/null +++ b/cmd/checkins.go @@ -0,0 +1,414 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + hbapi "github.com/honeybadger-io/api-go" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + checkinsProjectID int + checkinID string + checkinsOutputFormat string + checkinCLIInputJSON string +) + +// checkinsCmd represents the checkins command +var checkinsCmd = &cobra.Command{ + Use: "check-ins", + Short: "Manage Honeybadger check-ins", + GroupID: GroupDataAPI, + Long: `View and manage check-ins (cron job monitoring) for your Honeybadger projects.`, +} + +// checkinsListCmd represents the checkins list command +var checkinsListCmd = &cobra.Command{ + Use: "list", + Short: "List check-ins for a project", + Long: `List all check-ins configured for a specific project.`, + RunE: func(_ *cobra.Command, _ []string) error { + if checkinsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + checkIns, err := client.CheckIns.List(ctx, checkinsProjectID) + if err != nil { + return fmt.Errorf("failed to list check-ins: %w", err) + } + + switch checkinsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(checkIns, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tNAME\tSLUG\tTYPE\tSCHEDULE\tLAST CHECK-IN") + for _, ci := range checkIns { + schedule := "" + if ci.ScheduleType == "simple" && ci.ReportPeriod != nil { + schedule = *ci.ReportPeriod + } else if ci.ScheduleType == "cron" && ci.CronSchedule != nil { + schedule = *ci.CronSchedule + } + + lastCheckIn := "Never" + if ci.ReportedAt != nil { + lastCheckIn = ci.ReportedAt.Format("2006-01-02 15:04") + } + + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + ci.ID, + ci.Name, + ci.Slug, + ci.ScheduleType, + schedule, + lastCheckIn) + } + _ = w.Flush() + } + + return nil + }, +} + +// checkinsGetCmd represents the checkins get command +var checkinsGetCmd = &cobra.Command{ + Use: "get", + Short: "Get a check-in by ID", + Long: `Get detailed information about a specific check-in.`, + RunE: func(_ *cobra.Command, _ []string) error { + if checkinsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if checkinID == "" { + return fmt.Errorf("check-in ID is required. Set it using --id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + checkIn, err := client.CheckIns.Get(ctx, checkinsProjectID, checkinID) + if err != nil { + return fmt.Errorf("failed to get check-in: %w", err) + } + + switch checkinsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(checkIn, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Check-in Details:\n") + fmt.Printf(" ID: %s\n", checkIn.ID) + fmt.Printf(" Name: %s\n", checkIn.Name) + fmt.Printf(" Slug: %s\n", checkIn.Slug) + fmt.Printf(" State: %s\n", checkIn.State) + fmt.Printf(" Schedule Type: %s\n", checkIn.ScheduleType) + if checkIn.ReportPeriod != nil { + fmt.Printf(" Report Period: %s\n", *checkIn.ReportPeriod) + } + if checkIn.GracePeriod != nil { + fmt.Printf(" Grace Period: %s\n", *checkIn.GracePeriod) + } + if checkIn.CronSchedule != nil { + fmt.Printf(" Cron Schedule: %s\n", *checkIn.CronSchedule) + } + if checkIn.CronTimezone != nil { + fmt.Printf(" Cron Timezone: %s\n", *checkIn.CronTimezone) + } + if checkIn.ReportedAt != nil { + fmt.Printf( + " Last Check-in: %s\n", + checkIn.ReportedAt.Format("2006-01-02 15:04:05"), + ) + } + } + + return nil + }, +} + +// checkinsCreateCmd represents the checkins create command +var checkinsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new check-in", + Long: `Create a new check-in for a project. + +The --cli-input-json flag accepts either a JSON string or a file path prefixed with 'file://'. + +Example JSON payload for simple schedule: +{ + "check_in": { + "name": "Daily Backup", + "slug": "daily-backup", + "schedule_type": "simple", + "report_period": "1 day", + "grace_period": "15 minutes" + } +} + +Example JSON payload for cron schedule: +{ + "check_in": { + "name": "Hourly Task", + "slug": "hourly-task", + "schedule_type": "cron", + "cron_schedule": "0 * * * *", + "cron_timezone": "America/New_York" + } +}`, + RunE: func(_ *cobra.Command, _ []string) error { + if checkinsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if checkinCLIInputJSON == "" { + return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + jsonData, err := readJSONInput(checkinCLIInputJSON) + if err != nil { + return fmt.Errorf("failed to read JSON input: %w", err) + } + + var payload struct { + CheckIn hbapi.CheckInParams `json:"check_in"` + } + if err := json.Unmarshal(jsonData, &payload); err != nil { + return fmt.Errorf("failed to parse JSON payload: %w", err) + } + + ctx := context.Background() + checkIn, err := client.CheckIns.Create(ctx, checkinsProjectID, payload.CheckIn) + if err != nil { + return fmt.Errorf("failed to create check-in: %w", err) + } + + switch checkinsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(checkIn, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Check-in created successfully!\n") + fmt.Printf(" ID: %s\n", checkIn.ID) + fmt.Printf(" Name: %s\n", checkIn.Name) + fmt.Printf(" Slug: %s\n", checkIn.Slug) + } + + return nil + }, +} + +// checkinsUpdateCmd represents the checkins update command +var checkinsUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update an existing check-in", + Long: `Update an existing check-in's settings. + +The --cli-input-json flag accepts either a JSON string or a file path prefixed with 'file://'. + +Example JSON payload: +{ + "check_in": { + "name": "Updated Name", + "report_period": "2 days" + } +}`, + RunE: func(_ *cobra.Command, _ []string) error { + if checkinsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if checkinID == "" { + return fmt.Errorf("check-in ID is required. Set it using --id flag") + } + if checkinCLIInputJSON == "" { + return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + jsonData, err := readJSONInput(checkinCLIInputJSON) + if err != nil { + return fmt.Errorf("failed to read JSON input: %w", err) + } + + var payload struct { + CheckIn hbapi.CheckInParams `json:"check_in"` + } + if err := json.Unmarshal(jsonData, &payload); err != nil { + return fmt.Errorf("failed to parse JSON payload: %w", err) + } + + ctx := context.Background() + if err := client.CheckIns.Update( + ctx, + checkinsProjectID, + checkinID, + payload.CheckIn, + ); err != nil { + return fmt.Errorf("failed to update check-in: %w", err) + } + + checkIn, err := client.CheckIns.Get(ctx, checkinsProjectID, checkinID) + if err != nil { + return fmt.Errorf("failed to fetch updated check-in: %w", err) + } + + switch checkinsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(checkIn, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Check-in updated successfully!\n") + fmt.Printf(" ID: %s\n", checkIn.ID) + fmt.Printf(" Name: %s\n", checkIn.Name) + fmt.Printf(" Slug: %s\n", checkIn.Slug) + } + + return nil + }, +} + +// checkinsDeleteCmd represents the checkins delete command +var checkinsDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a check-in", + Long: `Delete a check-in by ID. This action cannot be undone.`, + RunE: func(_ *cobra.Command, _ []string) error { + if checkinsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if checkinID == "" { + return fmt.Errorf("check-in ID is required. Set it using --id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + err := client.CheckIns.Delete(ctx, checkinsProjectID, checkinID) + if err != nil { + return fmt.Errorf("failed to delete check-in: %w", err) + } + + fmt.Println("Check-in deleted successfully") + return nil + }, +} + +func init() { + rootCmd.AddCommand(checkinsCmd) + checkinsCmd.AddCommand(checkinsListCmd) + checkinsCmd.AddCommand(checkinsGetCmd) + checkinsCmd.AddCommand(checkinsCreateCmd) + checkinsCmd.AddCommand(checkinsUpdateCmd) + checkinsCmd.AddCommand(checkinsDeleteCmd) + + // Common flags + checkinsCmd.PersistentFlags().IntVar(&checkinsProjectID, "project-id", 0, "Project ID") + + // Flags for list command + checkinsListCmd.Flags(). + StringVarP(&checkinsOutputFormat, "output", "o", "table", "Output format (table or json)") + + // Flags for get command + checkinsGetCmd.Flags().StringVar(&checkinID, "id", "", "Check-in ID") + checkinsGetCmd.Flags(). + StringVarP(&checkinsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = checkinsGetCmd.MarkFlagRequired("id") + + // Flags for create command + checkinsCreateCmd.Flags(). + StringVar(&checkinCLIInputJSON, "cli-input-json", "", "JSON payload (string or file://path)") + checkinsCreateCmd.Flags(). + StringVarP(&checkinsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = checkinsCreateCmd.MarkFlagRequired("cli-input-json") + + // Flags for update command + checkinsUpdateCmd.Flags().StringVar(&checkinID, "id", "", "Check-in ID") + checkinsUpdateCmd.Flags(). + StringVar(&checkinCLIInputJSON, "cli-input-json", "", "JSON payload (string or file://path)") + checkinsUpdateCmd.Flags(). + StringVarP(&checkinsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = checkinsUpdateCmd.MarkFlagRequired("id") + _ = checkinsUpdateCmd.MarkFlagRequired("cli-input-json") + + // Flags for delete command + checkinsDeleteCmd.Flags().StringVar(&checkinID, "id", "", "Check-in ID") + _ = checkinsDeleteCmd.MarkFlagRequired("id") +} diff --git a/cmd/checkins_test.go b/cmd/checkins_test.go new file mode 100644 index 0000000..332da02 --- /dev/null +++ b/cmd/checkins_test.go @@ -0,0 +1,214 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestCheckinsListCommand(t *testing.T) { + tests := []struct { + name string + projectIDValue int + authToken string + serverStatus int + serverBody string + expectedError bool + errorContains string + }{ + { + name: "successful list", + projectIDValue: 123, + authToken: "test-token", + serverStatus: http.StatusOK, + serverBody: `{ + "results": [{"id": "abc123", "name": "Daily Backup", "slug": "daily-backup", "schedule_type": "simple", "report_period": "1 day"}], + "links": {"self": "/v2/projects/123/check_ins"} + }`, + expectedError: false, + }, + { + name: "missing project ID", + projectIDValue: 0, + authToken: "test-token", + expectedError: true, + errorContains: "project ID is required", + }, + { + name: "missing auth token", + projectIDValue: 123, + authToken: "", + expectedError: true, + errorContains: "auth token is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var serverURL string + if tt.authToken != "" && tt.projectIDValue != 0 { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.serverStatus) + _, _ = w.Write([]byte(tt.serverBody)) + }), + ) + defer server.Close() + serverURL = server.URL + } else { + serverURL = "http://localhost:9999" + } + + viper.Reset() + viper.Set("endpoint", serverURL) + if tt.authToken != "" { + viper.Set("auth_token", tt.authToken) + } + + checkinsProjectID = tt.projectIDValue + checkinsOutputFormat = "table" + + err := checkinsListCmd.RunE(checkinsListCmd, []string{}) + + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestCheckinsGetCommand(t *testing.T) { + tests := []struct { + name string + projectIDValue int + checkinIDValue string + authToken string + serverStatus int + serverBody string + expectedError bool + errorContains string + }{ + { + name: "successful get", + projectIDValue: 123, + checkinIDValue: "abc123", + authToken: "test-token", + serverStatus: http.StatusOK, + serverBody: `{ + "id": "abc123", + "name": "Daily Backup", + "slug": "daily-backup", + "schedule_type": "simple", + "report_period": "1 day" + }`, + expectedError: false, + }, + { + name: "missing project ID", + projectIDValue: 0, + checkinIDValue: "abc123", + authToken: "test-token", + expectedError: true, + errorContains: "project ID is required", + }, + { + name: "missing checkin ID", + projectIDValue: 123, + checkinIDValue: "", + authToken: "test-token", + expectedError: true, + errorContains: "check-in ID is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var serverURL string + if tt.authToken != "" && tt.projectIDValue != 0 && tt.checkinIDValue != "" { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.serverStatus) + _, _ = w.Write([]byte(tt.serverBody)) + }), + ) + defer server.Close() + serverURL = server.URL + } else { + serverURL = "http://localhost:9999" + } + + viper.Reset() + viper.Set("endpoint", serverURL) + if tt.authToken != "" { + viper.Set("auth_token", tt.authToken) + } + + checkinsProjectID = tt.projectIDValue + checkinID = tt.checkinIDValue + checkinsOutputFormat = "text" + + err := checkinsGetCmd.RunE(checkinsGetCmd, []string{}) + + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestCheckinsOutputFormat(t *testing.T) { + mockResponse := `{ + "results": [{"id": "abc123", "name": "Daily Backup", "slug": "daily-backup", "schedule_type": "simple", "report_period": "1 day"}], + "links": {"self": "/v2/projects/123/check_ins"} + }` + + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(mockResponse)) + }), + ) + defer server.Close() + + tests := []struct { + name string + format string + }{ + {name: "table format", format: "table"}, + {name: "json format", format: "json"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + viper.Reset() + viper.Set("endpoint", server.URL) + viper.Set("auth_token", "test-token") + + checkinsProjectID = 123 + checkinsOutputFormat = tt.format + + err := checkinsListCmd.RunE(checkinsListCmd, []string{}) + assert.NoError(t, err) + }) + } +} diff --git a/cmd/comments.go b/cmd/comments.go new file mode 100644 index 0000000..23f68fa --- /dev/null +++ b/cmd/comments.go @@ -0,0 +1,353 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + hbapi "github.com/honeybadger-io/api-go" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + commentsProjectID int + commentsFaultID int + commentID int + commentsOutputFormat string + commentBody string +) + +// commentsCmd represents the comments command +var commentsCmd = &cobra.Command{ + Use: "comments", + Short: "Manage fault comments", + GroupID: GroupDataAPI, + Long: `View and manage comments on faults in your Honeybadger projects.`, +} + +// commentsListCmd represents the comments list command +var commentsListCmd = &cobra.Command{ + Use: "list", + Short: "List comments for a fault", + Long: `List all comments on a specific fault.`, + RunE: func(_ *cobra.Command, _ []string) error { + if commentsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if commentsFaultID == 0 { + return fmt.Errorf("fault ID is required. Set it using --fault-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + comments, err := client.Comments.List(ctx, commentsProjectID, commentsFaultID) + if err != nil { + return fmt.Errorf("failed to list comments: %w", err) + } + + switch commentsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(comments, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tAUTHOR\tEVENT\tCREATED\tBODY") + for _, c := range comments { + author := "System" + if c.Author != "" { + author = c.Author + } + + body := c.Body + if len(body) > 40 { + body = body[:37] + "..." + } + + _, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", + c.ID, + author, + c.Event, + c.CreatedAt.Format("2006-01-02 15:04"), + body) + } + _ = w.Flush() + } + + return nil + }, +} + +// commentsGetCmd represents the comments get command +var commentsGetCmd = &cobra.Command{ + Use: "get", + Short: "Get a comment by ID", + Long: `Get detailed information about a specific comment.`, + RunE: func(_ *cobra.Command, _ []string) error { + if commentsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if commentsFaultID == 0 { + return fmt.Errorf("fault ID is required. Set it using --fault-id flag") + } + if commentID == 0 { + return fmt.Errorf("comment ID is required. Set it using --id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + comment, err := client.Comments.Get(ctx, commentsProjectID, commentsFaultID, commentID) + if err != nil { + return fmt.Errorf("failed to get comment: %w", err) + } + + switch commentsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(comment, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Comment Details:\n") + fmt.Printf(" ID: %d\n", comment.ID) + fmt.Printf(" Fault ID: %d\n", comment.FaultID) + fmt.Printf(" Event: %s\n", comment.Event) + fmt.Printf(" Source: %s\n", comment.Source) + if comment.Author != "" { + fmt.Printf(" Author: %s\n", comment.Author) + } + fmt.Printf(" Created: %s\n", comment.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf(" Body:\n %s\n", comment.Body) + } + + return nil + }, +} + +// commentsCreateCmd represents the comments create command +var commentsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new comment", + Long: `Create a new comment on a fault.`, + RunE: func(_ *cobra.Command, _ []string) error { + if commentsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if commentsFaultID == 0 { + return fmt.Errorf("fault ID is required. Set it using --fault-id flag") + } + if commentBody == "" { + return fmt.Errorf("comment body is required. Set it using --body flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + comment, err := client.Comments.Create(ctx, commentsProjectID, commentsFaultID, commentBody) + if err != nil { + return fmt.Errorf("failed to create comment: %w", err) + } + + switch commentsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(comment, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Comment created successfully!\n") + fmt.Printf(" ID: %d\n", comment.ID) + fmt.Printf(" Body: %s\n", comment.Body) + } + + return nil + }, +} + +// commentsUpdateCmd represents the comments update command +var commentsUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update an existing comment", + Long: `Update the body of an existing comment.`, + RunE: func(_ *cobra.Command, _ []string) error { + if commentsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if commentsFaultID == 0 { + return fmt.Errorf("fault ID is required. Set it using --fault-id flag") + } + if commentID == 0 { + return fmt.Errorf("comment ID is required. Set it using --id flag") + } + if commentBody == "" { + return fmt.Errorf("comment body is required. Set it using --body flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + if err := client.Comments.Update( + ctx, + commentsProjectID, + commentsFaultID, + commentID, + commentBody, + ); err != nil { + return fmt.Errorf("failed to update comment: %w", err) + } + + comment, err := client.Comments.Get(ctx, commentsProjectID, commentsFaultID, commentID) + if err != nil { + return fmt.Errorf("failed to fetch updated comment: %w", err) + } + + switch commentsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(comment, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Comment updated successfully!\n") + fmt.Printf(" ID: %d\n", comment.ID) + fmt.Printf(" Body: %s\n", comment.Body) + } + + return nil + }, +} + +// commentsDeleteCmd represents the comments delete command +var commentsDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a comment", + Long: `Delete a comment by ID. This action cannot be undone.`, + RunE: func(_ *cobra.Command, _ []string) error { + if commentsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if commentsFaultID == 0 { + return fmt.Errorf("fault ID is required. Set it using --fault-id flag") + } + if commentID == 0 { + return fmt.Errorf("comment ID is required. Set it using --id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + err := client.Comments.Delete(ctx, commentsProjectID, commentsFaultID, commentID) + if err != nil { + return fmt.Errorf("failed to delete comment: %w", err) + } + + fmt.Println("Comment deleted successfully") + return nil + }, +} + +func init() { + rootCmd.AddCommand(commentsCmd) + commentsCmd.AddCommand(commentsListCmd) + commentsCmd.AddCommand(commentsGetCmd) + commentsCmd.AddCommand(commentsCreateCmd) + commentsCmd.AddCommand(commentsUpdateCmd) + commentsCmd.AddCommand(commentsDeleteCmd) + + // Common flags + commentsCmd.PersistentFlags().IntVar(&commentsProjectID, "project-id", 0, "Project ID") + commentsCmd.PersistentFlags().IntVar(&commentsFaultID, "fault-id", 0, "Fault ID") + + // Flags for list command + commentsListCmd.Flags(). + StringVarP(&commentsOutputFormat, "output", "o", "table", "Output format (table or json)") + + // Flags for get command + commentsGetCmd.Flags().IntVar(&commentID, "id", 0, "Comment ID") + commentsGetCmd.Flags(). + StringVarP(&commentsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = commentsGetCmd.MarkFlagRequired("id") + + // Flags for create command + commentsCreateCmd.Flags().StringVar(&commentBody, "body", "", "Comment body text") + commentsCreateCmd.Flags(). + StringVarP(&commentsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = commentsCreateCmd.MarkFlagRequired("body") + + // Flags for update command + commentsUpdateCmd.Flags().IntVar(&commentID, "id", 0, "Comment ID") + commentsUpdateCmd.Flags().StringVar(&commentBody, "body", "", "New comment body text") + commentsUpdateCmd.Flags(). + StringVarP(&commentsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = commentsUpdateCmd.MarkFlagRequired("id") + _ = commentsUpdateCmd.MarkFlagRequired("body") + + // Flags for delete command + commentsDeleteCmd.Flags().IntVar(&commentID, "id", 0, "Comment ID") + _ = commentsDeleteCmd.MarkFlagRequired("id") +} diff --git a/cmd/dataapi_test.go b/cmd/dataapi_test.go new file mode 100644 index 0000000..b2a18f7 --- /dev/null +++ b/cmd/dataapi_test.go @@ -0,0 +1,323 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +// TestCommentsListCommand tests the comments list command validation +func TestCommentsListCommand(t *testing.T) { + tests := []struct { + name string + projectIDValue int + faultIDValue int + authToken string + serverStatus int + serverBody string + expectedError bool + errorContains string + }{ + { + name: "successful list", + projectIDValue: 123, + faultIDValue: 456, + authToken: "test-token", + serverStatus: http.StatusOK, + serverBody: `{"results": [], "links": {"self": "/v2/projects/123/faults/456/comments"}}`, + expectedError: false, + }, + { + name: "missing project ID", + projectIDValue: 0, + faultIDValue: 456, + authToken: "test-token", + expectedError: true, + errorContains: "project ID is required", + }, + { + name: "missing fault ID", + projectIDValue: 123, + faultIDValue: 0, + authToken: "test-token", + expectedError: true, + errorContains: "fault ID is required", + }, + { + name: "missing auth token", + projectIDValue: 123, + faultIDValue: 456, + authToken: "", + expectedError: true, + errorContains: "auth token is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var serverURL string + if tt.authToken != "" && tt.projectIDValue != 0 && tt.faultIDValue != 0 { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.serverStatus) + _, _ = w.Write([]byte(tt.serverBody)) + }), + ) + defer server.Close() + serverURL = server.URL + } else { + serverURL = "http://localhost:9999" + } + + viper.Reset() + viper.Set("endpoint", serverURL) + if tt.authToken != "" { + viper.Set("auth_token", tt.authToken) + } + + commentsProjectID = tt.projectIDValue + commentsFaultID = tt.faultIDValue + commentsOutputFormat = "table" + + err := commentsListCmd.RunE(commentsListCmd, []string{}) + + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestDeploymentsListCommand tests the deployments list command validation +func TestDeploymentsListCommand(t *testing.T) { + tests := []struct { + name string + projectIDValue int + authToken string + serverStatus int + serverBody string + expectedError bool + errorContains string + }{ + { + name: "successful list", + projectIDValue: 123, + authToken: "test-token", + serverStatus: http.StatusOK, + serverBody: `{"results": [], "links": {"self": "/v2/projects/123/deploys"}}`, + expectedError: false, + }, + { + name: "missing project ID", + projectIDValue: 0, + authToken: "test-token", + expectedError: true, + errorContains: "project ID is required", + }, + { + name: "missing auth token", + projectIDValue: 123, + authToken: "", + expectedError: true, + errorContains: "auth token is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var serverURL string + if tt.authToken != "" && tt.projectIDValue != 0 { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.serverStatus) + _, _ = w.Write([]byte(tt.serverBody)) + }), + ) + defer server.Close() + serverURL = server.URL + } else { + serverURL = "http://localhost:9999" + } + + viper.Reset() + viper.Set("endpoint", serverURL) + if tt.authToken != "" { + viper.Set("auth_token", tt.authToken) + } + + deploymentsProjectID = tt.projectIDValue + deploymentsOutputFormat = "table" + + err := deploymentsListCmd.RunE(deploymentsListCmd, []string{}) + + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestEnvironmentsListCommand tests the environments list command validation +func TestEnvironmentsListCommand(t *testing.T) { + tests := []struct { + name string + projectIDValue int + authToken string + serverStatus int + serverBody string + expectedError bool + errorContains string + }{ + { + name: "successful list", + projectIDValue: 123, + authToken: "test-token", + serverStatus: http.StatusOK, + serverBody: `{"results": [], "links": {"self": "/v2/projects/123/environments"}}`, + expectedError: false, + }, + { + name: "missing project ID", + projectIDValue: 0, + authToken: "test-token", + expectedError: true, + errorContains: "project ID is required", + }, + { + name: "missing auth token", + projectIDValue: 123, + authToken: "", + expectedError: true, + errorContains: "auth token is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var serverURL string + if tt.authToken != "" && tt.projectIDValue != 0 { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.serverStatus) + _, _ = w.Write([]byte(tt.serverBody)) + }), + ) + defer server.Close() + serverURL = server.URL + } else { + serverURL = "http://localhost:9999" + } + + viper.Reset() + viper.Set("endpoint", serverURL) + if tt.authToken != "" { + viper.Set("auth_token", tt.authToken) + } + + environmentsProjectID = tt.projectIDValue + environmentsOutputFormat = "table" + + err := environmentsListCmd.RunE(environmentsListCmd, []string{}) + + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestStatuspagesListCommand tests the statuspages list command validation +func TestStatuspagesListCommand(t *testing.T) { + tests := []struct { + name string + accountIDValue string + authToken string + serverStatus int + serverBody string + expectedError bool + errorContains string + }{ + { + name: "successful list", + accountIDValue: "123", + authToken: "test-token", + serverStatus: http.StatusOK, + serverBody: `{"results": []}`, + expectedError: false, + }, + { + name: "missing account ID", + accountIDValue: "", + authToken: "test-token", + expectedError: true, + errorContains: "account ID is required", + }, + { + name: "missing auth token", + accountIDValue: "123", + authToken: "", + expectedError: true, + errorContains: "auth token is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var serverURL string + if tt.authToken != "" && tt.accountIDValue != "" { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.serverStatus) + _, _ = w.Write([]byte(tt.serverBody)) + }), + ) + defer server.Close() + serverURL = server.URL + } else { + serverURL = "http://localhost:9999" + } + + viper.Reset() + viper.Set("endpoint", serverURL) + if tt.authToken != "" { + viper.Set("auth_token", tt.authToken) + } + + statuspagesAccountID = tt.accountIDValue + statuspagesOutputFormat = "table" + + err := statuspagesListCmd.RunE(statuspagesListCmd, []string{}) + + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/cmd/deploy.go b/cmd/deploy.go index ab92a74..01e39a7 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -31,8 +31,9 @@ type deployPayload struct { // deployCmd represents the deploy command var deployCmd = &cobra.Command{ - Use: "deploy", - Short: "Report a deployment to Honeybadger", + Use: "deploy", + Short: "Report a deployment to Honeybadger", + GroupID: GroupReportingAPI, Long: `Report a deployment to Honeybadger's Reporting API. This command sends deployment information including environment, repository, revision, and the local username of the person deploying.`, diff --git a/cmd/deployments.go b/cmd/deployments.go new file mode 100644 index 0000000..ef16cb4 --- /dev/null +++ b/cmd/deployments.go @@ -0,0 +1,224 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + hbapi "github.com/honeybadger-io/api-go" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + deploymentsProjectID int + deploymentID int + deploymentsOutputFormat string + deploymentsEnvironment string + deploymentsLocalUser string + deploymentsCreatedAfter int64 + deploymentsCreatedBefore int64 + deploymentsLimit int +) + +// deploymentsCmd represents the deployments command +var deploymentsCmd = &cobra.Command{ + Use: "deployments", + Short: "View and manage deployments", + GroupID: GroupDataAPI, + Long: `View and manage deployment records for your Honeybadger projects.`, +} + +// deploymentsListCmd represents the deployments list command +var deploymentsListCmd = &cobra.Command{ + Use: "list", + Short: "List deployments for a project", + Long: `List all deployments for a specific project with optional filtering.`, + RunE: func(_ *cobra.Command, _ []string) error { + if deploymentsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + options := hbapi.DeploymentListOptions{ + Environment: deploymentsEnvironment, + LocalUsername: deploymentsLocalUser, + CreatedAfter: deploymentsCreatedAfter, + CreatedBefore: deploymentsCreatedBefore, + Limit: deploymentsLimit, + } + + ctx := context.Background() + deployments, err := client.Deployments.List(ctx, deploymentsProjectID, options) + if err != nil { + return fmt.Errorf("failed to list deployments: %w", err) + } + + switch deploymentsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(deployments, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tENVIRONMENT\tREVISION\tUSER\tCREATED") + for _, d := range deployments { + revision := d.Revision + if len(revision) > 12 { + revision = revision[:12] + } + + _, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", + d.ID, + d.Environment, + revision, + d.LocalUsername, + d.CreatedAt.Format("2006-01-02 15:04")) + } + _ = w.Flush() + } + + return nil + }, +} + +// deploymentsGetCmd represents the deployments get command +var deploymentsGetCmd = &cobra.Command{ + Use: "get", + Short: "Get a deployment by ID", + Long: `Get detailed information about a specific deployment.`, + RunE: func(_ *cobra.Command, _ []string) error { + if deploymentsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if deploymentID == 0 { + return fmt.Errorf("deployment ID is required. Set it using --id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + deployment, err := client.Deployments.Get(ctx, deploymentsProjectID, deploymentID) + if err != nil { + return fmt.Errorf("failed to get deployment: %w", err) + } + + switch deploymentsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(deployment, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Deployment Details:\n") + fmt.Printf(" ID: %d\n", deployment.ID) + fmt.Printf(" Environment: %s\n", deployment.Environment) + fmt.Printf(" Revision: %s\n", deployment.Revision) + fmt.Printf(" Repository: %s\n", deployment.Repository) + fmt.Printf(" Local Username: %s\n", deployment.LocalUsername) + fmt.Printf(" Project ID: %d\n", deployment.ProjectID) + fmt.Printf(" Created: %s\n", deployment.CreatedAt.Format("2006-01-02 15:04:05")) + } + + return nil + }, +} + +// deploymentsDeleteCmd represents the deployments delete command +var deploymentsDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a deployment", + Long: `Delete a deployment record by ID. This action cannot be undone.`, + RunE: func(_ *cobra.Command, _ []string) error { + if deploymentsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if deploymentID == 0 { + return fmt.Errorf("deployment ID is required. Set it using --id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + err := client.Deployments.Delete(ctx, deploymentsProjectID, deploymentID) + if err != nil { + return fmt.Errorf("failed to delete deployment: %w", err) + } + + fmt.Println("Deployment deleted successfully") + return nil + }, +} + +func init() { + rootCmd.AddCommand(deploymentsCmd) + deploymentsCmd.AddCommand(deploymentsListCmd) + deploymentsCmd.AddCommand(deploymentsGetCmd) + deploymentsCmd.AddCommand(deploymentsDeleteCmd) + + // Common flags + deploymentsCmd.PersistentFlags().IntVar(&deploymentsProjectID, "project-id", 0, "Project ID") + + // Flags for list command + deploymentsListCmd.Flags(). + StringVarP(&deploymentsOutputFormat, "output", "o", "table", "Output format (table or json)") + deploymentsListCmd.Flags(). + StringVarP(&deploymentsEnvironment, "environment", "e", "", "Filter by environment") + deploymentsListCmd.Flags(). + StringVar(&deploymentsLocalUser, "local-user", "", "Filter by local username") + deploymentsListCmd.Flags(). + Int64Var(&deploymentsCreatedAfter, "created-after", 0, "Filter by creation time (Unix timestamp)") + deploymentsListCmd.Flags(). + Int64Var(&deploymentsCreatedBefore, "created-before", 0, "Filter by creation time (Unix timestamp)") + deploymentsListCmd.Flags(). + IntVar(&deploymentsLimit, "limit", 25, "Maximum number of deployments to return (max 25)") + + // Flags for get command + deploymentsGetCmd.Flags().IntVar(&deploymentID, "id", 0, "Deployment ID") + deploymentsGetCmd.Flags(). + StringVarP(&deploymentsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = deploymentsGetCmd.MarkFlagRequired("id") + + // Flags for delete command + deploymentsDeleteCmd.Flags().IntVar(&deploymentID, "id", 0, "Deployment ID") + _ = deploymentsDeleteCmd.MarkFlagRequired("id") +} diff --git a/cmd/environments.go b/cmd/environments.go new file mode 100644 index 0000000..381ce6e --- /dev/null +++ b/cmd/environments.go @@ -0,0 +1,358 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + hbapi "github.com/honeybadger-io/api-go" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + environmentsProjectID int + environmentID int + environmentsOutputFormat string + environmentCLIInputJSON string +) + +// environmentsCmd represents the environments command +var environmentsCmd = &cobra.Command{ + Use: "environments", + Short: "Manage project environments", + GroupID: GroupDataAPI, + Long: `View and manage environments for your Honeybadger projects.`, +} + +// environmentsListCmd represents the environments list command +var environmentsListCmd = &cobra.Command{ + Use: "list", + Short: "List environments for a project", + Long: `List all environments configured for a specific project.`, + RunE: func(_ *cobra.Command, _ []string) error { + if environmentsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + environments, err := client.Environments.List(ctx, environmentsProjectID) + if err != nil { + return fmt.Errorf("failed to list environments: %w", err) + } + + switch environmentsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(environments, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tNAME\tNOTIFICATIONS\tCREATED") + for _, env := range environments { + notifications := "No" + if env.Notifications { + notifications = "Yes" + } + + _, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", + env.ID, + env.Name, + notifications, + env.CreatedAt.Format("2006-01-02 15:04")) + } + _ = w.Flush() + } + + return nil + }, +} + +// environmentsGetCmd represents the environments get command +var environmentsGetCmd = &cobra.Command{ + Use: "get", + Short: "Get an environment by ID", + Long: `Get detailed information about a specific environment.`, + RunE: func(_ *cobra.Command, _ []string) error { + if environmentsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if environmentID == 0 { + return fmt.Errorf("environment ID is required. Set it using --id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + environment, err := client.Environments.Get(ctx, environmentsProjectID, environmentID) + if err != nil { + return fmt.Errorf("failed to get environment: %w", err) + } + + switch environmentsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(environment, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Environment Details:\n") + fmt.Printf(" ID: %d\n", environment.ID) + fmt.Printf(" Name: %s\n", environment.Name) + fmt.Printf(" Project ID: %d\n", environment.ProjectID) + fmt.Printf(" Notifications: %v\n", environment.Notifications) + fmt.Printf(" Created: %s\n", environment.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf(" Updated: %s\n", environment.UpdatedAt.Format("2006-01-02 15:04:05")) + } + + return nil + }, +} + +// environmentsCreateCmd represents the environments create command +var environmentsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new environment", + Long: `Create a new environment for a project. + +The --cli-input-json flag accepts either a JSON string or a file path prefixed with 'file://'. + +Example JSON payload: +{ + "environment": { + "name": "staging", + "notifications": true + } +}`, + RunE: func(_ *cobra.Command, _ []string) error { + if environmentsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if environmentCLIInputJSON == "" { + return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + jsonData, err := readJSONInput(environmentCLIInputJSON) + if err != nil { + return fmt.Errorf("failed to read JSON input: %w", err) + } + + var payload struct { + Environment hbapi.EnvironmentParams `json:"environment"` + } + if err := json.Unmarshal(jsonData, &payload); err != nil { + return fmt.Errorf("failed to parse JSON payload: %w", err) + } + + ctx := context.Background() + environment, err := client.Environments.Create( + ctx, + environmentsProjectID, + payload.Environment, + ) + if err != nil { + return fmt.Errorf("failed to create environment: %w", err) + } + + switch environmentsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(environment, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Environment created successfully!\n") + fmt.Printf(" ID: %d\n", environment.ID) + fmt.Printf(" Name: %s\n", environment.Name) + } + + return nil + }, +} + +// environmentsUpdateCmd represents the environments update command +var environmentsUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update an existing environment", + Long: `Update an existing environment's settings. + +The --cli-input-json flag accepts either a JSON string or a file path prefixed with 'file://'. + +Example JSON payload: +{ + "environment": { + "name": "production", + "notifications": false + } +}`, + RunE: func(_ *cobra.Command, _ []string) error { + if environmentsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if environmentID == 0 { + return fmt.Errorf("environment ID is required. Set it using --id flag") + } + if environmentCLIInputJSON == "" { + return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + jsonData, err := readJSONInput(environmentCLIInputJSON) + if err != nil { + return fmt.Errorf("failed to read JSON input: %w", err) + } + + var payload struct { + Environment hbapi.EnvironmentParams `json:"environment"` + } + if err := json.Unmarshal(jsonData, &payload); err != nil { + return fmt.Errorf("failed to parse JSON payload: %w", err) + } + + ctx := context.Background() + err = client.Environments.Update( + ctx, + environmentsProjectID, + environmentID, + payload.Environment, + ) + if err != nil { + return fmt.Errorf("failed to update environment: %w", err) + } + + fmt.Println("Environment updated successfully") + return nil + }, +} + +// environmentsDeleteCmd represents the environments delete command +var environmentsDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete an environment", + Long: `Delete an environment by ID. This action cannot be undone.`, + RunE: func(_ *cobra.Command, _ []string) error { + if environmentsProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if environmentID == 0 { + return fmt.Errorf("environment ID is required. Set it using --id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + err := client.Environments.Delete(ctx, environmentsProjectID, environmentID) + if err != nil { + return fmt.Errorf("failed to delete environment: %w", err) + } + + fmt.Println("Environment deleted successfully") + return nil + }, +} + +func init() { + rootCmd.AddCommand(environmentsCmd) + environmentsCmd.AddCommand(environmentsListCmd) + environmentsCmd.AddCommand(environmentsGetCmd) + environmentsCmd.AddCommand(environmentsCreateCmd) + environmentsCmd.AddCommand(environmentsUpdateCmd) + environmentsCmd.AddCommand(environmentsDeleteCmd) + + // Common flags + environmentsCmd.PersistentFlags().IntVar(&environmentsProjectID, "project-id", 0, "Project ID") + + // Flags for list command + environmentsListCmd.Flags(). + StringVarP(&environmentsOutputFormat, "output", "o", "table", "Output format (table or json)") + + // Flags for get command + environmentsGetCmd.Flags().IntVar(&environmentID, "id", 0, "Environment ID") + environmentsGetCmd.Flags(). + StringVarP(&environmentsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = environmentsGetCmd.MarkFlagRequired("id") + + // Flags for create command + environmentsCreateCmd.Flags(). + StringVar(&environmentCLIInputJSON, "cli-input-json", "", "JSON payload (string or file://path)") + environmentsCreateCmd.Flags(). + StringVarP(&environmentsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = environmentsCreateCmd.MarkFlagRequired("cli-input-json") + + // Flags for update command + environmentsUpdateCmd.Flags().IntVar(&environmentID, "id", 0, "Environment ID") + environmentsUpdateCmd.Flags(). + StringVar(&environmentCLIInputJSON, "cli-input-json", "", "JSON payload (string or file://path)") + _ = environmentsUpdateCmd.MarkFlagRequired("id") + _ = environmentsUpdateCmd.MarkFlagRequired("cli-input-json") + + // Flags for delete command + environmentsDeleteCmd.Flags().IntVar(&environmentID, "id", 0, "Environment ID") + _ = environmentsDeleteCmd.MarkFlagRequired("id") +} diff --git a/cmd/faults.go b/cmd/faults.go index 60319aa..5d4cbf5 100644 --- a/cmd/faults.go +++ b/cmd/faults.go @@ -24,9 +24,10 @@ var ( // faultsCmd represents the faults command var faultsCmd = &cobra.Command{ - Use: "faults", - Short: "Manage Honeybadger faults", - Long: `View and manage faults (errors) in your Honeybadger projects.`, + Use: "faults", + Short: "Manage Honeybadger faults", + GroupID: GroupDataAPI, + Long: `View and manage faults (errors) in your Honeybadger projects.`, } // faultsListCmd represents the faults list command diff --git a/cmd/insights.go b/cmd/insights.go index 1e40a35..c9ed602 100644 --- a/cmd/insights.go +++ b/cmd/insights.go @@ -23,9 +23,10 @@ var ( // insightsCmd represents the insights command var insightsCmd = &cobra.Command{ - Use: "insights", - Short: "Query Honeybadger Insights data", - Long: `Execute BadgerQL queries against your Honeybadger Insights data.`, + Use: "insights", + Short: "Query Honeybadger Insights data", + GroupID: GroupDataAPI, + Long: `Execute BadgerQL queries against your Honeybadger Insights data.`, } // insightsQueryCmd represents the insights query command diff --git a/cmd/projects.go b/cmd/projects.go index 4b3a604..7df4275 100644 --- a/cmd/projects.go +++ b/cmd/projects.go @@ -28,9 +28,10 @@ var ( // projectsCmd represents the projects command var projectsCmd = &cobra.Command{ - Use: "projects", - Short: "Manage Honeybadger projects", - Long: `View and manage your Honeybadger projects.`, + Use: "projects", + Short: "Manage Honeybadger projects", + GroupID: GroupDataAPI, + Long: `View and manage your Honeybadger projects.`, } // projectsListCmd represents the projects list command diff --git a/cmd/root.go b/cmd/root.go index 41c5774..2e6ddc3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "net/url" "os" "strings" @@ -24,12 +25,25 @@ var ( Date string ) +// Command group IDs +const ( + GroupReportingAPI = "reporting" + GroupDataAPI = "data" +) + // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "hb", Short: "Honeybadger CLI tool", - Long: `A command line interface for interacting with Honeybadger's Reporting API. -This tool allows you to manage deployments and other reporting features.`, + Long: `A command line interface for interacting with Honeybadger. + +This tool provides access to two APIs: + + Reporting API - For sending data to Honeybadger (deployments, metrics) + Authenticate with --api-key or HONEYBADGER_API_KEY + + Data API - For reading and managing your Honeybadger data + Authenticate with --auth-token or HONEYBADGER_AUTH_TOKEN`, } // Execute adds all child commands to the root command and sets flags appropriately. @@ -40,6 +54,16 @@ func Execute() error { func init() { cobra.OnInitialize(initConfig) + // Add command groups + rootCmd.AddGroup(&cobra.Group{ + ID: GroupReportingAPI, + Title: "Reporting API Commands (use --api-key):", + }) + rootCmd.AddGroup(&cobra.Group{ + ID: GroupDataAPI, + Title: "Data API Commands (use --auth-token):", + }) + rootCmd.PersistentFlags(). StringVar(&cfgFile, "config", "", "config file (default is ~/.honeybadger-cli.yaml)") rootCmd.PersistentFlags(). @@ -49,7 +73,8 @@ func init() { rootCmd.PersistentFlags(). StringVar(&endpoint, "endpoint", defaultEndpoint, "Honeybadger endpoint") - if err := viper.BindPFlag("api_key", rootCmd.PersistentFlags().Lookup("api-key")); err != nil { + err := viper.BindPFlag("api_key", rootCmd.PersistentFlags().Lookup("api-key")) + if err != nil { fmt.Printf("error binding api-key flag: %v\n", err) } if err := viper.BindPFlag( @@ -102,13 +127,31 @@ func initConfig() { // convertEndpointForDataAPI converts api.honeybadger.io to app.honeybadger.io for Data API calls func convertEndpointForDataAPI(endpoint string) string { - switch endpoint { + trimmed := strings.TrimSpace(endpoint) + if trimmed == "" { + return endpoint + } + + parsed, err := url.Parse(trimmed) + if err == nil && parsed.Scheme != "" && parsed.Host != "" { + switch parsed.Host { + case "api.honeybadger.io": + parsed.Host = "app.honeybadger.io" + case "eu-api.honeybadger.io": + parsed.Host = "eu-app.honeybadger.io" + } + return parsed.String() + } + + normalized := strings.TrimRight(trimmed, "/") + switch normalized { case "https://api.honeybadger.io": return "https://app.honeybadger.io" case "https://eu-api.honeybadger.io": return "https://eu-app.honeybadger.io" + default: + return trimmed } - return endpoint } // readJSONInput reads JSON from either a direct string or a file path prefixed with 'file://' diff --git a/cmd/statuspages.go b/cmd/statuspages.go new file mode 100644 index 0000000..85b8f71 --- /dev/null +++ b/cmd/statuspages.go @@ -0,0 +1,370 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + hbapi "github.com/honeybadger-io/api-go" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + statuspagesAccountID string + statuspageID string + statuspagesOutputFormat string + statuspageCLIInputJSON string +) + +// statuspagesCmd represents the statuspages command +var statuspagesCmd = &cobra.Command{ + Use: "statuspages", + Short: "Manage status pages", + GroupID: GroupDataAPI, + Long: `View and manage status pages for your Honeybadger accounts.`, +} + +// statuspagesListCmd represents the statuspages list command +var statuspagesListCmd = &cobra.Command{ + Use: "list", + Short: "List status pages for an account", + Long: `List all status pages configured for a specific account.`, + RunE: func(_ *cobra.Command, _ []string) error { + if statuspagesAccountID == "" { + return fmt.Errorf("account ID is required. Set it using --account-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + statusPages, err := client.StatusPages.List(ctx, statuspagesAccountID) + if err != nil { + return fmt.Errorf("failed to list status pages: %w", err) + } + + switch statuspagesOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(statusPages, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tNAME\tURL\tSITES\tCHECK-INS") + for _, sp := range statusPages { + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%d\n", + sp.ID, + sp.Name, + sp.URL, + len(sp.Sites), + len(sp.CheckIns)) + } + _ = w.Flush() + } + + return nil + }, +} + +// statuspagesGetCmd represents the statuspages get command +var statuspagesGetCmd = &cobra.Command{ + Use: "get", + Short: "Get a status page by ID", + Long: `Get detailed information about a specific status page.`, + RunE: func(_ *cobra.Command, _ []string) error { + if statuspagesAccountID == "" { + return fmt.Errorf("account ID is required. Set it using --account-id flag") + } + if statuspageID == "" { + return fmt.Errorf("status page ID is required. Set it using --id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + statusPage, err := client.StatusPages.Get(ctx, statuspagesAccountID, statuspageID) + if err != nil { + return fmt.Errorf("failed to get status page: %w", err) + } + + switch statuspagesOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(statusPage, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Status Page Details:\n") + fmt.Printf(" ID: %s\n", statusPage.ID) + fmt.Printf(" Name: %s\n", statusPage.Name) + fmt.Printf(" URL: %s\n", statusPage.URL) + fmt.Printf(" Account ID: %s\n", statusPage.AccountID) + if statusPage.Domain != nil { + fmt.Printf(" Domain: %s\n", *statusPage.Domain) + } + fmt.Printf(" Created: %s\n", statusPage.CreatedAt.Format("2006-01-02 15:04:05")) + if statusPage.DomainVerifiedAt != nil { + fmt.Printf( + " Domain Verified: %s\n", + statusPage.DomainVerifiedAt.Format("2006-01-02 15:04:05"), + ) + } + if len(statusPage.Sites) > 0 { + fmt.Printf(" Sites:\n") + for _, site := range statusPage.Sites { + fmt.Printf(" - %s (%s)\n", site.DisplayName, site.State) + } + } + if len(statusPage.CheckIns) > 0 { + fmt.Printf(" Check-ins:\n") + for _, ci := range statusPage.CheckIns { + fmt.Printf(" - %s (%s)\n", ci.DisplayName, ci.State) + } + } + } + + return nil + }, +} + +// statuspagesCreateCmd represents the statuspages create command +var statuspagesCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new status page", + Long: `Create a new status page for an account. + +The --cli-input-json flag accepts either a JSON string or a file path prefixed with 'file://'. + +Example JSON payload: +{ + "status_page": { + "name": "My Status Page", + "domain": "status.example.com", + "sites": ["site-id-1", "site-id-2"], + "check_ins": ["checkin-slug-1"], + "hide_branding": false + } +}`, + RunE: func(_ *cobra.Command, _ []string) error { + if statuspagesAccountID == "" { + return fmt.Errorf("account ID is required. Set it using --account-id flag") + } + if statuspageCLIInputJSON == "" { + return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + jsonData, err := readJSONInput(statuspageCLIInputJSON) + if err != nil { + return fmt.Errorf("failed to read JSON input: %w", err) + } + + var payload struct { + StatusPage hbapi.StatusPageParams `json:"status_page"` + } + if err := json.Unmarshal(jsonData, &payload); err != nil { + return fmt.Errorf("failed to parse JSON payload: %w", err) + } + + ctx := context.Background() + statusPage, err := client.StatusPages.Create(ctx, statuspagesAccountID, payload.StatusPage) + if err != nil { + return fmt.Errorf("failed to create status page: %w", err) + } + + switch statuspagesOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(statusPage, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Status page created successfully!\n") + fmt.Printf(" ID: %s\n", statusPage.ID) + fmt.Printf(" Name: %s\n", statusPage.Name) + fmt.Printf(" URL: %s\n", statusPage.URL) + } + + return nil + }, +} + +// statuspagesUpdateCmd represents the statuspages update command +var statuspagesUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update an existing status page", + Long: `Update an existing status page's settings. + +The --cli-input-json flag accepts either a JSON string or a file path prefixed with 'file://'. + +Example JSON payload: +{ + "status_page": { + "name": "Updated Status Page", + "sites": ["site-id-1", "site-id-3"] + } +}`, + RunE: func(_ *cobra.Command, _ []string) error { + if statuspagesAccountID == "" { + return fmt.Errorf("account ID is required. Set it using --account-id flag") + } + if statuspageID == "" { + return fmt.Errorf("status page ID is required. Set it using --id flag") + } + if statuspageCLIInputJSON == "" { + return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + jsonData, err := readJSONInput(statuspageCLIInputJSON) + if err != nil { + return fmt.Errorf("failed to read JSON input: %w", err) + } + + var payload struct { + StatusPage hbapi.StatusPageParams `json:"status_page"` + } + if err := json.Unmarshal(jsonData, &payload); err != nil { + return fmt.Errorf("failed to parse JSON payload: %w", err) + } + + ctx := context.Background() + err = client.StatusPages.Update(ctx, statuspagesAccountID, statuspageID, payload.StatusPage) + if err != nil { + return fmt.Errorf("failed to update status page: %w", err) + } + + fmt.Println("Status page updated successfully") + return nil + }, +} + +// statuspagesDeleteCmd represents the statuspages delete command +var statuspagesDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a status page", + Long: `Delete a status page by ID. This action cannot be undone.`, + RunE: func(_ *cobra.Command, _ []string) error { + if statuspagesAccountID == "" { + return fmt.Errorf("account ID is required. Set it using --account-id flag") + } + if statuspageID == "" { + return fmt.Errorf("status page ID is required. Set it using --id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + err := client.StatusPages.Delete(ctx, statuspagesAccountID, statuspageID) + if err != nil { + return fmt.Errorf("failed to delete status page: %w", err) + } + + fmt.Println("Status page deleted successfully") + return nil + }, +} + +func init() { + rootCmd.AddCommand(statuspagesCmd) + statuspagesCmd.AddCommand(statuspagesListCmd) + statuspagesCmd.AddCommand(statuspagesGetCmd) + statuspagesCmd.AddCommand(statuspagesCreateCmd) + statuspagesCmd.AddCommand(statuspagesUpdateCmd) + statuspagesCmd.AddCommand(statuspagesDeleteCmd) + + // Common flags + statuspagesCmd.PersistentFlags(). + StringVar(&statuspagesAccountID, "account-id", "", "Account ID") + + // Flags for list command + statuspagesListCmd.Flags(). + StringVarP(&statuspagesOutputFormat, "output", "o", "table", "Output format (table or json)") + + // Flags for get command + statuspagesGetCmd.Flags().StringVar(&statuspageID, "id", "", "Status page ID") + statuspagesGetCmd.Flags(). + StringVarP(&statuspagesOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = statuspagesGetCmd.MarkFlagRequired("id") + + // Flags for create command + statuspagesCreateCmd.Flags(). + StringVar(&statuspageCLIInputJSON, "cli-input-json", "", "JSON payload (string or file://path)") + statuspagesCreateCmd.Flags(). + StringVarP(&statuspagesOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = statuspagesCreateCmd.MarkFlagRequired("cli-input-json") + + // Flags for update command + statuspagesUpdateCmd.Flags().StringVar(&statuspageID, "id", "", "Status page ID") + statuspagesUpdateCmd.Flags(). + StringVar(&statuspageCLIInputJSON, "cli-input-json", "", "JSON payload (string or file://path)") + _ = statuspagesUpdateCmd.MarkFlagRequired("id") + _ = statuspagesUpdateCmd.MarkFlagRequired("cli-input-json") + + // Flags for delete command + statuspagesDeleteCmd.Flags().StringVar(&statuspageID, "id", "", "Status page ID") + _ = statuspagesDeleteCmd.MarkFlagRequired("id") +} diff --git a/cmd/teams.go b/cmd/teams.go new file mode 100644 index 0000000..66b748f --- /dev/null +++ b/cmd/teams.go @@ -0,0 +1,867 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + hbapi "github.com/honeybadger-io/api-go" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + teamsAccountID string + teamID int + teamsOutputFormat string + teamName string + teamMemberID int + teamMemberAdmin bool + teamInvitationID int + teamCLIInputJSON string +) + +// teamsCmd represents the teams command +var teamsCmd = &cobra.Command{ + Use: "teams", + Short: "Manage Honeybadger teams", + GroupID: GroupDataAPI, + Long: `View and manage teams, team members, and team invitations.`, +} + +// teamsListCmd represents the teams list command +var teamsListCmd = &cobra.Command{ + Use: "list", + Short: "List teams for an account", + Long: `List all teams for a specific account.`, + RunE: func(_ *cobra.Command, _ []string) error { + if teamsAccountID == "" { + return fmt.Errorf("account ID is required. Set it using --account-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + teams, err := client.Teams.List(ctx, teamsAccountID) + if err != nil { + return fmt.Errorf("failed to list teams: %w", err) + } + + switch teamsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(teams, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tNAME\tCREATED") + for _, team := range teams { + _, _ = fmt.Fprintf(w, "%d\t%s\t%s\n", + team.ID, + team.Name, + team.CreatedAt.Format("2006-01-02 15:04")) + } + _ = w.Flush() + } + + return nil + }, +} + +// teamsGetCmd represents the teams get command +var teamsGetCmd = &cobra.Command{ + Use: "get", + Short: "Get a team by ID", + Long: `Get detailed information about a specific team.`, + RunE: func(_ *cobra.Command, _ []string) error { + if teamID == 0 { + return fmt.Errorf("team ID is required. Set it using --id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + team, err := client.Teams.Get(ctx, teamID) + if err != nil { + return fmt.Errorf("failed to get team: %w", err) + } + + switch teamsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(team, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Team Details:\n") + fmt.Printf(" ID: %d\n", team.ID) + fmt.Printf(" Name: %s\n", team.Name) + fmt.Printf(" Account ID: %d\n", team.AccountID) + fmt.Printf(" Created: %s\n", team.CreatedAt.Format("2006-01-02 15:04:05")) + } + + return nil + }, +} + +// teamsCreateCmd represents the teams create command +var teamsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new team", + Long: `Create a new team for an account.`, + RunE: func(_ *cobra.Command, _ []string) error { + if teamsAccountID == "" { + return fmt.Errorf("account ID is required. Set it using --account-id flag") + } + if teamName == "" { + return fmt.Errorf("team name is required. Set it using --name flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + team, err := client.Teams.Create(ctx, teamsAccountID, teamName) + if err != nil { + return fmt.Errorf("failed to create team: %w", err) + } + + switch teamsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(team, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Team created successfully!\n") + fmt.Printf(" ID: %d\n", team.ID) + fmt.Printf(" Name: %s\n", team.Name) + } + + return nil + }, +} + +// teamsUpdateCmd represents the teams update command +var teamsUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update an existing team", + Long: `Update an existing team's name.`, + RunE: func(_ *cobra.Command, _ []string) error { + if teamID == 0 { + return fmt.Errorf("team ID is required. Set it using --id flag") + } + if teamName == "" { + return fmt.Errorf("team name is required. Set it using --name flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + if err := client.Teams.Update(ctx, teamID, teamName); err != nil { + return fmt.Errorf("failed to update team: %w", err) + } + + team, err := client.Teams.Get(ctx, teamID) + if err != nil { + return fmt.Errorf("failed to fetch updated team: %w", err) + } + + switch teamsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(team, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Team updated successfully!\n") + fmt.Printf(" ID: %d\n", team.ID) + fmt.Printf(" Name: %s\n", team.Name) + } + + return nil + }, +} + +// teamsDeleteCmd represents the teams delete command +var teamsDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a team", + Long: `Delete a team by ID. This action cannot be undone.`, + RunE: func(_ *cobra.Command, _ []string) error { + if teamID == 0 { + return fmt.Errorf("team ID is required. Set it using --id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + err := client.Teams.Delete(ctx, teamID) + if err != nil { + return fmt.Errorf("failed to delete team: %w", err) + } + + fmt.Println("Team deleted successfully") + return nil + }, +} + +// teamsMembersCmd is the parent command for team member operations +var teamsMembersCmd = &cobra.Command{ + Use: "members", + Short: "Manage team members", + Long: `View and manage members of a team.`, +} + +// teamsMembersListCmd represents the teams members list command +var teamsMembersListCmd = &cobra.Command{ + Use: "list", + Short: "List members of a team", + Long: `List all members of a specific team.`, + RunE: func(_ *cobra.Command, _ []string) error { + if teamID == 0 { + return fmt.Errorf("team ID is required. Set it using --team-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + members, err := client.Teams.ListMembers(ctx, teamID) + if err != nil { + return fmt.Errorf("failed to list team members: %w", err) + } + + switch teamsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(members, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tNAME\tEMAIL\tADMIN") + for _, member := range members { + admin := " " + if member.Admin { + admin = "Yes" + } + _, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", + member.ID, + member.Name, + member.Email, + admin) + } + _ = w.Flush() + } + + return nil + }, +} + +// teamsMembersUpdateCmd represents the teams members update command +var teamsMembersUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update a team member's permissions", + Long: `Update a team member's admin status.`, + RunE: func(_ *cobra.Command, _ []string) error { + if teamID == 0 { + return fmt.Errorf("team ID is required. Set it using --team-id flag") + } + if teamMemberID == 0 { + return fmt.Errorf("member ID is required. Set it using --member-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + if err := client.Teams.UpdateMember( + ctx, + teamID, + teamMemberID, + teamMemberAdmin, + ); err != nil { + return fmt.Errorf("failed to update team member: %w", err) + } + + members, err := client.Teams.ListMembers(ctx, teamID) + if err != nil { + return fmt.Errorf("failed to fetch updated team member: %w", err) + } + + var member *hbapi.TeamMember + for i := range members { + if members[i].ID == teamMemberID { + member = &members[i] + break + } + } + if member == nil { + return fmt.Errorf("updated team member not found: %d", teamMemberID) + } + + switch teamsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(member, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Team member updated successfully!\n") + fmt.Printf(" ID: %d\n", member.ID) + fmt.Printf(" Name: %s\n", member.Name) + fmt.Printf(" Admin: %v\n", member.Admin) + } + + return nil + }, +} + +// teamsMembersRemoveCmd represents the teams members remove command +var teamsMembersRemoveCmd = &cobra.Command{ + Use: "remove", + Short: "Remove a member from a team", + Long: `Remove a member from a team. This action cannot be undone.`, + RunE: func(_ *cobra.Command, _ []string) error { + if teamID == 0 { + return fmt.Errorf("team ID is required. Set it using --team-id flag") + } + if teamMemberID == 0 { + return fmt.Errorf("member ID is required. Set it using --member-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + err := client.Teams.RemoveMember(ctx, teamID, teamMemberID) + if err != nil { + return fmt.Errorf("failed to remove team member: %w", err) + } + + fmt.Println("Team member removed successfully") + return nil + }, +} + +// teamsInvitationsCmd is the parent command for team invitation operations +var teamsInvitationsCmd = &cobra.Command{ + Use: "invitations", + Short: "Manage team invitations", + Long: `View and manage invitations to join a team.`, +} + +// teamsInvitationsListCmd represents the teams invitations list command +var teamsInvitationsListCmd = &cobra.Command{ + Use: "list", + Short: "List invitations for a team", + Long: `List all pending invitations for a team.`, + RunE: func(_ *cobra.Command, _ []string) error { + if teamID == 0 { + return fmt.Errorf("team ID is required. Set it using --team-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + invitations, err := client.Teams.ListInvitations(ctx, teamID) + if err != nil { + return fmt.Errorf("failed to list team invitations: %w", err) + } + + switch teamsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(invitations, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tEMAIL\tADMIN\tCREATED\tACCEPTED") + for _, inv := range invitations { + admin := " " + if inv.Admin { + admin = "Yes" + } + accepted := "No" + if inv.AcceptedAt != nil { + accepted = inv.AcceptedAt.Format("2006-01-02 15:04") + } + _, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", + inv.ID, + inv.Email, + admin, + inv.CreatedAt.Format("2006-01-02 15:04"), + accepted) + } + _ = w.Flush() + } + + return nil + }, +} + +// teamsInvitationsGetCmd represents the teams invitations get command +var teamsInvitationsGetCmd = &cobra.Command{ + Use: "get", + Short: "Get a team invitation by ID", + Long: `Get detailed information about a specific team invitation.`, + RunE: func(_ *cobra.Command, _ []string) error { + if teamID == 0 { + return fmt.Errorf("team ID is required. Set it using --team-id flag") + } + if teamInvitationID == 0 { + return fmt.Errorf("invitation ID is required. Set it using --invitation-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + invitation, err := client.Teams.GetInvitation(ctx, teamID, teamInvitationID) + if err != nil { + return fmt.Errorf("failed to get team invitation: %w", err) + } + + switch teamsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(invitation, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Team Invitation Details:\n") + fmt.Printf(" ID: %d\n", invitation.ID) + fmt.Printf(" Email: %s\n", invitation.Email) + fmt.Printf(" Admin: %v\n", invitation.Admin) + fmt.Printf(" Token: %s\n", invitation.Token) + fmt.Printf(" Created: %s\n", invitation.CreatedAt.Format("2006-01-02 15:04:05")) + if invitation.AcceptedAt != nil { + fmt.Printf(" Accepted: %s\n", invitation.AcceptedAt.Format("2006-01-02 15:04:05")) + } + if invitation.Message != nil { + fmt.Printf(" Message: %s\n", *invitation.Message) + } + } + + return nil + }, +} + +// teamsInvitationsCreateCmd represents the teams invitations create command +var teamsInvitationsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new team invitation", + Long: `Create a new invitation to join a team. + +The --cli-input-json flag accepts either a JSON string or a file path prefixed with 'file://'. + +Example JSON payload: +{ + "team_invitation": { + "email": "user@example.com", + "admin": false, + "message": "Welcome to the team!" + } +}`, + RunE: func(_ *cobra.Command, _ []string) error { + if teamID == 0 { + return fmt.Errorf("team ID is required. Set it using --team-id flag") + } + if teamCLIInputJSON == "" { + return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + jsonData, err := readJSONInput(teamCLIInputJSON) + if err != nil { + return fmt.Errorf("failed to read JSON input: %w", err) + } + + var payload struct { + TeamInvitation hbapi.TeamInvitationParams `json:"team_invitation"` + } + if err := json.Unmarshal(jsonData, &payload); err != nil { + return fmt.Errorf("failed to parse JSON payload: %w", err) + } + + ctx := context.Background() + invitation, err := client.Teams.CreateInvitation(ctx, teamID, payload.TeamInvitation) + if err != nil { + return fmt.Errorf("failed to create team invitation: %w", err) + } + + switch teamsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(invitation, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Team invitation created successfully!\n") + fmt.Printf(" ID: %d\n", invitation.ID) + fmt.Printf(" Email: %s\n", invitation.Email) + fmt.Printf(" Token: %s\n", invitation.Token) + } + + return nil + }, +} + +// teamsInvitationsUpdateCmd represents the teams invitations update command +var teamsInvitationsUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update a team invitation", + Long: `Update an existing team invitation. + +The --cli-input-json flag accepts either a JSON string or a file path prefixed with 'file://'. + +Example JSON payload: +{ + "team_invitation": { + "admin": true + } +}`, + RunE: func(_ *cobra.Command, _ []string) error { + if teamID == 0 { + return fmt.Errorf("team ID is required. Set it using --team-id flag") + } + if teamInvitationID == 0 { + return fmt.Errorf("invitation ID is required. Set it using --invitation-id flag") + } + if teamCLIInputJSON == "" { + return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + jsonData, err := readJSONInput(teamCLIInputJSON) + if err != nil { + return fmt.Errorf("failed to read JSON input: %w", err) + } + + var payload struct { + TeamInvitation hbapi.TeamInvitationParams `json:"team_invitation"` + } + if err := json.Unmarshal(jsonData, &payload); err != nil { + return fmt.Errorf("failed to parse JSON payload: %w", err) + } + + ctx := context.Background() + if err := client.Teams.UpdateInvitation( + ctx, + teamID, + teamInvitationID, + payload.TeamInvitation, + ); err != nil { + return fmt.Errorf("failed to update team invitation: %w", err) + } + + invitation, err := client.Teams.GetInvitation(ctx, teamID, teamInvitationID) + if err != nil { + return fmt.Errorf("failed to fetch updated team invitation: %w", err) + } + + switch teamsOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(invitation, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Team invitation updated successfully!\n") + fmt.Printf(" ID: %d\n", invitation.ID) + fmt.Printf(" Email: %s\n", invitation.Email) + fmt.Printf(" Admin: %v\n", invitation.Admin) + } + + return nil + }, +} + +// teamsInvitationsDeleteCmd represents the teams invitations delete command +var teamsInvitationsDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a team invitation", + Long: `Delete a pending team invitation. This action cannot be undone.`, + RunE: func(_ *cobra.Command, _ []string) error { + if teamID == 0 { + return fmt.Errorf("team ID is required. Set it using --team-id flag") + } + if teamInvitationID == 0 { + return fmt.Errorf("invitation ID is required. Set it using --invitation-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + err := client.Teams.DeleteInvitation(ctx, teamID, teamInvitationID) + if err != nil { + return fmt.Errorf("failed to delete team invitation: %w", err) + } + + fmt.Println("Team invitation deleted successfully") + return nil + }, +} + +func init() { + rootCmd.AddCommand(teamsCmd) + + // Add subcommands + teamsCmd.AddCommand(teamsListCmd) + teamsCmd.AddCommand(teamsGetCmd) + teamsCmd.AddCommand(teamsCreateCmd) + teamsCmd.AddCommand(teamsUpdateCmd) + teamsCmd.AddCommand(teamsDeleteCmd) + teamsCmd.AddCommand(teamsMembersCmd) + teamsCmd.AddCommand(teamsInvitationsCmd) + + // Members subcommands + teamsMembersCmd.AddCommand(teamsMembersListCmd) + teamsMembersCmd.AddCommand(teamsMembersUpdateCmd) + teamsMembersCmd.AddCommand(teamsMembersRemoveCmd) + + // Invitations subcommands + teamsInvitationsCmd.AddCommand(teamsInvitationsListCmd) + teamsInvitationsCmd.AddCommand(teamsInvitationsGetCmd) + teamsInvitationsCmd.AddCommand(teamsInvitationsCreateCmd) + teamsInvitationsCmd.AddCommand(teamsInvitationsUpdateCmd) + teamsInvitationsCmd.AddCommand(teamsInvitationsDeleteCmd) + + // Flags for list command + teamsListCmd.Flags().StringVar(&teamsAccountID, "account-id", "", "Account ID") + teamsListCmd.Flags(). + StringVarP(&teamsOutputFormat, "output", "o", "table", "Output format (table or json)") + _ = teamsListCmd.MarkFlagRequired("account-id") + + // Flags for get command + teamsGetCmd.Flags().IntVar(&teamID, "id", 0, "Team ID") + teamsGetCmd.Flags(). + StringVarP(&teamsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = teamsGetCmd.MarkFlagRequired("id") + + // Flags for create command + teamsCreateCmd.Flags().StringVar(&teamsAccountID, "account-id", "", "Account ID") + teamsCreateCmd.Flags().StringVar(&teamName, "name", "", "Team name") + teamsCreateCmd.Flags(). + StringVarP(&teamsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = teamsCreateCmd.MarkFlagRequired("account-id") + _ = teamsCreateCmd.MarkFlagRequired("name") + + // Flags for update command + teamsUpdateCmd.Flags().IntVar(&teamID, "id", 0, "Team ID") + teamsUpdateCmd.Flags().StringVar(&teamName, "name", "", "New team name") + teamsUpdateCmd.Flags(). + StringVarP(&teamsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = teamsUpdateCmd.MarkFlagRequired("id") + _ = teamsUpdateCmd.MarkFlagRequired("name") + + // Flags for delete command + teamsDeleteCmd.Flags().IntVar(&teamID, "id", 0, "Team ID") + _ = teamsDeleteCmd.MarkFlagRequired("id") + + // Common team ID flag for members subcommands + teamsMembersCmd.PersistentFlags().IntVar(&teamID, "team-id", 0, "Team ID") + + // Flags for members list + teamsMembersListCmd.Flags(). + StringVarP(&teamsOutputFormat, "output", "o", "table", "Output format (table or json)") + + // Flags for members update + teamsMembersUpdateCmd.Flags().IntVar(&teamMemberID, "member-id", 0, "Member ID") + teamsMembersUpdateCmd.Flags().BoolVar(&teamMemberAdmin, "admin", false, "Set admin status") + teamsMembersUpdateCmd.Flags(). + StringVarP(&teamsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = teamsMembersUpdateCmd.MarkFlagRequired("member-id") + + // Flags for members remove + teamsMembersRemoveCmd.Flags().IntVar(&teamMemberID, "member-id", 0, "Member ID") + _ = teamsMembersRemoveCmd.MarkFlagRequired("member-id") + + // Common team ID flag for invitations subcommands + teamsInvitationsCmd.PersistentFlags().IntVar(&teamID, "team-id", 0, "Team ID") + + // Flags for invitations list + teamsInvitationsListCmd.Flags(). + StringVarP(&teamsOutputFormat, "output", "o", "table", "Output format (table or json)") + + // Flags for invitations get + teamsInvitationsGetCmd.Flags().IntVar(&teamInvitationID, "invitation-id", 0, "Invitation ID") + teamsInvitationsGetCmd.Flags(). + StringVarP(&teamsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = teamsInvitationsGetCmd.MarkFlagRequired("invitation-id") + + // Flags for invitations create + teamsInvitationsCreateCmd.Flags(). + StringVar(&teamCLIInputJSON, "cli-input-json", "", "JSON payload (string or file://path)") + teamsInvitationsCreateCmd.Flags(). + StringVarP(&teamsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = teamsInvitationsCreateCmd.MarkFlagRequired("cli-input-json") + + // Flags for invitations update + teamsInvitationsUpdateCmd.Flags().IntVar(&teamInvitationID, "invitation-id", 0, "Invitation ID") + teamsInvitationsUpdateCmd.Flags(). + StringVar(&teamCLIInputJSON, "cli-input-json", "", "JSON payload (string or file://path)") + teamsInvitationsUpdateCmd.Flags(). + StringVarP(&teamsOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = teamsInvitationsUpdateCmd.MarkFlagRequired("invitation-id") + _ = teamsInvitationsUpdateCmd.MarkFlagRequired("cli-input-json") + + // Flags for invitations delete + teamsInvitationsDeleteCmd.Flags().IntVar(&teamInvitationID, "invitation-id", 0, "Invitation ID") + _ = teamsInvitationsDeleteCmd.MarkFlagRequired("invitation-id") +} diff --git a/cmd/teams_test.go b/cmd/teams_test.go new file mode 100644 index 0000000..7342886 --- /dev/null +++ b/cmd/teams_test.go @@ -0,0 +1,366 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestTeamsListCommand(t *testing.T) { + tests := []struct { + name string + accountIDValue string + authToken string + serverStatus int + serverBody string + expectedError bool + errorContains string + }{ + { + name: "successful list", + accountIDValue: "123", + authToken: "test-token", + serverStatus: http.StatusOK, + serverBody: `{ + "results": [{"id": 1, "name": "Team 1", "account_id": 123, "created_at": "2024-01-01T00:00:00Z"}], + "links": {"self": "/v2/accounts/123/teams"} + }`, + expectedError: false, + }, + { + name: "missing account ID", + accountIDValue: "", + authToken: "test-token", + expectedError: true, + errorContains: "account ID is required", + }, + { + name: "missing auth token", + accountIDValue: "123", + authToken: "", + expectedError: true, + errorContains: "auth token is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var serverURL string + if tt.authToken != "" && tt.accountIDValue != "" { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.serverStatus) + _, _ = w.Write([]byte(tt.serverBody)) + }), + ) + defer server.Close() + serverURL = server.URL + } else { + serverURL = "http://localhost:9999" + } + + viper.Reset() + viper.Set("endpoint", serverURL) + if tt.authToken != "" { + viper.Set("auth_token", tt.authToken) + } + + teamsAccountID = tt.accountIDValue + teamsOutputFormat = "table" + + err := teamsListCmd.RunE(teamsListCmd, []string{}) + + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestTeamsGetCommand(t *testing.T) { + tests := []struct { + name string + teamIDValue int + authToken string + serverStatus int + serverBody string + expectedError bool + errorContains string + }{ + { + name: "successful get", + teamIDValue: 1, + authToken: "test-token", + serverStatus: http.StatusOK, + serverBody: `{ + "id": 1, + "name": "Team 1", + "account_id": 123, + "created_at": "2024-01-01T00:00:00Z" + }`, + expectedError: false, + }, + { + name: "missing team ID", + teamIDValue: 0, + authToken: "test-token", + expectedError: true, + errorContains: "team ID is required", + }, + { + name: "missing auth token", + teamIDValue: 1, + authToken: "", + expectedError: true, + errorContains: "auth token is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var serverURL string + if tt.authToken != "" && tt.teamIDValue != 0 { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.serverStatus) + _, _ = w.Write([]byte(tt.serverBody)) + }), + ) + defer server.Close() + serverURL = server.URL + } else { + serverURL = "http://localhost:9999" + } + + viper.Reset() + viper.Set("endpoint", serverURL) + if tt.authToken != "" { + viper.Set("auth_token", tt.authToken) + } + + teamID = tt.teamIDValue + teamsOutputFormat = "text" + + err := teamsGetCmd.RunE(teamsGetCmd, []string{}) + + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestTeamsCreateCommand(t *testing.T) { + tests := []struct { + name string + accountIDValue string + teamNameValue string + authToken string + serverStatus int + serverBody string + expectedError bool + errorContains string + }{ + { + name: "successful create", + accountIDValue: "123", + teamNameValue: "New Team", + authToken: "test-token", + serverStatus: http.StatusCreated, + serverBody: `{ + "id": 1, + "name": "New Team", + "account_id": 123, + "created_at": "2024-01-01T00:00:00Z" + }`, + expectedError: false, + }, + { + name: "missing account ID", + accountIDValue: "", + teamNameValue: "New Team", + authToken: "test-token", + expectedError: true, + errorContains: "account ID is required", + }, + { + name: "missing team name", + accountIDValue: "123", + teamNameValue: "", + authToken: "test-token", + expectedError: true, + errorContains: "team name is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var serverURL string + if tt.authToken != "" && tt.accountIDValue != "" && tt.teamNameValue != "" { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.serverStatus) + _, _ = w.Write([]byte(tt.serverBody)) + }), + ) + defer server.Close() + serverURL = server.URL + } else { + serverURL = "http://localhost:9999" + } + + viper.Reset() + viper.Set("endpoint", serverURL) + if tt.authToken != "" { + viper.Set("auth_token", tt.authToken) + } + + teamsAccountID = tt.accountIDValue + teamName = tt.teamNameValue + teamsOutputFormat = "text" + + err := teamsCreateCmd.RunE(teamsCreateCmd, []string{}) + + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestTeamsMembersListCommand(t *testing.T) { + tests := []struct { + name string + teamIDValue int + authToken string + serverStatus int + serverBody string + expectedError bool + errorContains string + }{ + { + name: "successful list members", + teamIDValue: 1, + authToken: "test-token", + serverStatus: http.StatusOK, + serverBody: `{ + "results": [{"id": 1, "name": "Member 1", "email": "member1@example.com", "admin": true}], + "links": {"self": "/v2/teams/1/team_members"} + }`, + expectedError: false, + }, + { + name: "missing team ID", + teamIDValue: 0, + authToken: "test-token", + expectedError: true, + errorContains: "team ID is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var serverURL string + if tt.authToken != "" && tt.teamIDValue != 0 { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.serverStatus) + _, _ = w.Write([]byte(tt.serverBody)) + }), + ) + defer server.Close() + serverURL = server.URL + } else { + serverURL = "http://localhost:9999" + } + + viper.Reset() + viper.Set("endpoint", serverURL) + if tt.authToken != "" { + viper.Set("auth_token", tt.authToken) + } + + teamID = tt.teamIDValue + teamsOutputFormat = "table" + + err := teamsMembersListCmd.RunE(teamsMembersListCmd, []string{}) + + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestTeamsOutputFormat(t *testing.T) { + mockResponse := `{ + "results": [{"id": 1, "name": "Team 1", "account_id": 123, "created_at": "2024-01-01T00:00:00Z"}], + "links": {"self": "/v2/accounts/123/teams"} + }` + + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(mockResponse)) + }), + ) + defer server.Close() + + tests := []struct { + name string + format string + }{ + {name: "table format", format: "table"}, + {name: "json format", format: "json"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + viper.Reset() + viper.Set("endpoint", server.URL) + viper.Set("auth_token", "test-token") + + teamsAccountID = "123" + teamsOutputFormat = tt.format + + err := teamsListCmd.RunE(teamsListCmd, []string{}) + assert.NoError(t, err) + }) + } +} diff --git a/cmd/uptime.go b/cmd/uptime.go new file mode 100644 index 0000000..240a368 --- /dev/null +++ b/cmd/uptime.go @@ -0,0 +1,563 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + hbapi "github.com/honeybadger-io/api-go" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + uptimeProjectID int + uptimeSiteID string + uptimeOutputFormat string + uptimeCLIInputJSON string + uptimeCreatedAfter int64 + uptimeCreatedBefore int64 + uptimeLimit int +) + +// uptimeCmd represents the uptime command +var uptimeCmd = &cobra.Command{ + Use: "uptime", + Short: "Manage uptime monitoring", + GroupID: GroupDataAPI, + Long: `View and manage uptime monitoring sites, outages, and checks for your Honeybadger projects.`, +} + +// uptimeSitesCmd represents the uptime sites parent command +var uptimeSitesCmd = &cobra.Command{ + Use: "sites", + Short: "Manage uptime sites", + Long: `View and manage uptime monitoring sites.`, +} + +// uptimeSitesListCmd represents the uptime sites list command +var uptimeSitesListCmd = &cobra.Command{ + Use: "list", + Short: "List uptime sites for a project", + Long: `List all uptime monitoring sites configured for a specific project.`, + RunE: func(_ *cobra.Command, _ []string) error { + if uptimeProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + sites, err := client.Uptime.List(ctx, uptimeProjectID) + if err != nil { + return fmt.Errorf("failed to list uptime sites: %w", err) + } + + switch uptimeOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(sites, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tNAME\tURL\tSTATE\tACTIVE\tFREQ") + for _, site := range sites { + active := " " + if site.Active { + active = "Yes" + } + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%dm\n", + site.ID, + site.Name, + site.URL, + site.State, + active, + site.Frequency) + } + _ = w.Flush() + } + + return nil + }, +} + +// uptimeSitesGetCmd represents the uptime sites get command +var uptimeSitesGetCmd = &cobra.Command{ + Use: "get", + Short: "Get an uptime site by ID", + Long: `Get detailed information about a specific uptime site.`, + RunE: func(_ *cobra.Command, _ []string) error { + if uptimeProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if uptimeSiteID == "" { + return fmt.Errorf("site ID is required. Set it using --site-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + site, err := client.Uptime.Get(ctx, uptimeProjectID, uptimeSiteID) + if err != nil { + return fmt.Errorf("failed to get uptime site: %w", err) + } + + switch uptimeOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(site, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Uptime Site Details:\n") + fmt.Printf(" ID: %s\n", site.ID) + fmt.Printf(" Name: %s\n", site.Name) + fmt.Printf(" URL: %s\n", site.URL) + fmt.Printf(" State: %s\n", site.State) + fmt.Printf(" Active: %v\n", site.Active) + fmt.Printf(" Frequency: %d minutes\n", site.Frequency) + fmt.Printf(" Match Type: %s\n", site.MatchType) + if site.Match != nil { + fmt.Printf(" Match: %s\n", *site.Match) + } + if site.LastCheckedAt != nil { + fmt.Printf(" Last Checked: %s\n", site.LastCheckedAt.Format("2006-01-02 15:04:05")) + } + } + + return nil + }, +} + +// uptimeSitesCreateCmd represents the uptime sites create command +var uptimeSitesCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new uptime site", + Long: `Create a new uptime monitoring site for a project. + +The --cli-input-json flag accepts either a JSON string or a file path prefixed with 'file://'. + +Example JSON payload: +{ + "site": { + "name": "My Website", + "url": "https://example.com", + "frequency": 5, + "match_type": "success", + "locations": ["Virginia", "Oregon"], + "validate_ssl": true + } +} + +Available options: +- frequency: 1, 5, or 15 (minutes) +- match_type: "success", "exact", "include", "exclude" +- request_method: "GET", "POST", "PUT", "PATCH", "DELETE" +- locations: "Virginia", "Oregon", "Frankfurt", "Singapore", "London"`, + RunE: func(_ *cobra.Command, _ []string) error { + if uptimeProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if uptimeCLIInputJSON == "" { + return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + jsonData, err := readJSONInput(uptimeCLIInputJSON) + if err != nil { + return fmt.Errorf("failed to read JSON input: %w", err) + } + + var payload struct { + Site hbapi.SiteParams `json:"site"` + } + if err := json.Unmarshal(jsonData, &payload); err != nil { + return fmt.Errorf("failed to parse JSON payload: %w", err) + } + + ctx := context.Background() + site, err := client.Uptime.Create(ctx, uptimeProjectID, payload.Site) + if err != nil { + return fmt.Errorf("failed to create uptime site: %w", err) + } + + switch uptimeOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(site, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Uptime site created successfully!\n") + fmt.Printf(" ID: %s\n", site.ID) + fmt.Printf(" Name: %s\n", site.Name) + fmt.Printf(" URL: %s\n", site.URL) + } + + return nil + }, +} + +// uptimeSitesUpdateCmd represents the uptime sites update command +var uptimeSitesUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update an existing uptime site", + Long: `Update an existing uptime monitoring site's settings. + +The --cli-input-json flag accepts either a JSON string or a file path prefixed with 'file://'. + +Example JSON payload: +{ + "site": { + "name": "Updated Website", + "frequency": 15, + "active": false + } +}`, + RunE: func(_ *cobra.Command, _ []string) error { + if uptimeProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if uptimeSiteID == "" { + return fmt.Errorf("site ID is required. Set it using --site-id flag") + } + if uptimeCLIInputJSON == "" { + return fmt.Errorf("JSON payload is required. Set it using --cli-input-json flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + jsonData, err := readJSONInput(uptimeCLIInputJSON) + if err != nil { + return fmt.Errorf("failed to read JSON input: %w", err) + } + + var payload struct { + Site hbapi.SiteParams `json:"site"` + } + if err := json.Unmarshal(jsonData, &payload); err != nil { + return fmt.Errorf("failed to parse JSON payload: %w", err) + } + + ctx := context.Background() + site, err := client.Uptime.Update(ctx, uptimeProjectID, uptimeSiteID, payload.Site) + if err != nil { + return fmt.Errorf("failed to update uptime site: %w", err) + } + + switch uptimeOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(site, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + fmt.Printf("Uptime site updated successfully!\n") + fmt.Printf(" ID: %s\n", site.ID) + fmt.Printf(" Name: %s\n", site.Name) + } + + return nil + }, +} + +// uptimeSitesDeleteCmd represents the uptime sites delete command +var uptimeSitesDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete an uptime site", + Long: `Delete an uptime monitoring site by ID. This action cannot be undone.`, + RunE: func(_ *cobra.Command, _ []string) error { + if uptimeProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if uptimeSiteID == "" { + return fmt.Errorf("site ID is required. Set it using --site-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + ctx := context.Background() + err := client.Uptime.Delete(ctx, uptimeProjectID, uptimeSiteID) + if err != nil { + return fmt.Errorf("failed to delete uptime site: %w", err) + } + + fmt.Println("Uptime site deleted successfully") + return nil + }, +} + +// uptimeOutagesCmd represents the uptime outages command +var uptimeOutagesCmd = &cobra.Command{ + Use: "outages", + Short: "List outages for a site", + Long: `List outages recorded for a specific uptime monitoring site.`, + RunE: func(_ *cobra.Command, _ []string) error { + if uptimeProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if uptimeSiteID == "" { + return fmt.Errorf("site ID is required. Set it using --site-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + options := hbapi.OutageListOptions{ + CreatedAfter: uptimeCreatedAfter, + CreatedBefore: uptimeCreatedBefore, + Limit: uptimeLimit, + } + + ctx := context.Background() + outages, err := client.Uptime.ListOutages(ctx, uptimeProjectID, uptimeSiteID, options) + if err != nil { + return fmt.Errorf("failed to list outages: %w", err) + } + + switch uptimeOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(outages, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "DOWN AT\tUP AT\tSTATUS\tREASON") + for _, outage := range outages { + upAt := "Still down" + if outage.UpAt != nil { + upAt = outage.UpAt.Format("2006-01-02 15:04") + } + + reason := outage.Reason + if len(reason) > 40 { + reason = reason[:37] + "..." + } + + _, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%s\n", + outage.DownAt.Format("2006-01-02 15:04"), + upAt, + outage.Status, + reason) + } + _ = w.Flush() + } + + return nil + }, +} + +// uptimeChecksCmd represents the uptime checks command +var uptimeChecksCmd = &cobra.Command{ + Use: "checks", + Short: "List uptime checks for a site", + Long: `List individual uptime checks performed for a specific site.`, + RunE: func(_ *cobra.Command, _ []string) error { + if uptimeProjectID == 0 { + return fmt.Errorf("project ID is required. Set it using --project-id flag") + } + if uptimeSiteID == "" { + return fmt.Errorf("site ID is required. Set it using --site-id flag") + } + + authToken := viper.GetString("auth_token") + if authToken == "" { + return fmt.Errorf( + "auth token is required. Set it using --auth-token flag or HONEYBADGER_AUTH_TOKEN environment variable", + ) + } + + endpoint := convertEndpointForDataAPI(viper.GetString("endpoint")) + + client := hbapi.NewClient(). + WithBaseURL(endpoint). + WithAuthToken(authToken) + + options := hbapi.UptimeCheckListOptions{ + CreatedAfter: uptimeCreatedAfter, + CreatedBefore: uptimeCreatedBefore, + Limit: uptimeLimit, + } + + ctx := context.Background() + checks, err := client.Uptime.ListUptimeChecks(ctx, uptimeProjectID, uptimeSiteID, options) + if err != nil { + return fmt.Errorf("failed to list uptime checks: %w", err) + } + + switch uptimeOutputFormat { + case "json": + jsonBytes, err := json.MarshalIndent(checks, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + default: + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "CREATED\tLOCATION\tUP\tDURATION") + for _, check := range checks { + up := "No" + if check.Up { + up = "Yes" + } + + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%dms\n", + check.CreatedAt.Format("2006-01-02 15:04:05"), + check.Location, + up, + check.Duration) + } + _ = w.Flush() + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(uptimeCmd) + + // Add subcommands + uptimeCmd.AddCommand(uptimeSitesCmd) + uptimeCmd.AddCommand(uptimeOutagesCmd) + uptimeCmd.AddCommand(uptimeChecksCmd) + + // Sites subcommands + uptimeSitesCmd.AddCommand(uptimeSitesListCmd) + uptimeSitesCmd.AddCommand(uptimeSitesGetCmd) + uptimeSitesCmd.AddCommand(uptimeSitesCreateCmd) + uptimeSitesCmd.AddCommand(uptimeSitesUpdateCmd) + uptimeSitesCmd.AddCommand(uptimeSitesDeleteCmd) + + // Common flags + uptimeCmd.PersistentFlags().IntVar(&uptimeProjectID, "project-id", 0, "Project ID") + + // Flags for sites list + uptimeSitesListCmd.Flags(). + StringVarP(&uptimeOutputFormat, "output", "o", "table", "Output format (table or json)") + + // Flags for sites get + uptimeSitesGetCmd.Flags().StringVar(&uptimeSiteID, "site-id", "", "Site ID") + uptimeSitesGetCmd.Flags(). + StringVarP(&uptimeOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = uptimeSitesGetCmd.MarkFlagRequired("site-id") + + // Flags for sites create + uptimeSitesCreateCmd.Flags(). + StringVar(&uptimeCLIInputJSON, "cli-input-json", "", "JSON payload (string or file://path)") + uptimeSitesCreateCmd.Flags(). + StringVarP(&uptimeOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = uptimeSitesCreateCmd.MarkFlagRequired("cli-input-json") + + // Flags for sites update + uptimeSitesUpdateCmd.Flags().StringVar(&uptimeSiteID, "site-id", "", "Site ID") + uptimeSitesUpdateCmd.Flags(). + StringVar(&uptimeCLIInputJSON, "cli-input-json", "", "JSON payload (string or file://path)") + uptimeSitesUpdateCmd.Flags(). + StringVarP(&uptimeOutputFormat, "output", "o", "text", "Output format (text or json)") + _ = uptimeSitesUpdateCmd.MarkFlagRequired("site-id") + _ = uptimeSitesUpdateCmd.MarkFlagRequired("cli-input-json") + + // Flags for sites delete + uptimeSitesDeleteCmd.Flags().StringVar(&uptimeSiteID, "site-id", "", "Site ID") + _ = uptimeSitesDeleteCmd.MarkFlagRequired("site-id") + + // Flags for outages + uptimeOutagesCmd.Flags().StringVar(&uptimeSiteID, "site-id", "", "Site ID") + uptimeOutagesCmd.Flags(). + Int64Var(&uptimeCreatedAfter, "created-after", 0, "Filter by creation time (Unix timestamp)") + uptimeOutagesCmd.Flags(). + Int64Var(&uptimeCreatedBefore, "created-before", 0, "Filter by creation time (Unix timestamp)") + uptimeOutagesCmd.Flags(). + IntVar(&uptimeLimit, "limit", 25, "Maximum number of outages to return (max 25)") + uptimeOutagesCmd.Flags(). + StringVarP(&uptimeOutputFormat, "output", "o", "table", "Output format (table or json)") + _ = uptimeOutagesCmd.MarkFlagRequired("site-id") + + // Flags for checks + uptimeChecksCmd.Flags().StringVar(&uptimeSiteID, "site-id", "", "Site ID") + uptimeChecksCmd.Flags(). + Int64Var(&uptimeCreatedAfter, "created-after", 0, "Filter by creation time (Unix timestamp)") + uptimeChecksCmd.Flags(). + Int64Var(&uptimeCreatedBefore, "created-before", 0, "Filter by creation time (Unix timestamp)") + uptimeChecksCmd.Flags(). + IntVar(&uptimeLimit, "limit", 25, "Maximum number of checks to return (max 25)") + uptimeChecksCmd.Flags(). + StringVarP(&uptimeOutputFormat, "output", "o", "table", "Output format (table or json)") + _ = uptimeChecksCmd.MarkFlagRequired("site-id") +} diff --git a/cmd/uptime_test.go b/cmd/uptime_test.go new file mode 100644 index 0000000..b324113 --- /dev/null +++ b/cmd/uptime_test.go @@ -0,0 +1,216 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestUptimeSitesListCommand(t *testing.T) { + tests := []struct { + name string + projectIDValue int + authToken string + serverStatus int + serverBody string + expectedError bool + errorContains string + }{ + { + name: "successful list", + projectIDValue: 123, + authToken: "test-token", + serverStatus: http.StatusOK, + serverBody: `{ + "results": [{"id": "site1", "name": "Site 1", "url": "https://example.com", "state": "up", "active": true, "frequency": 5}], + "links": {"self": "/v2/projects/123/sites"} + }`, + expectedError: false, + }, + { + name: "missing project ID", + projectIDValue: 0, + authToken: "test-token", + expectedError: true, + errorContains: "project ID is required", + }, + { + name: "missing auth token", + projectIDValue: 123, + authToken: "", + expectedError: true, + errorContains: "auth token is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var serverURL string + if tt.authToken != "" && tt.projectIDValue != 0 { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.serverStatus) + _, _ = w.Write([]byte(tt.serverBody)) + }), + ) + defer server.Close() + serverURL = server.URL + } else { + serverURL = "http://localhost:9999" + } + + viper.Reset() + viper.Set("endpoint", serverURL) + if tt.authToken != "" { + viper.Set("auth_token", tt.authToken) + } + + uptimeProjectID = tt.projectIDValue + uptimeOutputFormat = "table" + + err := uptimeSitesListCmd.RunE(uptimeSitesListCmd, []string{}) + + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestUptimeSitesGetCommand(t *testing.T) { + tests := []struct { + name string + projectIDValue int + siteIDValue string + authToken string + serverStatus int + serverBody string + expectedError bool + errorContains string + }{ + { + name: "successful get", + projectIDValue: 123, + siteIDValue: "site1", + authToken: "test-token", + serverStatus: http.StatusOK, + serverBody: `{ + "id": "site1", + "name": "Site 1", + "url": "https://example.com", + "state": "up", + "active": true, + "frequency": 5, + "match_type": "contains" + }`, + expectedError: false, + }, + { + name: "missing project ID", + projectIDValue: 0, + siteIDValue: "site1", + authToken: "test-token", + expectedError: true, + errorContains: "project ID is required", + }, + { + name: "missing site ID", + projectIDValue: 123, + siteIDValue: "", + authToken: "test-token", + expectedError: true, + errorContains: "site ID is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var serverURL string + if tt.authToken != "" && tt.projectIDValue != 0 && tt.siteIDValue != "" { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.serverStatus) + _, _ = w.Write([]byte(tt.serverBody)) + }), + ) + defer server.Close() + serverURL = server.URL + } else { + serverURL = "http://localhost:9999" + } + + viper.Reset() + viper.Set("endpoint", serverURL) + if tt.authToken != "" { + viper.Set("auth_token", tt.authToken) + } + + uptimeProjectID = tt.projectIDValue + uptimeSiteID = tt.siteIDValue + uptimeOutputFormat = "text" + + err := uptimeSitesGetCmd.RunE(uptimeSitesGetCmd, []string{}) + + if tt.expectedError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestUptimeOutputFormat(t *testing.T) { + mockResponse := `{ + "results": [{"id": "site1", "name": "Site 1", "url": "https://example.com", "state": "up", "active": true, "frequency": 5}], + "links": {"self": "/v2/projects/123/sites"} + }` + + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(mockResponse)) + }), + ) + defer server.Close() + + tests := []struct { + name string + format string + }{ + {name: "table format", format: "table"}, + {name: "json format", format: "json"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + viper.Reset() + viper.Set("endpoint", server.URL) + viper.Set("auth_token", "test-token") + + uptimeProjectID = 123 + uptimeOutputFormat = tt.format + + err := uptimeSitesListCmd.RunE(uptimeSitesListCmd, []string{}) + assert.NoError(t, err) + }) + } +} diff --git a/go.mod b/go.mod index ac839c8..d9588ae 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/honeybadger-io/cli go 1.23 require ( - github.com/honeybadger-io/api-go v0.1.0 + github.com/honeybadger-io/api-go v0.2.0 github.com/shirou/gopsutil/v3 v3.24.5 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 diff --git a/go.sum b/go.sum index 1a15045..e7ac228 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/honeybadger-io/api-go v0.1.0 h1:KmzuDflewX85Elq5CbQvbsKvZursulaXXcinqvKQaRk= -github.com/honeybadger-io/api-go v0.1.0/go.mod h1:VurinfWN9qR1Bd9Zju2pIDgG1vIi5Qq3GpcsfToQiBU= +github.com/honeybadger-io/api-go v0.2.0 h1:ezoDgaBomS8eP022F4rmzFgKhyRmzqvcYDiMSKhd6sg= +github.com/honeybadger-io/api-go v0.2.0/go.mod h1:VurinfWN9qR1Bd9Zju2pIDgG1vIi5Qq3GpcsfToQiBU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=