diff --git a/.surface b/.surface index 18b76b40..62cc22d4 100644 --- a/.surface +++ b/.surface @@ -812,6 +812,7 @@ CMD basecamp msgs trash CMD basecamp msgs unpin CMD basecamp msgs update CMD basecamp notifications +CMD basecamp notifications bubbleups CMD basecamp notifications list CMD basecamp notifications read CMD basecamp people @@ -10395,6 +10396,28 @@ FLAG basecamp notifications --stats type=bool FLAG basecamp notifications --styled type=bool FLAG basecamp notifications --todolist type=string FLAG basecamp notifications --verbose type=count +FLAG basecamp notifications bubbleups --account type=string +FLAG basecamp notifications bubbleups --agent type=bool +FLAG basecamp notifications bubbleups --cache-dir type=string +FLAG basecamp notifications bubbleups --count type=bool +FLAG basecamp notifications bubbleups --help type=bool +FLAG basecamp notifications bubbleups --hints type=bool +FLAG basecamp notifications bubbleups --ids-only type=bool +FLAG basecamp notifications bubbleups --in type=string +FLAG basecamp notifications bubbleups --jq type=string +FLAG basecamp notifications bubbleups --json type=bool +FLAG basecamp notifications bubbleups --markdown type=bool +FLAG basecamp notifications bubbleups --md type=bool +FLAG basecamp notifications bubbleups --no-hints type=bool +FLAG basecamp notifications bubbleups --no-stats type=bool +FLAG basecamp notifications bubbleups --page type=int32 +FLAG basecamp notifications bubbleups --profile type=string +FLAG basecamp notifications bubbleups --project type=string +FLAG basecamp notifications bubbleups --quiet type=bool +FLAG basecamp notifications bubbleups --stats type=bool +FLAG basecamp notifications bubbleups --styled type=bool +FLAG basecamp notifications bubbleups --todolist type=string +FLAG basecamp notifications bubbleups --verbose type=count FLAG basecamp notifications list --account type=string FLAG basecamp notifications list --agent type=bool FLAG basecamp notifications list --cache-dir type=string @@ -10405,6 +10428,7 @@ FLAG basecamp notifications list --ids-only type=bool FLAG basecamp notifications list --in type=string FLAG basecamp notifications list --jq type=string FLAG basecamp notifications list --json type=bool +FLAG basecamp notifications list --limit-bubble-ups type=bool FLAG basecamp notifications list --markdown type=bool FLAG basecamp notifications list --md type=bool FLAG basecamp notifications list --no-hints type=bool @@ -13935,6 +13959,7 @@ FLAG basecamp tools create --styled type=bool FLAG basecamp tools create --todolist type=string FLAG basecamp tools create --type type=string FLAG basecamp tools create --verbose type=count +FLAG basecamp tools create --visible-to-clients type=bool FLAG basecamp tools delete --account type=string FLAG basecamp tools delete --agent type=bool FLAG basecamp tools delete --cache-dir type=string @@ -16812,6 +16837,7 @@ SUB basecamp msgs trash SUB basecamp msgs unpin SUB basecamp msgs update SUB basecamp notifications +SUB basecamp notifications bubbleups SUB basecamp notifications list SUB basecamp notifications read SUB basecamp people diff --git a/API-COVERAGE.md b/API-COVERAGE.md index f47b742e..355d4ad5 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -6,11 +6,11 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. | Status | Sections | Endpoints | |--------|----------|-----------| -| ✅ Implemented | 44 | 166 | +| ✅ Implemented | 45 | 167 | | ⏭️ Out of scope | 4 | 12 | -| **Total tracked** | **48** | **178** | +| **Total tracked** | **49** | **179** | -**100% coverage of tracked in-scope API** (166/166 endpoints). This is not a +**100% coverage of tracked in-scope API** (167/167 endpoints). This is not a complete bc-api parity figure; the other five BC5 sections introduced by bc-api#410 remain untracked and outside this coverage matrix. @@ -18,7 +18,7 @@ Out-of-scope sections are excluded from parity totals and scripts: chatbots (dif > Note: the per-row `Endpoints` column in the Coverage by Section table sums higher than the Summary totals above. The discrepancy predates the BC5 baseline; the row count (48 sections) is authoritative for the `Since` column. Reconciling endpoint counts is pre-existing maintenance, tracked separately. -**SDK version:** v0.8.1-0.20260725044648-3d4ec395922c — adds the `Wormholes` service (`Create`/`Update`/`Delete`) and decodes `CardTable.Wormholes` (`card_table_wormholes`, basecamp/basecamp-sdk#397), consumed by `cards wormholes` and `cards move --to-wormhole`; also carries `Todolists.Reposition` (`PUT /todosets/todolists/{id}/position.json`, basecamp/basecamp-sdk#398) behind `todolists position`, create-time `visible_to_clients` support behind `todolists create --visible-to-clients`, `Campfires.UpdateLine` (`PUT /chats/{c}/lines/{l}`, basecamp/basecamp-sdk#295) behind `chat update`, and the BC5 `Tools.Create` create-by-type reshape. API date 2026-07-24. +**SDK version:** v0.9.1-0.20260727173625-bb363c847b92 — reshapes `MessageTypes` to the bucket-scoped categories API (`/buckets/{id}/categories…`, basecamp/basecamp-sdk#393), consumed by the now project-scoped `messagetypes` commands; adds the Bubble Ups successor surface (`MyNotifications.BubbleUps` via `GET /my/readings/bubble_ups.json` + `GetWithOptions`/`WithLimitBubbleUps`, basecamp/basecamp-sdk#426) behind `notifications bubbleups` and `notifications list --limit-bubble-ups`; adds create-time `visible_to_clients` on `Tools.Create` (basecamp/basecamp-sdk#419) behind `tools create --visible-to-clients`; and carries model-only Door fields on Recording (basecamp/basecamp-sdk#431) and activity-timeline event fields (basecamp/basecamp-sdk#424) that flow through `--json`. API date 2026-07-26. ## Coverage by Section @@ -32,18 +32,19 @@ The **Since** column tags each row with the Basecamp version that introduced its | todolists | 9 | `todolists` | ✅ | BC4 | - | list, show, create, update, position | | todosets | 3 | `todosets` | ✅ | BC4 | - | Container for todolists, accessed via project dock (BC5: `todos_count`, `completed_loose_todos_count`, `todos_url`, `app_todos_url`) | | todolist_groups | 8 | `todolistgroups` | ✅ | BC4 | - | list, show, create, update, position | -| dock_tools | 7 | `tools` | ✅ | BC4 | - | Dock tool management: show, update, trash, enable, disable, reposition. `create` is BC5-only (create-by-type: `POST /buckets/{id}/dock/tools.json`), replacing the removed clone call | +| dock_tools | 7 | `tools` | ✅ | BC4 | - | Dock tool management: show, update, trash, enable, disable, reposition. `create` is BC5-only (create-by-type: `POST /buckets/{id}/dock/tools.json`), replacing the removed clone call; create-time `visible_to_clients` behind `tools create --visible-to-clients` (chat/kanban only) | | **Hill Charts** | | hill_charts | 2 | `hillcharts` | ✅ | BC4 | - | show, track/untrack todolists | | gauges | 7 | `gauges` | ✅ | BC4 | - | list, needles, needle, create, update, delete, enable/disable | | **Communication** | | messages | 10 | `messages`, `message` | ✅ | BC4 | - | list, show, create, update, publish, pin, unpin. Create supports `--subscribe`/`--no-subscribe` and `--draft`. Publish promotes drafts to active | | message_boards | 3 | `messageboards` | ✅ | BC4 | - | Container, accessed via project dock | -| message_types | 5 | `messagetypes` | ✅ | BC4 | - | list, show, create, update, delete | +| message_types | 5 | `messagetypes` | ✅ | BC4 | - | list, show, create, update, delete. Bucket-scoped (`/buckets/{id}/categories…`); commands are project-scoped via `--in`/`--project` | | campfires | 14 | `chat` | ✅ | BC4 | - | list, messages, post, line show/update/delete. @mentions in content | | comments | 8 | `comment`, `comments` | ✅ | BC4 | - | list, show, create, update. @mentions in content | | boosts | 6 | `boost`, `react` | ✅ | BC4 | - | list (recording + event), show, create (recording + event), delete | | notifications | 2 | `notifications` | ✅ | BC4 | - | list, mark as read (BC5: `bubble_ups`/`scheduled_bubble_ups` sections; `memories` is BC4-only) | +| bubble_ups | 1 | `notifications bubbleups` | ✅ | BC5 | - | Dedicated Bubble Ups list (`GET /my/readings/bubble_ups.json`, paginated) plus the `limit_bubble_ups` variant behind `notifications list --limit-bubble-ups` | | **Cards (Kanban)** | | card_tables | 3 | `cards` | ✅ | BC4 | - | Accessed via project dock | | card_table_cards | 9 | `cards` | ✅ | BC4 | - | list, show, create, update, move | diff --git a/e2e/messagetypes.bats b/e2e/messagetypes.bats index f6816de4..5e4b33b4 100644 --- a/e2e/messagetypes.bats +++ b/e2e/messagetypes.bats @@ -13,6 +13,30 @@ load test_helper } +# Missing project errors + +@test "messagetypes list without project shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp messagetypes list + assert_failure + assert_output_contains "project" +} + + +# Flag parsing errors + +@test "messagetypes --project without value shows error" { + create_credentials + create_global_config '{}' + + run basecamp messagetypes list --project + assert_failure + assert_output_contains "--project requires a value" +} + + # Missing context errors @test "messagetypes show without id shows error" { diff --git a/e2e/smoke/.qa-allowlist b/e2e/smoke/.qa-allowlist index 166ccaa8..693f7c9b 100644 --- a/e2e/smoke/.qa-allowlist +++ b/e2e/smoke/.qa-allowlist @@ -4,7 +4,7 @@ # # Format: # -# Message types — account-wide feature, no create command in scope +# Message types — per-project categories, no create command in scope test_messagetypes_list_returns_message_types # not_found on some servers — smoke test_messagetypes_show_returns_message_type_detail # depends on messagetypes list — smoke @@ -39,3 +39,6 @@ test_cards_wormholes_create_needs_a_destination_table_on_another_board # no seco test_cards_wormholes_update_needs_an_existing_wormhole_and_destination # depends on wormhole create — smoke test_cards_wormholes_delete_needs_an_existing_wormhole # depends on wormhole create — smoke test_cards_move_to_wormhole_needs_a_linked_wormhole_and_is_destructive # needs linked wormhole + destructive async — smoke + +# Bubble Ups — BC5-added endpoint, may not exist on all QA environments +test_notifications_bubbleups_lists_bubble_ups # endpoint not available on some servers — smoke diff --git a/e2e/smoke/smoke_communication.bats b/e2e/smoke/smoke_communication.bats index 1007aca3..03c9a5c5 100644 --- a/e2e/smoke/smoke_communication.bats +++ b/e2e/smoke/smoke_communication.bats @@ -8,10 +8,10 @@ setup_file() { ensure_project || return 1 } -# --- Message types (account-wide) --- +# --- Message types (per-project) --- @test "messagetypes list returns message types" { - run_smoke basecamp messagetypes list --json + run_smoke basecamp messagetypes list -p "$QA_PROJECT" --json # Message types may not exist on all environments [[ "$status" -ne 0 ]] && mark_unverifiable "Message types not available" assert_json_value '.ok' 'true' @@ -27,7 +27,7 @@ setup_file() { mt_id=$(<"$id_file") [[ -n "$mt_id" ]] || mark_unverifiable "Messagetype ID is empty" - run_smoke basecamp messagetypes show "$mt_id" --json + run_smoke basecamp messagetypes show "$mt_id" -p "$QA_PROJECT" --json assert_success assert_json_value '.ok' 'true' assert_json_not_null '.data.id' diff --git a/e2e/smoke/smoke_notifications.bats b/e2e/smoke/smoke_notifications.bats index d7d14ff6..dcb73b83 100644 --- a/e2e/smoke/smoke_notifications.bats +++ b/e2e/smoke/smoke_notifications.bats @@ -18,3 +18,10 @@ setup_file() { assert_failure assert_output_contains "not found" } + +@test "notifications bubbleups lists bubble ups" { + run_smoke basecamp notifications bubbleups --json + # Bubble Ups are a BC5-added endpoint; may not exist on all QA environments + [[ "$status" -ne 0 ]] && mark_unverifiable "Bubble Ups endpoint not available" + assert_json_value '.ok' 'true' +} diff --git a/e2e/tools.bats b/e2e/tools.bats index 9a6baf9b..62a53972 100644 --- a/e2e/tools.bats +++ b/e2e/tools.bats @@ -31,3 +31,14 @@ load test_helper assert_success assert_output_contains "basecamp tools " } + +# Create validation + +@test "tools create --visible-to-clients without --type shows usage error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp tools create --visible-to-clients + assert_failure + assert_output_contains "--type is required" +} diff --git a/go.mod b/go.mod index 315978b1..2485c3b5 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( charm.land/bubbles/v2 v2.1.1 charm.land/bubbletea/v2 v2.0.8 charm.land/lipgloss/v2 v2.0.5 - github.com/basecamp/basecamp-sdk/go v0.8.1-0.20260725044648-3d4ec395922c + github.com/basecamp/basecamp-sdk/go v0.9.1-0.20260727173625-bb363c847b92 github.com/basecamp/cli v0.2.1 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/glamour v1.0.0 diff --git a/go.sum b/go.sum index a70002c3..68a29485 100644 --- a/go.sum +++ b/go.sum @@ -23,8 +23,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/basecamp/basecamp-sdk/go v0.8.1-0.20260725044648-3d4ec395922c h1:fZX/ctMBCzahWf5NEO+//W0/xeyHp2Yljyvr06xAprE= -github.com/basecamp/basecamp-sdk/go v0.8.1-0.20260725044648-3d4ec395922c/go.mod h1:SvHmG8I8jnB/3p8+Kqxs5NLPpvWssh8KUvr39bzRsl0= +github.com/basecamp/basecamp-sdk/go v0.9.1-0.20260727173625-bb363c847b92 h1:4gQJTR8e5kBvXHDHLvef+Q00XY7KzWTxnQSLWMSPcyA= +github.com/basecamp/basecamp-sdk/go v0.9.1-0.20260727173625-bb363c847b92/go.mod h1:r83ralDQ0q9vbAby5qQ5x9hgCgUdJLDLHYpiU6jaFjE= github.com/basecamp/cli v0.2.1 h1:8GyehPVtsTXla0oOPu4QgXRjwwzJ99prlByvyi+0HRQ= github.com/basecamp/cli v0.2.1/go.mod h1:p8tt/DatJ2LAzWO6N6tNfV8x3gF5T3IxDTo+U8FfWPo= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 1156516e..c56e18f8 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -94,7 +94,7 @@ func CommandCategories() []CommandCategory { {Name: "attachments", Category: "communication", Description: "List and download attachments", Actions: []string{"list", "download"}}, {Name: "comments", Category: "communication", Description: "Manage comments", Actions: []string{"create", "list", "show", "update", "trash", "archive", "restore"}}, {Name: "boost", Category: "communication", Description: "Manage boosts (reactions)", Actions: []string{"list", "show", "create", "delete"}}, - {Name: "notifications", Category: "communication", Description: "View and manage notifications", Actions: []string{"list", "read"}}, + {Name: "notifications", Category: "communication", Description: "View and manage notifications", Actions: []string{"list", "read", "bubbleups"}}, }, }, { diff --git a/internal/commands/messagetypes.go b/internal/commands/messagetypes.go index 90bc7b0a..1d427692 100644 --- a/internal/commands/messagetypes.go +++ b/internal/commands/messagetypes.go @@ -14,73 +14,106 @@ import ( // NewMessagetypesCmd creates the messagetypes command for managing message types. func NewMessagetypesCmd() *cobra.Command { + var project string + cmd := &cobra.Command{ Use: "messagetypes", Short: "Manage message types (categories)", - Long: `Manage message types (categories) for the message board. + Long: `Manage message types (categories) for a project's message board. Message types categorize messages on the message board. Each type has a name -and an emoji icon that appears alongside messages of that type.`, +and an emoji icon that appears alongside messages of that type. Message types +are per-project: every command takes --in/--project to pick the project. + + basecamp messagetypes list --in MyProject`, + Annotations: map[string]string{ + "agent_notes": "Message types (categories) are per-project — every action needs --in .\n" + + "Each type has a name and an emoji icon shown alongside messages of that type.", + }, } + cmd.PersistentFlags().StringVarP(&project, "project", "p", "", "Project ID or name") + cmd.PersistentFlags().StringVar(&project, "in", "", "Project ID or name (alias for --project)") + cmd.AddCommand( - newMessagetypesListCmd(), - newMessagetypesShowCmd(), - newMessagetypesCreateCmd(), - newMessagetypesUpdateCmd(), - newMessagetypesDeleteCmd(), + newMessagetypesListCmd(&project), + newMessagetypesShowCmd(&project), + newMessagetypesCreateCmd(&project), + newMessagetypesUpdateCmd(&project), + newMessagetypesDeleteCmd(&project), ) return cmd } -func newMessagetypesListCmd() *cobra.Command { +// resolveMessagetypesBucket resolves the project into the numeric bucket ID +// required by the bucket-scoped categories API. Returns the bucket ID and the +// resolved project ID string (for breadcrumbs). +func resolveMessagetypesBucket(cmd *cobra.Command, app *appctx.App, project string) (int64, string, error) { + resolvedProjectID, err := resolveProjectID(cmd, app, project) + if err != nil { + return 0, "", err + } + + bucketID, err := strconv.ParseInt(resolvedProjectID, 10, 64) + if err != nil { + return 0, "", output.ErrUsage("Invalid project ID") + } + return bucketID, resolvedProjectID, nil +} + +func newMessagetypesListCmd(project *string) *cobra.Command { return &cobra.Command{ Use: "list", Short: "List message types", - Long: "List all message types.", + Long: `List all message types in a project. + + basecamp messagetypes list --in MyProject`, RunE: func(cmd *cobra.Command, args []string) error { - return runMessagetypesList(cmd) - }, - } -} + app := appctx.FromContext(cmd.Context()) -func runMessagetypesList(cmd *cobra.Command) error { - app := appctx.FromContext(cmd.Context()) + if err := ensureAccount(cmd, app); err != nil { + return err + } - if err := ensureAccount(cmd, app); err != nil { - return err - } + bucketID, resolvedProjectID, err := resolveMessagetypesBucket(cmd, app, *project) + if err != nil { + return err + } - typesResult, err := app.Account().MessageTypes().List(cmd.Context(), nil) - if err != nil { - return convertSDKError(err) + typesResult, err := app.Account().MessageTypes().List(cmd.Context(), bucketID, nil) + if err != nil { + return convertSDKError(err) + } + types := typesResult.MessageTypes + + return app.OK(types, + output.WithSummary(fmt.Sprintf("%d message types", len(types))), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "show", + Cmd: fmt.Sprintf("basecamp messagetypes show --in %s", resolvedProjectID), + Description: "View message type", + }, + output.Breadcrumb{ + Action: "create", + Cmd: fmt.Sprintf("basecamp messagetypes create \"Name\" --icon \"emoji\" --in %s", resolvedProjectID), + Description: "Create message type", + }, + ), + ) + }, } - types := typesResult.MessageTypes - - return app.OK(types, - output.WithSummary(fmt.Sprintf("%d message types", len(types))), - output.WithBreadcrumbs( - output.Breadcrumb{ - Action: "show", - Cmd: "basecamp messagetypes show ", - Description: "View message type", - }, - output.Breadcrumb{ - Action: "create", - Cmd: "basecamp messagetypes create \"Name\" --icon \"emoji\"", - Description: "Create message type", - }, - ), - ) } -func newMessagetypesShowCmd() *cobra.Command { +func newMessagetypesShowCmd(project *string) *cobra.Command { return &cobra.Command{ Use: "show ", Short: "Show message type details", - Long: "Display detailed information about a message type.", - Args: cobra.ExactArgs(1), + Long: `Display detailed information about a message type. + + basecamp messagetypes show 12345 --in MyProject`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) @@ -94,7 +127,12 @@ func newMessagetypesShowCmd() *cobra.Command { return output.ErrUsage("Invalid message type ID") } - msgType, err := app.Account().MessageTypes().Get(cmd.Context(), typeID) + bucketID, resolvedProjectID, err := resolveMessagetypesBucket(cmd, app, *project) + if err != nil { + return err + } + + msgType, err := app.Account().MessageTypes().Get(cmd.Context(), bucketID, typeID) if err != nil { return convertSDKError(err) } @@ -104,17 +142,17 @@ func newMessagetypesShowCmd() *cobra.Command { output.WithBreadcrumbs( output.Breadcrumb{ Action: "update", - Cmd: fmt.Sprintf("basecamp messagetypes update %s --name \"New Name\"", typeIDStr), + Cmd: fmt.Sprintf("basecamp messagetypes update %s --name \"New Name\" --in %s", typeIDStr, resolvedProjectID), Description: "Update message type", }, output.Breadcrumb{ Action: "delete", - Cmd: fmt.Sprintf("basecamp messagetypes delete %s", typeIDStr), + Cmd: fmt.Sprintf("basecamp messagetypes delete %s --in %s", typeIDStr, resolvedProjectID), Description: "Delete message type", }, output.Breadcrumb{ Action: "list", - Cmd: "basecamp messagetypes", + Cmd: fmt.Sprintf("basecamp messagetypes list --in %s", resolvedProjectID), Description: "List message types", }, ), @@ -123,15 +161,17 @@ func newMessagetypesShowCmd() *cobra.Command { } } -func newMessagetypesCreateCmd() *cobra.Command { +func newMessagetypesCreateCmd(project *string) *cobra.Command { var name string var icon string cmd := &cobra.Command{ Use: "create ", Short: "Create a new message type", - Long: "Create a new message type with a name and emoji icon.", - Args: cobra.MaximumNArgs(1), + Long: `Create a new message type with a name and emoji icon. + + basecamp messagetypes create "Announcement" --icon "📣" --in MyProject`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Name from positional arg or flag if len(args) > 0 && name == "" { @@ -153,12 +193,17 @@ func newMessagetypesCreateCmd() *cobra.Command { return output.ErrUsage("--icon is required") } + bucketID, resolvedProjectID, err := resolveMessagetypesBucket(cmd, app, *project) + if err != nil { + return err + } + req := &basecamp.CreateMessageTypeRequest{ Name: name, Icon: icon, } - msgType, err := app.Account().MessageTypes().Create(cmd.Context(), req) + msgType, err := app.Account().MessageTypes().Create(cmd.Context(), bucketID, req) if err != nil { return convertSDKError(err) } @@ -168,12 +213,12 @@ func newMessagetypesCreateCmd() *cobra.Command { output.WithBreadcrumbs( output.Breadcrumb{ Action: "show", - Cmd: fmt.Sprintf("basecamp messagetypes show %d", msgType.ID), + Cmd: fmt.Sprintf("basecamp messagetypes show %d --in %s", msgType.ID, resolvedProjectID), Description: "View message type", }, output.Breadcrumb{ Action: "list", - Cmd: "basecamp messagetypes", + Cmd: fmt.Sprintf("basecamp messagetypes list --in %s", resolvedProjectID), Description: "List message types", }, ), @@ -187,15 +232,17 @@ func newMessagetypesCreateCmd() *cobra.Command { return cmd } -func newMessagetypesUpdateCmd() *cobra.Command { +func newMessagetypesUpdateCmd(project *string) *cobra.Command { var name string var icon string cmd := &cobra.Command{ Use: "update ", Short: "Update a message type", - Long: "Update an existing message type's name or icon.", - Args: cobra.ExactArgs(1), + Long: `Update an existing message type's name or icon. + + basecamp messagetypes update 12345 --name "New Name" --in MyProject`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) @@ -213,12 +260,17 @@ func newMessagetypesUpdateCmd() *cobra.Command { return noChanges(cmd) } + bucketID, resolvedProjectID, err := resolveMessagetypesBucket(cmd, app, *project) + if err != nil { + return err + } + req := &basecamp.UpdateMessageTypeRequest{ Name: name, Icon: icon, } - msgType, err := app.Account().MessageTypes().Update(cmd.Context(), typeID, req) + msgType, err := app.Account().MessageTypes().Update(cmd.Context(), bucketID, typeID, req) if err != nil { return convertSDKError(err) } @@ -228,7 +280,7 @@ func newMessagetypesUpdateCmd() *cobra.Command { output.WithBreadcrumbs( output.Breadcrumb{ Action: "show", - Cmd: fmt.Sprintf("basecamp messagetypes show %s", typeIDStr), + Cmd: fmt.Sprintf("basecamp messagetypes show %s --in %s", typeIDStr, resolvedProjectID), Description: "View message type", }, ), @@ -242,12 +294,14 @@ func newMessagetypesUpdateCmd() *cobra.Command { return cmd } -func newMessagetypesDeleteCmd() *cobra.Command { +func newMessagetypesDeleteCmd(project *string) *cobra.Command { return &cobra.Command{ Use: "delete ", Short: "Delete a message type", - Long: "Delete an existing message type.", - Args: cobra.ExactArgs(1), + Long: `Delete an existing message type. + + basecamp messagetypes delete 12345 --in MyProject`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) @@ -261,7 +315,12 @@ func newMessagetypesDeleteCmd() *cobra.Command { return output.ErrUsage("Invalid message type ID") } - err = app.Account().MessageTypes().Delete(cmd.Context(), typeID) + bucketID, resolvedProjectID, err := resolveMessagetypesBucket(cmd, app, *project) + if err != nil { + return err + } + + err = app.Account().MessageTypes().Delete(cmd.Context(), bucketID, typeID) if err != nil { return convertSDKError(err) } @@ -271,7 +330,7 @@ func newMessagetypesDeleteCmd() *cobra.Command { output.WithBreadcrumbs( output.Breadcrumb{ Action: "list", - Cmd: "basecamp messagetypes", + Cmd: fmt.Sprintf("basecamp messagetypes list --in %s", resolvedProjectID), Description: "List message types", }, ), diff --git a/internal/commands/messagetypes_test.go b/internal/commands/messagetypes_test.go new file mode 100644 index 00000000..7b00059b --- /dev/null +++ b/internal/commands/messagetypes_test.go @@ -0,0 +1,140 @@ +package commands + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" +) + +const messageTypeJSON = `{"id":456,"name":"Announcement","icon":"📣","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}` + +// Message types are bucket-scoped: every operation must forward the resolved +// project as the bucket in the URL. Collection paths carry .json; member +// paths do not (pinned by SDK message_types_test.go). + +func TestMessagetypesListHitsBucketScopedPath(t *testing.T) { + app, transport := setupRecordingTestApp(t, projectsRoute(), stubRoute{ + method: http.MethodGet, + path: "/99999/buckets/123/categories.json", + status: http.StatusOK, + body: "[" + messageTypeJSON + "]", + }) + + err := executeRecordingCommand(NewMessagetypesCmd(), app, "list", "-p", "123") + require.NoError(t, err) + + last := transport.last(t) + assert.Equal(t, http.MethodGet, last.Method) + assert.Equal(t, "/99999/buckets/123/categories.json", last.Path) +} + +func TestMessagetypesShowHitsBucketScopedPath(t *testing.T) { + app, transport := setupRecordingTestApp(t, projectsRoute(), stubRoute{ + method: http.MethodGet, + path: "/99999/buckets/123/categories/456", + status: http.StatusOK, + body: messageTypeJSON, + }) + + err := executeRecordingCommand(NewMessagetypesCmd(), app, "show", "456", "-p", "123") + require.NoError(t, err) + + last := transport.last(t) + assert.Equal(t, http.MethodGet, last.Method) + assert.Equal(t, "/99999/buckets/123/categories/456", last.Path) +} + +func TestMessagetypesCreateHitsBucketScopedPath(t *testing.T) { + app, transport := setupRecordingTestApp(t, projectsRoute(), stubRoute{ + method: http.MethodPost, + path: "/99999/buckets/123/categories.json", + status: http.StatusCreated, + body: messageTypeJSON, + }) + + err := executeRecordingCommand(NewMessagetypesCmd(), app, + "create", "Announcement", "--icon", "📣", "-p", "123") + require.NoError(t, err) + + last := transport.last(t) + assert.Equal(t, http.MethodPost, last.Method) + assert.Equal(t, "/99999/buckets/123/categories.json", last.Path) +} + +func TestMessagetypesUpdateHitsBucketScopedPath(t *testing.T) { + app, transport := setupRecordingTestApp(t, projectsRoute(), stubRoute{ + method: http.MethodPut, + path: "/99999/buckets/123/categories/456", + status: http.StatusOK, + body: messageTypeJSON, + }) + + err := executeRecordingCommand(NewMessagetypesCmd(), app, + "update", "456", "--name", "Heads-up", "-p", "123") + require.NoError(t, err) + + last := transport.last(t) + assert.Equal(t, http.MethodPut, last.Method) + assert.Equal(t, "/99999/buckets/123/categories/456", last.Path) +} + +func TestMessagetypesDeleteHitsBucketScopedPath(t *testing.T) { + app, transport := setupRecordingTestApp(t, projectsRoute(), stubRoute{ + method: http.MethodDelete, + path: "/99999/buckets/123/categories/456", + status: http.StatusNoContent, + body: "", + }) + + err := executeRecordingCommand(NewMessagetypesCmd(), app, "delete", "456", "-p", "123") + require.NoError(t, err) + + last := transport.last(t) + assert.Equal(t, http.MethodDelete, last.Method) + assert.Equal(t, "/99999/buckets/123/categories/456", last.Path) +} + +// Validation must run before project resolution: a usage error should surface +// without any network traffic. + +func TestMessagetypesCreateIconValidationPrecedesResolution(t *testing.T) { + app, transport := setupRecordingTestApp(t, projectsRoute()) + + err := executeRecordingCommand(NewMessagetypesCmd(), app, "create", "Announcement", "-p", "123") + require.Error(t, err) + + var e *output.Error + require.True(t, errors.As(err, &e), "expected *output.Error, got %T: %v", err, err) + assert.Equal(t, "--icon is required", e.Message) + assert.Empty(t, transport.recorded(), "validation should fail before project resolution") +} + +func TestMessagetypesUpdateNoChangesPrecedesResolution(t *testing.T) { + t.Setenv("BASECAMP_NONINTERACTIVE", "1") + app, transport := setupRecordingTestApp(t, projectsRoute()) + + err := executeRecordingCommand(NewMessagetypesCmd(), app, "update", "456", "-p", "123") + require.Error(t, err) + + var e *output.Error + require.True(t, errors.As(err, &e), "expected *output.Error, got %T: %v", err, err) + assert.Equal(t, "No update fields specified", e.Message) + assert.Empty(t, transport.recorded(), "no-changes check should fail before project resolution") +} + +func TestMessagetypesShowInvalidIDPrecedesResolution(t *testing.T) { + app, transport := setupRecordingTestApp(t, projectsRoute()) + + err := executeRecordingCommand(NewMessagetypesCmd(), app, "show", "abc", "-p", "123") + require.Error(t, err) + + var e *output.Error + require.True(t, errors.As(err, &e), "expected *output.Error, got %T: %v", err, err) + assert.Equal(t, "Invalid message type ID", e.Message) + assert.Empty(t, transport.recorded(), "ID validation should fail before project resolution") +} diff --git a/internal/commands/notifications.go b/internal/commands/notifications.go index c4e60bef..12375edd 100644 --- a/internal/commands/notifications.go +++ b/internal/commands/notifications.go @@ -22,21 +22,24 @@ func NewNotificationsCmd() *cobra.Command { Shows unread and read notifications, plus resurfaced items: Bubble Ups (and Scheduled Bubble Ups) on Basecamp 5, Memories on Basecamp 4. -Use 'read' to mark notifications as read.`, +Use 'read' to mark notifications as read, 'bubbleups' to page through +all current and scheduled Bubble Ups.`, Args: cobra.NoArgs, Annotations: map[string]string{ "agent_notes": "Account-wide notifications — no --in needed.\n" + "Returns unreads and reads sections, plus bubble_ups/scheduled_bubble_ups (BC5) or memories (BC4).\n" + - "Use 'read' with notification IDs to mark as read.", + "Use 'read' with notification IDs to mark as read.\n" + + "Use 'bubbleups' (BC5) for the full bubble-ups list; 'list --limit-bubble-ups' caps inline bubble-ups at 2.", }, RunE: func(cmd *cobra.Command, args []string) error { - return runNotificationsList(cmd, 0) + return runNotificationsList(cmd, 0, false) }, } cmd.AddCommand( newNotificationsListCmd(), newNotificationsReadCmd(), + newNotificationsBubbleupsCmd(), ) return cmd @@ -44,50 +47,81 @@ Use 'read' to mark notifications as read.`, func newNotificationsListCmd() *cobra.Command { var page int32 + var limitBubbleUps bool cmd := &cobra.Command{ Use: "list", Short: "List notifications", - Long: "List notifications (same as bare 'notifications').", + Long: `List notifications (same as bare 'notifications'). + +With --limit-bubble-ups, caps the inline bubble_ups list at 2 and omits +scheduled_bubble_ups (the totals are still reported). Use +'basecamp notifications bubbleups' to page through the full list.`, RunE: func(cmd *cobra.Command, args []string) error { - return runNotificationsList(cmd, page) + return runNotificationsList(cmd, page, limitBubbleUps) }, } cmd.Flags().Int32Var(&page, "page", 0, "Page number (default: first page)") + cmd.Flags().BoolVar(&limitBubbleUps, "limit-bubble-ups", false, "Cap inline bubble-ups at 2 and omit scheduled bubble-ups (BC5)") return cmd } -func runNotificationsList(cmd *cobra.Command, page int32) error { +func runNotificationsList(cmd *cobra.Command, page int32, limitBubbleUps bool) error { app := appctx.FromContext(cmd.Context()) if err := ensureAccount(cmd, app); err != nil { return err } - result, err := app.Account().MyNotifications().Get(cmd.Context(), page) + var result *basecamp.NotificationsResult + var err error + if limitBubbleUps { + result, err = app.Account().MyNotifications().GetWithOptions(cmd.Context(), page, basecamp.WithLimitBubbleUps()) + } else { + result, err = app.Account().MyNotifications().Get(cmd.Context(), page) + } if err != nil { return convertSDKError(err) } // BC5 carries resurfaced items in BubbleUps and may also populate // Memories as a compat alias of the same list; BC4 populates Memories - // only. Count whichever list carries them, never both. + // only. Count whichever list carries them, never both. When the + // bubble-up arrays are capped/omitted by --limit-bubble-ups, the count + // fields carry the true totals — use them so the headline total matches + // the non-limited mode. resurfaced := len(result.BubbleUps) + scheduled := len(result.ScheduledBubbleUps) + if limitBubbleUps { + resurfaced = int(result.BubbleUpsCount) + scheduled = int(result.ScheduledBubbleUpsCount) + } if resurfaced == 0 { resurfaced = len(result.Memories) } - total := len(result.Unreads) + len(result.Reads) + resurfaced + len(result.ScheduledBubbleUps) + total := len(result.Unreads) + len(result.Reads) + resurfaced + scheduled summary := fmt.Sprintf("%d notification(s)", total) if len(result.Unreads) > 0 { summary += fmt.Sprintf(" (%d unread)", len(result.Unreads)) } - if len(result.BubbleUps) > 0 { - summary += fmt.Sprintf(", %d bubble-up(s)", len(result.BubbleUps)) - } - if len(result.ScheduledBubbleUps) > 0 { - summary += fmt.Sprintf(", %d scheduled bubble-up(s)", len(result.ScheduledBubbleUps)) + if limitBubbleUps { + // The bubble_ups array is capped at 2 and scheduled_bubble_ups is + // omitted; the counts report the uncapped totals. + if result.BubbleUpsCount > 0 { + summary += fmt.Sprintf(", %d of %d bubble-up(s)", len(result.BubbleUps), result.BubbleUpsCount) + } + if result.ScheduledBubbleUpsCount > 0 { + summary += fmt.Sprintf(", %d scheduled bubble-up(s) not shown", result.ScheduledBubbleUpsCount) + } + } else { + if len(result.BubbleUps) > 0 { + summary += fmt.Sprintf(", %d bubble-up(s)", len(result.BubbleUps)) + } + if len(result.ScheduledBubbleUps) > 0 { + summary += fmt.Sprintf(", %d scheduled bubble-up(s)", len(result.ScheduledBubbleUps)) + } } nextPage := page + 1 @@ -98,6 +132,10 @@ func runNotificationsList(cmd *cobra.Command, page int32) error { if page > 0 { readCmd = fmt.Sprintf("basecamp notifications read --page %d", page) } + nextCmd := fmt.Sprintf("basecamp notifications list --page %d", nextPage) + if limitBubbleUps { + nextCmd += " --limit-bubble-ups" + } breadcrumbs := []output.Breadcrumb{ { Action: "read", @@ -106,10 +144,17 @@ func runNotificationsList(cmd *cobra.Command, page int32) error { }, { Action: "next", - Cmd: fmt.Sprintf("basecamp notifications list --page %d", nextPage), + Cmd: nextCmd, Description: "Next page", }, } + if limitBubbleUps { + breadcrumbs = append(breadcrumbs, output.Breadcrumb{ + Action: "bubbleups", + Cmd: "basecamp notifications bubbleups", + Description: "All bubble-ups", + }) + } return app.OK(result, output.WithSummary(summary), @@ -210,3 +255,57 @@ match the page you listed (defaults to first page). return cmd } + +func newNotificationsBubbleupsCmd() *cobra.Command { + var page int32 + + cmd := &cobra.Command{ + Use: "bubbleups", + Short: "List all bubble-ups (Basecamp 5)", + Long: `List all current and scheduled Bubble Ups (Basecamp 5). + +Current bubble-ups come first (most recently bubbled up), then scheduled +bubble-ups (by scheduled time). Follows pagination by default; pass --page +to fetch a single page (50 per page). + + basecamp notifications bubbleups + basecamp notifications bubbleups --page 2`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + + if err := ensureAccount(cmd, app); err != nil { + return err + } + + result, err := app.Account().MyNotifications().BubbleUps(cmd.Context(), page) + if err != nil { + return convertSDKError(err) + } + bubbleUps := result.BubbleUps + + summary := fmt.Sprintf("%d bubble-up(s)", len(bubbleUps)) + if result.Meta.TotalCount > 0 && len(bubbleUps) < result.Meta.TotalCount { + summary = fmt.Sprintf("%d of %d bubble-up(s)", len(bubbleUps), result.Meta.TotalCount) + } + + // No "read" breadcrumb: notifications read resolves IDs from + // the notification feed, not this dedicated endpoint, so it + // cannot find bubble-ups that only appear here. + return app.OK(bubbleUps, + output.WithSummary(summary), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "notifications", + Cmd: "basecamp notifications", + Description: "View notifications", + }, + ), + ) + }, + } + + cmd.Flags().Int32Var(&page, "page", 0, "Fetch a single page (default: all pages)") + + return cmd +} diff --git a/internal/commands/notifications_test.go b/internal/commands/notifications_test.go new file mode 100644 index 00000000..130dd3da --- /dev/null +++ b/internal/commands/notifications_test.go @@ -0,0 +1,133 @@ +package commands + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" +) + +func TestNotificationsBubbleupsHitsDedicatedEndpoint(t *testing.T) { + app, transport := setupRecordingTestApp(t, stubRoute{ + method: http.MethodGet, + path: "/99999/my/readings/bubble_ups.json", + status: http.StatusOK, + body: `[{"id":1,"title":"Bubbled up","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}]`, + }) + + err := executeRecordingCommand(NewNotificationsCmd(), app, "bubbleups") + require.NoError(t, err) + + last := transport.last(t) + assert.Equal(t, http.MethodGet, last.Method) + assert.Equal(t, "/99999/my/readings/bubble_ups.json", last.Path) +} + +// notifications read resolves IDs from the notification feed, not the +// dedicated Bubble Ups endpoint, so bubbleups must not advertise a read +// breadcrumb — it would fail for any bubble-up not on the feed's first page. +func TestNotificationsBubbleupsOffersNoReadBreadcrumb(t *testing.T) { + app, _ := setupRecordingTestApp(t, stubRoute{ + method: http.MethodGet, + path: "/99999/my/readings/bubble_ups.json", + status: http.StatusOK, + body: `[{"id":1,"title":"Bubbled up","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}]`, + }) + buf := &bytes.Buffer{} + app.Output = output.New(output.Options{Format: output.FormatJSON, Writer: buf}) + app.Flags.Hints = true + + err := executeRecordingCommand(NewNotificationsCmd(), app, "bubbleups") + require.NoError(t, err) + + var envelope struct { + Breadcrumbs []struct { + Action string `json:"action"` + } `json:"breadcrumbs"` + } + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope)) + require.NotEmpty(t, envelope.Breadcrumbs) + + for _, bc := range envelope.Breadcrumbs { + assert.NotEqual(t, "read", bc.Action, + "read cannot resolve IDs from the dedicated bubble-ups endpoint") + } +} + +func TestNotificationsListLimitBubbleUpsSendsQueryParam(t *testing.T) { + app, transport := setupRecordingTestApp(t, stubRoute{ + method: http.MethodGet, + path: "/99999/my/readings.json", + status: http.StatusOK, + body: `{"unreads":[],"reads":[],"bubble_ups_count":5,"scheduled_bubble_ups_count":3}`, + }) + + err := executeRecordingCommand(NewNotificationsCmd(), app, "list", "--limit-bubble-ups") + require.NoError(t, err) + + last := transport.last(t) + assert.Equal(t, http.MethodGet, last.Method) + assert.Equal(t, "/99999/my/readings.json", last.Path) + assert.Contains(t, last.Query, "limit_bubble_ups=true") +} + +func TestNotificationsListLimitBubbleUpsNextBreadcrumbKeepsFlag(t *testing.T) { + app, _ := setupRecordingTestApp(t, stubRoute{ + method: http.MethodGet, + path: "/99999/my/readings.json", + status: http.StatusOK, + body: `{"unreads":[{"id":1,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}],` + + `"reads":[],"bubble_ups":[{"id":2,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}],` + + `"bubble_ups_count":5,"scheduled_bubble_ups_count":3}`, + }) + buf := &bytes.Buffer{} + app.Output = output.New(output.Options{Format: output.FormatJSON, Writer: buf}) + app.Flags.Hints = true + + err := executeRecordingCommand(NewNotificationsCmd(), app, "list", "--limit-bubble-ups") + require.NoError(t, err) + + var envelope struct { + Summary string `json:"summary"` + Breadcrumbs []struct { + Action string `json:"action"` + Cmd string `json:"cmd"` + } `json:"breadcrumbs"` + } + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope)) + + // The headline total must come from the uncapped count fields + // (1 unread + 5 bubble-ups + 3 scheduled), not the capped arrays. + assert.Contains(t, envelope.Summary, "9 notification(s)") + assert.Contains(t, envelope.Summary, "1 of 5 bubble-up(s)") + + var nextCmd string + for _, bc := range envelope.Breadcrumbs { + if bc.Action == "next" { + nextCmd = bc.Cmd + } + } + assert.Contains(t, nextCmd, "--limit-bubble-ups", + "next-page breadcrumb must preserve the cap") +} + +func TestNotificationsListWithoutLimitOmitsQueryParam(t *testing.T) { + app, transport := setupRecordingTestApp(t, stubRoute{ + method: http.MethodGet, + path: "/99999/my/readings.json", + status: http.StatusOK, + body: `{"unreads":[],"reads":[]}`, + }) + + err := executeRecordingCommand(NewNotificationsCmd(), app, "list") + require.NoError(t, err) + + last := transport.last(t) + assert.Equal(t, "/99999/my/readings.json", last.Path) + assert.NotContains(t, last.Query, "limit_bubble_ups") +} diff --git a/internal/commands/recording_test.go b/internal/commands/recording_test.go new file mode 100644 index 00000000..71ddd08a --- /dev/null +++ b/internal/commands/recording_test.go @@ -0,0 +1,171 @@ +package commands + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "strings" + "sync" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/auth" + "github.com/basecamp/basecamp-cli/internal/config" + "github.com/basecamp/basecamp-cli/internal/names" + "github.com/basecamp/basecamp-cli/internal/output" +) + +// Shared recording-transport test harness for request-level assertions: +// commands run against a stub route table, every request is recorded, and +// unmatched requests fail the command rather than passing silently. + +// recordedCall captures one request seen by the recording transport. +type recordedCall struct { + Method string + Path string + Query string + Body string +} + +// stubRoute matches a request by method + exact URL path and serves a canned +// response. +type stubRoute struct { + method string + path string + status int + body string +} + +// recordingTransport is an http.RoundTripper that records every request and +// serves canned responses from its route table. Unmatched requests get an +// error response — a wrong route fails the command rather than passing +// silently. +type recordingTransport struct { + mu sync.Mutex + requests []recordedCall + routes []stubRoute +} + +func (t *recordingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + body := "" + if req.Body != nil { + defer req.Body.Close() + b, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + body = string(b) + } + + t.mu.Lock() + t.requests = append(t.requests, recordedCall{ + Method: req.Method, + Path: req.URL.Path, + Query: req.URL.RawQuery, + Body: body, + }) + t.mu.Unlock() + + for _, route := range t.routes { + if route.method == req.Method && route.path == req.URL.Path { + return &http.Response{ + StatusCode: route.status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(route.body)), + Request: req, + }, nil + } + } + + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(fmt.Sprintf( + `{"error":"no stub route for %s %s"}`, req.Method, req.URL.Path))), + Request: req, + }, nil +} + +// recorded returns a snapshot of the requests seen so far. +func (t *recordingTransport) recorded() []recordedCall { + t.mu.Lock() + defer t.mu.Unlock() + return append([]recordedCall(nil), t.requests...) +} + +// last returns the most recent request, failing the test if none were made. +func (t *recordingTransport) last(tb testing.TB) recordedCall { + tb.Helper() + reqs := t.recorded() + require.NotEmpty(tb, reqs, "expected at least one request") + return reqs[len(reqs)-1] +} + +// projectsRoute serves the project list that name resolution fetches first. +func projectsRoute() stubRoute { + return stubRoute{ + method: http.MethodGet, + path: "/99999/projects.json", + status: http.StatusOK, + body: `[{"id":123,"name":"Test Project"}]`, + } +} + +// recordingTestTokenProvider is a mock token provider for recording tests. +type recordingTestTokenProvider struct{} + +func (recordingTestTokenProvider) AccessToken(_ context.Context) (string, error) { + return "test-token", nil +} + +// setupRecordingTestApp creates a test app whose SDK client talks to a +// recording stub transport seeded with the given routes. +func setupRecordingTestApp(t *testing.T, routes ...stubRoute) (*appctx.App, *recordingTransport) { + t.Helper() + + t.Setenv("BASECAMP_NO_KEYRING", "1") + + transport := &recordingTransport{routes: routes} + cfg := &config.Config{ + AccountID: "99999", + } + + authMgr := auth.NewManager(cfg, nil) + sdkCfg := &basecamp.Config{BaseURL: "https://3.basecampapi.com"} + sdkClient := basecamp.NewClient(sdkCfg, recordingTestTokenProvider{}, + basecamp.WithTransport(transport), + basecamp.WithMaxRetries(1), + ) + nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID) + + app := &appctx.App{ + Config: cfg, + Auth: authMgr, + SDK: sdkClient, + Names: nameResolver, + Output: output.New(output.Options{ + Format: output.FormatJSON, + Writer: &bytes.Buffer{}, + }), + } + return app, transport +} + +// executeRecordingCommand executes a cobra command against the test app. +func executeRecordingCommand(cmd *cobra.Command, app *appctx.App, args ...string) error { + cmd.SetArgs(args) + ctx := appctx.WithApp(context.Background(), app) + cmd.SetContext(ctx) + + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + return cmd.Execute() +} diff --git a/internal/commands/tools.go b/internal/commands/tools.go index 78c9c0e0..85d546a0 100644 --- a/internal/commands/tools.go +++ b/internal/commands/tools.go @@ -257,6 +257,7 @@ func resolveToolBucketID(cmd *cobra.Command, app *appctx.App, project string) (i func newToolsCreateCmd(project *string) *cobra.Command { var toolType string + var visibleToClients bool cmd := &cobra.Command{ Use: "create [title]", @@ -267,6 +268,10 @@ For example, add a second chat with --type chat, or a Message Board with --type message_board. An optional title may be given; without one, Basecamp assigns the default title for the type. +Use --visible-to-clients to set client visibility at create time. Only chat +and kanban_board tools manage their own visibility; other types ignore the +flag and inherit the project default. + Accepted types: %s. Create-by-type is BC5-only.`, strings.Join(toolTypeFriendlyNames(), ", ")), Args: cobra.MaximumNArgs(1), @@ -310,8 +315,11 @@ Accepted types: %s. Create-by-type is BC5-only.`, inFlag := toolBreadcrumbFlag(resolvedProjectID) var opts *basecamp.CreateToolOptions - if title != "" { + if title != "" || cmd.Flags().Changed("visible-to-clients") { opts = &basecamp.CreateToolOptions{Title: title} + if cmd.Flags().Changed("visible-to-clients") { + opts.VisibleToClients = &visibleToClients + } } created, err := app.Account().Tools().Create(cmd.Context(), bucketID, canonicalType, opts) @@ -340,6 +348,7 @@ Accepted types: %s. Create-by-type is BC5-only.`, } cmd.Flags().StringVarP(&toolType, "type", "t", "", "Tool type to create (required)") + cmd.Flags().BoolVar(&visibleToClients, "visible-to-clients", false, "Make the tool visible to clients (chat and kanban_board only)") _ = cmd.RegisterFlagCompletionFunc("type", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) { return toolTypeFriendlyNames(), cobra.ShellCompDirectiveNoFileComp }) diff --git a/internal/commands/tools_test.go b/internal/commands/tools_test.go index 4bfb73f0..07609fda 100644 --- a/internal/commands/tools_test.go +++ b/internal/commands/tools_test.go @@ -597,3 +597,53 @@ func TestToolsShowBreadcrumbsWithoutProject(t *testing.T) { assert.NotEqual(t, "project", bc.Action) } } + +// The visible_to_clients wire contract is tri-state: the key must be absent +// when the flag is not given, and an explicit true or false must reach the +// wire verbatim when it is. + +func TestToolsCreateVisibleToClientsOmittedByDefault(t *testing.T) { + transport := &mockToolCreateTransport{} + app, _ := newTestAppWithTransport(t, transport) + app.Config.ProjectID = "123" + + project := "" + cmd := newToolsCreateCmd(&project) + + err := executeCommand(cmd, app, "--type", "chat") + require.NoError(t, err) + + require.True(t, transport.createCalled) + _, hasVisibility := transport.capturedBody["visible_to_clients"] + assert.False(t, hasVisibility, "visible_to_clients should be omitted when flag not given") +} + +func TestToolsCreateVisibleToClientsTrue(t *testing.T) { + transport := &mockToolCreateTransport{} + app, _ := newTestAppWithTransport(t, transport) + app.Config.ProjectID = "123" + + project := "" + cmd := newToolsCreateCmd(&project) + + err := executeCommand(cmd, app, "--type", "chat", "--visible-to-clients") + require.NoError(t, err) + + require.True(t, transport.createCalled) + assert.Equal(t, true, transport.capturedBody["visible_to_clients"]) +} + +func TestToolsCreateVisibleToClientsExplicitFalse(t *testing.T) { + transport := &mockToolCreateTransport{} + app, _ := newTestAppWithTransport(t, transport) + app.Config.ProjectID = "123" + + project := "" + cmd := newToolsCreateCmd(&project) + + err := executeCommand(cmd, app, "--type", "kanban_board", "--visible-to-clients=false") + require.NoError(t, err) + + require.True(t, transport.createCalled) + assert.Equal(t, false, transport.capturedBody["visible_to_clients"]) +} diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index 714747c9..2c47d43a 100644 --- a/internal/version/sdk-provenance.json +++ b/internal/version/sdk-provenance.json @@ -1,13 +1,13 @@ { "sdk": { "module": "github.com/basecamp/basecamp-sdk/go", - "version": "v0.8.1-0.20260725044648-3d4ec395922c", - "revision": "3d4ec395922c", - "updated_at": "2026-07-25T04:46:48Z" + "version": "v0.9.1-0.20260727173625-bb363c847b92", + "revision": "bb363c847b92", + "updated_at": "2026-07-27T17:36:25Z" }, "api": { "repo": "basecamp/bc3", - "revision": "ca1d34bcc40d2e2403a08bce9ead0d4b0276b1fa", - "synced_at": "2026-07-24" + "revision": "c308693171680e8ec9d3ae9e68181ef2ea80f077", + "synced_at": "2026-07-26" } } diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 6cdc2603..52210c57 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -205,6 +205,7 @@ basecamp --page 1 # First page only, no auto-pagination | Completed assignments | `basecamp assignments completed --json` | | Notifications | `basecamp notifications --json` | | Mark notification read | `basecamp notifications read --json` | +| All bubble-ups (BC5) | `basecamp notifications bubbleups --json` | | Gauges (account-wide) | `basecamp gauges list --json` | | Gauge needles | `basecamp gauges needles --in --json` | | Create needle | `basecamp gauges create --position 75 --color green --in --json` | @@ -656,8 +657,10 @@ basecamp messages create "For the client" "..." --visible-to-clients --in --json # Mark as read basecamp notifications read --page 2 --json # Mark from page 2 +basecamp notifications bubbleups --json # All bubble-ups (BC5) +basecamp notifications list --limit-bubble-ups --json # Cap inline bubble-ups at 2 ``` **Note:** `read` resolves notification IDs from the specified page. Use `--page` to match the page you listed. +**Bubble Ups (BC5):** `bubbleups` lists all current and scheduled bubble-ups +(paginated; `--page` fetches a single page). `list --limit-bubble-ups` keeps the +notification feed compact: at most 2 inline bubble-ups, scheduled ones omitted, +with the uncapped counts still reported. + ### Accounts ```bash