diff --git a/README.md b/README.md index 2a7a64a..c8eadf4 100644 --- a/README.md +++ b/README.md @@ -82,18 +82,29 @@ sudo rpm -i fizzy-cli_VERSION_linux_amd64.rpm -## Usage +## Next Steps + +Start with a few common commands: ```bash -fizzy board list # List boards -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 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 accesses --board ID # Show board access settings and users +fizzy activity list --board ID # List recent board activity +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/SURFACE.txt b/SURFACE.txt index fdd8b25..45d4400 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 @@ -47,6 +52,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 +200,10 @@ 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 CMD fizzy user list CMD fizzy user ls @@ -207,6 +217,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 @@ -387,6 +398,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 @@ -514,6 +589,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 +2821,63 @@ 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 +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 +3077,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 @@ -3065,6 +3230,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 @@ -3074,6 +3243,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 +3391,10 @@ 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 SUB fizzy user list SUB fizzy user ls @@ -3234,6 +3408,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/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/activity_test.go b/e2e/cli_tests/activity_test.go new file mode 100644 index 0000000..3862308 --- /dev/null +++ b/e2e/cli_tests/activity_test.go @@ -0,0 +1,55 @@ +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) + + cardNumStr := strconv.Itoa(cardNum) + var result *harness.Result + foundCard := false + for attempt := 0; attempt < 10; attempt++ { + r := h.Run("activity", "list", "--board", boardID) + if r.ExitCode == harness.ExitSuccess { + result = r + for _, item := range r.GetDataArray() { + m := asMap(item) + if m == nil { + continue + } + if eventable := asMap(m["eventable"]); eventable != nil { + if mapValueString(eventable, "number") == cardNumStr { + foundCard = true + break + } + } + } + if foundCard { + break + } + } + time.Sleep(200 * time.Millisecond) + } + if result == nil { + t.Fatal("expected at least one successful activity list call") + } + assertOK(t, result) + if !foundCard { + t.Fatalf("activity list did not expose created card number %d after retries", 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/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 a68b2b6..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" ) @@ -174,6 +175,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}}, @@ -216,6 +218,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}}, @@ -239,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 f458f06..85668e1 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}, @@ -67,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)) +} 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")) +} diff --git a/internal/commands/activity.go b/internal/commands/activity.go new file mode 100644 index 0000000..abb3f0e --- /dev/null +++ b/internal/commands/activity.go @@ -0,0 +1,117 @@ +package commands + +import ( + "fmt" + "net/url" + "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.", + Args: cobra.NoArgs, + 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[]="+url.QueryEscape(activityListBoard)) + } + if activityListCreator != "" { + params = append(params, "creator_ids[]="+url.QueryEscape(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 + } + nextCmd := []string{"fizzy", "activity", "list"} + if activityListBoard != "" { + nextCmd = append(nextCmd, "--board", activityListBoard) + } + if activityListCreator != "" { + nextCmd = append(nextCmd, "--creator", activityListCreator) + } + nextCmd = append(nextCmd, "--page", strconv.Itoa(nextPage)) + breadcrumbs = append(breadcrumbs, breadcrumb("next", strings.Join(nextCmd, " "), "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..cea6892 --- /dev/null +++ b/internal/commands/activity_test.go @@ -0,0 +1,178 @@ +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("next-page breadcrumb preserves active filters", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{ + StatusCode: 200, + Data: []any{map[string]any{"id": "1"}}, + LinkNext: "/activities.json?board_ids[]=board-123&creator_ids[]=user-123&page=2", + } + + result := 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) + + var nextCmd string + for _, b := range result.Response.Breadcrumbs { + if b.Action == "next" { + nextCmd = b.Cmd + break + } + } + expected := "fizzy activity list --board board-123 --creator user-123 --page 2" + if nextCmd != expected { + t.Errorf("expected next breadcrumb %q, got %q", expected, nextCmd) + } + }) + + t.Run("rejects positional args", func(t *testing.T) { + if err := activityListCmd.Args(activityListCmd, []string{"unexpected"}); err == nil { + t.Fatal("expected positional args to be rejected") + } + }) + + 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/board.go b/internal/commands/board.go index 37878d3..a5a6d4c 100644 --- a/internal/commands/board.go +++ b/internal/commands/board.go @@ -415,6 +415,61 @@ 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.", + Args: cobra.NoArgs, + 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, resp, err := getSDK().Boards().ListBoardAccesses(cmd.Context(), boardID, page) + if err != nil { + return convertSDKError(err) + } + linkNext := parseSDKLinkNext(resp) + + 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"), + } + + hasNext := linkNext != "" + if hasNext { + nextPage := boardAccessesPage + 1 + if boardAccessesPage == 0 { + nextPage = 2 + } + breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy board accesses --board %s --page %d", boardID, nextPage), "Next page")) + } + + printDetailPaginated(normalizeAny(data), summary, breadcrumbs, hasNext, linkNext) + return nil + }, +} + // Board closed flags var boardClosedBoard string var boardClosedPage int @@ -705,6 +760,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..cc11e23 100644 --- a/internal/commands/board_test.go +++ b/internal/commands/board_test.go @@ -664,6 +664,120 @@ 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 len(mock.GetCalls) != 1 { + t.Fatalf("expected 1 GET call, got %d", len(mock.GetCalls)) + } + 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 len(mock.GetCalls) != 1 { + t.Fatalf("expected 1 GET call, got %d", len(mock.GetCalls)) + } + 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("includes next pagination context and breadcrumb", func(t *testing.T) { + mock := NewMockClient() + mock.GetResponse = &client.APIResponse{ + StatusCode: 200, + Data: map[string]any{"board_id": "123", "all_access": false, "users": []any{}}, + LinkNext: "/boards/123/accesses.json?page=2", + } + + result := 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) + + var nextCmd string + for _, bc := range result.Response.Breadcrumbs { + if bc.Action == "next" { + nextCmd = bc.Cmd + break + } + } + if nextCmd != "fizzy board accesses --board 123 --page 2" { + t.Fatalf("expected next breadcrumb, got %q", nextCmd) + } + + pagination, ok := result.Response.Context["pagination"].(map[string]any) + if !ok { + t.Fatalf("expected pagination context, got %#v", result.Response.Context) + } + if pagination["has_next"] != true || pagination["next_url"] != "/boards/123/accesses.json?page=2" { + t.Fatalf("unexpected pagination context: %#v", pagination) + } + }) + + t.Run("rejects positional args", func(t *testing.T) { + if err := boardAccessesCmd.Args(boardAccessesCmd, []string{"unexpected"}); err == nil { + t.Fatal("expected positional args to be rejected") + } + }) + + 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/card.go b/internal/commands/card.go index 4380506..45012bd 100644 --- a/internal/commands/card.go +++ b/internal/commands/card.go @@ -57,34 +57,32 @@ 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 + return errors.NewInvalidArgsError("invalid pseudo-column kind: " + pseudo.Kind) } } else { if effectiveIndexedBy != "" { return errors.NewInvalidArgsError("cannot combine --indexed-by with --column") } - clientSideColumnFilter = columnFilter + params = append(params, "column_ids[]="+columnFilter) } } @@ -128,10 +126,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 +144,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 +1036,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..a3ddf8c 100644 --- a/internal/commands/card_test.go +++ b/internal/commands/card_test.go @@ -88,29 +88,83 @@ func TestCardList(t *testing.T) { } }) - t.Run("requires --all for client-side triage filter", func(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() - 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 +173,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 +380,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) { @@ -799,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{}} @@ -817,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{}} diff --git a/internal/commands/columns.go b/internal/commands/columns.go index 99a5b1b..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"}, @@ -77,4 +84,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/commands.go b/internal/commands/commands.go index 4fa2d5b..ca164f9 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -31,7 +31,7 @@ var commandCatalogTitles = map[string]string{ } var commandCatalogGroups = map[string][]string{ - "core": {"board", "card", "column", "comment", "search", "step"}, + "core": {"activity", "board", "card", "column", "comment", "search", "step"}, "collaboration": {"notification", "pin", "reaction", "tag", "user"}, "admin": {"auth", "account", "identity", "webhook", "upload", "migrate"}, "utilities": {"setup", "signup", "completion", "doctor", "config", "skill", "commands", "version"}, diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index 90291c3..586589c 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -21,7 +21,7 @@ func TestCommandsStyledOutputRendersHumanCatalog(t *testing.T) { if !strings.Contains(raw, "CORE COMMANDS") { t.Fatalf("expected styled catalog heading, got:\n%s", raw) } - if !strings.Contains(raw, "auth") || !strings.Contains(raw, "board") { + if !strings.Contains(raw, "auth") || !strings.Contains(raw, "activity") || !strings.Contains(raw, "board") { t.Fatalf("expected styled catalog to include commands, got:\n%s", raw) } if strings.Contains(raw, "list, show") { @@ -51,6 +51,25 @@ func TestCommandsFilterRendersMatchingHumanCatalog(t *testing.T) { } } +func TestCommandsFilterFindsActivity(t *testing.T) { + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestFormat(output.FormatStyled) + defer resetTest() + + if err := commandsCmd.RunE(commandsCmd, []string{"activity"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + raw := TestOutput() + if !strings.Contains(raw, "activity") || !strings.Contains(raw, "list") { + t.Fatalf("expected filtered catalog to include activity list, got:\n%s", raw) + } + if strings.Contains(raw, "No commands match") { + t.Fatalf("expected activity to be discoverable, got:\n%s", raw) + } +} + func TestCommandsJSONOutputReturnsStructuredCatalog(t *testing.T) { mock := NewMockClient() result := SetTestModeWithSDK(mock) diff --git a/internal/commands/help.go b/internal/commands/help.go index fe8dd7c..2d35de0 100644 --- a/internal/commands/help.go +++ b/internal/commands/help.go @@ -379,7 +379,7 @@ var rootCommandGroupTitles = map[string]string{ } var rootCommandGroups = map[string][]string{ - "core": {"auth", "board", "card", "search"}, + "core": {"auth", "activity", "board", "card", "search"}, "collaboration": {"comment", "notification"}, "getting-started": {"setup", "signup"}, "discover": {"doctor", "config", "commands", "version"}, @@ -389,6 +389,8 @@ var commandExamples = map[string]string{ "fizzy auth": "$ fizzy auth status\n$ fizzy auth login TOKEN --profile acme", "fizzy auth status": "$ fizzy auth status", "fizzy auth list": "$ fizzy auth list\n$ fizzy auth switch acme", + "fizzy activity": "$ fizzy activity list\n$ fizzy activity list --board ", + "fizzy activity list": "$ fizzy activity list --board \n$ fizzy activity list --creator ", "fizzy board": "$ fizzy board list\n$ fizzy board show ", "fizzy board list": "$ fizzy board list\n$ fizzy board list --page 2", "fizzy board show": "$ fizzy board show ", diff --git a/internal/commands/help_test.go b/internal/commands/help_test.go index cf4d147..33b8a98 100644 --- a/internal/commands/help_test.go +++ b/internal/commands/help_test.go @@ -15,7 +15,7 @@ func TestRenderRootHelp(t *testing.T) { renderHelp(rootCmd, &buf) out := buf.String() - for _, want := range []string{"CORE COMMANDS", "GETTING STARTED", "DISCOVER", "FLAGS", "--profile", "LEARN MORE", "Use `fizzy commands` to see the full command catalog.", "implies --json"} { + for _, want := range []string{"CORE COMMANDS", "activity", "GETTING STARTED", "DISCOVER", "FLAGS", "--profile", "LEARN MORE", "Use `fizzy commands` to see the full command catalog.", "implies --json"} { if !strings.Contains(out, want) { t.Fatalf("expected root help to contain %q, got:\n%s", want, out) } diff --git a/internal/commands/pseudocolumns.go b/internal/commands/pseudocolumns.go index 5ebd593..6be7349 100644 --- a/internal/commands/pseudocolumns.go +++ b/internal/commands/pseudocolumns.go @@ -11,9 +11,11 @@ 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. Kind remains "triage" for triage endpoints/aliases; + // card listing maps this pseudo-column to indexed_by=maybe server-side. 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/root.go b/internal/commands/root.go index ff88854..3d6a398 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -808,6 +808,11 @@ func printListPaginated(data any, cols render.Columns, hasNext bool, nextURL str // printDetail renders a single object with format-aware dispatch. func printDetail(data any, summary string, breadcrumbs []Breadcrumb) { + printDetailPaginated(data, summary, breadcrumbs, false, "") +} + +// printDetailPaginated renders a single object and includes pagination context when present. +func printDetailPaginated(data any, summary string, breadcrumbs []Breadcrumb, hasNext bool, nextURL string) { switch out.EffectiveFormat() { case output.FormatStyled: body := render.StyledDetail(toMap(data), summary) @@ -818,7 +823,18 @@ func printDetail(data any, summary string, breadcrumbs []Breadcrumb) { writeOutputString(appendHumanSections(body, "", "", breadcrumbs, true)) captureResponse() default: - printSuccessWithBreadcrumbs(data, summary, breadcrumbs) + opts := []output.ResponseOption{output.WithBreadcrumbs(breadcrumbs...)} + if summary != "" { + opts = append(opts, output.WithSummary(summary)) + } + if hasNext || nextURL != "" { + opts = append(opts, output.WithContext("pagination", map[string]any{ + "has_next": hasNext, + "next_url": nextURL, + })) + } + recordOutputError(out.OK(data, opts...)) + captureResponse() } } 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/internal/commands/user.go b/internal/commands/user.go index 98ff828..c98ac8c 100644 --- a/internal/commands/user.go +++ b/internal/commands/user.go @@ -272,6 +272,141 @@ 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 + }, +} + +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 @@ -377,6 +512,15 @@ func init() { // Avatar remove userCmd.AddCommand(userAvatarRemoveCmd) + // Exports + 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 b19c2de..7df393e 100644 --- a/internal/commands/user_test.go +++ b/internal/commands/user_test.go @@ -257,6 +257,131 @@ 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 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/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 e26c04c..52f3437 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) @@ -100,18 +100,19 @@ 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` | - | - | - | - | - | +| 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` | - | | 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 update ID` | - | `user deactivate ID`, `user role ID`, `user avatar-remove ID`, `user export-create USER_ID`, `user export-show USER_ID EXPORT_ID`, `user email-change-request USER_ID --email user@example.com`, `user email-change-confirm USER_ID TOKEN`, `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 show ID --board ID` | `webhook create` | `webhook update ID` | `webhook delete ID` | `webhook reactivate ID` | +| 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` | --- @@ -360,9 +361,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 +476,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 @@ -490,6 +491,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 @@ -501,6 +508,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 @@ -559,7 +567,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 @@ -721,6 +729,10 @@ 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 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 ``` @@ -750,6 +762,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,...]