From 705a54240f391463acd6274727e0f9a1ffa01133 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 9 Mar 2026 22:02:09 -0400 Subject: [PATCH 1/5] Add webhook CLI commands Add full CRUD support for managing webhooks on boards: list, show, create, update, delete, and reactivate. All commands require --board flag (with fallback to configured default board) and account admin access on the API side. Update agent skill documentation with webhook command reference, schema, and supported actions. --- SURFACE.txt | 133 ++++++++++ internal/commands/columns.go | 7 + internal/commands/webhook.go | 373 +++++++++++++++++++++++++++ internal/commands/webhook_test.go | 405 ++++++++++++++++++++++++++++++ internal/skills/SKILL.md | 38 ++- skills/fizzy/SKILL.md | 38 ++- 6 files changed, 990 insertions(+), 4 deletions(-) create mode 100644 internal/commands/webhook.go create mode 100644 internal/commands/webhook_test.go diff --git a/SURFACE.txt b/SURFACE.txt index 794c691..5266b1d 100644 --- a/SURFACE.txt +++ b/SURFACE.txt @@ -113,6 +113,14 @@ CMD fizzy user list CMD fizzy user show CMD fizzy user update CMD fizzy version +CMD fizzy webhook +CMD fizzy webhook create +CMD fizzy webhook delete +CMD fizzy webhook help +CMD fizzy webhook list +CMD fizzy webhook reactivate +CMD fizzy webhook show +CMD fizzy webhook update FLAG fizzy --agent type=bool FLAG fizzy --api-url type=string FLAG fizzy --count type=bool @@ -1717,6 +1725,123 @@ FLAG fizzy version --quiet type=bool FLAG fizzy version --styled type=bool FLAG fizzy version --token type=string FLAG fizzy version --verbose type=bool +FLAG fizzy webhook --agent type=bool +FLAG fizzy webhook --api-url type=string +FLAG fizzy webhook --count type=bool +FLAG fizzy webhook --help type=bool +FLAG fizzy webhook --ids-only type=bool +FLAG fizzy webhook --json type=bool +FLAG fizzy webhook --limit type=int +FLAG fizzy webhook --markdown type=bool +FLAG fizzy webhook --profile type=string +FLAG fizzy webhook --quiet type=bool +FLAG fizzy webhook --styled type=bool +FLAG fizzy webhook --token type=string +FLAG fizzy webhook --verbose type=bool +FLAG fizzy webhook create --actions type=stringSlice +FLAG fizzy webhook create --agent type=bool +FLAG fizzy webhook create --api-url type=string +FLAG fizzy webhook create --board type=string +FLAG fizzy webhook create --count type=bool +FLAG fizzy webhook create --help type=bool +FLAG fizzy webhook create --ids-only type=bool +FLAG fizzy webhook create --json type=bool +FLAG fizzy webhook create --limit type=int +FLAG fizzy webhook create --markdown type=bool +FLAG fizzy webhook create --name type=string +FLAG fizzy webhook create --profile type=string +FLAG fizzy webhook create --quiet type=bool +FLAG fizzy webhook create --styled type=bool +FLAG fizzy webhook create --token type=string +FLAG fizzy webhook create --url type=string +FLAG fizzy webhook create --verbose type=bool +FLAG fizzy webhook delete --agent type=bool +FLAG fizzy webhook delete --api-url type=string +FLAG fizzy webhook delete --board type=string +FLAG fizzy webhook delete --count type=bool +FLAG fizzy webhook delete --help type=bool +FLAG fizzy webhook delete --ids-only type=bool +FLAG fizzy webhook delete --json type=bool +FLAG fizzy webhook delete --limit type=int +FLAG fizzy webhook delete --markdown type=bool +FLAG fizzy webhook delete --profile type=string +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 help --agent type=bool +FLAG fizzy webhook help --api-url type=string +FLAG fizzy webhook help --count type=bool +FLAG fizzy webhook help --help type=bool +FLAG fizzy webhook help --ids-only type=bool +FLAG fizzy webhook help --json type=bool +FLAG fizzy webhook help --limit type=int +FLAG fizzy webhook help --markdown type=bool +FLAG fizzy webhook help --profile type=string +FLAG fizzy webhook help --quiet type=bool +FLAG fizzy webhook help --styled type=bool +FLAG fizzy webhook help --token type=string +FLAG fizzy webhook help --verbose type=bool +FLAG fizzy webhook list --agent type=bool +FLAG fizzy webhook list --all type=bool +FLAG fizzy webhook list --api-url type=string +FLAG fizzy webhook list --board type=string +FLAG fizzy webhook list --count type=bool +FLAG fizzy webhook list --help type=bool +FLAG fizzy webhook list --ids-only type=bool +FLAG fizzy webhook list --json type=bool +FLAG fizzy webhook list --limit type=int +FLAG fizzy webhook list --markdown type=bool +FLAG fizzy webhook list --page type=int +FLAG fizzy webhook list --profile type=string +FLAG fizzy webhook list --quiet type=bool +FLAG fizzy webhook list --styled type=bool +FLAG fizzy webhook list --token type=string +FLAG fizzy webhook list --verbose type=bool +FLAG fizzy webhook reactivate --agent type=bool +FLAG fizzy webhook reactivate --api-url type=string +FLAG fizzy webhook reactivate --board type=string +FLAG fizzy webhook reactivate --count type=bool +FLAG fizzy webhook reactivate --help type=bool +FLAG fizzy webhook reactivate --ids-only type=bool +FLAG fizzy webhook reactivate --json type=bool +FLAG fizzy webhook reactivate --limit type=int +FLAG fizzy webhook reactivate --markdown type=bool +FLAG fizzy webhook reactivate --profile type=string +FLAG fizzy webhook reactivate --quiet type=bool +FLAG fizzy webhook reactivate --styled type=bool +FLAG fizzy webhook reactivate --token type=string +FLAG fizzy webhook reactivate --verbose type=bool +FLAG fizzy webhook show --agent type=bool +FLAG fizzy webhook show --api-url type=string +FLAG fizzy webhook show --board type=string +FLAG fizzy webhook show --count type=bool +FLAG fizzy webhook show --help type=bool +FLAG fizzy webhook show --ids-only type=bool +FLAG fizzy webhook show --json type=bool +FLAG fizzy webhook show --limit type=int +FLAG fizzy webhook show --markdown type=bool +FLAG fizzy webhook show --profile type=string +FLAG fizzy webhook show --quiet type=bool +FLAG fizzy webhook show --styled type=bool +FLAG fizzy webhook show --token type=string +FLAG fizzy webhook show --verbose type=bool +FLAG fizzy webhook update --actions type=stringSlice +FLAG fizzy webhook update --agent type=bool +FLAG fizzy webhook update --api-url type=string +FLAG fizzy webhook update --board type=string +FLAG fizzy webhook update --count type=bool +FLAG fizzy webhook update --help type=bool +FLAG fizzy webhook update --ids-only type=bool +FLAG fizzy webhook update --json type=bool +FLAG fizzy webhook update --limit type=int +FLAG fizzy webhook update --markdown type=bool +FLAG fizzy webhook update --name type=string +FLAG fizzy webhook update --profile type=string +FLAG fizzy webhook update --quiet type=bool +FLAG fizzy webhook update --styled type=bool +FLAG fizzy webhook update --token type=string +FLAG fizzy webhook update --verbose type=bool SUB fizzy auth SUB fizzy auth help SUB fizzy auth list @@ -1831,3 +1956,11 @@ SUB fizzy user list SUB fizzy user show SUB fizzy user update SUB fizzy version +SUB fizzy webhook +SUB fizzy webhook create +SUB fizzy webhook delete +SUB fizzy webhook help +SUB fizzy webhook list +SUB fizzy webhook reactivate +SUB fizzy webhook show +SUB fizzy webhook update diff --git a/internal/commands/columns.go b/internal/commands/columns.go index 39e368b..d2a8d3a 100644 --- a/internal/commands/columns.go +++ b/internal/commands/columns.go @@ -57,4 +57,11 @@ var ( {Header: "Type", Field: "content_type"}, {Header: "Size", Field: "filesize"}, } + + webhookColumns = render.Columns{ + {Header: "ID", Field: "id"}, + {Header: "Name", Field: "name"}, + {Header: "URL", Field: "payload_url"}, + {Header: "Active", Field: "active"}, + } ) diff --git a/internal/commands/webhook.go b/internal/commands/webhook.go new file mode 100644 index 0000000..35af2da --- /dev/null +++ b/internal/commands/webhook.go @@ -0,0 +1,373 @@ +package commands + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +var webhookCmd = &cobra.Command{ + Use: "webhook", + Short: "Manage webhooks", + Long: "Commands for managing webhooks on a board. Requires account admin access.", +} + +// Webhook list flags +var webhookListBoard string +var webhookListPage int +var webhookListAll bool + +var webhookListCmd = &cobra.Command{ + Use: "list", + Short: "List webhooks for a board", + Long: "Lists all webhooks configured on a board.", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + if err := checkLimitAll(webhookListAll); err != nil { + return err + } + + boardID, err := requireBoard(webhookListBoard) + if err != nil { + return err + } + + client := getClient() + path := fmt.Sprintf("/boards/%s/webhooks.json", boardID) + if webhookListPage > 0 { + path += fmt.Sprintf("?page=%d", webhookListPage) + } + + resp, err := client.GetWithPagination(path, webhookListAll) + if err != nil { + return err + } + + count := 0 + if arr, ok := resp.Data.([]any); ok { + count = len(arr) + } + summary := fmt.Sprintf("%d webhooks", count) + if webhookListAll { + summary += " (all)" + } else if webhookListPage > 0 { + summary += fmt.Sprintf(" (page %d)", webhookListPage) + } + + breadcrumbs := []Breadcrumb{ + breadcrumb("show", "fizzy webhook show --board WEBHOOK_ID", "View webhook details"), + breadcrumb("create", fmt.Sprintf("fizzy webhook create --board %s --name \"name\" --url \"url\"", boardID), "Create webhook"), + } + + hasNext := resp.LinkNext != "" + if hasNext { + nextPage := webhookListPage + 1 + if nextPage == 0 { + nextPage = 2 + } + breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy webhook list --board %s --page %d", boardID, nextPage), "Next page")) + } + + printListPaginated(resp.Data, webhookColumns, hasNext, resp.LinkNext, webhookListAll, summary, breadcrumbs) + return nil + }, +} + +// Webhook show +var webhookShowBoard string + +var webhookShowCmd = &cobra.Command{ + Use: "show WEBHOOK_ID", + Short: "Show a webhook", + Long: "Shows details of a specific webhook.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + + boardID, err := requireBoard(webhookShowBoard) + if err != nil { + return err + } + + webhookID := args[0] + + client := getClient() + resp, err := client.Get(fmt.Sprintf("/boards/%s/webhooks/%s.json", boardID, webhookID)) + if err != nil { + return err + } + + summary := "Webhook" + if wh, ok := resp.Data.(map[string]any); ok { + if name, ok := wh["name"].(string); ok { + summary = fmt.Sprintf("Webhook: %s", name) + } + } + + breadcrumbs := []Breadcrumb{ + breadcrumb("update", fmt.Sprintf("fizzy webhook update --board %s %s --name \"name\"", boardID, webhookID), "Update webhook"), + breadcrumb("delete", fmt.Sprintf("fizzy webhook delete --board %s %s", boardID, webhookID), "Delete webhook"), + breadcrumb("reactivate", fmt.Sprintf("fizzy webhook reactivate --board %s %s", boardID, webhookID), "Reactivate webhook"), + } + + printDetail(resp.Data, summary, breadcrumbs) + return nil + }, +} + +// Webhook create flags +var webhookCreateBoard string +var webhookCreateName string +var webhookCreateURL string +var webhookCreateActions []string + +var webhookCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a webhook", + Long: `Creates a new webhook on a board. + +Supported actions: card_assigned, card_closed, card_postponed, card_auto_postponed, +card_board_changed, card_published, card_reopened, card_sent_back_to_triage, +card_triaged, card_unassigned, comment_created`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + + boardID, err := requireBoard(webhookCreateBoard) + if err != nil { + return err + } + + if webhookCreateName == "" { + return newRequiredFlagError("name") + } + if webhookCreateURL == "" { + return newRequiredFlagError("url") + } + + webhookParams := map[string]any{ + "name": webhookCreateName, + "url": webhookCreateURL, + } + + if len(webhookCreateActions) > 0 { + webhookParams["subscribed_actions"] = webhookCreateActions + } + + body := map[string]any{ + "webhook": webhookParams, + } + + client := getClient() + resp, err := client.Post(fmt.Sprintf("/boards/%s/webhooks.json", boardID), body) + if err != nil { + return err + } + + if resp.Location != "" { + followResp, err := client.FollowLocation(resp.Location) + if err == nil && followResp != nil { + webhookID := "" + if wh, ok := followResp.Data.(map[string]any); ok { + if id, ok := wh["id"].(string); ok { + webhookID = id + } + } + + var breadcrumbs []Breadcrumb + if webhookID != "" { + breadcrumbs = []Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy webhook show --board %s %s", boardID, webhookID), "View webhook"), + breadcrumb("update", fmt.Sprintf("fizzy webhook update --board %s %s --name \"name\"", boardID, webhookID), "Update webhook"), + } + } + + printMutationWithLocation(followResp.Data, resp.Location, breadcrumbs) + return nil + } + printSuccessWithLocation(resp.Location) + return nil + } + + printSuccess(resp.Data) + return nil + }, +} + +// Webhook update flags +var webhookUpdateBoard string +var webhookUpdateName string +var webhookUpdateActions []string + +var webhookUpdateCmd = &cobra.Command{ + Use: "update WEBHOOK_ID", + Short: "Update a webhook", + Long: "Updates an existing webhook. Note: URL is immutable after creation.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + + boardID, err := requireBoard(webhookUpdateBoard) + if err != nil { + return err + } + + webhookID := args[0] + + webhookParams := make(map[string]any) + + if webhookUpdateName != "" { + webhookParams["name"] = webhookUpdateName + } + if len(webhookUpdateActions) > 0 { + webhookParams["subscribed_actions"] = webhookUpdateActions + } + + body := map[string]any{ + "webhook": webhookParams, + } + + client := getClient() + resp, err := client.Patch(fmt.Sprintf("/boards/%s/webhooks/%s.json", boardID, webhookID), body) + if err != nil { + return err + } + + breadcrumbs := []Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy webhook show --board %s %s", boardID, webhookID), "View webhook"), + breadcrumb("delete", fmt.Sprintf("fizzy webhook delete --board %s %s", boardID, webhookID), "Delete webhook"), + } + + printMutation(resp.Data, "", breadcrumbs) + return nil + }, +} + +// Webhook delete +var webhookDeleteBoard string + +var webhookDeleteCmd = &cobra.Command{ + Use: "delete WEBHOOK_ID", + Short: "Delete a webhook", + Long: "Deletes a webhook from a board.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + + boardID, err := requireBoard(webhookDeleteBoard) + if err != nil { + return err + } + + client := getClient() + _, err = client.Delete(fmt.Sprintf("/boards/%s/webhooks/%s.json", boardID, args[0])) + if err != nil { + return err + } + + breadcrumbs := []Breadcrumb{ + breadcrumb("webhooks", fmt.Sprintf("fizzy webhook list --board %s", boardID), "List webhooks"), + breadcrumb("create", fmt.Sprintf("fizzy webhook create --board %s --name \"name\" --url \"url\"", boardID), "Create new webhook"), + } + + printMutation(map[string]any{ + "deleted": true, + }, "", breadcrumbs) + return nil + }, +} + +// Webhook reactivate +var webhookReactivateBoard string + +var webhookReactivateCmd = &cobra.Command{ + Use: "reactivate WEBHOOK_ID", + Short: "Reactivate a webhook", + Long: "Reactivates a deactivated webhook.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireAuthAndAccount(); err != nil { + return err + } + + boardID, err := requireBoard(webhookReactivateBoard) + if err != nil { + return err + } + + webhookID := args[0] + + client := getClient() + resp, err := client.Post(fmt.Sprintf("/boards/%s/webhooks/%s/activation.json", boardID, webhookID), nil) + if err != nil { + return err + } + + breadcrumbs := []Breadcrumb{ + breadcrumb("show", fmt.Sprintf("fizzy webhook show --board %s %s", boardID, webhookID), "View webhook"), + breadcrumb("webhooks", fmt.Sprintf("fizzy webhook list --board %s", boardID), "List webhooks"), + } + + printMutation(resp.Data, "", breadcrumbs) + return nil + }, +} + +func init() { + rootCmd.AddCommand(webhookCmd) + + // List + webhookListCmd.Flags().StringVar(&webhookListBoard, "board", "", "Board ID (required)") + webhookListCmd.Flags().IntVar(&webhookListPage, "page", 0, "Page number") + webhookListCmd.Flags().BoolVar(&webhookListAll, "all", false, "Fetch all pages") + webhookCmd.AddCommand(webhookListCmd) + + // Show + webhookShowCmd.Flags().StringVar(&webhookShowBoard, "board", "", "Board ID (required)") + webhookCmd.AddCommand(webhookShowCmd) + + // Create + webhookCreateCmd.Flags().StringVar(&webhookCreateBoard, "board", "", "Board ID (required)") + webhookCreateCmd.Flags().StringVar(&webhookCreateName, "name", "", "Webhook name (required)") + webhookCreateCmd.Flags().StringVar(&webhookCreateURL, "url", "", "Payload URL (required)") + webhookCreateCmd.Flags().StringSliceVar(&webhookCreateActions, "actions", nil, "Subscribed actions (comma-separated: "+strings.Join(validWebhookActions, ", ")+")") + webhookCmd.AddCommand(webhookCreateCmd) + + // Update + webhookUpdateCmd.Flags().StringVar(&webhookUpdateBoard, "board", "", "Board ID (required)") + webhookUpdateCmd.Flags().StringVar(&webhookUpdateName, "name", "", "Webhook name") + webhookUpdateCmd.Flags().StringSliceVar(&webhookUpdateActions, "actions", nil, "Subscribed actions (comma-separated)") + webhookCmd.AddCommand(webhookUpdateCmd) + + // Delete + webhookDeleteCmd.Flags().StringVar(&webhookDeleteBoard, "board", "", "Board ID (required)") + webhookCmd.AddCommand(webhookDeleteCmd) + + // Reactivate + webhookReactivateCmd.Flags().StringVar(&webhookReactivateBoard, "board", "", "Board ID (required)") + webhookCmd.AddCommand(webhookReactivateCmd) +} + +var validWebhookActions = []string{ + "card_assigned", + "card_closed", + "card_postponed", + "card_auto_postponed", + "card_board_changed", + "card_published", + "card_reopened", + "card_sent_back_to_triage", + "card_triaged", + "card_unassigned", + "comment_created", +} diff --git a/internal/commands/webhook_test.go b/internal/commands/webhook_test.go new file mode 100644 index 0000000..efaa511 --- /dev/null +++ b/internal/commands/webhook_test.go @@ -0,0 +1,405 @@ +package commands + +import ( + "testing" + + "github.com/basecamp/fizzy-cli/internal/client" + "github.com/basecamp/fizzy-cli/internal/errors" +) + +func TestWebhookList(t *testing.T) { + t.Run("returns list of webhooks", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{ + StatusCode: 200, + Data: []any{ + map[string]any{"id": "1", "name": "Webhook 1", "payload_url": "https://example.com/hook1", "active": true}, + map[string]any{"id": "2", "name": "Webhook 2", "payload_url": "https://example.com/hook2", "active": false}, + }, + } + + result := SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + webhookListBoard = "board-1" + err := webhookListCmd.RunE(webhookListCmd, []string{}) + webhookListBoard = "" + + assertExitCode(t, err, 0) + if !result.Response.OK { + t.Error("expected success response") + } + if len(mock.GetWithPaginationCalls) != 1 { + t.Errorf("expected 1 GetWithPagination call, got %d", len(mock.GetWithPaginationCalls)) + } + if mock.GetWithPaginationCalls[0].Path != "/boards/board-1/webhooks.json" { + t.Errorf("expected path '/boards/board-1/webhooks.json', got '%s'", mock.GetWithPaginationCalls[0].Path) + } + }) + + t.Run("requires board", func(t *testing.T) { + mock := NewMockClient() + SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + webhookListBoard = "" + err := webhookListCmd.RunE(webhookListCmd, []string{}) + + assertExitCode(t, err, errors.ExitInvalidArgs) + }) + + t.Run("requires authentication", func(t *testing.T) { + mock := NewMockClient() + SetTestMode(mock) + SetTestConfig("", "account", "https://api.example.com") + defer ResetTestMode() + + webhookListBoard = "board-1" + err := webhookListCmd.RunE(webhookListCmd, []string{}) + webhookListBoard = "" + + assertExitCode(t, err, errors.ExitAuthFailure) + }) + + t.Run("handles pagination", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{ + StatusCode: 200, + Data: []any{}, + } + + SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + webhookListBoard = "board-1" + webhookListPage = 3 + err := webhookListCmd.RunE(webhookListCmd, []string{}) + webhookListBoard = "" + webhookListPage = 0 + + assertExitCode(t, err, 0) + if mock.GetWithPaginationCalls[0].Path != "/boards/board-1/webhooks.json?page=3" { + t.Errorf("expected path with page=3, got '%s'", mock.GetWithPaginationCalls[0].Path) + } + }) +} + +func TestWebhookShow(t *testing.T) { + t.Run("shows webhook by ID", func(t *testing.T) { + mock := NewMockClient() + mock.GetResponse = &client.APIResponse{ + StatusCode: 200, + Data: map[string]any{ + "id": "wh-1", + "name": "Production", + "payload_url": "https://example.com/hook", + "active": true, + "subscribed_actions": []any{"card_published", "card_closed"}, + }, + } + + result := SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + webhookShowBoard = "board-1" + err := webhookShowCmd.RunE(webhookShowCmd, []string{"wh-1"}) + webhookShowBoard = "" + + assertExitCode(t, err, 0) + if !result.Response.OK { + t.Error("expected success response") + } + if mock.GetCalls[0].Path != "/boards/board-1/webhooks/wh-1.json" { + t.Errorf("expected path '/boards/board-1/webhooks/wh-1.json', got '%s'", mock.GetCalls[0].Path) + } + }) + + t.Run("handles not found", func(t *testing.T) { + mock := NewMockClient() + mock.GetError = errors.NewNotFoundError("Webhook not found") + + SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + webhookShowBoard = "board-1" + err := webhookShowCmd.RunE(webhookShowCmd, []string{"bad-id"}) + webhookShowBoard = "" + + assertExitCode(t, err, errors.ExitNotFound) + }) +} + +func TestWebhookCreate(t *testing.T) { + t.Run("creates webhook with name and url", func(t *testing.T) { + mock := NewMockClient() + mock.PostResponse = &client.APIResponse{ + StatusCode: 201, + Location: "https://api.example.com/boards/board-1/webhooks/wh-new", + Data: map[string]any{"id": "wh-new"}, + } + mock.FollowLocationResponse = &client.APIResponse{ + StatusCode: 200, + Data: map[string]any{ + "id": "wh-new", + "name": "My Hook", + "payload_url": "https://example.com/hook", + "active": true, + }, + } + + result := SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + webhookCreateBoard = "board-1" + webhookCreateName = "My Hook" + webhookCreateURL = "https://example.com/hook" + err := webhookCreateCmd.RunE(webhookCreateCmd, []string{}) + webhookCreateBoard = "" + webhookCreateName = "" + webhookCreateURL = "" + + assertExitCode(t, err, 0) + if !result.Response.OK { + t.Error("expected success response") + } + if mock.PostCalls[0].Path != "/boards/board-1/webhooks.json" { + t.Errorf("expected path '/boards/board-1/webhooks.json', got '%s'", mock.PostCalls[0].Path) + } + + body := mock.PostCalls[0].Body.(map[string]any) + webhookParams := body["webhook"].(map[string]any) + if webhookParams["name"] != "My Hook" { + t.Errorf("expected name 'My Hook', got '%v'", webhookParams["name"]) + } + if webhookParams["url"] != "https://example.com/hook" { + t.Errorf("expected url 'https://example.com/hook', got '%v'", webhookParams["url"]) + } + }) + + t.Run("creates webhook with actions", func(t *testing.T) { + mock := NewMockClient() + mock.PostResponse = &client.APIResponse{ + StatusCode: 201, + Location: "https://api.example.com/boards/board-1/webhooks/wh-new", + } + mock.FollowLocationResponse = &client.APIResponse{ + StatusCode: 200, + Data: map[string]any{"id": "wh-new"}, + } + + SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + webhookCreateBoard = "board-1" + webhookCreateName = "My Hook" + webhookCreateURL = "https://example.com/hook" + webhookCreateActions = []string{"card_published", "card_closed"} + err := webhookCreateCmd.RunE(webhookCreateCmd, []string{}) + webhookCreateBoard = "" + webhookCreateName = "" + webhookCreateURL = "" + webhookCreateActions = nil + + assertExitCode(t, err, 0) + + body := mock.PostCalls[0].Body.(map[string]any) + webhookParams := body["webhook"].(map[string]any) + actions := webhookParams["subscribed_actions"].([]string) + if len(actions) != 2 || actions[0] != "card_published" || actions[1] != "card_closed" { + t.Errorf("expected actions [card_published, card_closed], got %v", actions) + } + }) + + t.Run("requires name flag", func(t *testing.T) { + mock := NewMockClient() + SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + webhookCreateBoard = "board-1" + webhookCreateName = "" + webhookCreateURL = "https://example.com/hook" + err := webhookCreateCmd.RunE(webhookCreateCmd, []string{}) + webhookCreateBoard = "" + webhookCreateURL = "" + + assertExitCode(t, err, errors.ExitInvalidArgs) + }) + + t.Run("requires url flag", func(t *testing.T) { + mock := NewMockClient() + SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + webhookCreateBoard = "board-1" + webhookCreateName = "My Hook" + webhookCreateURL = "" + err := webhookCreateCmd.RunE(webhookCreateCmd, []string{}) + webhookCreateBoard = "" + webhookCreateName = "" + + assertExitCode(t, err, errors.ExitInvalidArgs) + }) +} + +func TestWebhookUpdate(t *testing.T) { + t.Run("updates webhook name", func(t *testing.T) { + mock := NewMockClient() + mock.PatchResponse = &client.APIResponse{ + StatusCode: 200, + Data: map[string]any{ + "id": "wh-1", + "name": "Updated Name", + }, + } + + SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + webhookUpdateBoard = "board-1" + webhookUpdateName = "Updated Name" + err := webhookUpdateCmd.RunE(webhookUpdateCmd, []string{"wh-1"}) + webhookUpdateBoard = "" + webhookUpdateName = "" + + assertExitCode(t, err, 0) + if mock.PatchCalls[0].Path != "/boards/board-1/webhooks/wh-1.json" { + t.Errorf("expected path '/boards/board-1/webhooks/wh-1.json', got '%s'", mock.PatchCalls[0].Path) + } + + body := mock.PatchCalls[0].Body.(map[string]any) + webhookParams := body["webhook"].(map[string]any) + if webhookParams["name"] != "Updated Name" { + t.Errorf("expected name 'Updated Name', got '%v'", webhookParams["name"]) + } + }) + + t.Run("updates webhook actions", func(t *testing.T) { + mock := NewMockClient() + mock.PatchResponse = &client.APIResponse{ + StatusCode: 200, + Data: map[string]any{"id": "wh-1"}, + } + + SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + webhookUpdateBoard = "board-1" + webhookUpdateActions = []string{"card_closed"} + err := webhookUpdateCmd.RunE(webhookUpdateCmd, []string{"wh-1"}) + webhookUpdateBoard = "" + webhookUpdateActions = nil + + assertExitCode(t, err, 0) + + body := mock.PatchCalls[0].Body.(map[string]any) + webhookParams := body["webhook"].(map[string]any) + actions := webhookParams["subscribed_actions"].([]string) + if len(actions) != 1 || actions[0] != "card_closed" { + t.Errorf("expected actions [card_closed], got %v", actions) + } + }) + + t.Run("handles API error", func(t *testing.T) { + mock := NewMockClient() + mock.PatchError = errors.NewValidationError("Invalid webhook") + + SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + webhookUpdateBoard = "board-1" + webhookUpdateName = "Test" + err := webhookUpdateCmd.RunE(webhookUpdateCmd, []string{"wh-1"}) + webhookUpdateBoard = "" + webhookUpdateName = "" + + assertExitCode(t, err, errors.ExitValidation) + }) +} + +func TestWebhookDelete(t *testing.T) { + t.Run("deletes webhook", func(t *testing.T) { + mock := NewMockClient() + mock.DeleteResponse = &client.APIResponse{ + StatusCode: 204, + Data: map[string]any{}, + } + + SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + webhookDeleteBoard = "board-1" + err := webhookDeleteCmd.RunE(webhookDeleteCmd, []string{"wh-1"}) + webhookDeleteBoard = "" + + assertExitCode(t, err, 0) + if mock.DeleteCalls[0].Path != "/boards/board-1/webhooks/wh-1.json" { + t.Errorf("expected path '/boards/board-1/webhooks/wh-1.json', got '%s'", mock.DeleteCalls[0].Path) + } + }) + + t.Run("handles not found", func(t *testing.T) { + mock := NewMockClient() + mock.DeleteError = errors.NewNotFoundError("Webhook not found") + + SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + webhookDeleteBoard = "board-1" + err := webhookDeleteCmd.RunE(webhookDeleteCmd, []string{"bad-id"}) + webhookDeleteBoard = "" + + assertExitCode(t, err, errors.ExitNotFound) + }) +} + +func TestWebhookReactivate(t *testing.T) { + t.Run("reactivates webhook", func(t *testing.T) { + mock := NewMockClient() + mock.PostResponse = &client.APIResponse{ + StatusCode: 201, + Data: map[string]any{ + "id": "wh-1", + "active": true, + }, + } + + SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + webhookReactivateBoard = "board-1" + err := webhookReactivateCmd.RunE(webhookReactivateCmd, []string{"wh-1"}) + webhookReactivateBoard = "" + + assertExitCode(t, err, 0) + if mock.PostCalls[0].Path != "/boards/board-1/webhooks/wh-1/activation.json" { + t.Errorf("expected path '/boards/board-1/webhooks/wh-1/activation.json', got '%s'", mock.PostCalls[0].Path) + } + }) + + t.Run("requires board", func(t *testing.T) { + mock := NewMockClient() + SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + webhookReactivateBoard = "" + err := webhookReactivateCmd.RunE(webhookReactivateCmd, []string{"wh-1"}) + + assertExitCode(t, err, errors.ExitInvalidArgs) + }) +} diff --git a/internal/skills/SKILL.md b/internal/skills/SKILL.md index 847e668..ea839a0 100644 --- a/internal/skills/SKILL.md +++ b/internal/skills/SKILL.md @@ -2,7 +2,7 @@ name: fizzy description: | Interact with Fizzy via the Fizzy CLI. Manage boards, cards, columns, comments, - steps, reactions, tags, users, notifications, and pins. Use for ANY Fizzy question or action. + steps, reactions, tags, users, notifications, pins, and webhooks. Use for ANY Fizzy question or action. triggers: # Direct invocations - fizzy @@ -16,6 +16,7 @@ triggers: - fizzy reaction - fizzy tag - fizzy notification + - fizzy webhook # Common actions - link to fizzy - track in fizzy @@ -52,7 +53,7 @@ argument-hint: "[action] [args...]" # /fizzy - Fizzy Workflow Command -Full CLI coverage: boards, cards, columns, comments, steps, reactions, tags, users, notifications, pins, search, and board migration. +Full CLI coverage: boards, cards, columns, comments, steps, reactions, tags, users, notifications, pins, webhooks, search, and board migration. ## Agent Invariants @@ -107,6 +108,7 @@ Want to change something? | user | `user list` | `user show ID` | - | `user update ID` | - | `user deactivate ID` | | notification | `notification list` | - | - | - | - | `notification tray`, `notification read-all` | | 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` | --- @@ -158,6 +160,7 @@ Commands supporting `--all` and `--page`: - `tag list` - `user list` - `notification list` +- `webhook list` --- @@ -693,6 +696,37 @@ fizzy notification read-all fizzy notification unread NOTIFICATION_ID ``` +### Webhooks + +Webhooks notify external services when events occur on a board. Requires account admin access. + +```bash +fizzy webhook list --board 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,...] +fizzy webhook delete WEBHOOK_ID --board ID +fizzy webhook reactivate WEBHOOK_ID --board ID # Reactivate a deactivated webhook +``` + +**Supported actions:** `card_assigned`, `card_closed`, `card_postponed`, `card_auto_postponed`, `card_board_changed`, `card_published`, `card_reopened`, `card_sent_back_to_triage`, `card_triaged`, `card_unassigned`, `comment_created` + +**Note:** Webhook URL is immutable after creation. Use `--actions` with comma-separated values. + +### Webhook Schema + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Webhook ID (use for CLI commands) | +| `name` | string | Webhook name | +| `payload_url` | string | Destination URL | +| `active` | boolean | Whether webhook is active | +| `signing_secret` | string | Secret for verifying payloads | +| `subscribed_actions` | array | List of subscribed event actions | +| `created_at` | timestamp | ISO 8601 | +| `url` | string | API URL | +| `board` | object | Nested Board | + ### File Uploads ```bash diff --git a/skills/fizzy/SKILL.md b/skills/fizzy/SKILL.md index 847e668..ea839a0 100644 --- a/skills/fizzy/SKILL.md +++ b/skills/fizzy/SKILL.md @@ -2,7 +2,7 @@ name: fizzy description: | Interact with Fizzy via the Fizzy CLI. Manage boards, cards, columns, comments, - steps, reactions, tags, users, notifications, and pins. Use for ANY Fizzy question or action. + steps, reactions, tags, users, notifications, pins, and webhooks. Use for ANY Fizzy question or action. triggers: # Direct invocations - fizzy @@ -16,6 +16,7 @@ triggers: - fizzy reaction - fizzy tag - fizzy notification + - fizzy webhook # Common actions - link to fizzy - track in fizzy @@ -52,7 +53,7 @@ argument-hint: "[action] [args...]" # /fizzy - Fizzy Workflow Command -Full CLI coverage: boards, cards, columns, comments, steps, reactions, tags, users, notifications, pins, search, and board migration. +Full CLI coverage: boards, cards, columns, comments, steps, reactions, tags, users, notifications, pins, webhooks, search, and board migration. ## Agent Invariants @@ -107,6 +108,7 @@ Want to change something? | user | `user list` | `user show ID` | - | `user update ID` | - | `user deactivate ID` | | notification | `notification list` | - | - | - | - | `notification tray`, `notification read-all` | | 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` | --- @@ -158,6 +160,7 @@ Commands supporting `--all` and `--page`: - `tag list` - `user list` - `notification list` +- `webhook list` --- @@ -693,6 +696,37 @@ fizzy notification read-all fizzy notification unread NOTIFICATION_ID ``` +### Webhooks + +Webhooks notify external services when events occur on a board. Requires account admin access. + +```bash +fizzy webhook list --board 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,...] +fizzy webhook delete WEBHOOK_ID --board ID +fizzy webhook reactivate WEBHOOK_ID --board ID # Reactivate a deactivated webhook +``` + +**Supported actions:** `card_assigned`, `card_closed`, `card_postponed`, `card_auto_postponed`, `card_board_changed`, `card_published`, `card_reopened`, `card_sent_back_to_triage`, `card_triaged`, `card_unassigned`, `comment_created` + +**Note:** Webhook URL is immutable after creation. Use `--actions` with comma-separated values. + +### Webhook Schema + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Webhook ID (use for CLI commands) | +| `name` | string | Webhook name | +| `payload_url` | string | Destination URL | +| `active` | boolean | Whether webhook is active | +| `signing_secret` | string | Secret for verifying payloads | +| `subscribed_actions` | array | List of subscribed event actions | +| `created_at` | timestamp | ISO 8601 | +| `url` | string | API URL | +| `board` | object | Nested Board | + ### File Uploads ```bash From 6cfde6c5f1307f84023acfb5d5f2c2cefecb7248 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 9 Mar 2026 22:07:49 -0400 Subject: [PATCH 2/5] Fix next-page breadcrumb when --page is not specified When --page defaults to 0, the next-page breadcrumb should point to page 2 (since page 1 is what the user is already viewing). The guard condition was checking nextPage == 0 which is unreachable; check webhookListPage == 0 instead. --- internal/commands/webhook.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/commands/webhook.go b/internal/commands/webhook.go index 35af2da..cb0e64a 100644 --- a/internal/commands/webhook.go +++ b/internal/commands/webhook.go @@ -65,7 +65,7 @@ var webhookListCmd = &cobra.Command{ hasNext := resp.LinkNext != "" if hasNext { nextPage := webhookListPage + 1 - if nextPage == 0 { + if webhookListPage == 0 { nextPage = 2 } breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy webhook list --board %s --page %d", boardID, nextPage), "Next page")) From f9fa57de3d7b1446e17ca5652ff9946637483516 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 9 Mar 2026 22:12:56 -0400 Subject: [PATCH 3/5] Revert next-page guard to match existing list command pattern Keep the nextPage == 0 guard consistent with board list, card list, and other paginated commands. --- internal/commands/webhook.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/commands/webhook.go b/internal/commands/webhook.go index cb0e64a..35af2da 100644 --- a/internal/commands/webhook.go +++ b/internal/commands/webhook.go @@ -65,7 +65,7 @@ var webhookListCmd = &cobra.Command{ hasNext := resp.LinkNext != "" if hasNext { nextPage := webhookListPage + 1 - if webhookListPage == 0 { + if nextPage == 0 { nextPage = 2 } breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy webhook list --board %s --page %d", boardID, nextPage), "Next page")) From 1295254323414ab2ac1b1066750bdf293c018453 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 9 Mar 2026 22:16:05 -0400 Subject: [PATCH 4/5] Fix next-page breadcrumb bug in all paginated list commands The guard `if nextPage == 0` was unreachable dead code since nextPage is always at least 1 (page variable defaults to 0, plus 1). This caused the "next page" breadcrumb to point to page 1 when the user was already viewing page 1 (the default). Fix by checking the page variable itself instead, and add a test that asserts the breadcrumb points to page 2. --- internal/commands/board.go | 2 +- internal/commands/board_test.go | 33 +++++++++++++++++++++++++++++++ internal/commands/card.go | 2 +- internal/commands/notification.go | 2 +- internal/commands/tag.go | 2 +- internal/commands/user.go | 2 +- internal/commands/webhook.go | 2 +- 7 files changed, 39 insertions(+), 6 deletions(-) diff --git a/internal/commands/board.go b/internal/commands/board.go index e34dcaa..042427e 100644 --- a/internal/commands/board.go +++ b/internal/commands/board.go @@ -62,7 +62,7 @@ var boardListCmd = &cobra.Command{ hasNext := resp.LinkNext != "" if hasNext { nextPage := boardListPage + 1 - if nextPage == 0 { + if boardListPage == 0 { nextPage = 2 } breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy board list --page %d", nextPage), "Next page")) diff --git a/internal/commands/board_test.go b/internal/commands/board_test.go index c7706fd..f7d6a93 100644 --- a/internal/commands/board_test.go +++ b/internal/commands/board_test.go @@ -59,6 +59,39 @@ func TestBoardList(t *testing.T) { assertExitCode(t, err, 0) }) + t.Run("next page breadcrumb points to page 2 when page not specified", func(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{ + StatusCode: 200, + Data: []any{map[string]any{"id": "1", "name": "Board 1"}}, + LinkNext: "https://api.example.com/boards.json?page=2", + } + + result := SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + boardListPage = 0 + boardListAll = false + err := boardListCmd.RunE(boardListCmd, []string{}) + boardListPage = 0 + + assertExitCode(t, err, 0) + + found := false + for _, bc := range result.Response.Breadcrumbs { + if bc.Action == "next" { + found = true + if bc.Cmd != "fizzy board list --page 2" { + t.Errorf("expected next breadcrumb 'fizzy board list --page 2', got '%s'", bc.Cmd) + } + } + } + if !found { + t.Error("expected 'next' breadcrumb but none found") + } + }) + t.Run("handles double-digit page numbers", func(t *testing.T) { mock := NewMockClient() mock.GetWithPaginationResponse = &client.APIResponse{ diff --git a/internal/commands/card.go b/internal/commands/card.go index a7dafc1..bff78ff 100644 --- a/internal/commands/card.go +++ b/internal/commands/card.go @@ -199,7 +199,7 @@ var cardListCmd = &cobra.Command{ hasNext := resp.LinkNext != "" if hasNext { nextPage := cardListPage + 1 - if nextPage == 0 { + if cardListPage == 0 { nextPage = 2 } breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy card list --page %d", nextPage), "Next page")) diff --git a/internal/commands/notification.go b/internal/commands/notification.go index ed2e508..7b841ff 100644 --- a/internal/commands/notification.go +++ b/internal/commands/notification.go @@ -70,7 +70,7 @@ var notificationListCmd = &cobra.Command{ hasNext := resp.LinkNext != "" if hasNext { nextPage := notificationListPage + 1 - if nextPage == 0 { + if notificationListPage == 0 { nextPage = 2 } breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy notification list --page %d", nextPage), "Next page")) diff --git a/internal/commands/tag.go b/internal/commands/tag.go index 40df008..31f2901 100644 --- a/internal/commands/tag.go +++ b/internal/commands/tag.go @@ -61,7 +61,7 @@ var tagListCmd = &cobra.Command{ hasNext := resp.LinkNext != "" if hasNext { nextPage := tagListPage + 1 - if nextPage == 0 { + if tagListPage == 0 { nextPage = 2 } breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy tag list --page %d", nextPage), "Next page")) diff --git a/internal/commands/user.go b/internal/commands/user.go index 890a499..9c413a9 100644 --- a/internal/commands/user.go +++ b/internal/commands/user.go @@ -61,7 +61,7 @@ var userListCmd = &cobra.Command{ hasNext := resp.LinkNext != "" if hasNext { nextPage := userListPage + 1 - if nextPage == 0 { + if userListPage == 0 { nextPage = 2 } breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy user list --page %d", nextPage), "Next page")) diff --git a/internal/commands/webhook.go b/internal/commands/webhook.go index 35af2da..cb0e64a 100644 --- a/internal/commands/webhook.go +++ b/internal/commands/webhook.go @@ -65,7 +65,7 @@ var webhookListCmd = &cobra.Command{ hasNext := resp.LinkNext != "" if hasNext { nextPage := webhookListPage + 1 - if nextPage == 0 { + if webhookListPage == 0 { nextPage = 2 } breadcrumbs = append(breadcrumbs, breadcrumb("next", fmt.Sprintf("fizzy webhook list --board %s --page %d", boardID, nextPage), "Next page")) From a1490fe5e29dbbdeaab5bcab99cb0795ad97cbfe Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 9 Mar 2026 22:23:51 -0400 Subject: [PATCH 5/5] DRY up supported actions list in webhook commands Generate the Long description and flag help text from validWebhookActions instead of duplicating the list. Also add supported actions to the update command's --actions flag help. --- internal/commands/webhook.go | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/internal/commands/webhook.go b/internal/commands/webhook.go index cb0e64a..7dbef35 100644 --- a/internal/commands/webhook.go +++ b/internal/commands/webhook.go @@ -7,6 +7,20 @@ import ( "github.com/spf13/cobra" ) +var validWebhookActions = []string{ + "card_assigned", + "card_closed", + "card_postponed", + "card_auto_postponed", + "card_board_changed", + "card_published", + "card_reopened", + "card_sent_back_to_triage", + "card_triaged", + "card_unassigned", + "comment_created", +} + var webhookCmd = &cobra.Command{ Use: "webhook", Short: "Manage webhooks", @@ -129,11 +143,7 @@ var webhookCreateActions []string var webhookCreateCmd = &cobra.Command{ Use: "create", Short: "Create a webhook", - Long: `Creates a new webhook on a board. - -Supported actions: card_assigned, card_closed, card_postponed, card_auto_postponed, -card_board_changed, card_published, card_reopened, card_sent_back_to_triage, -card_triaged, card_unassigned, comment_created`, + Long: "Creates a new webhook on a board.\n\nSupported actions: " + strings.Join(validWebhookActions, ", "), RunE: func(cmd *cobra.Command, args []string) error { if err := requireAuthAndAccount(); err != nil { return err @@ -346,7 +356,7 @@ func init() { // Update webhookUpdateCmd.Flags().StringVar(&webhookUpdateBoard, "board", "", "Board ID (required)") webhookUpdateCmd.Flags().StringVar(&webhookUpdateName, "name", "", "Webhook name") - webhookUpdateCmd.Flags().StringSliceVar(&webhookUpdateActions, "actions", nil, "Subscribed actions (comma-separated)") + webhookUpdateCmd.Flags().StringSliceVar(&webhookUpdateActions, "actions", nil, "Subscribed actions (comma-separated: "+strings.Join(validWebhookActions, ", ")+")") webhookCmd.AddCommand(webhookUpdateCmd) // Delete @@ -357,17 +367,3 @@ func init() { webhookReactivateCmd.Flags().StringVar(&webhookReactivateBoard, "board", "", "Board ID (required)") webhookCmd.AddCommand(webhookReactivateCmd) } - -var validWebhookActions = []string{ - "card_assigned", - "card_closed", - "card_postponed", - "card_auto_postponed", - "card_board_changed", - "card_published", - "card_reopened", - "card_sent_back_to_triage", - "card_triaged", - "card_unassigned", - "comment_created", -}