diff --git a/.ai/skills/rollbar-cli/SKILL.md b/.ai/skills/rollbar-cli/SKILL.md index 8ab438e..fb84c1b 100644 --- a/.ai/skills/rollbar-cli/SKILL.md +++ b/.ai/skills/rollbar-cli/SKILL.md @@ -17,6 +17,8 @@ Use this skill to quickly find and triage Rollbar issues with `rollbar-cli`. - You need a fast view of current active issues. - You want recent issues in stable JSON or NDJSON for automation or triage notes. - You want to narrow by environment and severity level. +- You need to discover which environments exist before filtering. +- You need to inspect deploy history or correlate regressions with a specific release. - You need to inspect raw occurrences for a specific item or fetch one occurrence directly. - You need to look up Rollbar account users before assigning an item. @@ -166,7 +168,89 @@ rollbar-cli occurrences get --uuid 89abcdef-0123-4567-89ab-cdef01234567 --json rollbar-cli occurences get --uuid 89abcdef-0123-4567-89ab-cdef01234567 --json ``` -### 13) List account users +### 13) List all environments + +```bash +# default text output +rollbar-cli environments list + +# stable JSON +rollbar-cli environments list --json + +# raw API page envelopes +rollbar-cli environments list --raw-json + +# NDJSON for downstream tooling +rollbar-cli environments list --ndjson + +# narrow text columns +rollbar-cli environments list --fields environment,project_id --no-headers +``` + +### 14) List deploys + +```bash +# default text output +rollbar-cli deploys list + +# page through deploy history +rollbar-cli deploys list --page 2 --limit 20 --json + +# raw API envelope +rollbar-cli deploys list --page 1 --raw-json + +# NDJSON for downstream tooling +rollbar-cli deploys list --page 1 --ndjson +``` + +### 15) Get one deploy by ID + +```bash +# positional id +rollbar-cli deploys get 12345 --json + +# or explicit flag +rollbar-cli deploys get --id 12345 + +# NDJSON for downstream tooling +rollbar-cli deploys get --id 12345 --ndjson + +# raw Rollbar envelope +rollbar-cli deploys get --id 12345 --raw-json +``` + +### 16) Create a deploy record + +```bash +rollbar-cli deploys create \ + --environment production \ + --revision aabbcc1 \ + --status started \ + --comment "Deploy started from CI" \ + --local-username ci-bot \ + --json + +# associate a Rollbar user instead of a local username +rollbar-cli deploys create \ + --environment production \ + --revision aabbcc1 \ + --rollbar-username dave +``` + +### 17) Update a deploy record + +```bash +# mark the deploy as complete +rollbar-cli deploys update 12345 \ + --status succeeded \ + --json + +# mark a deploy as failed +rollbar-cli deploys update --id 12345 \ + --status failed +``` + +### 18) List account users ```bash # default text output @@ -185,7 +269,7 @@ rollbar-cli users list --ndjson rollbar-cli users list --fields id,username,email --no-headers ``` -### 14) Get one user by ID +### 19) Get one user by ID ```bash # positional id @@ -227,19 +311,25 @@ rollbar-cli items list --status active --json \ 2. Narrow with `--last`, `--since`, `--sort`, and `--limit`. 3. Open top counters/IDs with `rollbar-cli items get --instances` for stack context. 4. Use `rollbar-cli occurrences list` when you want to inspect occurrence-level payloads for an item. -5. Use `rollbar-cli users list` to find candidate assignee IDs before assigning items. -6. Use `items resolve|mute|assign|snooze` for common triage actions. +5. Use `rollbar-cli deploys list --page 1` when you need to correlate an error spike with a recent deploy. +6. Use `rollbar-cli environments list` if you need the exact environment names before applying `--environment`. +7. Use `rollbar-cli users list` to find candidate assignee IDs before assigning items. +8. Use `items resolve|mute|assign|snooze` for common triage actions. ## Example Follow-up Commands ```bash rollbar-cli items list --status active --environment production --last 24h --sort counter_desc --limit 10 --json +rollbar-cli deploys list --page 1 --limit 20 --json +rollbar-cli deploys get --id 12345 --json rollbar-cli items get --id 275123456 --instances --payload summary --payload-section request rollbar-cli items get --uuid 01234567-89ab-cdef-0123-456789abcdef --instances --raw-json +rollbar-cli environments list --json rollbar-cli occurrences list --item-id 275123456 --ndjson rollbar-cli occurrences get --uuid 89abcdef-0123-4567-89ab-cdef01234567 --json rollbar-cli users list --json rollbar-cli users get --id 7 --json +rollbar-cli deploys update --id 12345 --status failed rollbar-cli items resolve --id 275123456 --resolved-in-version aabbcc1 rollbar-cli items assign --uuid 01234567-89ab-cdef-0123-456789abcdef --assigned-user-id 321 ``` diff --git a/README.md b/README.md index 6fe5a0b..811fb1e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ workflows. - List Rollbar items with environment, level, status, time, sort, and paging filters - Fetch a single item by ID or UUID, optionally with associated occurrences - List or fetch occurrences for an item +- List, fetch, create, and update deploy records +- List all project environments - List account users - Update item status, title, level, assignment, and snooze state - Render stable JSON, raw API JSON, NDJSON, or text/TUI output @@ -48,6 +50,12 @@ export ROLLBAR_ACCESS_TOKEN=rbac_... # list account users ./bin/rollbar-cli users list + +# list all environments +./bin/rollbar-cli environments list + +# list recent deploys +./bin/rollbar-cli deploys list --page 1 ``` If you install with `go install` or `make install`, you can run `rollbar-cli ...` directly instead of `./bin/rollbar-cli`. @@ -242,6 +250,57 @@ rollbar-cli users list --raw-json rollbar-cli users list --ndjson ``` +### Deploys + +```bash +# list deploys +rollbar-cli deploys list + +# page through deploy history +rollbar-cli deploys list --page 2 --limit 20 --json + +# get one deploy by id +rollbar-cli deploys get 12345 +# or +rollbar-cli deploys get --id 12345 --json + +# create a deploy record +rollbar-cli deploys create \ + --environment production \ + --revision aabbcc1 \ + --status started \ + --comment "Deploy started from CI" \ + --local-username ci-bot + +# create a deploy and associate a Rollbar username +rollbar-cli deploys create \ + --environment production \ + --revision aabbcc1 \ + --rollbar-username dave \ + --json + +# update a deploy after completion +rollbar-cli deploys update 12345 \ + --status succeeded \ + --json +``` + +### Environments + +```bash +# list all environments across every API page +rollbar-cli environments list + +# stable JSON output +rollbar-cli environments list --json + +# raw Rollbar API page envelopes +rollbar-cli environments list --raw-json + +# NDJSON for scripting +rollbar-cli environments list --ndjson +``` + ## Shell Completion ```bash diff --git a/cmd/deploys.go b/cmd/deploys.go new file mode 100644 index 0000000..6f5b865 --- /dev/null +++ b/cmd/deploys.go @@ -0,0 +1,384 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "rollbar-cli/internal/rollbar" + "rollbar-cli/internal/ui" +) + +var validDeployStatuses = map[string]struct{}{ + "started": {}, + "succeeded": {}, + "failed": {}, + "timed_out": {}, +} + +type deploysListOptions struct { + Page int + Limit int + Output string + JSON bool + RawJSON bool + NDJSON bool + Fields []string + NoHeaders bool +} + +type deploysGetOptions struct { + ID int64 + Output string + JSON bool + RawJSON bool + NDJSON bool +} + +type deploysCreateOptions struct { + Environment string + Revision string + Status string + Comment string + LocalUsername string + RollbarUsername string + Output string + JSON bool + RawJSON bool + NDJSON bool +} + +type deploysUpdateOptions struct { + ID int64 + Status string + Output string + JSON bool + RawJSON bool + NDJSON bool +} + +type deployListJSONOutput struct { + Deploys []rollbar.Deploy `json:"deploys"` +} + +type deployGetJSONOutput struct { + Deploy rollbar.Deploy `json:"deploy"` +} + +func newDeploysCmd(cfg *cliConfig) *cobra.Command { + var ( + listOpts deploysListOptions + getOpts deploysGetOptions + createOpts deploysCreateOptions + updateOpts deploysUpdateOptions + ) + + deploysCmd := &cobra.Command{ + Use: "deploys", + Aliases: []string{"deploy", "deployment", "deployments"}, + Short: "Query and manage Rollbar deploys", + } + + listCmd := &cobra.Command{ + Use: "list", + Short: "List deploys in a Rollbar project", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireToken(cfg); err != nil { + return err + } + return runDeploysList(cmd, cfg, listOpts) + }, + } + + getCmd := &cobra.Command{ + Use: "get [id]", + Short: "Get a deploy by ID", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireToken(cfg); err != nil { + return err + } + + output, err := resolveOutputModeWithAliases(getOpts.Output, getOpts.JSON, getOpts.RawJSON, getOpts.NDJSON, outputText, outputJSON, outputRawJSON, outputNDJSON) + if err != nil { + return err + } + + id, err := resolveDeployID(cmd, args, getOpts.ID) + if err != nil { + return err + } + + client := newRollbarClient(cfg) + resp, err := client.GetDeployByID(cmd.Context(), id) + if err != nil { + return err + } + return writeSingleDeployOutput(resp.Deploy, resp.Raw, output) + }, + } + + createCmd := &cobra.Command{ + Use: "create", + Short: "Create a deploy record", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireToken(cfg); err != nil { + return err + } + + body, err := buildDeployCreateBody(createOpts) + if err != nil { + return err + } + + output, err := resolveOutputModeWithAliases(createOpts.Output, createOpts.JSON, createOpts.RawJSON, createOpts.NDJSON, outputText, outputJSON, outputRawJSON, outputNDJSON) + if err != nil { + return err + } + + client := newRollbarClient(cfg) + resp, err := client.CreateDeploy(cmd.Context(), body) + if err != nil { + return err + } + return writeSingleDeployOutput(resp.Deploy, resp.Raw, output) + }, + } + + updateCmd := &cobra.Command{ + Use: "update [id]", + Short: "Update a deploy record", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireToken(cfg); err != nil { + return err + } + + id, err := resolveDeployID(cmd, args, updateOpts.ID) + if err != nil { + return err + } + + body, err := buildDeployUpdateBody(updateOpts) + if err != nil { + return err + } + + output, err := resolveOutputModeWithAliases(updateOpts.Output, updateOpts.JSON, updateOpts.RawJSON, updateOpts.NDJSON, outputText, outputJSON, outputRawJSON, outputNDJSON) + if err != nil { + return err + } + + client := newRollbarClient(cfg) + resp, err := client.UpdateDeployByID(cmd.Context(), id, body) + if err != nil { + return err + } + return writeSingleDeployOutput(resp.Deploy, resp.Raw, output) + }, + } + + listCmd.Flags().IntVar(&listOpts.Page, "page", 1, "Page number") + listCmd.Flags().IntVar(&listOpts.Limit, "limit", 0, "Maximum number of deploys to return") + listCmd.Flags().StringVarP(&listOpts.Output, "output", "o", outputText, "Output format: text|json|raw-json|ndjson") + listCmd.Flags().BoolVar(&listOpts.JSON, "json", false, "Shortcut for --output json") + listCmd.Flags().BoolVar(&listOpts.RawJSON, "raw-json", false, "Shortcut for --output raw-json") + listCmd.Flags().BoolVar(&listOpts.NDJSON, "ndjson", false, "Shortcut for --output ndjson") + listCmd.Flags().StringSliceVar(&listOpts.Fields, "fields", nil, "Fields to render in text output") + listCmd.Flags().BoolVar(&listOpts.NoHeaders, "no-headers", false, "Hide table headers in text output") + + getCmd.Flags().Int64Var(&getOpts.ID, "id", 0, "Deploy ID") + getCmd.Flags().StringVarP(&getOpts.Output, "output", "o", outputText, "Output format: text|json|raw-json|ndjson") + getCmd.Flags().BoolVar(&getOpts.JSON, "json", false, "Shortcut for --output json") + getCmd.Flags().BoolVar(&getOpts.RawJSON, "raw-json", false, "Shortcut for --output raw-json") + getCmd.Flags().BoolVar(&getOpts.NDJSON, "ndjson", false, "Shortcut for --output ndjson") + + createCmd.Flags().StringVar(&createOpts.Environment, "environment", "", "Deploy environment") + createCmd.Flags().StringVar(&createOpts.Revision, "revision", "", "Deploy revision") + createCmd.Flags().StringVar(&createOpts.Status, "status", "", "Deploy status: started|succeeded|failed|timed_out") + createCmd.Flags().StringVar(&createOpts.Comment, "comment", "", "Deploy comment") + createCmd.Flags().StringVar(&createOpts.LocalUsername, "local-username", "", "Local deploy username") + createCmd.Flags().StringVar(&createOpts.RollbarUsername, "rollbar-username", "", "Rollbar username") + createCmd.Flags().StringVarP(&createOpts.Output, "output", "o", outputText, "Output format: text|json|raw-json|ndjson") + createCmd.Flags().BoolVar(&createOpts.JSON, "json", false, "Shortcut for --output json") + createCmd.Flags().BoolVar(&createOpts.RawJSON, "raw-json", false, "Shortcut for --output raw-json") + createCmd.Flags().BoolVar(&createOpts.NDJSON, "ndjson", false, "Shortcut for --output ndjson") + + updateCmd.Flags().Int64Var(&updateOpts.ID, "id", 0, "Deploy ID") + updateCmd.Flags().StringVar(&updateOpts.Status, "status", "", "Deploy status: started|succeeded|failed|timed_out") + updateCmd.Flags().StringVarP(&updateOpts.Output, "output", "o", outputText, "Output format: text|json|raw-json|ndjson") + updateCmd.Flags().BoolVar(&updateOpts.JSON, "json", false, "Shortcut for --output json") + updateCmd.Flags().BoolVar(&updateOpts.RawJSON, "raw-json", false, "Shortcut for --output raw-json") + updateCmd.Flags().BoolVar(&updateOpts.NDJSON, "ndjson", false, "Shortcut for --output ndjson") + + deploysCmd.AddCommand(listCmd, getCmd, createCmd, updateCmd) + return deploysCmd +} + +func runDeploysList(cmd *cobra.Command, cfg *cliConfig, opts deploysListOptions) error { + output, err := resolveOutputModeWithAliases(opts.Output, opts.JSON, opts.RawJSON, opts.NDJSON, outputText, outputJSON, outputRawJSON, outputNDJSON) + if err != nil { + return err + } + + deploys, raw, err := collectDeploys(cmd, cfg, opts) + if err != nil { + return err + } + + switch output { + case outputRawJSON: + return writeJSON(raw) + case outputJSON: + return writeJSON(deployListJSONOutput{Deploys: deploys}) + case outputNDJSON: + records := make([]any, 0, len(deploys)) + for _, deploy := range deploys { + records = append(records, deploy) + } + return writeNDJSON(records) + default: + return ui.RenderDeploysWithOptions(deploys, ui.DeployRenderOptions{ + Fields: normalizeFields(opts.Fields), + NoHeaders: opts.NoHeaders, + }) + } +} + +func collectDeploys(cmd *cobra.Command, cfg *cliConfig, opts deploysListOptions) ([]rollbar.Deploy, map[string]any, error) { + if opts.Limit < 0 { + return nil, nil, fmt.Errorf("--limit must be >= 0") + } + + client := newRollbarClient(cfg) + page := opts.Page + if page <= 0 { + page = 1 + } + + resp, err := client.ListDeploys(cmd.Context(), rollbar.ListDeploysOptions{ + Page: page, + Limit: opts.Limit, + }) + if err != nil { + return nil, nil, err + } + + deploys := resp.Deploys + if opts.Limit > 0 && len(deploys) > opts.Limit { + deploys = deploys[:opts.Limit] + } + return deploys, resp.Raw, nil +} + +func writeSingleDeployOutput(deploy rollbar.Deploy, raw map[string]any, output string) error { + switch output { + case outputRawJSON: + return writeJSON(raw) + case outputJSON: + return writeJSON(deployGetJSONOutput{Deploy: deploy}) + case outputNDJSON: + return writeNDJSON([]any{deploy}) + default: + return ui.RenderDeploy(deploy) + } +} + +func resolveDeployID(cmd *cobra.Command, args []string, id int64) (int64, error) { + idSet := cmd.Flags().Changed("id") + arg := "" + if len(args) > 0 { + arg = args[0] + } + + sources := 0 + if arg != "" { + sources++ + } + if idSet { + sources++ + } + if sources == 0 { + return 0, fmt.Errorf("missing deploy identifier: pass [id] or --id") + } + if sources > 1 { + return 0, fmt.Errorf("provide only one deploy identifier: [id] or --id") + } + + if arg != "" { + if !isIntegerToken(arg) { + return 0, fmt.Errorf("invalid deploy id %q: must be > 0", arg) + } + return parsePositiveDeployID(arg) + } + + if id <= 0 { + return 0, fmt.Errorf("invalid deploy id: must be > 0") + } + return id, nil +} + +func parsePositiveDeployID(raw string) (int64, error) { + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil || id <= 0 { + return 0, fmt.Errorf("invalid deploy id %q: must be > 0", raw) + } + return id, nil +} + +func buildDeployCreateBody(opts deploysCreateOptions) (map[string]any, error) { + environment := strings.TrimSpace(opts.Environment) + if environment == "" { + return nil, fmt.Errorf("missing required flag: --environment") + } + revision := strings.TrimSpace(opts.Revision) + if revision == "" { + return nil, fmt.Errorf("missing required flag: --revision") + } + + body := map[string]any{ + "environment": environment, + "revision": revision, + } + + if status, err := normalizeDeployStatus(opts.Status); err != nil { + return nil, err + } else if status != "" { + body["status"] = status + } + if comment := strings.TrimSpace(opts.Comment); comment != "" { + body["comment"] = comment + } + if localUsername := strings.TrimSpace(opts.LocalUsername); localUsername != "" { + body["local_username"] = localUsername + } + if rollbarUsername := strings.TrimSpace(opts.RollbarUsername); rollbarUsername != "" { + body["rollbar_username"] = rollbarUsername + } + return body, nil +} + +func buildDeployUpdateBody(opts deploysUpdateOptions) (map[string]any, error) { + status, err := normalizeDeployStatus(opts.Status) + if err != nil { + return nil, err + } + if status == "" { + return nil, fmt.Errorf("missing required flag: --status") + } + + return map[string]any{"status": status}, nil +} + +func normalizeDeployStatus(raw string) (string, error) { + status := strings.TrimSpace(strings.ToLower(raw)) + if status == "" { + return "", nil + } + if _, ok := validDeployStatuses[status]; !ok { + return "", fmt.Errorf("invalid deploy status %q: expected started|succeeded|failed|timed_out", raw) + } + return status, nil +} diff --git a/cmd/deploys_cmd_test.go b/cmd/deploys_cmd_test.go new file mode 100644 index 0000000..ee69ecc --- /dev/null +++ b/cmd/deploys_cmd_test.go @@ -0,0 +1,202 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestDeploysListCommandJSON(t *testing.T) { + var gotPages []string + var gotLimits []string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPages = append(gotPages, r.URL.Query().Get("page")) + gotLimits = append(gotLimits, r.URL.Query().Get("limit")) + _, _ = w.Write([]byte(`{"err":0,"result":{"deploys":[{"id":123,"environment":"production","revision":"aabbcc1","status":"started"},{"id":124,"environment":"production","revision":"ddbbcc2","status":"succeeded"}]}}`)) + })) + defer ts.Close() + + out, err := runCLIWithCapturedStdout(t, + "deploys", "list", + "--page", "2", + "--limit", "1", + "--json", + "--token", "tok", + "--base-url", ts.URL, + ) + if err != nil { + t.Fatalf("unexpected command error: %v", err) + } + if len(gotPages) != 1 || gotPages[0] != "2" { + t.Fatalf("unexpected requested pages: %#v", gotPages) + } + if len(gotLimits) != 1 || gotLimits[0] != "1" { + t.Fatalf("unexpected limits: %#v", gotLimits) + } + if !strings.Contains(out, "\"deploys\"") || strings.Contains(out, "\"ID\": 124") || !strings.Contains(out, "\"ID\": 123") { + t.Fatalf("unexpected output: %q", out) + } +} + +func TestDeploysListCommandRawJSON(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"err":0,"result":{"deploys":[{"id":123,"environment":"production","revision":"aabbcc1","status":"started"}]}}`)) + })) + defer ts.Close() + + out, err := runCLIWithCapturedStdout(t, + "deploys", "list", + "--raw-json", + "--token", "tok", + "--base-url", ts.URL, + ) + if err != nil { + t.Fatalf("unexpected command error: %v", err) + } + if !strings.Contains(out, "\"result\"") || !strings.Contains(out, "\"deploys\"") { + t.Fatalf("expected raw API envelope, got %q", out) + } + if strings.Contains(out, "\"pages\"") { + t.Fatalf("expected direct raw API envelope, got %q", out) + } +} + +func TestDeploysGetCommandNDJSON(t *testing.T) { + var gotPath string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte(`{"err":0,"result":{"deploy":{"id":123,"environment":"production","revision":"aabbcc1","status":"started"}}}`)) + })) + defer ts.Close() + + out, err := runCLIWithCapturedStdout(t, + "deploys", "get", "123", + "--ndjson", + "--token", "tok", + "--base-url", ts.URL, + ) + if err != nil { + t.Fatalf("unexpected command error: %v", err) + } + if gotPath != "/api/1/deploy/123" { + t.Fatalf("unexpected request path: %s", gotPath) + } + if strings.Contains(out, "\"deploy\"") || !strings.Contains(out, "\"Status\":\"started\"") { + t.Fatalf("unexpected ndjson output: %q", out) + } +} + +func TestDeploysCreateCommandJSON(t *testing.T) { + var gotBody map[string]any + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("unexpected method: %s", r.Method) + } + if r.URL.Path != "/api/1/deploy" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + _, _ = w.Write([]byte(`{"err":0,"result":{"deploy":{"id":123,"environment":"production","revision":"aabbcc1","status":"started"}}}`)) + })) + defer ts.Close() + + out, err := runCLIWithCapturedStdout(t, + "deploys", "create", + "--environment", "production", + "--revision", "aabbcc1", + "--status", "started", + "--comment", "Deploy started from CI", + "--local-username", "ci-bot", + "--rollbar-username", "alice", + "--json", + "--token", "tok", + "--base-url", ts.URL, + ) + if err != nil { + t.Fatalf("unexpected command error: %v", err) + } + if gotBody["environment"] != "production" || gotBody["revision"] != "aabbcc1" || gotBody["rollbar_username"] != "alice" { + t.Fatalf("unexpected request body: %#v", gotBody) + } + if !strings.Contains(out, "\"deploy\"") || !strings.Contains(out, "\"ID\": 123") { + t.Fatalf("unexpected output: %q", out) + } +} + +func TestDeploysUpdateCommandJSON(t *testing.T) { + var gotBody map[string]any + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Fatalf("unexpected method: %s", r.Method) + } + if r.URL.Path != "/api/1/deploy/123" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + _, _ = w.Write([]byte(`{"err":0,"result":{"deploy":{"id":123,"environment":"production","revision":"aabbcc1","status":"succeeded"}}}`)) + })) + defer ts.Close() + + out, err := runCLIWithCapturedStdout(t, + "deploys", "update", "123", + "--status", "succeeded", + "--json", + "--token", "tok", + "--base-url", ts.URL, + ) + if err != nil { + t.Fatalf("unexpected command error: %v", err) + } + if len(gotBody) != 1 || gotBody["status"] != "succeeded" { + t.Fatalf("unexpected request body: %#v", gotBody) + } + if !strings.Contains(out, "\"Status\": \"succeeded\"") { + t.Fatalf("unexpected output: %q", out) + } +} + +func TestDeploysCommandValidationErrors(t *testing.T) { + _, err := runCLIWithCapturedStdout(t, + "deploys", "get", + "--token", "tok", + ) + if err == nil || !strings.Contains(err.Error(), "missing deploy identifier") { + t.Fatalf("expected missing identifier error, got %v", err) + } + + _, err = runCLIWithCapturedStdout(t, + "deploys", "create", + "--environment", "production", + "--token", "tok", + ) + if err == nil || !strings.Contains(err.Error(), "missing required flag: --revision") { + t.Fatalf("expected missing revision error, got %v", err) + } + + _, err = runCLIWithCapturedStdout(t, + "deploys", "update", "123", + "--status", "bogus", + "--token", "tok", + ) + if err == nil || !strings.Contains(err.Error(), "invalid deploy status") { + t.Fatalf("expected invalid status error, got %v", err) + } + + _, err = runCLIWithCapturedStdout(t, + "deploys", "update", "123", + "--token", "tok", + ) + if err == nil || !strings.Contains(err.Error(), "missing required flag: --status") { + t.Fatalf("expected missing status error, got %v", err) + } +} diff --git a/cmd/environments.go b/cmd/environments.go new file mode 100644 index 0000000..d31f229 --- /dev/null +++ b/cmd/environments.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "rollbar-cli/internal/rollbar" + "rollbar-cli/internal/ui" +) + +type environmentsListOptions struct { + Output string + JSON bool + RawJSON bool + NDJSON bool + Fields []string + NoHeaders bool +} + +type environmentListJSONOutput struct { + Environments []rollbar.Environment `json:"environments"` +} + +type environmentListRawOutput struct { + Pages []map[string]any `json:"pages"` +} + +func newEnvironmentsCmd(cfg *cliConfig) *cobra.Command { + var listOpts environmentsListOptions + + environmentsCmd := &cobra.Command{ + Use: "environments", + Short: "Query Rollbar environments", + } + + listCmd := &cobra.Command{ + Use: "list", + Short: "List all environments in the Rollbar account", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireToken(cfg); err != nil { + return err + } + + output, err := resolveOutputModeWithAliases(listOpts.Output, listOpts.JSON, listOpts.RawJSON, listOpts.NDJSON, outputText, outputJSON, outputRawJSON, outputNDJSON) + if err != nil { + return err + } + + client := newRollbarClient(cfg) + resp, err := client.ListEnvironments(cmd.Context()) + if err != nil { + return err + } + + switch output { + case outputRawJSON: + return writeJSON(environmentListRawOutput{Pages: resp.RawPages}) + case outputJSON: + return writeJSON(environmentListJSONOutput{Environments: resp.Environments}) + case outputNDJSON: + records := make([]any, 0, len(resp.Environments)) + for _, environment := range resp.Environments { + records = append(records, environment) + } + return writeNDJSON(records) + default: + return ui.RenderEnvironmentsWithOptions(resp.Environments, ui.EnvironmentRenderOptions{ + Fields: normalizeFields(listOpts.Fields), + NoHeaders: listOpts.NoHeaders, + }) + } + }, + } + + listCmd.Flags().StringVarP(&listOpts.Output, "output", "o", outputText, "Output format: text|json|raw-json|ndjson") + listCmd.Flags().BoolVar(&listOpts.JSON, "json", false, "Shortcut for --output json") + listCmd.Flags().BoolVar(&listOpts.RawJSON, "raw-json", false, "Shortcut for --output raw-json") + listCmd.Flags().BoolVar(&listOpts.NDJSON, "ndjson", false, "Shortcut for --output ndjson") + listCmd.Flags().StringSliceVar(&listOpts.Fields, "fields", nil, "Fields to render in text output") + listCmd.Flags().BoolVar(&listOpts.NoHeaders, "no-headers", false, "Hide table headers in text output") + + environmentsCmd.AddCommand(listCmd) + return environmentsCmd +} diff --git a/cmd/environments_cmd_test.go b/cmd/environments_cmd_test.go new file mode 100644 index 0000000..0f5d1bb --- /dev/null +++ b/cmd/environments_cmd_test.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestEnvironmentsListCommandJSONPaginates(t *testing.T) { + var gotPages []string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPages = append(gotPages, r.URL.Query().Get("page")) + switch r.URL.Query().Get("page") { + case "1": + _, _ = w.Write([]byte(`{"err":0,"result":{"environments":[ + {"id":1,"project_id":42,"environment":"production"}, + {"id":2,"project_id":42,"environment":"staging"}, + {"id":3,"project_id":42,"environment":"dev"}, + {"id":4,"project_id":42,"environment":"qa"}, + {"id":5,"project_id":42,"environment":"preview-1"}, + {"id":6,"project_id":42,"environment":"preview-2"}, + {"id":7,"project_id":42,"environment":"preview-3"}, + {"id":8,"project_id":42,"environment":"preview-4"}, + {"id":9,"project_id":42,"environment":"preview-5"}, + {"id":10,"project_id":42,"environment":"preview-6"}, + {"id":11,"project_id":42,"environment":"preview-7"}, + {"id":12,"project_id":42,"environment":"preview-8"}, + {"id":13,"project_id":42,"environment":"preview-9"}, + {"id":14,"project_id":42,"environment":"preview-10"}, + {"id":15,"project_id":42,"environment":"preview-11"}, + {"id":16,"project_id":42,"environment":"preview-12"}, + {"id":17,"project_id":42,"environment":"preview-13"}, + {"id":18,"project_id":42,"environment":"preview-14"}, + {"id":19,"project_id":42,"environment":"preview-15"}, + {"id":20,"project_id":42,"environment":"preview-16"} + ]}}`)) + case "2": + _, _ = w.Write([]byte(`{"err":0,"result":{"environments":[{"id":21,"project_id":42,"environment":"sandbox"}]}}`)) + case "3": + _, _ = w.Write([]byte(`{"err":0,"result":{"environments":[]}}`)) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + out, err := runCLIWithCapturedStdout(t, + "environments", "list", + "--json", + "--token", "tok", + "--base-url", ts.URL, + ) + if err != nil { + t.Fatalf("unexpected command error: %v", err) + } + if len(gotPages) != 3 || gotPages[0] != "1" || gotPages[1] != "2" || gotPages[2] != "3" { + t.Fatalf("unexpected requested pages: %#v", gotPages) + } + if !strings.Contains(out, "\"environments\"") || !strings.Contains(out, "\"Name\": \"sandbox\"") { + t.Fatalf("unexpected output: %q", out) + } +} + +func TestEnvironmentsListCommandRawJSONAggregatesPages(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("page") { + case "1": + _, _ = w.Write([]byte(`{"err":0,"result":{"environments":[{"id":1,"project_id":42,"environment":"production"}]}}`)) + case "2": + _, _ = w.Write([]byte(`{"err":0,"result":{"environments":[]}}`)) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + out, err := runCLIWithCapturedStdout(t, + "environments", "list", + "--raw-json", + "--token", "tok", + "--base-url", ts.URL, + ) + if err != nil { + t.Fatalf("unexpected command error: %v", err) + } + if !strings.Contains(out, "\"pages\"") || !strings.Contains(out, "\"environment\": \"production\"") || !strings.Contains(out, "\"environments\": []") { + t.Fatalf("unexpected raw-json output: %q", out) + } +} diff --git a/cmd/root.go b/cmd/root.go index c5840de..b0448b7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -48,6 +48,8 @@ func newRootCmd() *cobra.Command { rootCmd.AddCommand(newItemsCmd(cfg)) rootCmd.AddCommand(newOccurrencesCmd(cfg)) + rootCmd.AddCommand(newDeploysCmd(cfg)) + rootCmd.AddCommand(newEnvironmentsCmd(cfg)) rootCmd.AddCommand(newUsersCmd(cfg)) rootCmd.AddCommand(newCompletionCmd()) diff --git a/internal/rollbar/client.go b/internal/rollbar/client.go index 1a46715..eac931d 100644 --- a/internal/rollbar/client.go +++ b/internal/rollbar/client.go @@ -32,6 +32,11 @@ type ListItemsOptions struct { Level []string } +type ListDeploysOptions struct { + Page int + Limit int +} + type Item struct { ID int64 Counter int64 @@ -49,6 +54,25 @@ type User struct { Email string } +type Environment struct { + ID int64 + ProjectID int64 + Name string +} + +type Deploy struct { + ID int64 + ProjectID int64 + Environment string + Revision string + Status string + Comment string + LocalUsername string + RollbarUsername string + StartTime int64 + FinishTime int64 +} + type StackFrame struct { Filename string Line int64 @@ -80,11 +104,26 @@ type ListUsersResponse struct { Raw map[string]any } +type ListDeploysResponse struct { + Deploys []Deploy + Raw map[string]any +} + +type ListEnvironmentsResponse struct { + Environments []Environment + RawPages []map[string]any +} + type GetUserResponse struct { User User Raw map[string]any } +type GetDeployResponse struct { + Deploy Deploy + Raw map[string]any +} + type ListItemInstancesResponse struct { Instances []ItemInstance Raw map[string]any @@ -100,6 +139,16 @@ type UpdateItemResponse struct { Raw map[string]any } +type CreateDeployResponse struct { + Deploy Deploy + Raw map[string]any +} + +type UpdateDeployResponse struct { + Deploy Deploy + Raw map[string]any +} + type apiEnvelope struct { Err int `json:"err"` Message string `json:"message"` @@ -114,6 +163,14 @@ type listUsersResult struct { Users []json.RawMessage `json:"users"` } +type listDeploysResult struct { + Deploys []json.RawMessage `json:"deploys"` +} + +type listEnvironmentsResult struct { + Environments []json.RawMessage `json:"environments"` +} + type listItemInstancesResult struct { Instances []json.RawMessage `json:"instances"` } @@ -325,6 +382,169 @@ func (c *Client) ListUsers(ctx context.Context) (*ListUsersResponse, error) { return &ListUsersResponse{Users: users, Raw: resp.Raw}, nil } +func (c *Client) ListEnvironments(ctx context.Context) (*ListEnvironmentsResponse, error) { + if c.accessToken == "" { + return nil, fmt.Errorf("missing access token") + } + + environments := make([]Environment, 0) + rawPages := make([]map[string]any, 0) + + for page := 1; ; page++ { + query := url.Values{} + query.Set("page", strconv.Itoa(page)) + + resp, err := c.doJSON(ctx, http.MethodGet, "/api/1/environments", query, nil) + if err != nil { + return nil, err + } + + var result listEnvironmentsResult + if len(resp.Envelope.Result) > 0 { + if err := json.Unmarshal(resp.Envelope.Result, &result); err != nil { + var directEnvironments []json.RawMessage + if directErr := json.Unmarshal(resp.Envelope.Result, &directEnvironments); directErr == nil { + result.Environments = directEnvironments + } else { + return nil, fmt.Errorf("parse result.environments: %w", err) + } + } + if len(result.Environments) == 0 { + var directEnvironments []json.RawMessage + if err := json.Unmarshal(resp.Envelope.Result, &directEnvironments); err == nil { + result.Environments = directEnvironments + } + } + } + + pageEnvironments := make([]Environment, 0, len(result.Environments)) + for idx, rawEnvironment := range result.Environments { + environment, err := normalizeEnvironment(rawEnvironment) + if err != nil { + return nil, fmt.Errorf("decode environment %d: %w", idx, err) + } + pageEnvironments = append(pageEnvironments, environment) + } + + rawPages = append(rawPages, resp.Raw) + environments = append(environments, pageEnvironments...) + + if len(pageEnvironments) == 0 { + break + } + } + + return &ListEnvironmentsResponse{Environments: environments, RawPages: rawPages}, nil +} + +func (c *Client) ListDeploys(ctx context.Context, opts ListDeploysOptions) (*ListDeploysResponse, error) { + if c.accessToken == "" { + return nil, fmt.Errorf("missing access token") + } + + query := url.Values{} + if opts.Page > 0 { + query.Set("page", strconv.Itoa(opts.Page)) + } + if opts.Limit > 0 { + query.Set("limit", strconv.Itoa(opts.Limit)) + } + + resp, err := c.doJSON(ctx, http.MethodGet, "/api/1/deploys", query, nil) + if err != nil { + return nil, err + } + + var result listDeploysResult + if len(resp.Envelope.Result) > 0 { + if err := json.Unmarshal(resp.Envelope.Result, &result); err != nil { + var directDeploys []json.RawMessage + if directErr := json.Unmarshal(resp.Envelope.Result, &directDeploys); directErr == nil { + result.Deploys = directDeploys + } else { + return nil, fmt.Errorf("parse result.deploys: %w", err) + } + } + if len(result.Deploys) == 0 { + var directDeploys []json.RawMessage + if err := json.Unmarshal(resp.Envelope.Result, &directDeploys); err == nil { + result.Deploys = directDeploys + } + } + } + + deploys := make([]Deploy, 0, len(result.Deploys)) + for idx, rawDeploy := range result.Deploys { + deploy, err := normalizeDeploy(rawDeploy) + if err != nil { + return nil, fmt.Errorf("decode deploy %d: %w", idx, err) + } + deploys = append(deploys, deploy) + } + + return &ListDeploysResponse{Deploys: deploys, Raw: resp.Raw}, nil +} + +func (c *Client) GetDeployByID(ctx context.Context, id int64) (*GetDeployResponse, error) { + if id <= 0 { + return nil, fmt.Errorf("invalid deploy id: must be > 0") + } + if c.accessToken == "" { + return nil, fmt.Errorf("missing access token") + } + + resp, err := c.doJSON(ctx, http.MethodGet, "/api/1/deploy/"+strconv.FormatInt(id, 10), nil, nil) + if err != nil { + return nil, err + } + + return &GetDeployResponse{ + Deploy: extractDeployResult(resp.Envelope.Result, id), + Raw: resp.Raw, + }, nil +} + +func (c *Client) CreateDeploy(ctx context.Context, body map[string]any) (*CreateDeployResponse, error) { + if len(body) == 0 { + return nil, fmt.Errorf("missing deploy fields") + } + if c.accessToken == "" { + return nil, fmt.Errorf("missing access token") + } + + resp, err := c.doJSON(ctx, http.MethodPost, "/api/1/deploy", nil, body) + if err != nil { + return nil, err + } + + return &CreateDeployResponse{ + Deploy: extractDeployResult(resp.Envelope.Result, 0), + Raw: resp.Raw, + }, nil +} + +func (c *Client) UpdateDeployByID(ctx context.Context, id int64, body map[string]any) (*UpdateDeployResponse, error) { + if id <= 0 { + return nil, fmt.Errorf("invalid deploy id: must be > 0") + } + if len(body) == 0 { + return nil, fmt.Errorf("missing update fields") + } + if c.accessToken == "" { + return nil, fmt.Errorf("missing access token") + } + + resp, err := c.doJSON(ctx, http.MethodPatch, "/api/1/deploy/"+strconv.FormatInt(id, 10), nil, body) + if err != nil { + return nil, err + } + + return &UpdateDeployResponse{ + Deploy: extractDeployResult(resp.Envelope.Result, id), + Raw: resp.Raw, + }, nil +} + func (c *Client) GetUserByID(ctx context.Context, id int64) (*GetUserResponse, error) { if id <= 0 { return nil, fmt.Errorf("invalid user id: must be > 0") @@ -577,6 +797,53 @@ func normalizeUserMap(m map[string]any) User { } } +func normalizeEnvironment(rawEnvironment json.RawMessage) (Environment, error) { + var m map[string]any + if err := json.Unmarshal(rawEnvironment, &m); err != nil { + return Environment{}, err + } + return normalizeEnvironmentMap(m), nil +} + +func normalizeEnvironmentMap(m map[string]any) Environment { + if m == nil { + return Environment{} + } + + return Environment{ + ID: firstInt64(m, "id", "environment_id"), + ProjectID: firstInt64(m, "project_id", "projectId"), + Name: firstString(m, "environment", "name"), + } +} + +func normalizeDeploy(rawDeploy json.RawMessage) (Deploy, error) { + var m map[string]any + if err := json.Unmarshal(rawDeploy, &m); err != nil { + return Deploy{}, err + } + return normalizeDeployMap(m), nil +} + +func normalizeDeployMap(m map[string]any) Deploy { + if m == nil { + return Deploy{} + } + + return Deploy{ + ID: firstInt64(m, "id", "deploy_id"), + ProjectID: firstInt64(m, "project_id", "projectId"), + Environment: firstString(m, "environment"), + Revision: firstString(m, "revision"), + Status: firstString(m, "status"), + Comment: firstString(m, "comment"), + LocalUsername: firstString(m, "local_username", "localUsername"), + RollbarUsername: firstString(m, "rollbar_username", "rollbar_name", "rollbarUsername"), + StartTime: firstInt64(m, "start_time", "startTime"), + FinishTime: firstInt64(m, "finish_time", "finishTime"), + } +} + func normalizeInstance(rawInstance json.RawMessage) (ItemInstance, error) { var m map[string]any if err := json.Unmarshal(rawInstance, &m); err != nil { @@ -684,6 +951,30 @@ func extractPayload(instance map[string]any) map[string]any { return payload } +func extractDeployResult(raw json.RawMessage, fallbackID int64) Deploy { + if len(raw) == 0 { + return Deploy{ID: fallbackID} + } + + var result map[string]any + if err := json.Unmarshal(raw, &result); err != nil { + return Deploy{ID: fallbackID} + } + + deployData := result + if v, ok := result["deploy"]; ok { + if nested, ok := v.(map[string]any); ok { + deployData = nested + } + } + + deploy := normalizeDeployMap(deployData) + if deploy.ID == 0 { + deploy.ID = fallbackID + } + return deploy +} + func firstString(data map[string]any, keys ...string) string { for _, key := range keys { if value, ok := data[key]; ok { diff --git a/internal/rollbar/client_test.go b/internal/rollbar/client_test.go index 71dbe15..41f14d6 100644 --- a/internal/rollbar/client_test.go +++ b/internal/rollbar/client_test.go @@ -209,6 +209,255 @@ func TestListUsers(t *testing.T) { } } +func TestListDeploys(t *testing.T) { + var gotPath string + var gotQuery url.Values + var gotToken string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.Query() + gotToken = r.Header.Get("X-Rollbar-Access-Token") + _, _ = w.Write([]byte(`{"err":0,"result":{"deploys":[{"id":"123","project_id":"42","environment":"production","revision":"aabbcc1","status":"succeeded","comment":"done","local_username":"ci-bot","rollbar_name":"alice","start_time":"1700000000","finish_time":"1700003600"}]}}`)) + })) + defer ts.Close() + + client := NewClient(Config{AccessToken: "tok", BaseURL: ts.URL}) + resp, err := client.ListDeploys(context.Background(), ListDeploysOptions{ + Page: 2, + Limit: 10, + }) + if err != nil { + t.Fatalf("unexpected list deploys error: %v", err) + } + + if gotPath != "/api/1/deploys" { + t.Fatalf("unexpected deploys path: %s", gotPath) + } + if gotToken != "tok" { + t.Fatalf("unexpected token header: %q", gotToken) + } + if gotQuery.Get("page") != "2" || gotQuery.Get("limit") != "10" { + t.Fatalf("unexpected query: %#v", gotQuery) + } + if len(resp.Deploys) != 1 { + t.Fatalf("expected 1 deploy, got %d", len(resp.Deploys)) + } + deploy := resp.Deploys[0] + if deploy.ID != 123 || deploy.ProjectID != 42 || deploy.RollbarUsername != "alice" || deploy.FinishTime != 1700003600 { + t.Fatalf("unexpected deploy: %#v", deploy) + } + if resp.Raw == nil || resp.Raw["err"] == nil { + t.Fatalf("expected raw response to be present: %#v", resp.Raw) + } +} + +func TestGetDeployByID(t *testing.T) { + var gotPath string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte(`{"err":0,"result":{"deploy":{"id":123,"project_id":42,"environment":"production","revision":"aabbcc1","status":"started"}}}`)) + })) + defer ts.Close() + + client := NewClient(Config{AccessToken: "tok", BaseURL: ts.URL}) + resp, err := client.GetDeployByID(context.Background(), 123) + if err != nil { + t.Fatalf("unexpected get deploy error: %v", err) + } + + if gotPath != "/api/1/deploy/123" { + t.Fatalf("unexpected deploy path: %s", gotPath) + } + if resp.Deploy.ID != 123 || resp.Deploy.Environment != "production" || resp.Deploy.Status != "started" { + t.Fatalf("unexpected deploy: %#v", resp.Deploy) + } + + if _, err := client.GetDeployByID(context.Background(), 0); err == nil { + t.Fatalf("expected invalid deploy id error") + } +} + +func TestCreateDeploy(t *testing.T) { + var gotMethod string + var gotPath string + var gotContentType string + var gotBody map[string]any + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotContentType = r.Header.Get("Content-Type") + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + _, _ = w.Write([]byte(`{"err":0,"result":{"deploy":{"id":123,"environment":"production","revision":"aabbcc1","status":"started"}}}`)) + })) + defer ts.Close() + + client := NewClient(Config{AccessToken: "tok", BaseURL: ts.URL}) + resp, err := client.CreateDeploy(context.Background(), map[string]any{ + "environment": "production", + "revision": "aabbcc1", + "status": "started", + "comment": "Deploy started from CI", + "local_username": "ci-bot", + "rollbar_username": "alice", + "start_time": int64(1700000000), + }) + if err != nil { + t.Fatalf("unexpected create deploy error: %v", err) + } + + if gotMethod != http.MethodPost || gotPath != "/api/1/deploy" { + t.Fatalf("unexpected request: %s %s", gotMethod, gotPath) + } + if gotContentType != "application/json" { + t.Fatalf("unexpected content type: %q", gotContentType) + } + if gotBody["environment"] != "production" || gotBody["status"] != "started" || gotBody["rollbar_username"] != "alice" { + t.Fatalf("unexpected request body: %#v", gotBody) + } + if resp.Deploy.ID != 123 || resp.Deploy.Status != "started" { + t.Fatalf("unexpected deploy: %#v", resp.Deploy) + } +} + +func TestUpdateDeployByID(t *testing.T) { + var gotMethod string + var gotPath string + var gotBody map[string]any + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + _, _ = w.Write([]byte(`{"err":0,"result":{"deploy_id":123}}`)) + })) + defer ts.Close() + + client := NewClient(Config{AccessToken: "tok", BaseURL: ts.URL}) + resp, err := client.UpdateDeployByID(context.Background(), 123, map[string]any{ + "status": "succeeded", + "finish_time": int64(1700003600), + }) + if err != nil { + t.Fatalf("unexpected update deploy error: %v", err) + } + + if gotMethod != http.MethodPatch || gotPath != "/api/1/deploy/123" { + t.Fatalf("unexpected request: %s %s", gotMethod, gotPath) + } + if gotBody["status"] != "succeeded" { + t.Fatalf("unexpected request body: %#v", gotBody) + } + if resp.Deploy.ID != 123 { + t.Fatalf("expected fallback deploy id, got %#v", resp.Deploy) + } +} + +func TestListEnvironments(t *testing.T) { + var gotPaths []string + var gotPages []string + var gotToken string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPaths = append(gotPaths, r.URL.Path) + gotPages = append(gotPages, r.URL.Query().Get("page")) + gotToken = r.Header.Get("X-Rollbar-Access-Token") + + switch r.URL.Query().Get("page") { + case "1": + _, _ = w.Write([]byte(`{"err":0,"result":{"environments":[ + {"id":"1","project_id":"88","environment":"production"}, + {"id":2,"project_id":88,"name":"staging"}, + {"id":3,"project_id":88,"environment":"preview-1"}, + {"id":4,"project_id":88,"environment":"preview-2"}, + {"id":5,"project_id":88,"environment":"preview-3"}, + {"id":6,"project_id":88,"environment":"preview-4"}, + {"id":7,"project_id":88,"environment":"preview-5"}, + {"id":8,"project_id":88,"environment":"preview-6"}, + {"id":9,"project_id":88,"environment":"preview-7"}, + {"id":10,"project_id":88,"environment":"preview-8"}, + {"id":11,"project_id":88,"environment":"preview-9"}, + {"id":12,"project_id":88,"environment":"preview-10"}, + {"id":13,"project_id":88,"environment":"preview-11"}, + {"id":14,"project_id":88,"environment":"preview-12"}, + {"id":15,"project_id":88,"environment":"preview-13"}, + {"id":16,"project_id":88,"environment":"preview-14"}, + {"id":17,"project_id":88,"environment":"preview-15"}, + {"id":18,"project_id":88,"environment":"preview-16"}, + {"id":19,"project_id":88,"environment":"preview-17"}, + {"id":20,"project_id":88,"environment":"preview-18"} + ]}}`)) + case "2": + _, _ = w.Write([]byte(`{"err":0,"result":{"environments":[{"id":21,"project_id":88,"environment":"sandbox"}]}}`)) + case "3": + _, _ = w.Write([]byte(`{"err":0,"result":{"environments":[]}}`)) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + client := NewClient(Config{AccessToken: "tok", BaseURL: ts.URL}) + resp, err := client.ListEnvironments(context.Background()) + if err != nil { + t.Fatalf("unexpected list environments error: %v", err) + } + + if len(gotPaths) != 3 || gotPaths[0] != "/api/1/environments" || gotPaths[1] != "/api/1/environments" || gotPaths[2] != "/api/1/environments" { + t.Fatalf("unexpected environment paths: %#v", gotPaths) + } + if len(gotPages) != 3 || gotPages[0] != "1" || gotPages[1] != "2" || gotPages[2] != "3" { + t.Fatalf("unexpected environment pages: %#v", gotPages) + } + if gotToken != "tok" { + t.Fatalf("unexpected token header: %q", gotToken) + } + if len(resp.Environments) != 21 { + t.Fatalf("expected 21 environments, got %d", len(resp.Environments)) + } + if resp.Environments[0].ID != 1 || resp.Environments[0].ProjectID != 88 || resp.Environments[0].Name != "production" { + t.Fatalf("unexpected first environment: %#v", resp.Environments[0]) + } + if resp.Environments[1].Name != "staging" { + t.Fatalf("expected fallback environment name, got %#v", resp.Environments[1]) + } + if len(resp.RawPages) != 3 || resp.RawPages[0]["err"] == nil || resp.RawPages[2]["err"] == nil { + t.Fatalf("expected raw pages to be present: %#v", resp.RawPages) + } +} + +func TestListEnvironmentsDirectArray(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("page") { + case "1": + _, _ = w.Write([]byte(`{"err":0,"result":[{"id":1,"project_id":88,"environment":"production"}]}`)) + case "2": + _, _ = w.Write([]byte(`{"err":0,"result":[]}`)) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + client := NewClient(Config{AccessToken: "tok", BaseURL: ts.URL}) + resp, err := client.ListEnvironments(context.Background()) + if err != nil { + t.Fatalf("unexpected list environments error: %v", err) + } + if len(resp.Environments) != 1 || resp.Environments[0].Name != "production" { + t.Fatalf("unexpected environments: %#v", resp.Environments) + } + if len(resp.RawPages) != 2 { + t.Fatalf("expected raw pages for data page and terminating empty page, got %#v", resp.RawPages) + } +} + func TestGetUserByID(t *testing.T) { var gotPath string var gotToken string @@ -243,6 +492,7 @@ func TestGetUserByID(t *testing.T) { t.Fatalf("expected invalid user id error") } } + func TestListItemInstances(t *testing.T) { var gotPath string var gotQuery url.Values diff --git a/internal/ui/deploys_table.go b/internal/ui/deploys_table.go new file mode 100644 index 0000000..0a55b83 --- /dev/null +++ b/internal/ui/deploys_table.go @@ -0,0 +1,123 @@ +package ui + +import ( + "fmt" + "io" + "os" + "strconv" + "strings" + "text/tabwriter" + + "rollbar-cli/internal/rollbar" +) + +type DeployRenderOptions struct { + Fields []string + NoHeaders bool +} + +var defaultDeployListFields = []string{"id", "status", "environment", "revision", "start_time", "finish_time", "comment"} + +func RenderDeploy(deploy rollbar.Deploy) error { + return renderDeploy(os.Stdout, deploy) +} + +func RenderDeploys(deploys []rollbar.Deploy) error { + return RenderDeploysWithOptions(deploys, DeployRenderOptions{}) +} + +func RenderDeploysWithOptions(deploys []rollbar.Deploy, opts DeployRenderOptions) error { + if len(deploys) == 0 { + _, err := fmt.Fprintln(os.Stdout, "No deploys found.") + return err + } + return renderDeploysPlain(os.Stdout, deploys, opts) +} + +func DefaultDeployListFields() []string { + return append([]string(nil), defaultDeployListFields...) +} + +func renderDeploy(w io.Writer, deploy rollbar.Deploy) error { + if _, err := fmt.Fprintf(w, "ID: %d\n", deploy.ID); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "Project ID: %d\n", deploy.ProjectID); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "Environment: %s\n", fallback(deploy.Environment)); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "Revision: %s\n", fallback(deploy.Revision)); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "Status: %s\n", fallback(deploy.Status)); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "Comment: %s\n", fallback(deploy.Comment)); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "Local Username: %s\n", fallback(deploy.LocalUsername)); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "Rollbar Username: %s\n", fallback(deploy.RollbarUsername)); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "Start Time: %s\n", formatUnix(deploy.StartTime)); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "Finish Time: %s\n", formatUnix(deploy.FinishTime)); err != nil { + return err + } + return nil +} + +func renderDeploysPlain(w io.Writer, deploys []rollbar.Deploy, opts DeployRenderOptions) error { + fields := opts.Fields + if len(fields) == 0 { + fields = defaultDeployListFields + } + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + if !opts.NoHeaders { + if _, err := fmt.Fprintln(tw, strings.Join(fieldHeaders(fields), "\t")); err != nil { + return err + } + } + for _, deploy := range deploys { + if _, err := fmt.Fprintln(tw, strings.Join(deployFieldValues(deploy, fields), "\t")); err != nil { + return err + } + } + return tw.Flush() +} + +func deployFieldValues(deploy rollbar.Deploy, fields []string) []string { + values := make([]string, 0, len(fields)) + for _, field := range fields { + switch field { + case "id", "deploy_id": + values = append(values, strconv.FormatInt(deploy.ID, 10)) + case "project_id": + values = append(values, strconv.FormatInt(deploy.ProjectID, 10)) + case "environment": + values = append(values, fallback(deploy.Environment)) + case "revision": + values = append(values, fallback(deploy.Revision)) + case "status": + values = append(values, fallback(deploy.Status)) + case "comment": + values = append(values, fallback(deploy.Comment)) + case "local_username": + values = append(values, fallback(deploy.LocalUsername)) + case "rollbar_username", "rollbar_name": + values = append(values, fallback(deploy.RollbarUsername)) + case "start_time": + values = append(values, formatUnix(deploy.StartTime)) + case "finish_time": + values = append(values, formatUnix(deploy.FinishTime)) + default: + values = append(values, "-") + } + } + return values +} diff --git a/internal/ui/deploys_table_test.go b/internal/ui/deploys_table_test.go new file mode 100644 index 0000000..9b4f231 --- /dev/null +++ b/internal/ui/deploys_table_test.go @@ -0,0 +1,59 @@ +package ui + +import ( + "bytes" + "strings" + "testing" + + "rollbar-cli/internal/rollbar" +) + +func TestRenderDeploysPlain(t *testing.T) { + var buf bytes.Buffer + err := renderDeploysPlain(&buf, []rollbar.Deploy{ + {ID: 123, Status: "succeeded", Environment: "production", Revision: "aabbcc1", Comment: "done"}, + }, DeployRenderOptions{}) + if err != nil { + t.Fatalf("renderDeploysPlain() error = %v", err) + } + + out := buf.String() + if !strings.Contains(out, "STATUS") || !strings.Contains(out, "production") || !strings.Contains(out, "aabbcc1") { + t.Fatalf("unexpected output: %q", out) + } +} + +func TestRenderDeploy(t *testing.T) { + var buf bytes.Buffer + err := renderDeploy(&buf, rollbar.Deploy{ + ID: 123, + ProjectID: 42, + Environment: "production", + Revision: "aabbcc1", + Status: "succeeded", + Comment: "done", + LocalUsername: "ci-bot", + RollbarUsername: "alice", + StartTime: 1700000000, + FinishTime: 1700003600, + }) + if err != nil { + t.Fatalf("renderDeploy() error = %v", err) + } + + out := buf.String() + if !strings.Contains(out, "ID: 123") || !strings.Contains(out, "Project ID: 42") || !strings.Contains(out, "Rollbar Username: alice") { + t.Fatalf("unexpected output: %q", out) + } +} + +func TestRenderDeploysEmpty(t *testing.T) { + out := captureStdout(t, func() { + if err := RenderDeploys(nil); err != nil { + t.Fatalf("RenderDeploys() error = %v", err) + } + }) + if !strings.Contains(out, "No deploys found") { + t.Fatalf("unexpected output: %q", out) + } +} diff --git a/internal/ui/environments_table.go b/internal/ui/environments_table.go new file mode 100644 index 0000000..fae7dc5 --- /dev/null +++ b/internal/ui/environments_table.go @@ -0,0 +1,65 @@ +package ui + +import ( + "fmt" + "io" + "os" + "strconv" + "strings" + "text/tabwriter" + + "rollbar-cli/internal/rollbar" +) + +type EnvironmentRenderOptions struct { + Fields []string + NoHeaders bool +} + +func RenderEnvironments(environments []rollbar.Environment) error { + return RenderEnvironmentsWithOptions(environments, EnvironmentRenderOptions{}) +} + +func RenderEnvironmentsWithOptions(environments []rollbar.Environment, opts EnvironmentRenderOptions) error { + if len(environments) == 0 { + _, err := fmt.Fprintln(os.Stdout, "No environments found.") + return err + } + return renderEnvironmentsPlain(os.Stdout, environments, opts) +} + +func renderEnvironmentsPlain(w io.Writer, environments []rollbar.Environment, opts EnvironmentRenderOptions) error { + fields := opts.Fields + if len(fields) == 0 { + fields = []string{"id", "project_id", "environment"} + } + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + if !opts.NoHeaders { + if _, err := fmt.Fprintln(tw, strings.Join(fieldHeaders(fields), "\t")); err != nil { + return err + } + } + for _, environment := range environments { + if _, err := fmt.Fprintln(tw, strings.Join(environmentFieldValues(environment, fields), "\t")); err != nil { + return err + } + } + return tw.Flush() +} + +func environmentFieldValues(environment rollbar.Environment, fields []string) []string { + values := make([]string, 0, len(fields)) + for _, field := range fields { + switch field { + case "id": + values = append(values, strconv.FormatInt(environment.ID, 10)) + case "project_id": + values = append(values, strconv.FormatInt(environment.ProjectID, 10)) + case "environment", "name": + values = append(values, fallback(environment.Name)) + default: + values = append(values, "-") + } + } + return values +} diff --git a/internal/ui/environments_table_test.go b/internal/ui/environments_table_test.go new file mode 100644 index 0000000..578a40c --- /dev/null +++ b/internal/ui/environments_table_test.go @@ -0,0 +1,35 @@ +package ui + +import ( + "bytes" + "strings" + "testing" + + "rollbar-cli/internal/rollbar" +) + +func TestRenderEnvironmentsPlain(t *testing.T) { + var buf bytes.Buffer + err := renderEnvironmentsPlain(&buf, []rollbar.Environment{ + {ID: 3, ProjectID: 99, Name: "production"}, + }, EnvironmentRenderOptions{}) + if err != nil { + t.Fatalf("renderEnvironmentsPlain() error = %v", err) + } + + out := buf.String() + if !strings.Contains(out, "ENVIRONMENT") || !strings.Contains(out, "production") { + t.Fatalf("unexpected output: %q", out) + } +} + +func TestRenderEnvironmentsEmpty(t *testing.T) { + out := captureStdout(t, func() { + if err := RenderEnvironments(nil); err != nil { + t.Fatalf("RenderEnvironments() error = %v", err) + } + }) + if !strings.Contains(out, "No environments found") { + t.Fatalf("unexpected output: %q", out) + } +}