diff --git a/.golangci.yml b/.golangci.yml index 591c0e6..232ce40 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -23,7 +23,7 @@ linters: linters-settings: dupl: - threshold: 120 + threshold: 100 funlen: lines: 40 statements: 30 diff --git a/README.md b/README.md index d774f13..7293639 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ go build ./cmd/rollbaz ## Configure Projects -Use a Rollbar project token with read access. +Use a Rollbar project token with read access for list/show commands. +Use a project token with write access for resolve/reopen/mute commands. Rollbar token types and permissions: https://docs.rollbar.com/docs/access-tokens @@ -50,6 +51,9 @@ rollbaz # default: list recent active issues for active project rollbaz active --limit 20 rollbaz recent --limit 20 rollbaz show 274 +rollbaz resolve 274 --yes +rollbaz reopen 274 --yes +rollbaz mute 274 --for 2h --yes ``` Use `--format json` on list and show commands for LLM-friendly machine output. @@ -65,11 +69,6 @@ List filters (for `rollbaz`, `active`, and `recent`): --max-occurrences ``` -Human output uses pretty terminal tables and UTC timestamps (`RFC3339`). -In interactive terminals, commands show a short progress indicator while data is loading. -Tables auto-size to terminal width and keep a consistent layout across commands. -Long fields are truncated in human mode to keep output readable. - ## Examples ```bash diff --git a/internal/app/service.go b/internal/app/service.go index 86dd9e1..b60be3b 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -17,6 +17,7 @@ import ( type RollbarAPI interface { ResolveItemIDByCounter(ctx context.Context, counter domain.ItemCounter) (domain.ItemID, error) GetItem(ctx context.Context, itemID domain.ItemID) (rollbar.Item, error) + UpdateItem(ctx context.Context, itemID domain.ItemID, patch rollbar.ItemPatch) error GetLatestInstance(ctx context.Context, itemID domain.ItemID) (*rollbar.ItemInstance, error) ListActiveItems(ctx context.Context, limit int) ([]rollbar.Item, error) ListItems(ctx context.Context, status string, page int) ([]rollbar.Item, error) @@ -58,6 +59,13 @@ type IssueFilters struct { MaxOccurrences *uint64 } +const maxResolvedVersionLength = 40 + +type ItemActionResult struct { + Action string `json:"action"` + Issue IssueSummary `json:"issue"` +} + func (s *Service) Active(ctx context.Context, limit int, filters IssueFilters) ([]IssueSummary, error) { items, err := s.api.ListActiveItems(ctx, limit) if err != nil { @@ -130,6 +138,46 @@ func (s *Service) Show(ctx context.Context, counter domain.ItemCounter) (IssueDe }, nil } +func (s *Service) Resolve(ctx context.Context, counter domain.ItemCounter, resolvedInVersion string) (ItemActionResult, error) { + trimmedVersion := strings.TrimSpace(resolvedInVersion) + if len(trimmedVersion) > maxResolvedVersionLength { + return ItemActionResult{}, fmt.Errorf("resolved_in_version must be <= %d characters", maxResolvedVersionLength) + } + + patch := rollbar.ItemPatch{Status: "resolved", ResolvedInVersion: trimmedVersion} + + return s.updateItemAndFetch(ctx, counter, patch, "resolved") +} + +func (s *Service) Reopen(ctx context.Context, counter domain.ItemCounter) (ItemActionResult, error) { + return s.updateItemAndFetch(ctx, counter, rollbar.ItemPatch{Status: "active"}, "reopened") +} + +func (s *Service) Mute(ctx context.Context, counter domain.ItemCounter, durationSeconds *int64) (ItemActionResult, error) { + snoozeEnabled := true + patch := rollbar.ItemPatch{Status: "muted", SnoozeEnabled: &snoozeEnabled, SnoozeExpirationInSeconds: durationSeconds} + + return s.updateItemAndFetch(ctx, counter, patch, "muted") +} + +func (s *Service) updateItemAndFetch(ctx context.Context, counter domain.ItemCounter, patch rollbar.ItemPatch, action string) (ItemActionResult, error) { + itemID, err := s.api.ResolveItemIDByCounter(ctx, counter) + if err != nil { + return ItemActionResult{}, fmt.Errorf("resolve item id: %w", err) + } + + if err := s.api.UpdateItem(ctx, itemID, patch); err != nil { + return ItemActionResult{}, fmt.Errorf("update item: %w", err) + } + + item, err := s.api.GetItem(ctx, itemID) + if err != nil { + return ItemActionResult{}, fmt.Errorf("get item: %w", err) + } + + return ItemActionResult{Action: action, Issue: mapSummary(item)}, nil +} + func mapSummaries(items []rollbar.Item) []IssueSummary { summaries := make([]IssueSummary, 0, len(items)) for _, item := range items { diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 86391ea..7a118d2 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "math" + "strings" "testing" "time" @@ -34,6 +35,14 @@ func (f fakeAPI) GetItem(ctx context.Context, itemID domain.ItemID) (rollbar.Ite return f.item, nil } +func (f fakeAPI) UpdateItem(ctx context.Context, itemID domain.ItemID, patch rollbar.ItemPatch) error { + if f.err != nil { + return f.err + } + + return nil +} + func (f fakeAPI) GetLatestInstance(ctx context.Context, itemID domain.ItemID) (*rollbar.ItemInstance, error) { if f.err != nil { return nil, f.err @@ -192,6 +201,163 @@ func TestServiceShowWithoutInstanceOrTitle(t *testing.T) { } } +type actionAPI struct { + resolvedID domain.ItemID + item rollbar.Item + resolveErr error + updateErr error + getItemErr error + updateCalls int + lastPatch rollbar.ItemPatch +} + +func (a *actionAPI) ResolveItemIDByCounter(ctx context.Context, counter domain.ItemCounter) (domain.ItemID, error) { + if a.resolveErr != nil { + return 0, a.resolveErr + } + + return a.resolvedID, nil +} + +func (a *actionAPI) GetItem(ctx context.Context, itemID domain.ItemID) (rollbar.Item, error) { + if a.getItemErr != nil { + return rollbar.Item{}, a.getItemErr + } + + return a.item, nil +} + +func (a *actionAPI) UpdateItem(ctx context.Context, itemID domain.ItemID, patch rollbar.ItemPatch) error { + a.lastPatch = patch + a.updateCalls++ + if a.updateErr != nil { + return a.updateErr + } + + return nil +} + +func (a *actionAPI) GetLatestInstance(ctx context.Context, itemID domain.ItemID) (*rollbar.ItemInstance, error) { + return nil, nil +} + +func (a *actionAPI) ListActiveItems(ctx context.Context, limit int) ([]rollbar.Item, error) { + return nil, nil +} + +func (a *actionAPI) ListItems(ctx context.Context, status string, page int) ([]rollbar.Item, error) { + return nil, nil +} + +func TestServiceResolve(t *testing.T) { + t.Parallel() + + api := &actionAPI{ + resolvedID: 99, + item: rollbar.Item{ID: 99, Counter: 7, Status: "resolved", Title: "x"}, + } + service := NewService(api) + + result, err := service.Resolve(context.Background(), 7, "v1.2.3") + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if result.Action != "resolved" || result.Issue.Status != "resolved" { + t.Fatalf("unexpected resolve result: %+v", result) + } + if api.updateCalls != 1 { + t.Fatalf("expected one update call, got %d", api.updateCalls) + } + if api.lastPatch.Status != "resolved" || api.lastPatch.ResolvedInVersion != "v1.2.3" { + t.Fatalf("unexpected resolve patch: %+v", api.lastPatch) + } +} + +func TestServiceResolveRejectsLongResolvedVersion(t *testing.T) { + t.Parallel() + + api := &actionAPI{resolvedID: 99, item: rollbar.Item{ID: 99, Counter: 7, Status: "resolved"}} + service := NewService(api) + tooLong := strings.Repeat("a", maxResolvedVersionLength+1) + + if _, err := service.Resolve(context.Background(), 7, tooLong); err == nil { + t.Fatalf("expected resolved version length error") + } + if api.updateCalls != 0 { + t.Fatalf("expected no update call, got %d", api.updateCalls) + } +} + +func TestServiceReopen(t *testing.T) { + t.Parallel() + + api := &actionAPI{ + resolvedID: 99, + item: rollbar.Item{ID: 99, Counter: 8, Status: "active", Title: "x"}, + } + service := NewService(api) + + result, err := service.Reopen(context.Background(), 8) + if err != nil { + t.Fatalf("Reopen() error = %v", err) + } + if result.Action != "reopened" || result.Issue.Status != "active" { + t.Fatalf("unexpected reopen result: %+v", result) + } + if api.lastPatch.Status != "active" { + t.Fatalf("unexpected reopen patch: %+v", api.lastPatch) + } +} + +func TestServiceMute(t *testing.T) { + t.Parallel() + + duration := int64(3600) + api := &actionAPI{ + resolvedID: 99, + item: rollbar.Item{ID: 99, Counter: 9, Status: "muted", Title: "x"}, + } + service := NewService(api) + + result, err := service.Mute(context.Background(), 9, &duration) + if err != nil { + t.Fatalf("Mute() error = %v", err) + } + if result.Action != "muted" || result.Issue.Status != "muted" { + t.Fatalf("unexpected mute result: %+v", result) + } + if api.lastPatch.Status != "muted" || api.lastPatch.SnoozeExpirationInSeconds == nil || *api.lastPatch.SnoozeExpirationInSeconds != duration { + t.Fatalf("unexpected mute patch: %+v", api.lastPatch) + } + if api.lastPatch.SnoozeEnabled == nil || !*api.lastPatch.SnoozeEnabled { + t.Fatalf("expected snooze_enabled true, got %+v", api.lastPatch) + } +} + +func TestServiceItemActionsErrors(t *testing.T) { + t.Parallel() + + api := &actionAPI{resolvedID: 99, item: rollbar.Item{ID: 99, Counter: 7}} + service := NewService(api) + + api.resolveErr = errors.New("resolve") + if _, err := service.Resolve(context.Background(), 7, ""); err == nil { + t.Fatalf("expected resolve id error") + } + + api.resolveErr = nil + api.updateErr = errors.New("update") + if _, err := service.Reopen(context.Background(), 7); err == nil { + t.Fatalf("expected update error") + } + + api.updateErr = nil + api.getItemErr = errors.New("get") + if _, err := service.Mute(context.Background(), 7, nil); err == nil { + t.Fatalf("expected get item error") + } +} + func TestServiceActiveFilters(t *testing.T) { t.Parallel() diff --git a/internal/cli/root.go b/internal/cli/root.go index a0d566d..8af2a48 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,12 +1,14 @@ package cli import ( + "bufio" "context" "errors" "fmt" "io" "os" "strconv" + "strings" "time" "github.com/jedib0t/go-pretty/v6/progress" @@ -25,6 +27,7 @@ type rootFlags struct { Format string Project string Token string + Yes bool Limit int Environment string Status string @@ -41,6 +44,7 @@ var ( getTerminalSize func(int) (int, int, error) = term.GetSize stdoutWriter io.Writer = os.Stdout stderrWriter io.Writer = os.Stderr + stdinReader io.Reader = os.Stdin ) const ( @@ -65,6 +69,7 @@ func NewRootCmd() *cobra.Command { cmd.PersistentFlags().StringVar(&flags.Format, "format", "human", "Output format: human or json") cmd.PersistentFlags().StringVar(&flags.Project, "project", "", "Configured project name") cmd.PersistentFlags().StringVar(&flags.Token, "token", "", "Rollbar project token (overrides configured project token)") + cmd.PersistentFlags().BoolVar(&flags.Yes, "yes", false, "Skip confirmation prompts for write commands") cmd.PersistentFlags().IntVar(&flags.Limit, "limit", 10, "Maximum number of issues to show") cmd.PersistentFlags().StringVar(&flags.Environment, "env", "", "Filter by environment") cmd.PersistentFlags().StringVar(&flags.Status, "status", "", "Filter by status") @@ -76,6 +81,9 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newActiveCmd(flags)) cmd.AddCommand(newRecentCmd(flags)) cmd.AddCommand(newShowCmd(flags)) + cmd.AddCommand(newResolveCmd(flags)) + cmd.AddCommand(newReopenCmd(flags)) + cmd.AddCommand(newMuteCmd(flags)) cmd.AddCommand(newProjectCmd()) return cmd @@ -117,15 +125,83 @@ func newShowCmd(flags *rootFlags) *cobra.Command { Short: "Show details for one item counter", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - parsedCounter, err := strconv.ParseUint(args[0], 10, 64) + counter, err := parseItemCounter(args[0]) if err != nil { - return fmt.Errorf("parse item counter: %w", err) + return err } - return runShow(cmd.Context(), *flags, domain.ItemCounter(parsedCounter)) + return runShow(cmd.Context(), *flags, counter) }, } } +func newResolveCmd(flags *rootFlags) *cobra.Command { + resolvedVersion := "" + resolveCmd := &cobra.Command{ + Use: "resolve ", + Short: "Resolve an issue", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + counter, err := parseItemCounter(args[0]) + if err != nil { + return err + } + + return runResolve(cmd.Context(), *flags, counter, resolvedVersion) + }, + } + resolveCmd.Flags().StringVar(&resolvedVersion, "resolved-in-version", "", "Version to store when resolving") + + return resolveCmd +} + +func newReopenCmd(flags *rootFlags) *cobra.Command { + return &cobra.Command{ + Use: "reopen ", + Short: "Reopen a resolved or muted issue", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + counter, err := parseItemCounter(args[0]) + if err != nil { + return err + } + + return runReopen(cmd.Context(), *flags, counter) + }, + } +} + +func newMuteCmd(flags *rootFlags) *cobra.Command { + muteFor := "" + muteCmd := &cobra.Command{ + Use: "mute ", + Short: "Mute an issue", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + counter, err := parseItemCounter(args[0]) + if err != nil { + return err + } + + return runMute(cmd.Context(), *flags, counter, muteFor) + }, + } + muteCmd.Flags().StringVar(&muteFor, "for", "", "Mute duration (examples: 30m, 2h, 24h)") + + return muteCmd +} + +func parseItemCounter(value string) (domain.ItemCounter, error) { + parsedCounter, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return 0, fmt.Errorf("parse item counter: %w", err) + } + if parsedCounter == 0 { + return 0, errors.New("item counter must be greater than 0") + } + + return domain.ItemCounter(parsedCounter), nil +} + func newProjectCmd() *cobra.Command { projectCmd := &cobra.Command{Use: "project", Short: "Manage configured Rollbar projects"} projectCmd.AddCommand( @@ -367,6 +443,109 @@ func runShow(parent context.Context, flags rootFlags, counter domain.ItemCounter return printOutput(flags.Format, output.RenderIssueDetailHumanWithWidth(detail, terminalRenderWidth()), jsonPayload) } +func runResolve(parent context.Context, flags rootFlags, counter domain.ItemCounter, resolvedVersion string) error { + return runIssueAction(parent, flags, "resolve", counter, func(ctx context.Context, service *app.Service) (app.ItemActionResult, error) { + return service.Resolve(ctx, counter, resolvedVersion) + }) +} + +func runReopen(parent context.Context, flags rootFlags, counter domain.ItemCounter) error { + return runIssueAction(parent, flags, "reopen", counter, func(ctx context.Context, service *app.Service) (app.ItemActionResult, error) { + return service.Reopen(ctx, counter) + }) +} + +func runMute(parent context.Context, flags rootFlags, counter domain.ItemCounter, muteFor string) error { + durationSeconds, err := parseMuteDuration(muteFor) + if err != nil { + return err + } + + return runIssueAction(parent, flags, "mute", counter, func(ctx context.Context, service *app.Service) (app.ItemActionResult, error) { + return service.Mute(ctx, counter, durationSeconds) + }) +} + +func parseMuteDuration(value string) (*int64, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, nil + } + + parsed, err := time.ParseDuration(value) + if err != nil { + return nil, fmt.Errorf("parse --for: %w", err) + } + if parsed < time.Second { + return nil, errors.New("--for must be at least 1s") + } + + seconds := int64(parsed / time.Second) + return &seconds, nil +} + +func runIssueAction(parent context.Context, flags rootFlags, action string, counter domain.ItemCounter, execute func(context.Context, *app.Service) (app.ItemActionResult, error)) error { + if err := confirmWrite(flags, action, counter); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(parent, 10*time.Second) + defer cancel() + + service, token, err := buildService(flags) + if err != nil { + return err + } + + result, err := runWithProgress(flags.Format, "Updating issue", func() (app.ItemActionResult, error) { + return execute(ctx, service) + }) + if err != nil { + return sanitizeError(err, token) + } + + human := fmt.Sprintf("%s issue %s\n\n%s", result.Action, result.Issue.Counter.String(), output.RenderIssueListHumanWithWidth([]app.IssueSummary{result.Issue}, terminalRenderWidth())) + jsonPayload := redact.Value(map[string]any{"action": result.Action, "issue": result.Issue}, token) + + return printOutput(flags.Format, human, jsonPayload) +} + +func confirmWrite(flags rootFlags, action string, counter domain.ItemCounter) error { + if flags.Yes { + return nil + } + if flags.Format != "human" || !canPromptConfirmation() { + return errors.New("confirmation required for write operation; rerun with --yes") + } + + _, _ = fmt.Fprintf(stdoutWriter, "Confirm %s issue %s? [y/N]: ", action, counter.String()) + reader := bufio.NewReader(stdinReader) + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("read confirmation: %w", err) + } + + value := strings.TrimSpace(strings.ToLower(line)) + if value != "y" && value != "yes" { + return errors.New("operation cancelled") + } + + return nil +} + +func canPromptConfirmation() bool { + stdoutFile, ok := stdoutFile() + if !ok || !isTerminal(int(stdoutFile.Fd())) { + return false + } + input, ok := stdinReader.(*os.File) + if !ok || !isTerminal(int(input.Fd())) { + return false + } + + return true +} + func printOutput(format string, human string, payload any) error { switch format { case "human": diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 4adfe44..d8dda63 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "encoding/json" "fmt" "io" "net/http" @@ -30,14 +31,12 @@ func TestRunShowHuman(t *testing.T) { } func TestRunActiveJSON(t *testing.T) { - stdout := setupServerAndStdout(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/1/reports/top_active_items" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - _, _ = fmt.Fprint(w, `{"err":0,"result":[{"item":{"id":1755568172,"counter":269,"title":"RST_STREAM","status":"active","environment":"production","occurrences":7,"last_occurrence_timestamp":1700000000}}]}`) - })) - - err := runActive(context.Background(), rootFlags{Format: "json", Limit: 10}) + stdout, err := runIssueListCommand( + t, + "/api/1/reports/top_active_items", + `{"err":0,"result":[{"item":{"id":1755568172,"counter":269,"title":"RST_STREAM","status":"active","environment":"production","occurrences":7,"last_occurrence_timestamp":1700000000}}]}`, + func() error { return runActive(context.Background(), rootFlags{Format: "json", Limit: 10}) }, + ) if err != nil { t.Fatalf("runActive() error = %v", err) } @@ -48,14 +47,12 @@ func TestRunActiveJSON(t *testing.T) { } func TestRunRecentHuman(t *testing.T) { - stdout := setupServerAndStdout(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/1/items" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - _, _ = fmt.Fprint(w, `{"err":0,"result":{"items":[{"id":1,"counter":2,"title":"Recent","status":"active","environment":"production","last_occurrence_timestamp":1700000000,"occurrences":5}]}}`) - })) - - err := runRecent(context.Background(), rootFlags{Format: "human", Limit: 10}) + stdout, err := runIssueListCommand( + t, + "/api/1/items", + `{"err":0,"result":{"items":[{"id":1,"counter":2,"title":"Recent","status":"active","environment":"production","last_occurrence_timestamp":1700000000,"occurrences":5}]}}`, + func() error { return runRecent(context.Background(), rootFlags{Format: "human", Limit: 10}) }, + ) if err != nil { t.Fatalf("runRecent() error = %v", err) } @@ -284,6 +281,55 @@ func TestActiveAndRecentSubcommands(t *testing.T) { } } +func TestResolveCommandRequiresConfirmation(t *testing.T) { + setupServerAndStdout(t, newActionSuccessHandler(t, nil)) + + cmd := NewRootCmd() + cmd.SetArgs([]string{"resolve", "269"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "confirmation required") { + t.Fatalf("expected confirmation error, got %v", err) + } +} + +func TestResolveCommandWithYes(t *testing.T) { + var patchPayload rollbar.ItemPatch + setupServerAndStdout(t, newActionSuccessHandler(t, &patchPayload)) + + cmd := NewRootCmd() + cmd.SetArgs([]string{"resolve", "269", "--yes", "--resolved-in-version", "v1.2.3"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("resolve command error = %v", err) + } + + if patchPayload.Status != "resolved" || patchPayload.ResolvedInVersion != "v1.2.3" { + t.Fatalf("unexpected patch payload: %+v", patchPayload) + } +} + +func TestMuteCommandInvalidDuration(t *testing.T) { + if err := runMute(context.Background(), rootFlags{}, 269, "500ms"); err == nil { + t.Fatalf("expected invalid duration error") + } +} + +func TestMuteCommandWithYesAndDuration(t *testing.T) { + var patchPayload rollbar.ItemPatch + setupServerAndStdout(t, newActionSuccessHandler(t, &patchPayload)) + + cmd := NewRootCmd() + cmd.SetArgs([]string{"mute", "269", "--for", "2h", "--yes"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("mute command error = %v", err) + } + if patchPayload.Status != "muted" { + t.Fatalf("expected muted status patch, got %+v", patchPayload) + } + if patchPayload.SnoozeExpirationInSeconds == nil || *patchPayload.SnoozeExpirationInSeconds != int64(7200) { + t.Fatalf("unexpected mute expiration in patch: %+v", patchPayload) + } +} + func setupServerAndStdout(t *testing.T, handler http.Handler) *bytes.Buffer { t.Helper() t.Setenv("ROLLBAR_ACCESS_TOKEN", "token") @@ -314,6 +360,21 @@ func runRootCommand(t *testing.T, args ...string) { } } +func runIssueListCommand(t *testing.T, expectedPath string, responseBody string, run func() error) (*bytes.Buffer, error) { + t.Helper() + + stdout := setupServerAndStdout(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != expectedPath { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + _, _ = fmt.Fprint(w, responseBody) + })) + + err := run() + + return stdout, err +} + func TestShowCommandInvalidCounter(t *testing.T) { cmd := NewRootCmd() cmd.SetArgs([]string{"show", "not-a-number"}) @@ -322,6 +383,15 @@ func TestShowCommandInvalidCounter(t *testing.T) { } } +func TestShowCommandRejectsZeroCounter(t *testing.T) { + cmd := NewRootCmd() + cmd.SetArgs([]string{"show", "0"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "item counter must be greater than 0") { + t.Fatalf("expected zero counter error, got %v", err) + } +} + func TestRunShowJSON(t *testing.T) { stdout, err := runShowForFormat(t, "json") if err != nil { @@ -574,3 +644,34 @@ func newSuccessHandler(t *testing.T) http.Handler { } }) } + +func newActionSuccessHandler(t *testing.T, capturedPatch *rollbar.ItemPatch) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/1/item_by_counter/269": + _, _ = fmt.Fprint(w, `{"err":0,"result":{"itemId":1755568172}}`) + case "/api/1/item/1755568172": + if r.Method != http.MethodPatch { + t.Fatalf("unexpected method for patch endpoint: %s", r.Method) + } + if capturedPatch != nil { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + if err := json.Unmarshal(body, capturedPatch); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + } + _, _ = fmt.Fprint(w, `{"err":0,"result":{}}`) + case "/api/1/item/1755568172/": + if r.Method != http.MethodGet { + t.Fatalf("unexpected method for get item endpoint: %s", r.Method) + } + _, _ = fmt.Fprint(w, `{"err":0,"result":{"id":1755568172,"project_id":766510,"counter":269,"title":"RST_STREAM","status":"active","environment":"production","total_occurrences":7}}`) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + }) +} diff --git a/internal/rollbar/client.go b/internal/rollbar/client.go index 060f43c..09e2ab0 100644 --- a/internal/rollbar/client.go +++ b/internal/rollbar/client.go @@ -1,6 +1,7 @@ package rollbar import ( + "bytes" "context" "encoding/json" "errors" @@ -19,6 +20,8 @@ import ( const defaultBaseURL = "https://api.rollbar.com/api/1" +const maxResponseBodyBytes = 4 << 20 + type Client struct { http *http.Client baseURL string @@ -96,6 +99,32 @@ func (c *Client) GetItem(ctx context.Context, itemID domain.ItemID) (Item, error return item, nil } +func (c *Client) UpdateItem(ctx context.Context, itemID domain.ItemID, patch ItemPatch) error { + body, err := json.Marshal(patch) + if err != nil { + return c.wrap(err, "encode update item request") + } + + raw, err := c.doPatch(ctx, "/item/"+itemID.String(), body, "update item") + if err != nil { + return err + } + + var envelope apiEnvelope + if err := json.Unmarshal(raw, &envelope); err != nil { + return c.wrap(err, "decode update item envelope") + } + if envelope.Err != 0 { + message := envelope.Message + if message == "" { + message = "unknown error from Rollbar" + } + return c.wrap(errors.New(message), "rollbar update item") + } + + return nil +} + func (c *Client) ListActiveItems(ctx context.Context, limit int) ([]Item, error) { raw, err := c.getResult(ctx, "/reports/top_active_items", "top active items") if err != nil { @@ -264,17 +293,28 @@ func (c *Client) getResult(ctx context.Context, endpointPath string, op string) } func (c *Client) doGet(ctx context.Context, endpointPath string, op string) ([]byte, error) { + return c.doRequest(ctx, http.MethodGet, endpointPath, nil, "", op) +} + +func (c *Client) doPatch(ctx context.Context, endpointPath string, body []byte, op string) ([]byte, error) { + return c.doRequest(ctx, http.MethodPatch, endpointPath, bytes.NewReader(body), "application/json", op) +} + +func (c *Client) doRequest(ctx context.Context, method string, endpointPath string, requestBody io.Reader, contentType string, op string) ([]byte, error) { requestURL, err := buildURL(c.baseURL, endpointPath) if err != nil { return nil, c.wrap(err, "build "+op+" URL") } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + req, err := http.NewRequestWithContext(ctx, method, requestURL, requestBody) if err != nil { return nil, c.wrap(err, "build "+op+" request") } req.Header.Set("X-Rollbar-Access-Token", c.accessToken) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } response, err := c.http.Do(req) if err != nil { @@ -289,12 +329,15 @@ func (c *Client) doGet(ctx context.Context, endpointPath string, op string) ([]b return nil, c.wrap(fmt.Errorf("status %d: %s", response.StatusCode, strings.TrimSpace(string(limited))), op+" returned non-success status") } - body, err := io.ReadAll(response.Body) + responseBody, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBodyBytes+1)) if err != nil { return nil, c.wrap(err, "read "+op+" response") } + if len(responseBody) > maxResponseBodyBytes { + return nil, c.wrap(fmt.Errorf("response exceeds %d bytes", maxResponseBodyBytes), "read "+op+" response") + } - return body, nil + return responseBody, nil } func buildURL(baseURL string, endpointPath string) (string, error) { diff --git a/internal/rollbar/client_test.go b/internal/rollbar/client_test.go index 96a6e7b..12d7dba 100644 --- a/internal/rollbar/client_test.go +++ b/internal/rollbar/client_test.go @@ -2,7 +2,9 @@ package rollbar import ( "context" + "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "strings" @@ -80,6 +82,55 @@ func TestGetItem(t *testing.T) { } } +func TestUpdateItem(t *testing.T) { + t.Parallel() + + expiration := int64(3600) + client := newTestClientWithHandler(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Fatalf("unexpected method: %s", r.Method) + } + if r.URL.Path != "/item/1755568172" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Fatalf("missing content-type header") + } + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + + var patch ItemPatch + if err := json.Unmarshal(body, &patch); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if patch.Status != "muted" || patch.SnoozeExpirationInSeconds == nil || *patch.SnoozeExpirationInSeconds != expiration { + t.Fatalf("unexpected patch payload: %+v", patch) + } + + _, _ = fmt.Fprint(w, `{"err":0,"result":{}}`) + }) + + err := client.UpdateItem(context.Background(), domain.ItemID(1755568172), ItemPatch{Status: "muted", SnoozeExpirationInSeconds: &expiration}) + if err != nil { + t.Fatalf("UpdateItem() error = %v", err) + } +} + +func TestUpdateItemEnvelopeError(t *testing.T) { + t.Parallel() + + client := newTestClientWithHandler(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `{"err":1,"message":"denied"}`) + }) + err := client.UpdateItem(context.Background(), domain.ItemID(1), ItemPatch{Status: "resolved"}) + if err == nil { + t.Fatalf("expected error") + } +} + func TestGetLatestInstanceSupportsListAndWrapped(t *testing.T) { t.Parallel() @@ -145,6 +196,19 @@ func TestGetItemNonSuccessStatus(t *testing.T) { } } +func TestGetItemResponseTooLarge(t *testing.T) { + t.Parallel() + + client := newTestClientWithHandler(t, func(w http.ResponseWriter, r *http.Request) { + large := strings.Repeat("a", maxResponseBodyBytes+1024) + _, _ = fmt.Fprintf(w, `{"err":0,"result":"%s"}`, large) + }) + _, err := client.GetItem(context.Background(), domain.ItemID(1)) + if err == nil { + t.Fatalf("expected response too large error") + } +} + func TestGetLatestInstanceReturnsNilForEmptyList(t *testing.T) { t.Parallel() diff --git a/internal/rollbar/models.go b/internal/rollbar/models.go index 57cb115..51fb00e 100644 --- a/internal/rollbar/models.go +++ b/internal/rollbar/models.go @@ -23,6 +23,13 @@ type Item struct { Raw json.RawMessage `json:"-"` } +type ItemPatch struct { + Status string `json:"status,omitempty"` + ResolvedInVersion string `json:"resolved_in_version,omitempty"` + SnoozeEnabled *bool `json:"snooze_enabled,omitempty"` + SnoozeExpirationInSeconds *int64 `json:"snooze_expiration_in_seconds,omitempty"` +} + func (i *Item) UnmarshalJSON(data []byte) error { type itemDTO struct { ID flexibleUint64 `json:"id"`