From 56c1b606f9fad2b49ee5d88b29c75f6137202b16 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 15 Apr 2026 11:27:05 -0400 Subject: [PATCH 1/8] Use server-side card column filtering --- internal/commands/card.go | 67 +++++-------------------- internal/commands/card_test.go | 79 ++++++++++++++++++++++-------- internal/commands/pseudocolumns.go | 5 +- internal/commands/search.go | 2 +- skills/fizzy/SKILL.md | 12 ++--- 5 files changed, 81 insertions(+), 84 deletions(-) diff --git a/internal/commands/card.go b/internal/commands/card.go index 4380506..41f12ff 100644 --- a/internal/commands/card.go +++ b/internal/commands/card.go @@ -57,34 +57,35 @@ var cardListCmd = &cobra.Command{ params = append(params, "board_ids[]="+boardID) } - clientSideColumnFilter := "" - clientSideTriage := false if columnFilter != "" { if pseudo, ok := parsePseudoColumnID(columnFilter); ok { switch pseudo.Kind { case "not_now": if effectiveIndexedBy != "" && effectiveIndexedBy != "not_now" { - return errors.NewInvalidArgsError("cannot combine --indexed-by with --column maybe") + return errors.NewInvalidArgsError("cannot combine --indexed-by with --column " + columnFilter) } effectiveIndexedBy = "not_now" case "closed": if effectiveIndexedBy != "" && effectiveIndexedBy != "closed" { - return errors.NewInvalidArgsError("cannot combine --indexed-by with --column done") + return errors.NewInvalidArgsError("cannot combine --indexed-by with --column " + columnFilter) } effectiveIndexedBy = "closed" case "triage": - if effectiveIndexedBy != "" { - return errors.NewInvalidArgsError("cannot combine --indexed-by with --column not-yet") + if effectiveIndexedBy != "" && effectiveIndexedBy != "maybe" { + return errors.NewInvalidArgsError("cannot combine --indexed-by with --column " + columnFilter) } - clientSideTriage = true + effectiveIndexedBy = "maybe" default: - clientSideColumnFilter = columnFilter + if effectiveIndexedBy != "" { + return errors.NewInvalidArgsError("cannot combine --indexed-by with --column") + } + params = append(params, "column_ids[]="+columnFilter) } } else { if effectiveIndexedBy != "" { return errors.NewInvalidArgsError("cannot combine --indexed-by with --column") } - clientSideColumnFilter = columnFilter + params = append(params, "column_ids[]="+columnFilter) } } @@ -128,10 +129,6 @@ var cardListCmd = &cobra.Command{ path += "?" + strings.Join(params, "&") } - if (clientSideTriage || clientSideColumnFilter != "") && !cardListAll && cardListPage == 0 { - return errors.NewInvalidArgsError("Filtering by column requires --all (or --page) because it is applied client-side") - } - var items any var linkNext string @@ -150,46 +147,6 @@ var cardListCmd = &cobra.Command{ linkNext = parseSDKLinkNext(resp) } - if clientSideTriage || clientSideColumnFilter != "" { - arr := toSliceAny(items) - if arr == nil { - return errors.NewError("Unexpected cards list response") - } - - filtered := make([]any, 0, len(arr)) - for _, item := range arr { - card, ok := item.(map[string]any) - if !ok { - continue - } - - columnID := "" - if v, ok := card["column_id"].(string); ok { - columnID = v - } - if columnID == "" { - if col, ok := card["column"].(map[string]any); ok { - if id, ok := col["id"].(string); ok { - columnID = id - } - } - } - - if clientSideTriage { - if columnID == "" { - filtered = append(filtered, item) - } - continue - } - - if clientSideColumnFilter != "" && columnID == clientSideColumnFilter { - filtered = append(filtered, item) - } - } - - items = filtered - } - // Build summary count := dataCount(items) summary := fmt.Sprintf("%d cards", count) @@ -1082,9 +1039,9 @@ func init() { // List cardListCmd.Flags().StringVar(&cardListBoard, "board", "", "Filter by board ID") - cardListCmd.Flags().StringVar(&cardListColumn, "column", "", "Filter by column ID or pseudo column (not-yet, maybe, done)") + cardListCmd.Flags().StringVar(&cardListColumn, "column", "", "Filter by column ID or pseudo column (not-now, maybe, done)") cardListCmd.Flags().StringVar(&cardListTag, "tag", "", "Filter by tag ID") - cardListCmd.Flags().StringVar(&cardListIndexedBy, "indexed-by", "", "Filter by lane/index (all, closed, not_now, stalled, postponing_soon, golden)") + cardListCmd.Flags().StringVar(&cardListIndexedBy, "indexed-by", "", "Filter by lane/index (all, closed, maybe, not_now, stalled, postponing_soon, golden)") cardListCmd.Flags().StringVar(&cardListIndexedBy, "status", "", "Alias for --indexed-by") _ = cardListCmd.Flags().MarkDeprecated("status", "use --indexed-by") cardListCmd.Flags().StringVar(&cardListAssignee, "assignee", "", "Filter by assignee ID") diff --git a/internal/commands/card_test.go b/internal/commands/card_test.go index 2e6cb40..2320852 100644 --- a/internal/commands/card_test.go +++ b/internal/commands/card_test.go @@ -88,29 +88,45 @@ func TestCardList(t *testing.T) { } }) - t.Run("requires --all for client-side triage filter", func(t *testing.T) { + t.Run("filters by real column server-side without client-side filtering", func(t *testing.T) { mock := NewMockClient() - SetTestModeWithSDK(mock) + mock.GetWithPaginationResponse = &client.APIResponse{ + StatusCode: 200, + Data: []any{ + map[string]any{"id": "1", "title": "Column 1", "column_id": "col-1"}, + map[string]any{"id": "2", "title": "Column 2", "column_id": "col-2"}, + }, + } + + result := SetTestModeWithSDK(mock) SetTestConfig("token", "account", "https://api.example.com") defer resetTest() - cardListColumn = "maybe" - cardListAll = false - cardListPage = 0 + cardListColumn = "col-1" err := cardListCmd.RunE(cardListCmd, []string{}) cardListColumn = "" - assertExitCode(t, err, errors.ExitInvalidArgs) + assertExitCode(t, err, 0) + if mock.GetWithPaginationCalls[0].Path != "/cards.json?column_ids[]=col-1" { + t.Errorf("expected server-side column_ids filter, got '%s'", mock.GetWithPaginationCalls[0].Path) + } + + arr, ok := result.Response.Data.([]any) + if !ok { + t.Fatalf("expected array response data, got %T", result.Response.Data) + } + if len(arr) != 2 { + t.Fatalf("expected server response to remain unfiltered client-side, got %d cards", len(arr)) + } }) - t.Run("filters triage client-side with --all", func(t *testing.T) { + t.Run("filters by pseudo column maybe server-side without all", func(t *testing.T) { mock := NewMockClient() mock.GetWithPaginationResponse = &client.APIResponse{ StatusCode: 200, Data: []any{ map[string]any{"id": "1", "title": "Triage", "column": nil}, - map[string]any{"id": "2", "title": "In Column", "column": map[string]any{"id": "col-1"}}, - map[string]any{"id": "3", "title": "In Column 2", "column_id": "col-2"}, + map[string]any{"id": "2", "title": "Unexpected extra", "column_id": "col-1"}, }, } @@ -119,26 +135,20 @@ func TestCardList(t *testing.T) { defer resetTest() cardListColumn = "maybe" - cardListAll = true err := cardListCmd.RunE(cardListCmd, []string{}) cardListColumn = "" - cardListAll = false assertExitCode(t, err, 0) - - if err != nil { - t.Fatalf("unexpected error: %v", err) + if mock.GetWithPaginationCalls[0].Path != "/cards.json?indexed_by=maybe" { + t.Errorf("expected server-side maybe filter, got '%s'", mock.GetWithPaginationCalls[0].Path) } + arr, ok := result.Response.Data.([]any) if !ok { t.Fatalf("expected array response data, got %T", result.Response.Data) } - if len(arr) != 1 { - t.Fatalf("expected 1 triage card, got %d", len(arr)) - } - card := arr[0].(map[string]any) - if card["id"] != "1" { - t.Errorf("expected triage card id '1', got '%v'", card["id"]) + if len(arr) != 2 { + t.Fatalf("expected server response to remain unfiltered client-side, got %d cards", len(arr)) } }) @@ -332,6 +342,35 @@ func TestCardList(t *testing.T) { t.Errorf("expected path '%s', got '%s'", expected, path) } }) + + t.Run("combines column with other filters without changing command shape", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{ + StatusCode: 200, + Data: []any{}, + } + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + cardListBoard = "123" + cardListColumn = "col-1" + cardListTag = "tag-1" + cardListAssignee = "user-1" + err := cardListCmd.RunE(cardListCmd, []string{}) + cardListBoard = "" + cardListColumn = "" + cardListTag = "" + cardListAssignee = "" + + assertExitCode(t, err, 0) + path := mock.GetWithPaginationCalls[0].Path + expected := "/cards.json?board_ids[]=123&column_ids[]=col-1&tag_ids[]=tag-1&assignee_ids[]=user-1" + if path != expected { + t.Errorf("expected path '%s', got '%s'", expected, path) + } + }) } func TestCardShow(t *testing.T) { diff --git a/internal/commands/pseudocolumns.go b/internal/commands/pseudocolumns.go index 5ebd593..6747112 100644 --- a/internal/commands/pseudocolumns.go +++ b/internal/commands/pseudocolumns.go @@ -11,9 +11,10 @@ type pseudoColumn struct { var ( // "Not Now" contains postponed cards (indexed_by=not_now) pseudoColumnNotNow = pseudoColumn{ID: "not-now", Name: "Not Now", Kind: "not_now"} - // "Maybe?" contains triage/backlog cards (null column_id) + // "Maybe?" contains triage/backlog cards (indexed_by=maybe) pseudoColumnMaybe = pseudoColumn{ID: "maybe", Name: "Maybe?", Kind: "triage"} - pseudoColumnDone = pseudoColumn{ID: "done", Name: "Done", Kind: "closed"} + // "Done" contains closed cards (indexed_by=closed) + pseudoColumnDone = pseudoColumn{ID: "done", Name: "Done", Kind: "closed"} ) func pseudoColumnObject(c pseudoColumn) map[string]any { diff --git a/internal/commands/search.go b/internal/commands/search.go index aa93fec..5b85af5 100644 --- a/internal/commands/search.go +++ b/internal/commands/search.go @@ -112,7 +112,7 @@ func init() { searchCmd.Flags().StringVar(&searchBoard, "board", "", "Filter by board ID") searchCmd.Flags().StringVar(&searchTag, "tag", "", "Filter by tag ID") searchCmd.Flags().StringVar(&searchAssignee, "assignee", "", "Filter by assignee ID") - searchCmd.Flags().StringVar(&searchIndexedBy, "indexed-by", "", "Filter by status (all, closed, not_now, golden)") + searchCmd.Flags().StringVar(&searchIndexedBy, "indexed-by", "", "Filter by status (all, closed, maybe, not_now, golden)") searchCmd.Flags().StringVar(&searchSort, "sort", "", "Sort order: newest, oldest, or latest (default)") searchCmd.Flags().IntVar(&searchPage, "page", 0, "Page number") searchCmd.Flags().BoolVar(&searchAll, "all", false, "Fetch all pages") diff --git a/skills/fizzy/SKILL.md b/skills/fizzy/SKILL.md index e26c04c..d8b3e5d 100644 --- a/skills/fizzy/SKILL.md +++ b/skills/fizzy/SKILL.md @@ -77,7 +77,7 @@ Full CLI coverage: boards, cards, columns, comments, steps, reactions, tags, use Need to find something? ├── Know the board? → fizzy card list --board ├── Full-text search? → fizzy search "query" -├── Filter by status? → fizzy card list --indexed-by closed|not_now|golden|stalled +├── Filter by status? → fizzy card list --indexed-by maybe|closed|not_now|golden|stalled ├── Filter by person? → fizzy card list --assignee ├── Filter by time? → fizzy card list --created today|thisweek|thismonth └── Cross-board? → fizzy search "query" (searches all boards) @@ -360,9 +360,9 @@ Cards exist in different states. By default, `fizzy card list` returns **open ca You can also use pseudo-columns: ```bash -fizzy card list --column done --all # Same as --indexed-by closed -fizzy card list --column not-now --all # Same as --indexed-by not_now -fizzy card list --column maybe --all # Cards in triage (no column assigned) +fizzy card list --column done # Same as --indexed-by closed +fizzy card list --column not-now # Same as --indexed-by not_now +fizzy card list --column maybe # Same as --indexed-by maybe ``` **Fetching all cards on a board:** @@ -475,7 +475,7 @@ fizzy search QUERY [flags] --board ID # Filter by board --assignee ID # Filter by assignee user ID --tag ID # Filter by tag ID - --indexed-by LANE # Filter: all, closed, not_now, golden + --indexed-by LANE # Filter: all, closed, maybe, not_now, golden --sort ORDER # Sort: newest, oldest, or latest (default) --page N # Page number --all # Fetch all pages @@ -559,7 +559,7 @@ fizzy card list [flags] --column ID # Filter by column ID or pseudo: not-now, maybe, done --assignee ID # Filter by assignee user ID --tag ID # Filter by tag ID - --indexed-by LANE # Filter: all, closed, not_now, stalled, postponing_soon, golden + --indexed-by LANE # Filter: all, closed, maybe, not_now, stalled, postponing_soon, golden --search "terms" # Search by text (space-separated for multiple terms) --sort ORDER # Sort: newest, oldest, or latest (default) --creator ID # Filter by creator user ID From 03e97f728477689f52e9a5e364deaae2ac5a698b Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 15 Apr 2026 11:35:11 -0400 Subject: [PATCH 2/8] Add board access, webhook delivery, and user export commands --- SURFACE.txt | 69 ++++++++++++++++++++++++++ internal/commands/board.go | 49 +++++++++++++++++++ internal/commands/board_test.go | 63 ++++++++++++++++++++++++ internal/commands/columns.go | 7 +++ internal/commands/user.go | 70 ++++++++++++++++++++++++++ internal/commands/user_test.go | 44 +++++++++++++++++ internal/commands/webhook.go | 81 +++++++++++++++++++++++++++++++ internal/commands/webhook_test.go | 77 +++++++++++++++++++++++++++++ skills/fizzy/SKILL.md | 9 +++- 9 files changed, 467 insertions(+), 2 deletions(-) diff --git a/SURFACE.txt b/SURFACE.txt index fdd8b25..c59398b 100644 --- a/SURFACE.txt +++ b/SURFACE.txt @@ -47,6 +47,7 @@ CMD fizzy auth ls CMD fizzy auth status CMD fizzy auth switch CMD fizzy board +CMD fizzy board accesses CMD fizzy board closed CMD fizzy board create CMD fizzy board delete @@ -194,6 +195,8 @@ CMD fizzy upload help CMD fizzy user CMD fizzy user avatar-remove CMD fizzy user deactivate +CMD fizzy user export-create +CMD fizzy user export-show CMD fizzy user help CMD fizzy user list CMD fizzy user ls @@ -207,6 +210,7 @@ CMD fizzy version CMD fizzy webhook CMD fizzy webhook create CMD fizzy webhook delete +CMD fizzy webhook deliveries CMD fizzy webhook help CMD fizzy webhook list CMD fizzy webhook ls @@ -514,6 +518,22 @@ FLAG fizzy board --quiet type=bool FLAG fizzy board --styled type=bool FLAG fizzy board --token type=string FLAG fizzy board --verbose type=bool +FLAG fizzy board accesses --agent type=bool +FLAG fizzy board accesses --api-url type=string +FLAG fizzy board accesses --board type=string +FLAG fizzy board accesses --count type=bool +FLAG fizzy board accesses --help type=bool +FLAG fizzy board accesses --ids-only type=bool +FLAG fizzy board accesses --jq type=string +FLAG fizzy board accesses --json type=bool +FLAG fizzy board accesses --limit type=int +FLAG fizzy board accesses --markdown type=bool +FLAG fizzy board accesses --page type=int +FLAG fizzy board accesses --profile type=string +FLAG fizzy board accesses --quiet type=bool +FLAG fizzy board accesses --styled type=bool +FLAG fizzy board accesses --token type=string +FLAG fizzy board accesses --verbose type=bool FLAG fizzy board closed --agent type=bool FLAG fizzy board closed --all type=bool FLAG fizzy board closed --api-url type=string @@ -2730,6 +2750,34 @@ FLAG fizzy user deactivate --quiet type=bool FLAG fizzy user deactivate --styled type=bool FLAG fizzy user deactivate --token type=string FLAG fizzy user deactivate --verbose type=bool +FLAG fizzy user export-create --agent type=bool +FLAG fizzy user export-create --api-url type=string +FLAG fizzy user export-create --count type=bool +FLAG fizzy user export-create --help type=bool +FLAG fizzy user export-create --ids-only type=bool +FLAG fizzy user export-create --jq type=string +FLAG fizzy user export-create --json type=bool +FLAG fizzy user export-create --limit type=int +FLAG fizzy user export-create --markdown type=bool +FLAG fizzy user export-create --profile type=string +FLAG fizzy user export-create --quiet type=bool +FLAG fizzy user export-create --styled type=bool +FLAG fizzy user export-create --token type=string +FLAG fizzy user export-create --verbose type=bool +FLAG fizzy user export-show --agent type=bool +FLAG fizzy user export-show --api-url type=string +FLAG fizzy user export-show --count type=bool +FLAG fizzy user export-show --help type=bool +FLAG fizzy user export-show --ids-only type=bool +FLAG fizzy user export-show --jq type=string +FLAG fizzy user export-show --json type=bool +FLAG fizzy user export-show --limit type=int +FLAG fizzy user export-show --markdown type=bool +FLAG fizzy user export-show --profile type=string +FLAG fizzy user export-show --quiet type=bool +FLAG fizzy user export-show --styled type=bool +FLAG fizzy user export-show --token type=string +FLAG fizzy user export-show --verbose type=bool FLAG fizzy user help --agent type=bool FLAG fizzy user help --api-url type=string FLAG fizzy user help --count type=bool @@ -2929,6 +2977,23 @@ FLAG fizzy webhook delete --quiet type=bool FLAG fizzy webhook delete --styled type=bool FLAG fizzy webhook delete --token type=string FLAG fizzy webhook delete --verbose type=bool +FLAG fizzy webhook deliveries --agent type=bool +FLAG fizzy webhook deliveries --all type=bool +FLAG fizzy webhook deliveries --api-url type=string +FLAG fizzy webhook deliveries --board type=string +FLAG fizzy webhook deliveries --count type=bool +FLAG fizzy webhook deliveries --help type=bool +FLAG fizzy webhook deliveries --ids-only type=bool +FLAG fizzy webhook deliveries --jq type=string +FLAG fizzy webhook deliveries --json type=bool +FLAG fizzy webhook deliveries --limit type=int +FLAG fizzy webhook deliveries --markdown type=bool +FLAG fizzy webhook deliveries --page type=int +FLAG fizzy webhook deliveries --profile type=string +FLAG fizzy webhook deliveries --quiet type=bool +FLAG fizzy webhook deliveries --styled type=bool +FLAG fizzy webhook deliveries --token type=string +FLAG fizzy webhook deliveries --verbose type=bool FLAG fizzy webhook help --agent type=bool FLAG fizzy webhook help --api-url type=string FLAG fizzy webhook help --count type=bool @@ -3074,6 +3139,7 @@ SUB fizzy auth ls SUB fizzy auth status SUB fizzy auth switch SUB fizzy board +SUB fizzy board accesses SUB fizzy board closed SUB fizzy board create SUB fizzy board delete @@ -3221,6 +3287,8 @@ SUB fizzy upload help SUB fizzy user SUB fizzy user avatar-remove SUB fizzy user deactivate +SUB fizzy user export-create +SUB fizzy user export-show SUB fizzy user help SUB fizzy user list SUB fizzy user ls @@ -3234,6 +3302,7 @@ SUB fizzy version SUB fizzy webhook SUB fizzy webhook create SUB fizzy webhook delete +SUB fizzy webhook deliveries SUB fizzy webhook help SUB fizzy webhook list SUB fizzy webhook ls diff --git a/internal/commands/board.go b/internal/commands/board.go index 37878d3..eaceaa9 100644 --- a/internal/commands/board.go +++ b/internal/commands/board.go @@ -415,6 +415,50 @@ var boardEntropyCmd = &cobra.Command{ }, } +// Board accesses flags +var boardAccessesBoard string +var boardAccessesPage int + +var boardAccessesCmd = &cobra.Command{ + Use: "accesses", + Short: "Show board accesses", + Long: "Shows access settings and users for a board.", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + + boardID, err := requireBoard(boardAccessesBoard) + if err != nil { + return err + } + + var page *int64 + if boardAccessesPage > 0 { + pageVal := int64(boardAccessesPage) + page = &pageVal + } + + data, _, err := getSDK().Boards().ListBoardAccesses(cmd.Context(), boardID, page) + if err != nil { + return convertSDKError(err) + } + + summary := "Board accesses" + if boardAccessesPage > 0 { + summary = fmt.Sprintf("Board accesses (page %d)", boardAccessesPage) + } + + breadcrumbs := []Breadcrumb{ + breadcrumb("board", fmt.Sprintf("fizzy board show %s", boardID), "View board"), + breadcrumb("cards", fmt.Sprintf("fizzy card list --board %s", boardID), "List cards"), + } + + printDetail(normalizeAny(data), summary, breadcrumbs) + return nil + }, +} + // Board closed flags var boardClosedBoard string var boardClosedPage int @@ -705,6 +749,11 @@ func init() { boardEntropyCmd.Flags().IntVar(&boardEntropyAutoPostponePeriodInDays, "auto_postpone_period_in_days", 0, "Auto postpone period in days ("+validAutoPostponePeriodsHelp+")") boardCmd.AddCommand(boardEntropyCmd) + // Accesses + boardAccessesCmd.Flags().StringVar(&boardAccessesBoard, "board", "", "Board ID (required)") + boardAccessesCmd.Flags().IntVar(&boardAccessesPage, "page", 0, "Page number") + boardCmd.AddCommand(boardAccessesCmd) + // Closed cards boardClosedCmd.Flags().StringVar(&boardClosedBoard, "board", "", "Board ID (required)") boardClosedCmd.Flags().IntVar(&boardClosedPage, "page", 0, "Page number") diff --git a/internal/commands/board_test.go b/internal/commands/board_test.go index 4e0d20a..01e0d5d 100644 --- a/internal/commands/board_test.go +++ b/internal/commands/board_test.go @@ -664,6 +664,69 @@ func TestBoardEntropy(t *testing.T) { }) } +func TestBoardAccesses(t *testing.T) { + t.Run("shows board accesses", func(t *testing.T) { + mock := NewMockClient() + mock.GetResponse = &client.APIResponse{ + StatusCode: 200, + Data: map[string]any{ + "board_id": "123", + "all_access": true, + "users": []any{ + map[string]any{"id": "user-1", "name": "User 1", "has_access": true}, + }, + }, + } + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + boardAccessesBoard = "123" + err := boardAccessesCmd.RunE(boardAccessesCmd, []string{}) + boardAccessesBoard = "" + boardAccessesPage = 0 + + assertExitCode(t, err, 0) + if mock.GetCalls[0].Path != "/boards/123/accesses.json" { + t.Errorf("expected path '/boards/123/accesses.json', got '%s'", mock.GetCalls[0].Path) + } + }) + + t.Run("passes page", func(t *testing.T) { + mock := NewMockClient() + mock.GetResponse = &client.APIResponse{ + StatusCode: 200, + Data: map[string]any{"board_id": "123", "all_access": false, "users": []any{}}, + } + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + boardAccessesBoard = "123" + boardAccessesPage = 2 + err := boardAccessesCmd.RunE(boardAccessesCmd, []string{}) + boardAccessesBoard = "" + boardAccessesPage = 0 + + assertExitCode(t, err, 0) + if mock.GetCalls[0].Path != "/boards/123/accesses.json?page=2" { + t.Errorf("expected path '/boards/123/accesses.json?page=2', got '%s'", mock.GetCalls[0].Path) + } + }) + + t.Run("requires board", func(t *testing.T) { + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + err := boardAccessesCmd.RunE(boardAccessesCmd, []string{}) + assertExitCode(t, err, errors.ExitInvalidArgs) + }) +} + func TestBoardClosed(t *testing.T) { t.Run("lists closed cards", func(t *testing.T) { mock := NewMockClient() diff --git a/internal/commands/columns.go b/internal/commands/columns.go index 99a5b1b..6e788b2 100644 --- a/internal/commands/columns.go +++ b/internal/commands/columns.go @@ -77,4 +77,11 @@ var ( {Header: "URL", Field: "payload_url"}, {Header: "Active", Field: "active"}, } + + webhookDeliveryColumns = render.Columns{ + {Header: "ID", Field: "id"}, + {Header: "State", Field: "state"}, + {Header: "Created", Field: "created_at"}, + {Header: "Updated", Field: "updated_at"}, + } ) diff --git a/internal/commands/user.go b/internal/commands/user.go index 98ff828..80c023b 100644 --- a/internal/commands/user.go +++ b/internal/commands/user.go @@ -272,6 +272,72 @@ var userAvatarRemoveCmd = &cobra.Command{ }, } +var userExportCreateCmd = &cobra.Command{ + Use: "export-create USER_ID", + Short: "Create a user export", + Long: "Creates a new user data export.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + + userID := args[0] + + data, _, err := getSDK().Users().CreateUserDataExport(cmd.Context(), userID) + if err != nil { + return convertSDKError(err) + } + + items := normalizeAny(data) + exportID := "" + if export, ok := items.(map[string]any); ok { + if id, ok := export["id"]; ok { + exportID = fmt.Sprintf("%v", id) + } + } + + var breadcrumbs []Breadcrumb + if exportID != "" { + breadcrumbs = []Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy user export-show %s %s", userID, exportID), "View export status"), + breadcrumb("user", fmt.Sprintf("fizzy user show %s", userID), "View user"), + } + } + + printMutation(items, "", breadcrumbs) + return nil + }, +} + +var userExportShowCmd = &cobra.Command{ + Use: "export-show USER_ID EXPORT_ID", + Short: "Show a user export", + Long: "Shows the status of a user data export.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + + userID := args[0] + exportID := args[1] + + data, _, err := getSDK().Users().GetUserDataExport(cmd.Context(), userID, exportID) + if err != nil { + return convertSDKError(err) + } + + breadcrumbs := []Breadcrumb{ + breadcrumb("user", fmt.Sprintf("fizzy user show %s", userID), "View user"), + breadcrumb("export-create", fmt.Sprintf("fizzy user export-create %s", userID), "Create another export"), + } + + printDetail(normalizeAny(data), "", breadcrumbs) + return nil + }, +} + // Push subscription create flags var pushSubCreateUser string var pushSubCreateEndpoint string @@ -377,6 +443,10 @@ func init() { // Avatar remove userCmd.AddCommand(userAvatarRemoveCmd) + // Exports + userCmd.AddCommand(userExportCreateCmd) + userCmd.AddCommand(userExportShowCmd) + // Push subscriptions userPushSubscriptionCreateCmd.Flags().StringVar(&pushSubCreateUser, "user", "", "User ID (required)") userPushSubscriptionCreateCmd.Flags().StringVar(&pushSubCreateEndpoint, "endpoint", "", "Push endpoint URL (required)") diff --git a/internal/commands/user_test.go b/internal/commands/user_test.go index b19c2de..69eba95 100644 --- a/internal/commands/user_test.go +++ b/internal/commands/user_test.go @@ -257,6 +257,50 @@ func TestUserAvatarRemove(t *testing.T) { }) } +func TestUserExport(t *testing.T) { + t.Run("creates user export", func(t *testing.T) { + mock := NewMockClient() + mock.PostResponse = &client.APIResponse{ + StatusCode: 201, + Data: map[string]any{ + "id": "export-1", + "status": "queued", + }, + } + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + err := userExportCreateCmd.RunE(userExportCreateCmd, []string{"user-1"}) + assertExitCode(t, err, 0) + if mock.PostCalls[0].Path != "/users/user-1/data_exports.json" { + t.Errorf("expected path '/users/user-1/data_exports.json', got '%s'", mock.PostCalls[0].Path) + } + }) + + t.Run("shows user export", func(t *testing.T) { + mock := NewMockClient() + mock.GetResponse = &client.APIResponse{ + StatusCode: 200, + Data: map[string]any{ + "id": "export-1", + "status": "complete", + }, + } + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + err := userExportShowCmd.RunE(userExportShowCmd, []string{"user-1", "export-1"}) + assertExitCode(t, err, 0) + if mock.GetCalls[0].Path != "/users/user-1/data_exports/export-1" { + t.Errorf("expected path '/users/user-1/data_exports/export-1', got '%s'", mock.GetCalls[0].Path) + } + }) +} + func TestUserPushSubscriptionCreate(t *testing.T) { t.Run("creates push subscription", func(t *testing.T) { mock := NewMockClient() diff --git a/internal/commands/webhook.go b/internal/commands/webhook.go index 7dbef35..fb3b9d0 100644 --- a/internal/commands/webhook.go +++ b/internal/commands/webhook.go @@ -90,6 +90,81 @@ var webhookListCmd = &cobra.Command{ }, } +// Webhook deliveries flags +var webhookDeliveriesBoard string +var webhookDeliveriesPage int +var webhookDeliveriesAll bool + +var webhookDeliveriesCmd = &cobra.Command{ + Use: "deliveries WEBHOOK_ID", + Short: "List webhook deliveries", + Long: "Lists deliveries for a webhook.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + if err := checkLimitAll(webhookDeliveriesAll); err != nil { + return err + } + + boardID, err := requireBoard(webhookDeliveriesBoard) + if err != nil { + return err + } + + webhookID := args[0] + ac := getSDK() + path := fmt.Sprintf("/boards/%s/webhooks/%s/deliveries.json", boardID, webhookID) + if webhookDeliveriesPage > 0 { + path += fmt.Sprintf("?page=%d", webhookDeliveriesPage) + } + + var items any + var linkNext string + + if webhookDeliveriesAll { + pages, err := ac.GetAll(cmd.Context(), path) + if err != nil { + return convertSDKError(err) + } + items = jsonAnySlice(pages) + } else { + data, resp, err := ac.Webhooks().ListWebhookDeliveries(cmd.Context(), boardID, webhookID, path) + if err != nil { + return convertSDKError(err) + } + items = normalizeAny(data) + linkNext = parseSDKLinkNext(resp) + } + + count := dataCount(items) + summary := fmt.Sprintf("%d webhook deliveries", count) + if webhookDeliveriesAll { + summary += " (all)" + } else if webhookDeliveriesPage > 0 { + summary += fmt.Sprintf(" (page %d)", webhookDeliveriesPage) + } + + breadcrumbs := []Breadcrumb{ + breadcrumb("webhook", fmt.Sprintf("fizzy webhook show --board %s %s", boardID, webhookID), "View webhook"), + breadcrumb("webhooks", fmt.Sprintf("fizzy webhook list --board %s", boardID), "List webhooks"), + } + + hasNext := linkNext != "" + if hasNext { + nextPage := webhookDeliveriesPage + 1 + if webhookDeliveriesPage == 0 { + nextPage = 2 + } + breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy webhook deliveries --board %s %s --page %d", boardID, webhookID, nextPage), "Next page")) + } + + printListPaginated(items, webhookDeliveryColumns, hasNext, linkNext, webhookDeliveriesAll, summary, breadcrumbs) + return nil + }, +} + // Webhook show var webhookShowBoard string @@ -342,6 +417,12 @@ func init() { webhookListCmd.Flags().BoolVar(&webhookListAll, "all", false, "Fetch all pages") webhookCmd.AddCommand(webhookListCmd) + // Deliveries + webhookDeliveriesCmd.Flags().StringVar(&webhookDeliveriesBoard, "board", "", "Board ID (required)") + webhookDeliveriesCmd.Flags().IntVar(&webhookDeliveriesPage, "page", 0, "Page number") + webhookDeliveriesCmd.Flags().BoolVar(&webhookDeliveriesAll, "all", false, "Fetch all pages") + webhookCmd.AddCommand(webhookDeliveriesCmd) + // Show webhookShowCmd.Flags().StringVar(&webhookShowBoard, "board", "", "Board ID (required)") webhookCmd.AddCommand(webhookShowCmd) diff --git a/internal/commands/webhook_test.go b/internal/commands/webhook_test.go index efaa511..9abfed0 100644 --- a/internal/commands/webhook_test.go +++ b/internal/commands/webhook_test.go @@ -87,6 +87,83 @@ func TestWebhookList(t *testing.T) { }) } +func TestWebhookDeliveries(t *testing.T) { + t.Run("lists webhook deliveries", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{ + StatusCode: 200, + Data: []any{ + map[string]any{"id": "wd-1", "state": "ok", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:01Z"}, + }, + } + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + webhookDeliveriesBoard = "board-1" + err := webhookDeliveriesCmd.RunE(webhookDeliveriesCmd, []string{"wh-1"}) + webhookDeliveriesBoard = "" + webhookDeliveriesPage = 0 + webhookDeliveriesAll = false + + assertExitCode(t, err, 0) + if mock.GetWithPaginationCalls[0].Path != "/boards/board-1/webhooks/wh-1/deliveries.json" { + t.Errorf("expected path '/boards/board-1/webhooks/wh-1/deliveries.json', got '%s'", mock.GetWithPaginationCalls[0].Path) + } + }) + + t.Run("handles page", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{StatusCode: 200, Data: []any{}} + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + webhookDeliveriesBoard = "board-1" + webhookDeliveriesPage = 2 + err := webhookDeliveriesCmd.RunE(webhookDeliveriesCmd, []string{"wh-1"}) + webhookDeliveriesBoard = "" + webhookDeliveriesPage = 0 + + assertExitCode(t, err, 0) + if mock.GetWithPaginationCalls[0].Path != "/boards/board-1/webhooks/wh-1/deliveries.json?page=2" { + t.Errorf("expected path with page=2, got '%s'", mock.GetWithPaginationCalls[0].Path) + } + }) + + t.Run("handles all", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{StatusCode: 200, Data: []any{map[string]any{"id": "wd-1"}}} + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + webhookDeliveriesBoard = "board-1" + webhookDeliveriesAll = true + err := webhookDeliveriesCmd.RunE(webhookDeliveriesCmd, []string{"wh-1"}) + webhookDeliveriesBoard = "" + webhookDeliveriesAll = false + + assertExitCode(t, err, 0) + if mock.GetWithPaginationCalls[0].Path != "/boards/board-1/webhooks/wh-1/deliveries.json" { + t.Errorf("expected path '/boards/board-1/webhooks/wh-1/deliveries.json', got '%s'", mock.GetWithPaginationCalls[0].Path) + } + }) + + t.Run("requires board", func(t *testing.T) { + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + err := webhookDeliveriesCmd.RunE(webhookDeliveriesCmd, []string{"wh-1"}) + assertExitCode(t, err, errors.ExitInvalidArgs) + }) +} + func TestWebhookShow(t *testing.T) { t.Run("shows webhook by ID", func(t *testing.T) { mock := NewMockClient() diff --git a/skills/fizzy/SKILL.md b/skills/fizzy/SKILL.md index d8b3e5d..c97d049 100644 --- a/skills/fizzy/SKILL.md +++ b/skills/fizzy/SKILL.md @@ -100,7 +100,7 @@ Want to change something? | Resource | List | Show | Create | Update | Delete | Other | |----------|------|------|--------|--------|--------|-------| | account | - | `account show` | - | `account settings-update` | - | `account entropy`, `account export-create`, `account export-show EXPORT_ID`, `account join-code-show`, `account join-code-reset`, `account join-code-update` | -| board | `board list` | `board show ID` | `board create` | `board update ID` | `board delete ID` | `board publish ID`, `board unpublish ID`, `board entropy ID`, `board closed`, `board postponed`, `board stream`, `board involvement ID`, `migrate board ID` | +| board | `board list` | `board show ID` | `board create` | `board update ID` | `board delete ID` | `board accesses --board ID`, `board publish ID`, `board unpublish ID`, `board entropy ID`, `board closed`, `board postponed`, `board stream`, `board involvement ID`, `migrate board ID` | | card | `card list` | `card show NUMBER` | `card create` | `card update NUMBER` | `card delete NUMBER` | `card move NUMBER`, `card publish NUMBER`, `card mark-read NUMBER`, `card mark-unread NUMBER` | | search | `search QUERY` | - | - | - | - | - | | column | `column list --board ID` | `column show ID --board ID` | `column create` | `column update ID` | `column delete ID` | `column move-left ID`, `column move-right ID` | @@ -108,9 +108,10 @@ Want to change something? | step | `step list --card NUMBER` | `step show ID --card NUMBER` | `step create` | `step update ID` | `step delete ID` | - | | reaction | `reaction list` | - | `reaction create` | - | `reaction delete ID` | - | | tag | `tag list` | - | - | - | - | - | -| user | `user list` | `user show ID` | - | `user update ID` | - | `user deactivate ID`, `user role ID`, `user avatar-remove ID`, `user push-subscription-create`, `user push-subscription-delete ID` | +| user | `user list` | `user show ID`, `user export-show USER_ID EXPORT_ID` | `user export-create USER_ID` | `user update ID` | - | `user deactivate ID`, `user role ID`, `user avatar-remove ID`, `user push-subscription-create`, `user push-subscription-delete ID` | | notification | `notification list` | - | - | - | - | `notification tray`, `notification read-all`, `notification settings-show`, `notification settings-update` | | pin | `pin list` | - | - | - | - | `card pin NUMBER`, `card unpin NUMBER` | +| webhook | `webhook list --board ID`, `webhook deliveries --board ID WEBHOOK_ID` | `webhook show ID --board ID` | `webhook create` | `webhook update ID` | `webhook delete ID` | `webhook reactivate ID` | | webhook | `webhook list --board ID` | `webhook show ID --board ID` | `webhook create` | `webhook update ID` | `webhook delete ID` | `webhook reactivate ID` | --- @@ -501,6 +502,7 @@ fizzy board publish BOARD_ID fizzy board unpublish BOARD_ID fizzy board delete BOARD_ID fizzy board entropy BOARD_ID --auto_postpone_period_in_days N # N: 3, 7, 11, 30, 90, 365 +fizzy board accesses --board ID [--page N] # Show board access settings and users fizzy board closed --board ID [--page N] [--all] # List closed cards fizzy board postponed --board ID [--page N] [--all] # List postponed cards fizzy board stream --board ID [--page N] [--all] # List stream cards @@ -721,6 +723,8 @@ fizzy user update USER_ID --avatar /path.jpg # Update user avatar fizzy user deactivate USER_ID # Deactivate user (requires admin/owner) fizzy user role USER_ID --role ROLE # Update user role (requires admin/owner) fizzy user avatar-remove USER_ID # Remove user avatar +fizzy user export-create USER_ID # Create user data export +fizzy user export-show USER_ID EXPORT_ID # Show user data export status fizzy user push-subscription-create --user ID --endpoint URL --p256dh-key KEY --auth-key KEY fizzy user push-subscription-delete SUB_ID --user ID ``` @@ -750,6 +754,7 @@ Webhooks notify external services when events occur on a board. Requires account ```bash fizzy webhook list --board ID [--page N] [--all] +fizzy webhook deliveries --board ID WEBHOOK_ID [--page N] [--all] fizzy webhook show WEBHOOK_ID --board ID fizzy webhook create --board ID --name "Name" --url "https://..." [--actions card_published,card_closed,...] fizzy webhook update WEBHOOK_ID --board ID [--name "Name"] [--actions card_closed,...] From 1e39e214f32d8008f5d3bb4bd8a7cddd9b67b7fa Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 15 Apr 2026 11:42:24 -0400 Subject: [PATCH 3/8] Add activity list command --- SURFACE.txt | 73 ++++++++++++++ e2e/cli_tests/activity_test.go | 57 +++++++++++ e2e/cli_tests/output_contract_test.go | 1 + internal/commands/activity.go | 107 ++++++++++++++++++++ internal/commands/activity_test.go | 139 ++++++++++++++++++++++++++ internal/commands/columns.go | 7 ++ skills/fizzy/SKILL.md | 7 ++ 7 files changed, 391 insertions(+) create mode 100644 e2e/cli_tests/activity_test.go create mode 100644 internal/commands/activity.go create mode 100644 internal/commands/activity_test.go diff --git a/SURFACE.txt b/SURFACE.txt index c59398b..1b3d994 100644 --- a/SURFACE.txt +++ b/SURFACE.txt @@ -1,4 +1,5 @@ ARG fizzy account help 00 [command] +ARG fizzy activity help 00 [command] ARG fizzy auth help 00 [command] ARG fizzy board help 00 [command] ARG fizzy card attachments download 00 [ATTACHMENT_INDEX] @@ -38,6 +39,10 @@ CMD fizzy account join-code-update CMD fizzy account settings-update CMD fizzy account show CMD fizzy account view +CMD fizzy activity +CMD fizzy activity help +CMD fizzy activity list +CMD fizzy activity ls CMD fizzy auth CMD fizzy auth help CMD fizzy auth list @@ -391,6 +396,70 @@ FLAG fizzy account view --quiet type=bool FLAG fizzy account view --styled type=bool FLAG fizzy account view --token type=string FLAG fizzy account view --verbose type=bool +FLAG fizzy activity --agent type=bool +FLAG fizzy activity --api-url type=string +FLAG fizzy activity --count type=bool +FLAG fizzy activity --help type=bool +FLAG fizzy activity --ids-only type=bool +FLAG fizzy activity --jq type=string +FLAG fizzy activity --json type=bool +FLAG fizzy activity --limit type=int +FLAG fizzy activity --markdown type=bool +FLAG fizzy activity --profile type=string +FLAG fizzy activity --quiet type=bool +FLAG fizzy activity --styled type=bool +FLAG fizzy activity --token type=string +FLAG fizzy activity --verbose type=bool +FLAG fizzy activity help --agent type=bool +FLAG fizzy activity help --api-url type=string +FLAG fizzy activity help --count type=bool +FLAG fizzy activity help --help type=bool +FLAG fizzy activity help --ids-only type=bool +FLAG fizzy activity help --jq type=string +FLAG fizzy activity help --json type=bool +FLAG fizzy activity help --limit type=int +FLAG fizzy activity help --markdown type=bool +FLAG fizzy activity help --profile type=string +FLAG fizzy activity help --quiet type=bool +FLAG fizzy activity help --styled type=bool +FLAG fizzy activity help --token type=string +FLAG fizzy activity help --verbose type=bool +FLAG fizzy activity list --agent type=bool +FLAG fizzy activity list --all type=bool +FLAG fizzy activity list --api-url type=string +FLAG fizzy activity list --board type=string +FLAG fizzy activity list --count type=bool +FLAG fizzy activity list --creator type=string +FLAG fizzy activity list --help type=bool +FLAG fizzy activity list --ids-only type=bool +FLAG fizzy activity list --jq type=string +FLAG fizzy activity list --json type=bool +FLAG fizzy activity list --limit type=int +FLAG fizzy activity list --markdown type=bool +FLAG fizzy activity list --page type=int +FLAG fizzy activity list --profile type=string +FLAG fizzy activity list --quiet type=bool +FLAG fizzy activity list --styled type=bool +FLAG fizzy activity list --token type=string +FLAG fizzy activity list --verbose type=bool +FLAG fizzy activity ls --agent type=bool +FLAG fizzy activity ls --all type=bool +FLAG fizzy activity ls --api-url type=string +FLAG fizzy activity ls --board type=string +FLAG fizzy activity ls --count type=bool +FLAG fizzy activity ls --creator type=string +FLAG fizzy activity ls --help type=bool +FLAG fizzy activity ls --ids-only type=bool +FLAG fizzy activity ls --jq type=string +FLAG fizzy activity ls --json type=bool +FLAG fizzy activity ls --limit type=int +FLAG fizzy activity ls --markdown type=bool +FLAG fizzy activity ls --page type=int +FLAG fizzy activity ls --profile type=string +FLAG fizzy activity ls --quiet type=bool +FLAG fizzy activity ls --styled type=bool +FLAG fizzy activity ls --token type=string +FLAG fizzy activity ls --verbose type=bool FLAG fizzy auth --agent type=bool FLAG fizzy auth --api-url type=string FLAG fizzy auth --count type=bool @@ -3130,6 +3199,10 @@ SUB fizzy account join-code-update SUB fizzy account settings-update SUB fizzy account show SUB fizzy account view +SUB fizzy activity +SUB fizzy activity help +SUB fizzy activity list +SUB fizzy activity ls SUB fizzy auth SUB fizzy auth help SUB fizzy auth list diff --git a/e2e/cli_tests/activity_test.go b/e2e/cli_tests/activity_test.go new file mode 100644 index 0000000..20c36eb --- /dev/null +++ b/e2e/cli_tests/activity_test.go @@ -0,0 +1,57 @@ +package clitests + +import ( + "strconv" + "testing" + "time" + + "github.com/basecamp/fizzy-cli/e2e/harness" +) + +func TestActivityList(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + cardNum := createCard(t, h, boardID) + creatorID := currentUserID(t, h) + + var result *harness.Result + for attempt := 0; attempt < 10; attempt++ { + r := h.Run("activity", "list", "--board", boardID) + if r.ExitCode == harness.ExitSuccess && len(r.GetDataArray()) > 0 { + result = r + break + } + time.Sleep(200 * time.Millisecond) + } + if result == nil { + t.Fatal("expected at least one activity for throwaway board") + } + + assertOK(t, result) + if len(result.GetDataArray()) == 0 { + t.Fatal("expected activity list to return at least one item") + } + + foundCard := false + for _, item := range result.GetDataArray() { + m := asMap(item) + if m == nil { + continue + } + if eventable := asMap(m["eventable"]); eventable != nil { + if got := mapValueString(eventable, "number"); got == strconv.Itoa(cardNum) { + foundCard = true + break + } + } + } + if !foundCard { + t.Logf("activity list did not expose created card number %d; continuing because board activity was non-empty", cardNum) + } + + creatorResult := h.Run("activity", "list", "--board", boardID, "--creator", creatorID) + assertOK(t, creatorResult) + if creatorResult.GetDataArray() == nil { + t.Fatal("expected activity creator-filter response array") + } +} diff --git a/e2e/cli_tests/output_contract_test.go b/e2e/cli_tests/output_contract_test.go index a68b2b6..8ba17da 100644 --- a/e2e/cli_tests/output_contract_test.go +++ b/e2e/cli_tests/output_contract_test.go @@ -174,6 +174,7 @@ func TestOutputContractListCommands(t *testing.T) { name string args []string }{ + {"activity-list", []string{"activity", "list", "--board", fixture.BoardID}}, {"board-list", []string{"board", "list"}}, {"board-closed", []string{"board", "closed", "--board", fixture.BoardID}}, {"board-postponed", []string{"board", "postponed", "--board", fixture.BoardID}}, diff --git a/internal/commands/activity.go b/internal/commands/activity.go new file mode 100644 index 0000000..944add3 --- /dev/null +++ b/internal/commands/activity.go @@ -0,0 +1,107 @@ +package commands + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" +) + +var activityCmd = &cobra.Command{ + Use: "activity", + Short: "Manage activities", + Long: "Commands for listing Fizzy activities.", +} + +var activityListBoard string +var activityListCreator string +var activityListPage int +var activityListAll bool + +var activityListCmd = &cobra.Command{ + Use: "list", + Short: "List activities", + Long: "Lists activities with optional board and creator filters.", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + if err := checkLimitAll(activityListAll); err != nil { + return err + } + + ac := getSDK() + path := "/activities.json" + + var params []string + if activityListBoard != "" { + params = append(params, "board_ids[]="+activityListBoard) + } + if activityListCreator != "" { + params = append(params, "creator_ids[]="+activityListCreator) + } + if activityListPage > 0 { + params = append(params, "page="+strconv.Itoa(activityListPage)) + } + if len(params) > 0 { + path += "?" + strings.Join(params, "&") + } + + var items any + var linkNext string + + if activityListAll { + pages, err := ac.GetAll(cmd.Context(), path) + if err != nil { + return convertSDKError(err) + } + items = jsonAnySlice(pages) + } else { + data, resp, err := ac.Cards().ListActivities(cmd.Context(), path) + if err != nil { + return convertSDKError(err) + } + items = normalizeAny(data) + linkNext = parseSDKLinkNext(resp) + } + + count := dataCount(items) + summary := fmt.Sprintf("%d activities", count) + if activityListAll { + summary += " (all)" + } else if activityListPage > 0 { + summary += fmt.Sprintf(" (page %d)", activityListPage) + } + + breadcrumbs := []Breadcrumb{ + breadcrumb("cards", "fizzy card show ", "View related card"), + breadcrumb("board", "fizzy board show ", "View related board"), + } + if activityListBoard != "" { + breadcrumbs = append(breadcrumbs, breadcrumb("board", fmt.Sprintf("fizzy board show %s", activityListBoard), "View board")) + } + + hasNext := linkNext != "" + if hasNext { + nextPage := activityListPage + 1 + if activityListPage == 0 { + nextPage = 2 + } + breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy activity list --page %d", nextPage), "Next page")) + } + + printListPaginated(items, activityColumns, hasNext, linkNext, activityListAll, summary, breadcrumbs) + return nil + }, +} + +func init() { + rootCmd.AddCommand(activityCmd) + + activityListCmd.Flags().StringVar(&activityListBoard, "board", "", "Filter by board ID") + activityListCmd.Flags().StringVar(&activityListCreator, "creator", "", "Filter by creator user ID") + activityListCmd.Flags().IntVar(&activityListPage, "page", 0, "Page number") + activityListCmd.Flags().BoolVar(&activityListAll, "all", false, "Fetch all pages") + activityCmd.AddCommand(activityListCmd) +} diff --git a/internal/commands/activity_test.go b/internal/commands/activity_test.go new file mode 100644 index 0000000..536ed70 --- /dev/null +++ b/internal/commands/activity_test.go @@ -0,0 +1,139 @@ +package commands + +import ( + "testing" + + "github.com/basecamp/fizzy-cli/internal/client" + "github.com/basecamp/fizzy-cli/internal/errors" +) + +func TestActivityList(t *testing.T) { + t.Run("returns list of activities", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{ + StatusCode: 200, + Data: []any{ + map[string]any{"id": "1", "action": "card_created", "description": "Created a card"}, + }, + } + + result := SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + err := activityListCmd.RunE(activityListCmd, []string{}) + assertExitCode(t, err, 0) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Response.OK { + t.Error("expected success response") + } + if mock.GetWithPaginationCalls[0].Path != "/activities.json" { + t.Errorf("expected path '/activities.json', got '%s'", mock.GetWithPaginationCalls[0].Path) + } + }) + + t.Run("applies board filter", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{StatusCode: 200, Data: []any{}} + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + activityListBoard = "board-123" + err := activityListCmd.RunE(activityListCmd, []string{}) + activityListBoard = "" + + assertExitCode(t, err, 0) + if mock.GetWithPaginationCalls[0].Path != "/activities.json?board_ids[]=board-123" { + t.Errorf("expected board filter path, got '%s'", mock.GetWithPaginationCalls[0].Path) + } + }) + + t.Run("applies creator filter", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{StatusCode: 200, Data: []any{}} + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + activityListCreator = "user-123" + err := activityListCmd.RunE(activityListCmd, []string{}) + activityListCreator = "" + + assertExitCode(t, err, 0) + if mock.GetWithPaginationCalls[0].Path != "/activities.json?creator_ids[]=user-123" { + t.Errorf("expected creator filter path, got '%s'", mock.GetWithPaginationCalls[0].Path) + } + }) + + t.Run("passes page", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{StatusCode: 200, Data: []any{}} + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + activityListPage = 3 + err := activityListCmd.RunE(activityListCmd, []string{}) + activityListPage = 0 + + assertExitCode(t, err, 0) + if mock.GetWithPaginationCalls[0].Path != "/activities.json?page=3" { + t.Errorf("expected page path, got '%s'", mock.GetWithPaginationCalls[0].Path) + } + }) + + t.Run("passes all", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{StatusCode: 200, Data: []any{map[string]any{"id": "1"}}} + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + activityListAll = true + err := activityListCmd.RunE(activityListCmd, []string{}) + activityListAll = false + + assertExitCode(t, err, 0) + if mock.GetWithPaginationCalls[0].Path != "/activities.json" { + t.Errorf("expected path '/activities.json', got '%s'", mock.GetWithPaginationCalls[0].Path) + } + }) + + t.Run("combines board and creator filters", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{StatusCode: 200, Data: []any{}} + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + activityListBoard = "board-123" + activityListCreator = "user-123" + err := activityListCmd.RunE(activityListCmd, []string{}) + activityListBoard = "" + activityListCreator = "" + + assertExitCode(t, err, 0) + if mock.GetWithPaginationCalls[0].Path != "/activities.json?board_ids[]=board-123&creator_ids[]=user-123" { + t.Errorf("expected combined filter path, got '%s'", mock.GetWithPaginationCalls[0].Path) + } + }) + + t.Run("requires authentication", func(t *testing.T) { + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestConfig("", "account", "https://api.example.com") + defer resetTest() + + err := activityListCmd.RunE(activityListCmd, []string{}) + assertExitCode(t, err, errors.ExitAuthFailure) + }) +} diff --git a/internal/commands/columns.go b/internal/commands/columns.go index 6e788b2..18ffd67 100644 --- a/internal/commands/columns.go +++ b/internal/commands/columns.go @@ -64,6 +64,13 @@ var ( searchColumns = cardColumns + activityColumns = render.Columns{ + {Header: "ID", Field: "id"}, + {Header: "Action", Field: "action"}, + {Header: "Description", Field: "description"}, + {Header: "Created", Field: "created_at"}, + } + attachmentColumns = render.Columns{ {Header: "#", Field: "index"}, {Header: "Filename", Field: "filename"}, diff --git a/skills/fizzy/SKILL.md b/skills/fizzy/SKILL.md index c97d049..9cc88cd 100644 --- a/skills/fizzy/SKILL.md +++ b/skills/fizzy/SKILL.md @@ -103,6 +103,7 @@ Want to change something? | board | `board list` | `board show ID` | `board create` | `board update ID` | `board delete ID` | `board accesses --board ID`, `board publish ID`, `board unpublish ID`, `board entropy ID`, `board closed`, `board postponed`, `board stream`, `board involvement ID`, `migrate board ID` | | card | `card list` | `card show NUMBER` | `card create` | `card update NUMBER` | `card delete NUMBER` | `card move NUMBER`, `card publish NUMBER`, `card mark-read NUMBER`, `card mark-unread NUMBER` | | search | `search QUERY` | - | - | - | - | - | +| activity | `activity list` | - | - | - | - | `activity list --board ID`, `activity list --creator ID` | | column | `column list --board ID` | `column show ID --board ID` | `column create` | `column update ID` | `column delete ID` | `column move-left ID`, `column move-right ID` | | comment | `comment list --card NUMBER` | `comment show ID --card NUMBER` | `comment create` | `comment update ID` | `comment delete ID` | `comment attachments show --card NUMBER` | | step | `step list --card NUMBER` | `step show ID --card NUMBER` | `step create` | `step update ID` | `step delete ID` | - | @@ -491,6 +492,12 @@ fizzy search "bug" --indexed-by closed # Include closed cards fizzy search "feature" --sort newest # Sort by newest first ``` +### Activities + +```bash +fizzy activity list [--board ID] [--creator ID] [--page N] [--all] +``` + ### Boards ```bash From b616fe6a37d1c3d9890fcc0ded4150095a5717ea Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 15 Apr 2026 11:44:24 -0400 Subject: [PATCH 4/8] Add user email change commands --- SURFACE.txt | 33 ++++++++++++++ internal/commands/user.go | 74 +++++++++++++++++++++++++++++++ internal/commands/user_test.go | 81 ++++++++++++++++++++++++++++++++++ skills/fizzy/SKILL.md | 4 +- 4 files changed, 191 insertions(+), 1 deletion(-) diff --git a/SURFACE.txt b/SURFACE.txt index 1b3d994..45d4400 100644 --- a/SURFACE.txt +++ b/SURFACE.txt @@ -200,6 +200,8 @@ CMD fizzy upload help CMD fizzy user CMD fizzy user avatar-remove CMD fizzy user deactivate +CMD fizzy user email-change-confirm +CMD fizzy user email-change-request CMD fizzy user export-create CMD fizzy user export-show CMD fizzy user help @@ -2819,6 +2821,35 @@ FLAG fizzy user deactivate --quiet type=bool FLAG fizzy user deactivate --styled type=bool FLAG fizzy user deactivate --token type=string FLAG fizzy user deactivate --verbose type=bool +FLAG fizzy user email-change-confirm --agent type=bool +FLAG fizzy user email-change-confirm --api-url type=string +FLAG fizzy user email-change-confirm --count type=bool +FLAG fizzy user email-change-confirm --help type=bool +FLAG fizzy user email-change-confirm --ids-only type=bool +FLAG fizzy user email-change-confirm --jq type=string +FLAG fizzy user email-change-confirm --json type=bool +FLAG fizzy user email-change-confirm --limit type=int +FLAG fizzy user email-change-confirm --markdown type=bool +FLAG fizzy user email-change-confirm --profile type=string +FLAG fizzy user email-change-confirm --quiet type=bool +FLAG fizzy user email-change-confirm --styled type=bool +FLAG fizzy user email-change-confirm --token type=string +FLAG fizzy user email-change-confirm --verbose type=bool +FLAG fizzy user email-change-request --agent type=bool +FLAG fizzy user email-change-request --api-url type=string +FLAG fizzy user email-change-request --count type=bool +FLAG fizzy user email-change-request --email type=string +FLAG fizzy user email-change-request --help type=bool +FLAG fizzy user email-change-request --ids-only type=bool +FLAG fizzy user email-change-request --jq type=string +FLAG fizzy user email-change-request --json type=bool +FLAG fizzy user email-change-request --limit type=int +FLAG fizzy user email-change-request --markdown type=bool +FLAG fizzy user email-change-request --profile type=string +FLAG fizzy user email-change-request --quiet type=bool +FLAG fizzy user email-change-request --styled type=bool +FLAG fizzy user email-change-request --token type=string +FLAG fizzy user email-change-request --verbose type=bool FLAG fizzy user export-create --agent type=bool FLAG fizzy user export-create --api-url type=string FLAG fizzy user export-create --count type=bool @@ -3360,6 +3391,8 @@ SUB fizzy upload help SUB fizzy user SUB fizzy user avatar-remove SUB fizzy user deactivate +SUB fizzy user email-change-confirm +SUB fizzy user email-change-request SUB fizzy user export-create SUB fizzy user export-show SUB fizzy user help diff --git a/internal/commands/user.go b/internal/commands/user.go index 80c023b..c98ac8c 100644 --- a/internal/commands/user.go +++ b/internal/commands/user.go @@ -338,6 +338,75 @@ var userExportShowCmd = &cobra.Command{ }, } +var userEmailChangeRequestEmail string + +var userEmailChangeRequestCmd = &cobra.Command{ + Use: "email-change-request USER_ID", + Short: "Request a user email address change", + Long: "Requests an email address change for a user.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + if userEmailChangeRequestEmail == "" { + return newRequiredFlagError("email") + } + + userID := args[0] + resp, err := getSDK().Users().RequestEmailAddressChange(cmd.Context(), userID, &generated.RequestEmailAddressChangeRequest{ + EmailAddress: userEmailChangeRequestEmail, + }) + if err != nil { + return convertSDKError(err) + } + + data := normalizeAny(resp.Data) + if data == nil { + data = map[string]any{"requested": true} + } + + breadcrumbs := []Breadcrumb{ + breadcrumb("user", fmt.Sprintf("fizzy user show %s", userID), "View user"), + } + + printMutation(data, "", breadcrumbs) + return nil + }, +} + +var userEmailChangeConfirmCmd = &cobra.Command{ + Use: "email-change-confirm USER_ID TOKEN", + Short: "Confirm a user email address change", + Long: "Confirms an email address change for a user.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + + userID := args[0] + token := args[1] + + resp, err := getSDK().Users().ConfirmEmailAddressChange(cmd.Context(), userID, token) + if err != nil { + return convertSDKError(err) + } + + data := normalizeAny(resp.Data) + if data == nil { + data = map[string]any{"confirmed": true} + } + + breadcrumbs := []Breadcrumb{ + breadcrumb("user", fmt.Sprintf("fizzy user show %s", userID), "View user"), + } + + printMutation(data, "", breadcrumbs) + return nil + }, +} + // Push subscription create flags var pushSubCreateUser string var pushSubCreateEndpoint string @@ -447,6 +516,11 @@ func init() { userCmd.AddCommand(userExportCreateCmd) userCmd.AddCommand(userExportShowCmd) + // Email change + userEmailChangeRequestCmd.Flags().StringVar(&userEmailChangeRequestEmail, "email", "", "New email address (required)") + userCmd.AddCommand(userEmailChangeRequestCmd) + userCmd.AddCommand(userEmailChangeConfirmCmd) + // Push subscriptions userPushSubscriptionCreateCmd.Flags().StringVar(&pushSubCreateUser, "user", "", "User ID (required)") userPushSubscriptionCreateCmd.Flags().StringVar(&pushSubCreateEndpoint, "endpoint", "", "Push endpoint URL (required)") diff --git a/internal/commands/user_test.go b/internal/commands/user_test.go index 69eba95..7df393e 100644 --- a/internal/commands/user_test.go +++ b/internal/commands/user_test.go @@ -301,6 +301,87 @@ func TestUserExport(t *testing.T) { }) } +func TestUserEmailChange(t *testing.T) { + t.Run("requests email change", func(t *testing.T) { + mock := NewMockClient() + mock.PostResponse = &client.APIResponse{StatusCode: 204, Data: nil} + + result := SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + userEmailChangeRequestEmail = "new@example.com" + err := userEmailChangeRequestCmd.RunE(userEmailChangeRequestCmd, []string{"user-1"}) + userEmailChangeRequestEmail = "" + + assertExitCode(t, err, 0) + if mock.PostCalls[0].Path != "/users/user-1/email_addresses.json" { + t.Errorf("expected path '/users/user-1/email_addresses.json', got '%s'", mock.PostCalls[0].Path) + } + body := mock.PostCalls[0].Body.(map[string]any) + if body["email_address"] != "new@example.com" { + t.Errorf("expected email_address 'new@example.com', got '%v'", body["email_address"]) + } + data, ok := result.Response.Data.(map[string]any) + if !ok || data["requested"] != true { + t.Fatalf("expected explicit requested=true payload, got %#v", result.Response.Data) + } + }) + + t.Run("confirms email change", func(t *testing.T) { + mock := NewMockClient() + mock.PostResponse = &client.APIResponse{StatusCode: 204, Data: nil} + + result := SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + err := userEmailChangeConfirmCmd.RunE(userEmailChangeConfirmCmd, []string{"user-1", "token-123"}) + + assertExitCode(t, err, 0) + if mock.PostCalls[0].Path != "/users/user-1/email_addresses/token-123/confirmation.json" { + t.Errorf("expected confirmation path, got '%s'", mock.PostCalls[0].Path) + } + data, ok := result.Response.Data.(map[string]any) + if !ok || data["confirmed"] != true { + t.Fatalf("expected explicit confirmed=true payload, got %#v", result.Response.Data) + } + }) + + t.Run("requires email flag", func(t *testing.T) { + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + userEmailChangeRequestEmail = "" + err := userEmailChangeRequestCmd.RunE(userEmailChangeRequestCmd, []string{"user-1"}) + assertExitCode(t, err, errors.ExitInvalidArgs) + }) + + t.Run("request requires authentication", func(t *testing.T) { + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestConfig("", "account", "https://api.example.com") + defer resetTest() + + userEmailChangeRequestEmail = "new@example.com" + err := userEmailChangeRequestCmd.RunE(userEmailChangeRequestCmd, []string{"user-1"}) + userEmailChangeRequestEmail = "" + assertExitCode(t, err, errors.ExitAuthFailure) + }) + + t.Run("confirm requires authentication", func(t *testing.T) { + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestConfig("", "account", "https://api.example.com") + defer resetTest() + + err := userEmailChangeConfirmCmd.RunE(userEmailChangeConfirmCmd, []string{"user-1", "token-123"}) + assertExitCode(t, err, errors.ExitAuthFailure) + }) +} + func TestUserPushSubscriptionCreate(t *testing.T) { t.Run("creates push subscription", func(t *testing.T) { mock := NewMockClient() diff --git a/skills/fizzy/SKILL.md b/skills/fizzy/SKILL.md index 9cc88cd..530d3b0 100644 --- a/skills/fizzy/SKILL.md +++ b/skills/fizzy/SKILL.md @@ -109,7 +109,7 @@ Want to change something? | step | `step list --card NUMBER` | `step show ID --card NUMBER` | `step create` | `step update ID` | `step delete ID` | - | | reaction | `reaction list` | - | `reaction create` | - | `reaction delete ID` | - | | tag | `tag list` | - | - | - | - | - | -| user | `user list` | `user show ID`, `user export-show USER_ID EXPORT_ID` | `user export-create USER_ID` | `user update ID` | - | `user deactivate ID`, `user role ID`, `user avatar-remove ID`, `user push-subscription-create`, `user push-subscription-delete ID` | +| user | `user list` | `user show ID`, `user export-show USER_ID EXPORT_ID` | `user export-create USER_ID`, `user email-change-request USER_ID --email user@example.com`, `user email-change-confirm USER_ID TOKEN` | `user update ID` | - | `user deactivate ID`, `user role ID`, `user avatar-remove ID`, `user push-subscription-create`, `user push-subscription-delete ID` | | notification | `notification list` | - | - | - | - | `notification tray`, `notification read-all`, `notification settings-show`, `notification settings-update` | | pin | `pin list` | - | - | - | - | `card pin NUMBER`, `card unpin NUMBER` | | webhook | `webhook list --board ID`, `webhook deliveries --board ID WEBHOOK_ID` | `webhook show ID --board ID` | `webhook create` | `webhook update ID` | `webhook delete ID` | `webhook reactivate ID` | @@ -732,6 +732,8 @@ fizzy user role USER_ID --role ROLE # Update user role (requires admi fizzy user avatar-remove USER_ID # Remove user avatar fizzy user export-create USER_ID # Create user data export fizzy user export-show USER_ID EXPORT_ID # Show user data export status +fizzy user email-change-request USER_ID --email user@example.com +fizzy user email-change-confirm USER_ID TOKEN fizzy user push-subscription-create --user ID --endpoint URL --p256dh-key KEY --auth-key KEY fizzy user push-subscription-delete SUB_ID --user ID ``` From 5e080585a24e6170a7ffb897374800c7fe7efa5c Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 15 Apr 2026 11:46:58 -0400 Subject: [PATCH 5/8] Add e2e coverage for new SDK-backed commands --- README.md | 4 ++ e2e/cli_tests/account_user_test.go | 24 ++++++++++++ e2e/cli_tests/crud_board_test.go | 17 +++++++++ e2e/cli_tests/output_contract_test.go | 1 + e2e/cli_tests/syntax_contract_test.go | 1 + e2e/cli_tests/webhook_test.go | 54 +++++++++++++++++++++++++++ 6 files changed, 101 insertions(+) diff --git a/README.md b/README.md index 2a7a64a..fbc36c1 100644 --- a/README.md +++ b/README.md @@ -86,12 +86,16 @@ sudo rpm -i fizzy-cli_VERSION_linux_amd64.rpm ```bash fizzy board list # List boards +fizzy board accesses --board ID # Show board access settings and users +fizzy activity list --board ID # List recent board activity fizzy card list # List cards on default board fizzy card show 42 # Show card details fizzy card create --board ID --title "Fix login bug" # Create card fizzy card close 42 # Close card fizzy search "authentication" # Search across cards fizzy comment create --card 42 --body "Looks good!" # Add comment +fizzy webhook deliveries --board ID WEBHOOK_ID # List webhook deliveries +fizzy user export-create USER_ID # Create a user data export ``` ### Attachments diff --git a/e2e/cli_tests/account_user_test.go b/e2e/cli_tests/account_user_test.go index 7e8d3fb..9c6d822 100644 --- a/e2e/cli_tests/account_user_test.go +++ b/e2e/cli_tests/account_user_test.go @@ -122,3 +122,27 @@ func TestUserAvatarUpdateAndRemove(t *testing.T) { t.Fatal("expected avatar endpoint to fall back to generated SVG after removal") } } + +func TestUserExportCreateShow(t *testing.T) { + h := newHarness(t) + userID := currentUserID(t, h) + + create := h.Run("user", "export-create", userID) + assertOK(t, create) + exportID := create.GetDataString("id") + if exportID == "" { + exportID = mapValueString(create.GetDataMap(), "id") + } + if exportID == "" { + t.Fatal("expected export ID in user export-create response") + } + + show := h.Run("user", "export-show", userID, exportID) + assertOK(t, show) + if got := mapValueString(show.GetDataMap(), "id"); got != exportID { + t.Fatalf("expected export-show id %q, got %q", exportID, got) + } + if got := mapValueString(show.GetDataMap(), "status"); got == "" { + t.Fatal("expected export status in user export-show response") + } +} diff --git a/e2e/cli_tests/crud_board_test.go b/e2e/cli_tests/crud_board_test.go index 065da8d..b727a6a 100644 --- a/e2e/cli_tests/crud_board_test.go +++ b/e2e/cli_tests/crud_board_test.go @@ -134,6 +134,23 @@ func TestBoardViews(t *testing.T) { } } +func TestBoardAccesses(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + + result := h.Run("board", "accesses", "--board", boardID) + assertOK(t, result) + if got := result.GetDataString("board_id"); got != boardID { + t.Fatalf("expected board_id %q, got %q", boardID, got) + } + if _, ok := result.GetDataMap()["all_access"]; !ok { + t.Fatal("expected all_access in board accesses response") + } + if _, ok := result.GetDataMap()["users"]; !ok { + t.Fatal("expected users in board accesses response") + } +} + func TestBoardInvolvement(t *testing.T) { h := newHarness(t) boardID := createBoard(t, h) diff --git a/e2e/cli_tests/output_contract_test.go b/e2e/cli_tests/output_contract_test.go index 8ba17da..bf722c2 100644 --- a/e2e/cli_tests/output_contract_test.go +++ b/e2e/cli_tests/output_contract_test.go @@ -217,6 +217,7 @@ func TestOutputContractShowCommands(t *testing.T) { args []string }{ {"board-show", []string{"board", "show", fixture.BoardID}}, + {"board-accesses", []string{"board", "accesses", "--board", fixture.BoardID}}, {"card-show", []string{"card", "show", cardNum}}, {"column-show", []string{"column", "show", fixture.ColumnID, "--board", fixture.BoardID}}, {"comment-show", []string{"comment", "show", fixture.CommentID, "--card", cardNum}}, diff --git a/e2e/cli_tests/syntax_contract_test.go b/e2e/cli_tests/syntax_contract_test.go index f458f06..1db0c52 100644 --- a/e2e/cli_tests/syntax_contract_test.go +++ b/e2e/cli_tests/syntax_contract_test.go @@ -10,6 +10,7 @@ import ( func TestBoardBoardScopedCommandsUseBoardFlag(t *testing.T) { h := newHarness(t) for name, args := range map[string][]string{ + "accesses": {"board", "accesses", "--board", fixture.BoardID}, "closed": {"board", "closed", "--board", fixture.BoardID}, "postponed": {"board", "postponed", "--board", fixture.BoardID}, "stream": {"board", "stream", "--board", fixture.BoardID}, diff --git a/e2e/cli_tests/webhook_test.go b/e2e/cli_tests/webhook_test.go index a039cf4..c6f0853 100644 --- a/e2e/cli_tests/webhook_test.go +++ b/e2e/cli_tests/webhook_test.go @@ -77,3 +77,57 @@ func TestWebhookCRUD(t *testing.T) { } assertResult(t, h.Run("webhook", "show", "--board", boardID, webhookID), harness.ExitNotFound) } + +func TestWebhookDeliveries(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + cardNum := createCard(t, h, boardID) + name := "CLI Delivery Hook " + strconv.FormatInt(time.Now().UnixNano(), 10) + + create := h.Run("webhook", "create", + "--board", boardID, + "--name", name, + "--url", "https://example.com/fizzy-cli-webhook-deliveries", + "--actions", "card_closed", + ) + assertOK(t, create) + webhookID := create.GetIDFromLocation() + if webhookID == "" { + webhookID = create.GetDataString("id") + } + if webhookID == "" { + t.Fatal("no webhook ID in create response") + } + t.Cleanup(func() { + newHarness(t).Run("webhook", "delete", "--board", boardID, webhookID) + }) + + assertOK(t, h.Run("card", "close", strconv.Itoa(cardNum))) + + var deliveries *harness.Result + for attempt := 0; attempt < 15; attempt++ { + r := h.Run("webhook", "deliveries", "--board", boardID, webhookID) + if r.ExitCode == harness.ExitSuccess && len(r.GetDataArray()) > 0 { + deliveries = r + break + } + time.Sleep(200 * time.Millisecond) + } + if deliveries == nil { + t.Fatal("expected at least one webhook delivery after triggering card_closed") + } + + assertOK(t, deliveries) + if len(deliveries.GetDataArray()) == 0 { + t.Fatal("expected webhook deliveries to be non-empty") + } + first := asMap(deliveries.GetDataArray()[0]) + if mapValueString(first, "id") == "" { + t.Fatal("expected delivery id") + } + if mapValueString(first, "state") == "" { + t.Fatal("expected delivery state") + } + + assertOK(t, h.Run("webhook", "deliveries", "--board", boardID, webhookID, "--all")) +} From 3c1e75cab5a606e1997998c8ed6917d1d6803da1 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 15 Apr 2026 12:12:18 -0400 Subject: [PATCH 6/8] Polish docs and contract coverage --- README.md | 27 ++++++---- e2e/cli_tests/output_contract_test.go | 72 +++++++++++++++++++++++++++ e2e/cli_tests/syntax_contract_test.go | 34 +++++++++++++ 3 files changed, 123 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fbc36c1..c8eadf4 100644 --- a/README.md +++ b/README.md @@ -82,22 +82,29 @@ sudo rpm -i fizzy-cli_VERSION_linux_amd64.rpm -## Usage +## Next Steps + +Start with a few common commands: + +```bash +fizzy board list +fizzy card list +fizzy card show 42 +fizzy search "authentication" +fizzy comment create --card 42 --body "Looks good!" +``` + +Then branch out as needed: ```bash -fizzy board list # List boards fizzy board accesses --board ID # Show board access settings and users fizzy activity list --board ID # List recent board activity -fizzy card list # List cards on default board -fizzy card show 42 # Show card details -fizzy card create --board ID --title "Fix login bug" # Create card -fizzy card close 42 # Close card -fizzy search "authentication" # Search across cards -fizzy comment create --card 42 --body "Looks good!" # Add comment -fizzy webhook deliveries --board ID WEBHOOK_ID # List webhook deliveries -fizzy user export-create USER_ID # Create a user data export +fizzy webhook deliveries --board ID WEBHOOK_ID +fizzy user export-create USER_ID ``` +For the full command surface, run `fizzy commands --json` or read [`skills/fizzy/SKILL.md`](skills/fizzy/SKILL.md). + ### Attachments Simple mode uses repeatable `--attach` and appends inline attachments to the end of card descriptions or comment bodies: diff --git a/e2e/cli_tests/output_contract_test.go b/e2e/cli_tests/output_contract_test.go index bf722c2..61e0873 100644 --- a/e2e/cli_tests/output_contract_test.go +++ b/e2e/cli_tests/output_contract_test.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/basecamp/fizzy-cli/e2e/harness" ) @@ -241,3 +242,74 @@ func TestOutputContractShowCommands(t *testing.T) { }) } } + +func TestOutputContractWebhookDeliveries(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + cardNum := createCard(t, h, boardID) + create := h.Run("webhook", "create", "--board", boardID, "--name", "Output Contract Hook", "--url", "https://example.com/fizzy-cli-output-contract", "--actions", "card_closed") + assertOK(t, create) + webhookID := create.GetIDFromLocation() + if webhookID == "" { + webhookID = create.GetDataString("id") + } + if webhookID == "" { + t.Fatal("expected webhook ID in create response") + } + t.Cleanup(func() { newHarness(t).Run("webhook", "delete", "--board", boardID, webhookID) }) + + assertOK(t, h.Run("card", "close", strconv.Itoa(cardNum))) + + var ready bool + for attempt := 0; attempt < 15; attempt++ { + result := h.Run("webhook", "deliveries", "--board", boardID, webhookID) + if result.ExitCode == harness.ExitSuccess && len(result.GetDataArray()) > 0 { + ready = true + break + } + time.Sleep(200 * time.Millisecond) + } + if !ready { + t.Fatal("expected webhook deliveries to contain at least one item") + } + + baseArgs := []string{"webhook", "deliveries", "--board", boardID, webhookID} + for _, f := range listFlagSuite() { + f := f + t.Run(f.name, func(t *testing.T) { + args := append(append([]string(nil), baseArgs...), f.extra...) + result := h.Run(args...) + if result.ExitCode != harness.ExitSuccess { + t.Fatalf("expected exit code 0, got %d\nstdout: %s\nstderr: %s", result.ExitCode, result.Stdout, result.Stderr) + } + f.check(t, result) + }) + } +} + +func TestOutputContractUserExportShow(t *testing.T) { + h := newHarness(t) + userID := currentUserID(t, h) + create := h.Run("user", "export-create", userID) + assertOK(t, create) + exportID := create.GetDataString("id") + if exportID == "" { + exportID = mapValueString(create.GetDataMap(), "id") + } + if exportID == "" { + t.Fatal("expected user export ID in create response") + } + + baseArgs := []string{"user", "export-show", userID, exportID} + for _, f := range showFlagSuite() { + f := f + t.Run(f.name, func(t *testing.T) { + args := append(append([]string(nil), baseArgs...), f.extra...) + result := h.Run(args...) + if result.ExitCode != harness.ExitSuccess { + t.Fatalf("expected exit code 0, got %d\nstdout: %s\nstderr: %s", result.ExitCode, result.Stdout, result.Stderr) + } + f.check(t, result) + }) + } +} diff --git a/e2e/cli_tests/syntax_contract_test.go b/e2e/cli_tests/syntax_contract_test.go index 1db0c52..85668e1 100644 --- a/e2e/cli_tests/syntax_contract_test.go +++ b/e2e/cli_tests/syntax_contract_test.go @@ -68,3 +68,37 @@ func TestAccountEntropyRejectsInvalidZeroValue(t *testing.T) { result := h.Run("account", "entropy", "--auto_postpone_period_in_days", "0") assertResult(t, result, harness.ExitUsage) } + +func TestUserExportCommandsUsePositionalIDs(t *testing.T) { + h := newHarness(t) + userID := currentUserID(t, h) + + create := h.Run("user", "export-create", userID) + assertOK(t, create) + exportID := create.GetDataString("id") + if exportID == "" { + exportID = mapValueString(create.GetDataMap(), "id") + } + if exportID == "" { + t.Fatal("expected export ID from user export-create") + } + + assertOK(t, h.Run("user", "export-show", userID, exportID)) +} + +func TestWebhookDeliveriesUsesBoardFlagAndWebhookID(t *testing.T) { + h := newHarness(t) + boardID := createBoard(t, h) + cardNum := createCard(t, h, boardID) + create := h.Run("webhook", "create", "--board", boardID, "--name", "Syntax Contract Hook", "--url", "https://example.com/fizzy-cli-syntax", "--actions", "card_closed") + assertOK(t, create) + webhookID := create.GetIDFromLocation() + if webhookID == "" { + webhookID = create.GetDataString("id") + } + if webhookID == "" { + t.Fatal("expected webhook ID from webhook create") + } + assertOK(t, h.Run("card", "close", strconv.Itoa(cardNum))) + assertOK(t, h.Run("webhook", "deliveries", "--board", boardID, webhookID)) +} From fcce8932441473c6394e13edb033af69407fec4e Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 15 Apr 2026 12:24:53 -0400 Subject: [PATCH 7/8] Add compatibility tests for pseudo-column aliases --- internal/commands/card_test.go | 74 ++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/internal/commands/card_test.go b/internal/commands/card_test.go index 2320852..a3ddf8c 100644 --- a/internal/commands/card_test.go +++ b/internal/commands/card_test.go @@ -88,6 +88,44 @@ func TestCardList(t *testing.T) { } }) + t.Run("supports legacy pseudo column aliases for listing", func(t *testing.T) { + t.Run("not_now", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{StatusCode: 200, Data: []any{}} + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + cardListColumn = "not_now" + err := cardListCmd.RunE(cardListCmd, []string{}) + cardListColumn = "" + + assertExitCode(t, err, 0) + if mock.GetWithPaginationCalls[0].Path != "/cards.json?indexed_by=not_now" { + t.Errorf("expected legacy alias to map to indexed_by=not_now, got '%s'", mock.GetWithPaginationCalls[0].Path) + } + }) + + t.Run("triage", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{StatusCode: 200, Data: []any{}} + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + cardListColumn = "triage" + err := cardListCmd.RunE(cardListCmd, []string{}) + cardListColumn = "" + + assertExitCode(t, err, 0) + if mock.GetWithPaginationCalls[0].Path != "/cards.json?indexed_by=maybe" { + t.Errorf("expected legacy alias to map to indexed_by=maybe, got '%s'", mock.GetWithPaginationCalls[0].Path) + } + }) + }) + t.Run("filters by real column server-side without client-side filtering", func(t *testing.T) { mock := NewMockClient() mock.GetWithPaginationResponse = &client.APIResponse{ @@ -838,6 +876,24 @@ func TestCardColumn(t *testing.T) { } }) + t.Run("not_now alias", func(t *testing.T) { + mock := NewMockClient() + mock.PostResponse = &client.APIResponse{StatusCode: 200, Data: map[string]any{}} + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + cardColumnColumn = "not_now" + err := cardColumnCmd.RunE(cardColumnCmd, []string{"42"}) + cardColumnColumn = "" + + assertExitCode(t, err, 0) + if len(mock.PostCalls) != 1 || mock.PostCalls[0].Path != "/cards/42/not_now.json" { + t.Errorf("expected post '/cards/42/not_now.json', got %+v", mock.PostCalls) + } + }) + t.Run("maybe", func(t *testing.T) { mock := NewMockClient() mock.DeleteResponse = &client.APIResponse{StatusCode: 200, Data: map[string]any{}} @@ -856,6 +912,24 @@ func TestCardColumn(t *testing.T) { } }) + t.Run("triage alias", func(t *testing.T) { + mock := NewMockClient() + mock.DeleteResponse = &client.APIResponse{StatusCode: 200, Data: map[string]any{}} + + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + cardColumnColumn = "triage" + err := cardColumnCmd.RunE(cardColumnCmd, []string{"42"}) + cardColumnColumn = "" + + assertExitCode(t, err, 0) + if len(mock.DeleteCalls) != 1 || mock.DeleteCalls[0].Path != "/cards/42/triage.json" { + t.Errorf("expected delete '/cards/42/triage.json', got %+v", mock.DeleteCalls) + } + }) + t.Run("done", func(t *testing.T) { mock := NewMockClient() mock.PostResponse = &client.APIResponse{StatusCode: 200, Data: map[string]any{}} From 01cf72ee04194904e0088201e08b13832002dea3 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 16 Apr 2026 11:46:16 -0400 Subject: [PATCH 8/8] Bump fizzy-sdk to v0.1.3 --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 3e72ce6..b5cbeb2 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26 require ( github.com/basecamp/cli v0.2.1 - github.com/basecamp/fizzy-sdk/go v0.1.2 + github.com/basecamp/fizzy-sdk/go v0.1.3 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/term v0.2.2 @@ -43,7 +43,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/zalando/go-keyring v0.2.7 // indirect + github.com/zalando/go-keyring v0.2.8 // indirect golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.23.0 // indirect diff --git a/go.sum b/go.sum index 26d8833..8d78cff 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/basecamp/cli v0.2.1 h1:8GyehPVtsTXla0oOPu4QgXRjwwzJ99prlByvyi+0HRQ= github.com/basecamp/cli v0.2.1/go.mod h1:p8tt/DatJ2LAzWO6N6tNfV8x3gF5T3IxDTo+U8FfWPo= -github.com/basecamp/fizzy-sdk/go v0.1.2 h1:AX6ToXC2AI6iy+fvI02+W/Sk7Ar3bE9n4Clg+d5BDqM= -github.com/basecamp/fizzy-sdk/go v0.1.2/go.mod h1:HpiKRY9LpWobnxISmFM0I6/Tw+Br00mnDm9vcct8IH8= +github.com/basecamp/fizzy-sdk/go v0.1.3 h1:rKlWHsyiEnBp0bWnYoyrV0SIXY6COpGjzCpkywUkI24= +github.com/basecamp/fizzy-sdk/go v0.1.3/go.mod h1:XvOTc+2/6NaECvb2mVhIMq2pNsl9P2wNqwvybIUtQ2g= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= @@ -97,8 +97,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/zalando/go-keyring v0.2.7 h1:YbqBw40+g4g69UNk4WsRM/fV9YErfVWwozE2+7Bn+7g= -github.com/zalando/go-keyring v0.2.7/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=