Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ linters:

linters-settings:
dupl:
threshold: 120
threshold: 100
funlen:
lines: 40
statements: 30
Expand Down
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -65,11 +69,6 @@ List filters (for `rollbaz`, `active`, and `recent`):
--max-occurrences <count>
```

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
Expand Down
48 changes: 48 additions & 0 deletions internal/app/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
166 changes: 166 additions & 0 deletions internal/app/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"math"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
Loading