diff --git a/.surface b/.surface index cacb7f11d..efc2ef0a2 100644 --- a/.surface +++ b/.surface @@ -33,6 +33,8 @@ ARG basecamp boosts create 01 ARG basecamp boosts delete 00 ARG basecamp boosts list 00 ARG basecamp boosts show 00 +ARG basecamp bubble-up add 00 +ARG basecamp bubble-up remove 00 ARG basecamp calendars show 00 ARG basecamp calendars update 00 ARG basecamp calendars update 01 @@ -536,6 +538,9 @@ CMD basecamp boosts create CMD basecamp boosts delete CMD basecamp boosts list CMD basecamp boosts show +CMD basecamp bubble-up +CMD basecamp bubble-up add +CMD basecamp bubble-up remove CMD basecamp calendars CMD basecamp calendars show CMD basecamp calendars update @@ -2483,6 +2488,70 @@ FLAG basecamp boosts show --stats type=bool FLAG basecamp boosts show --styled type=bool FLAG basecamp boosts show --todolist type=string FLAG basecamp boosts show --verbose type=count +FLAG basecamp bubble-up --account type=string +FLAG basecamp bubble-up --agent type=bool +FLAG basecamp bubble-up --cache-dir type=string +FLAG basecamp bubble-up --count type=bool +FLAG basecamp bubble-up --help type=bool +FLAG basecamp bubble-up --hints type=bool +FLAG basecamp bubble-up --ids-only type=bool +FLAG basecamp bubble-up --in type=string +FLAG basecamp bubble-up --jq type=string +FLAG basecamp bubble-up --json type=bool +FLAG basecamp bubble-up --markdown type=bool +FLAG basecamp bubble-up --md type=bool +FLAG basecamp bubble-up --no-hints type=bool +FLAG basecamp bubble-up --no-stats type=bool +FLAG basecamp bubble-up --profile type=string +FLAG basecamp bubble-up --project type=string +FLAG basecamp bubble-up --quiet type=bool +FLAG basecamp bubble-up --stats type=bool +FLAG basecamp bubble-up --styled type=bool +FLAG basecamp bubble-up --todolist type=string +FLAG basecamp bubble-up --verbose type=count +FLAG basecamp bubble-up add --account type=string +FLAG basecamp bubble-up add --agent type=bool +FLAG basecamp bubble-up add --at type=string +FLAG basecamp bubble-up add --cache-dir type=string +FLAG basecamp bubble-up add --count type=bool +FLAG basecamp bubble-up add --help type=bool +FLAG basecamp bubble-up add --hints type=bool +FLAG basecamp bubble-up add --ids-only type=bool +FLAG basecamp bubble-up add --in type=string +FLAG basecamp bubble-up add --jq type=string +FLAG basecamp bubble-up add --json type=bool +FLAG basecamp bubble-up add --markdown type=bool +FLAG basecamp bubble-up add --md type=bool +FLAG basecamp bubble-up add --no-hints type=bool +FLAG basecamp bubble-up add --no-stats type=bool +FLAG basecamp bubble-up add --profile type=string +FLAG basecamp bubble-up add --project type=string +FLAG basecamp bubble-up add --quiet type=bool +FLAG basecamp bubble-up add --stats type=bool +FLAG basecamp bubble-up add --styled type=bool +FLAG basecamp bubble-up add --todolist type=string +FLAG basecamp bubble-up add --verbose type=count +FLAG basecamp bubble-up remove --account type=string +FLAG basecamp bubble-up remove --agent type=bool +FLAG basecamp bubble-up remove --cache-dir type=string +FLAG basecamp bubble-up remove --count type=bool +FLAG basecamp bubble-up remove --help type=bool +FLAG basecamp bubble-up remove --hints type=bool +FLAG basecamp bubble-up remove --ids-only type=bool +FLAG basecamp bubble-up remove --in type=string +FLAG basecamp bubble-up remove --jq type=string +FLAG basecamp bubble-up remove --json type=bool +FLAG basecamp bubble-up remove --markdown type=bool +FLAG basecamp bubble-up remove --md type=bool +FLAG basecamp bubble-up remove --no-hints type=bool +FLAG basecamp bubble-up remove --no-stats type=bool +FLAG basecamp bubble-up remove --profile type=string +FLAG basecamp bubble-up remove --project type=string +FLAG basecamp bubble-up remove --quiet type=bool +FLAG basecamp bubble-up remove --stats type=bool +FLAG basecamp bubble-up remove --styled type=bool +FLAG basecamp bubble-up remove --todolist type=string +FLAG basecamp bubble-up remove --verbose type=count FLAG basecamp calendars --account type=string FLAG basecamp calendars --agent type=bool FLAG basecamp calendars --cache-dir type=string @@ -17826,6 +17895,9 @@ SUB basecamp boosts create SUB basecamp boosts delete SUB basecamp boosts list SUB basecamp boosts show +SUB basecamp bubble-up +SUB basecamp bubble-up add +SUB basecamp bubble-up remove SUB basecamp calendars SUB basecamp calendars show SUB basecamp calendars update diff --git a/API-COVERAGE.md b/API-COVERAGE.md index ec02b5dac..859db67a3 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -6,12 +6,12 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. | Status | Sections | Endpoints | |--------|----------|-----------| -| ✅ Implemented | 50 | 184 | +| ✅ Implemented | 50 | 186 | | ⚠️ Blocked | 0 | 0 | | ⏭️ Out of scope | 4 | 12 | -| **Total tracked** | **54** | **196** | +| **Total tracked** | **54** | **198** | -**184 of 184 tracked in-scope endpoints.** The last gap — `GET +**186 of 186 tracked in-scope endpoints.** The last gap — `GET /uploads/:id/versions.json` — closed with the v0.14.0 SDK bump. The command (`files versions`) was written earlier but held: the SDK's `UploadsService.ListVersions` decoded the response as `[]Upload` when the API @@ -43,8 +43,9 @@ 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.15.0 (`internal/version/sdk-provenance.json` is -authoritative). The command surface below largely dates to the v0.12.0 bump, +**SDK version:** v0.15.1 (adds Bubble Up write support; pinned to the +pre-release bubble-up SDK head until the v0.15.1 tag — +`internal/version/sdk-provenance.json` is authoritative). The command surface below largely dates to the v0.12.0 bump, which added 20 exported Go methods over 13 new backend operations; the extra seven wrapped endpoints that already existed but were reachable only through the raw generated client, which the andon-cord rule forbids the CLI from @@ -197,7 +198,7 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A | comments | 8 | `comment`, `comments` | ✅ | BC4 | - | list, show, thread, create, update. @mentions in content. `show` surfaces `reply_target` + paste-ready `mention` from its single Get (no new calls). `thread` composes Get + parent recording (via type endpoint) + List into a deterministic reply-ready context (no new endpoints) | | boosts | 6 | `boost`, `react` | ✅ | BC4 | - | list (recording + event), show, create (recording + event), delete. No account-wide listing — BC5 withdrew `/boosts.json` (basecamp/bc3#12464); temporary, returns via basecamp/bc3#12463 | | 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` | +| bubble_ups | 3 | `bubble-up`, `notifications bubbleups` | ✅ | BC5 | - | `bubble-up add`/`remove` create and delete a per-recording bubble-up (`POST`/`DELETE /recordings/{id}/bubble_up.json`); `add --at` schedules. Dedicated list is `notifications bubbleups` (`GET /my/readings/bubble_ups.json`, paginated) plus the `limit_bubble_ups` variant behind `notifications list --limit-bubble-ups`. Per-recording GET is an unrenderable API gap, so there is no `check`. | | **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/go.mod b/go.mod index 0c6748775..66ffb47b5 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.0.9 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.15.0 + github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902205921-f439f493caf2 github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d github.com/basecamp/surfguard/go v0.1.0 @@ -108,7 +108,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect - github.com/oapi-codegen/runtime v1.6.0 // indirect + github.com/oapi-codegen/runtime v1.7.0 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -130,9 +130,9 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.45.0 // indirect - go.opentelemetry.io/otel/metric v1.45.0 // indirect - go.opentelemetry.io/otel/trace v1.45.0 // indirect + go.opentelemetry.io/otel v1.46.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/otel/trace v1.46.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect diff --git a/go.sum b/go.sum index dda78c991..ded8979f5 100644 --- a/go.sum +++ b/go.sum @@ -87,8 +87,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.15.0 h1:Yxp3WM7rZ7PDcXrOTc4dI9EFOqwHojpqVJ/zbdXnoVg= -github.com/basecamp/basecamp-sdk/go v0.15.0/go.mod h1:00mgcmi89PlnHnLJNwcJjwryo4W5bYJtA2FoqVgHP54= +github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902205921-f439f493caf2 h1:bIaTwvIoWTNirPigLsXsC912abkIYDTFHuOrkSyB96U= +github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902205921-f439f493caf2/go.mod h1:Cs9DV8iRJaVT4+IQXGZTeyG2nQAV2kQda7GHuBOeonY= github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d h1:jAzDrCCzDpIwhbFT1xVVs0z2xpXoDEkomHfKB2bUUp8= github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE= github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4= @@ -348,8 +348,8 @@ github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0 github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= -github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= -github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k= +github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -451,16 +451,16 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= -go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= -go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= -go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= -go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= -go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= -go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= -go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/internal/cli/root.go b/internal/cli/root.go index cee088442..aca2f9df5 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -355,6 +355,7 @@ func Execute() { cmd.AddCommand(commands.NewGaugesCmd()) cmd.AddCommand(commands.NewAssignmentsCmd()) cmd.AddCommand(commands.NewBookmarksCmd()) + cmd.AddCommand(commands.NewBubbleUpCmd()) cmd.AddCommand(commands.NewDraftsCmd()) cmd.AddCommand(commands.NewNotesCmd()) cmd.AddCommand(commands.NewCalendarsCmd()) diff --git a/internal/commands/bubble_up.go b/internal/commands/bubble_up.go new file mode 100644 index 000000000..8b3abd792 --- /dev/null +++ b/internal/commands/bubble_up.go @@ -0,0 +1,202 @@ +package commands + +import ( + "fmt" + "strconv" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/output" +) + +// NewBubbleUpCmd creates the bubble-up command for resurfacing a recording in +// the current user's readings — the BC5 successor to "save". +// +// Bubble-up is per-person, like bookmarks: it links the current user to one +// recording, so there is no project to scope it to and no --in flag. Every leaf +// addresses a recording by id or URL. +func NewBubbleUpCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "bubble-up", + Short: "Bubble a recording up in your readings", + Long: `Bubble a recording up so it resurfaces in your readings. + +Bubble-up is private to you and points at a single recording (a to-do, message, +document, card, and so on), addressed by its id or by pasting its Basecamp URL. + + basecamp bubble-up add 42 + basecamp bubble-up add 42 --at tomorrow + basecamp bubble-up remove 42`, + Annotations: map[string]string{ + "agent_notes": "Account-wide and personal — no --in needed.\n" + + "add/remove are idempotent. add takes --at to schedule; bc3 requires a\n" + + "value, so add sends \"now\" when --at is omitted. There is no status\n" + + "read (per-recording GET is an unrenderable API gap); the full\n" + + "current-and-scheduled list is basecamp notifications bubbleups.", + }, + } + + cmd.AddCommand( + newBubbleUpAddCmd(), + newBubbleUpRemoveCmd(), + ) + + return cmd +} + +func newBubbleUpAddCmd() *cobra.Command { + var at string + + cmd := &cobra.Command{ + Use: "add ", + Short: "Bubble a recording up", + Long: `Bubble a recording up so it resurfaces in your readings. + +By default it bubbles up now. Pass --at to schedule it instead: a keyword +("today", "tomorrow", "weekend", "next_week") or a calendar date (YYYY-MM-DD). +bc3 schedules at day granularity, so a date is honored to the day, not the hour. + +Idempotent: bubbling up something already bubbled up is a no-op that still +succeeds. + + basecamp bubble-up add 42 + basecamp bubble-up add 42 --at tomorrow + basecamp bubble-up add https://3.basecamp.com/1234567/buckets/89/todos/42`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + + recordingID, err := bubbleUpRecordingID(args[0]) + if err != nil { + return err + } + if err := validateBubbleUpAt(at); err != nil { + return err + } + if err := ensureAccount(cmd, app); err != nil { + return err + } + + // bc3 requires a value for `at` (an omitted param raises server-side + // on Date.iso8601(nil)), so default the immediate case to "now" + // rather than sending nothing. + when := at + if when == "" { + when = "now" + } + + if err := app.Account().BubbleUps().Create(cmd.Context(), recordingID, &when); err != nil { + return convertSDKError(err) + } + + // add is idempotent and bc3 answers with a bare 204: adding over + // an already bubbled-up recording is a no-op reported identically, + // so there is no readable post-state. Report the requested timing + // rather than assert a resulting state we can't confirm. + immediate := when == "now" + summary := fmt.Sprintf("Requested bubble-up for recording %d", recordingID) + if !immediate { + summary = fmt.Sprintf("Requested bubble-up for recording %d at %s", recordingID, when) + } + + return app.OK(map[string]any{"id": recordingID, "at": when}, + output.WithSummary(summary), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "remove", + Cmd: fmt.Sprintf("basecamp bubble-up remove %d", recordingID), + Description: "Pop this bubble-up", + }, + output.Breadcrumb{ + Action: "show", + Cmd: fmt.Sprintf("basecamp show %d", recordingID), + Description: "View the recording", + }, + ), + ) + }, + } + + cmd.Flags().StringVar(&at, "at", "", `When to bubble up: "now" (default), a keyword ("today", "tomorrow", "weekend", "next_week"), or a calendar date (YYYY-MM-DD)`) + + return cmd +} + +func newBubbleUpRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Pop a bubble-up", + Long: `Remove a recording's bubble-up from your readings. + +Idempotent: popping something not bubbled up also succeeds, so this is safe to +run without checking first. + + basecamp bubble-up remove 42`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + + recordingID, err := bubbleUpRecordingID(args[0]) + if err != nil { + return err + } + if err := ensureAccount(cmd, app); err != nil { + return err + } + + if err := app.Account().BubbleUps().Delete(cmd.Context(), recordingID); err != nil { + return convertSDKError(err) + } + + return app.OK(map[string]any{"id": recordingID, "bubbled_up": false}, + output.WithSummary(fmt.Sprintf("Popped bubble-up on recording %d", recordingID)), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "add", + Cmd: fmt.Sprintf("basecamp bubble-up add %d", recordingID), + Description: "Bubble it up again", + }, + ), + ) + }, + } +} + +// bubbleUpRecordingID resolves the positional every bubble-up verb +// takes. With no way to browse bubble-uppable recordings from this group, a +// pasted URL is the natural way to name one. +func bubbleUpRecordingID(arg string) (int64, error) { + id, err := strconv.ParseInt(extractID(arg), 10, 64) + if err != nil || id <= 0 { + return 0, output.ErrUsageHint( + fmt.Sprintf("%q is not a recording id or Basecamp URL", arg), + "Pass a numeric recording id, or paste the recording's Basecamp URL", + ) + } + return id, nil +} + +// validateBubbleUpAt rejects an --at value bc3 cannot parse before any account +// resolution or network call, so a typo like "tomorow" is a local usage error +// rather than a server-side Date.iso8601 raise. Valid values are the empty +// default, "now", the schedule keywords, or a calendar date (YYYY-MM-DD). +// +// bc3 schedules bubble-ups at a date granularity — a keyword maps to a fixed +// morning/afternoon hour and Date.iso8601 reduces any value to a calendar day — +// so a timestamp with a time component would be silently truncated. We accept +// only YYYY-MM-DD rather than echo an exact time the server never honors. +func validateBubbleUpAt(at string) error { + switch at { + case "", "now", "today", "tomorrow", "weekend", "next_week": + return nil + } + if _, err := time.Parse("2006-01-02", at); err == nil { + return nil + } + return output.ErrUsageHint( + fmt.Sprintf("%q is not a valid --at value", at), + `Use "now", a keyword ("today", "tomorrow", "weekend", "next_week"), or a calendar date (YYYY-MM-DD)`, + ) +} diff --git a/internal/commands/bubble_up_test.go b/internal/commands/bubble_up_test.go new file mode 100644 index 000000000..2515b0c18 --- /dev/null +++ b/internal/commands/bubble_up_test.go @@ -0,0 +1,156 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func bubbleUpRecordingPath(id int64) string { + return fmt.Sprintf("/99999/recordings/%d/bubble_up.json", id) +} + +func bubbleUpRoute(id int64, method string) stubRoute { + return stubRoute{ + method: method, + path: bubbleUpRecordingPath(id), + status: http.StatusNoContent, + body: "", + } +} + +// Default add bubbles up now: bc3 requires a value for `at`, so the command +// sends "now" rather than omitting it. +func TestBubbleUpAddSendsNowByDefault(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodPost)) + + require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "add", "42")) + + call := transport.last(t) + assert.Equal(t, http.MethodPost, call.Method) + assert.Equal(t, bubbleUpRecordingPath(42), call.Path) + + var body map[string]any + require.NoError(t, json.Unmarshal([]byte(call.Body), &body)) + assert.Equal(t, "now", body["at"]) +} + +// --at schedules: the keyword reaches the wire verbatim. +func TestBubbleUpAddSchedulesWithAt(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodPost)) + + require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "add", "42", "--at", "tomorrow")) + + call := transport.last(t) + assert.Equal(t, http.MethodPost, call.Method) + + var body map[string]any + require.NoError(t, json.Unmarshal([]byte(call.Body), &body)) + assert.Equal(t, "tomorrow", body["at"]) +} + +func TestBubbleUpAddAcceptsAURL(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodPost)) + + require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "add", + "https://3.basecamp.com/99999/buckets/89/todos/42")) + + assert.Equal(t, bubbleUpRecordingPath(42), transport.last(t).Path) +} + +// bubbleUpData reads the single-object success envelope the add/remove verbs emit. +func bubbleUpData(t *testing.T, out *bytes.Buffer) map[string]any { + t.Helper() + var envelope struct { + Data map[string]any `json:"data"` + Summary string `json:"summary"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &envelope)) + return map[string]any{"data": envelope.Data, "summary": envelope.Summary} +} + +// A scheduled add reports the requested timing, not a resulting state: bc3 +// returns a bare 204 and add is idempotent, so the command must not claim the +// bubble-up now sits in the scheduled set (it may have been a no-op over an +// existing immediate bubble-up). It echoes the requested `at` and no +// bubbled_up state assertion. +func TestBubbleUpAddScheduledReportsRequestedTiming(t *testing.T) { + app, _, out := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodPost)) + + require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "add", "42", "--at", "tomorrow")) + + env := bubbleUpData(t, out) + data := env["data"].(map[string]any) + assert.NotContains(t, data, "bubbled_up", "add must not assert a resulting bubbled-up state") + assert.Equal(t, "tomorrow", data["at"]) + assert.Equal(t, "Requested bubble-up for recording 42 at tomorrow", env["summary"]) +} + +// An immediate add (the omitted default, or an explicit --at now) reports the +// request without a resulting-state claim. +func TestBubbleUpAddImmediateReportsRequest(t *testing.T) { + app, _, out := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodPost)) + + require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "add", "42", "--at", "now")) + + env := bubbleUpData(t, out) + data := env["data"].(map[string]any) + assert.NotContains(t, data, "bubbled_up", "add must not assert a resulting bubbled-up state") + assert.Equal(t, "now", data["at"]) + assert.Equal(t, "Requested bubble-up for recording 42", env["summary"]) +} + +// A calendar date reaches the wire verbatim. +func TestBubbleUpAddAcceptsACalendarDate(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodPost)) + + require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "add", "42", "--at", "2026-09-10")) + + var body map[string]any + require.NoError(t, json.Unmarshal([]byte(transport.last(t).Body), &body)) + assert.Equal(t, "2026-09-10", body["at"]) +} + +// A malformed --at, and a timestamp bc3 would truncate to a date, are both local +// usage errors before any request is issued. +func TestBubbleUpAddRejectsInvalidAt(t *testing.T) { + for _, bad := range []string{"tomorow", "2026-09-10T09:00:00Z", "20260910"} { + app, transport, _ := setupPersonalFeedApp(t) + + err := executeRecordingCommand(NewBubbleUpCmd(), app, "add", "42", "--at", bad) + requireBookmarksUsageError(t, err) + assert.Empty(t, transport.recorded(), "no request should be made for --at %q", bad) + } +} + +// A non-positive id is rejected as a usage error, not sent to the API. +func TestBubbleUpAddRejectsNonPositiveID(t *testing.T) { + app, _, _ := setupPersonalFeedApp(t) + + err := executeRecordingCommand(NewBubbleUpCmd(), app, "add", "0") + requireBookmarksUsageError(t, err) +} + +func TestBubbleUpRemoveDeletesTheBubbleUp(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, bubbleUpRoute(42, http.MethodDelete)) + + require.NoError(t, executeRecordingCommand(NewBubbleUpCmd(), app, "remove", "42")) + + call := transport.last(t) + assert.Equal(t, http.MethodDelete, call.Method) + assert.Equal(t, bubbleUpRecordingPath(42), call.Path) +} + +func TestBubbleUpVerbsRejectANonID(t *testing.T) { + app, _, _ := setupPersonalFeedApp(t) + + for _, verb := range []string{"add", "remove"} { + err := executeRecordingCommand(NewBubbleUpCmd(), app, verb, "not-an-id") + requireBookmarksUsageError(t, err) + } +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 849d4830e..e321c8910 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -80,6 +80,7 @@ func CommandCategories() []CommandCategory { Name: "Personal", Commands: []CommandInfo{ {Name: "bookmarks", Category: "personal", Description: "Manage your personal bookmarks", Actions: []string{"list", "add", "remove", "check"}}, + {Name: "bubble-up", Category: "personal", Description: "Bubble a recording up in your readings", Actions: []string{"add", "remove"}}, {Name: "drafts", Category: "personal", Description: "List your unpublished drafts", Actions: []string{"list"}}, {Name: "notes", Category: "personal", Description: "Read and write your personal note", Actions: []string{"show", "set"}}, }, diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index 50e4da03c..d295c3353 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -112,6 +112,7 @@ func buildRootWithAllCommands() *cobra.Command { root.AddCommand(commands.NewGaugesCmd()) root.AddCommand(commands.NewAssignmentsCmd()) root.AddCommand(commands.NewBookmarksCmd()) + root.AddCommand(commands.NewBubbleUpCmd()) root.AddCommand(commands.NewDraftsCmd()) root.AddCommand(commands.NewNotesCmd()) root.AddCommand(commands.NewCalendarsCmd()) diff --git a/internal/mcpserver/catalog_test.go b/internal/mcpserver/catalog_test.go index 28f80cbf9..b2cf4be9a 100644 --- a/internal/mcpserver/catalog_test.go +++ b/internal/mcpserver/catalog_test.go @@ -61,7 +61,7 @@ func TestCatalogExcludesBinaryUploads(t *testing.T) { assert.False(t, excluded[op.ID], "operation %q should be excluded from the vendored model", op.ID) } } - assert.Equal(t, 247, total, "served operation count") + assert.Equal(t, 253, total, "served operation count") } // TestCatalogIsAccountScoped pins the rescope: the CLI's account-scoped SDK diff --git a/internal/mcpserver/domains.go b/internal/mcpserver/domains.go index a65d461be..ad9e03d30 100644 --- a/internal/mcpserver/domains.go +++ b/internal/mcpserver/domains.go @@ -91,7 +91,7 @@ var DomainSpecs = []catalog.DomainSpec{ }, { Key: "account", - Tags: []string{"Account", "Gauges", "MyAssignments", "MyNotes", "MyNotifications", "Bookmarks", "Drafts"}, - Blurb: "Account info and your personal surface: gauges and needles, my assignments and priorities, notifications and bubble-ups, bookmarks, personal note, and drafts.", + Tags: []string{"Account", "Gauges", "MyAssignments", "MyNotes", "MyNotifications", "Bookmarks", "BubbleUps", "Drafts"}, + Blurb: "Account info and your personal surface: gauges and needles, my assignments and priorities, notifications and bubble-ups, bookmarks, bubbling recordings up, personal note, and drafts.", }, } diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 26a3cad08..c6ef1f8f3 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "1dd547b3fd85bcdbd491ac85a66566c87f295549", - "ref": "go/v0.15.0", + "commit": "f439f493caf24ec026d7efe4649eed87b82a30a8", + "ref": "go/v0.15.1-0.20260902205921-f439f493caf2", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "tags assigned to operations the export leaves untagged (PATCHED_TAGS); binary-upload operations dropped (EXCLUDED_OPERATIONS) — see the sync script" diff --git a/internal/mcpserver/model/behavior-model.json b/internal/mcpserver/model/behavior-model.json index 047211c04..f555b3972 100644 --- a/internal/mcpserver/model/behavior-model.json +++ b/internal/mcpserver/model/behavior-model.json @@ -62,6 +62,18 @@ ] } }, + "CreateBubbleUp": { + "idempotent": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "CreateCampfireLine": { "retry": { "max": 2, @@ -438,6 +450,18 @@ ] } }, + "DeleteBubbleUp": { + "idempotent": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "DeleteCampfireLine": { "idempotent": true, "retry": { @@ -2204,6 +2228,18 @@ ] } }, + "ListRecentProjects": { + "readonly": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "ListRecordingBoosts": { "readonly": true, "pagination": { @@ -2449,6 +2485,18 @@ ] } }, + "RecordProjectVisit": { + "idempotent": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "RemoveAccountLogo": { "idempotent": true, "retry": { @@ -2648,6 +2696,18 @@ ] } }, + "SpotlightRecording": { + "idempotent": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "Subscribe": { "idempotent": true, "retry": { @@ -2756,6 +2816,18 @@ ] } }, + "UnspotlightRecording": { + "idempotent": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "Unsubscribe": { "idempotent": true, "retry": { diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index db362eefc..3509d438d 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Basecamp", - "version": "2026-08-11", + "version": "2026-08-31", "description": "Basecamp API", "contact": { "name": "Basecamp", @@ -12190,6 +12190,89 @@ } } }, + "/{accountId}/my/recent_projects.json": { + "get": { + "description": "List the projects the current user has most recently visited, most recent visit first.\nReads the per-user visit log \u2014 capped at the 50 most recent visits, keeping\nonly active projects the user can still access \u2014 not the home grid's\npinned-exclusion and padding. This endpoint is not paginated. Each entry is\nthe standard project projection plus the current user's bookmarked flag. A\nvisit is recorded when the user opens a project in Basecamp, when they\ncreate one, and by RecordProjectVisit.", + "operationId": "ListRecentProjects", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "ListRecentProjects 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListRecentProjectsResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Projects" + ], + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, "/{accountId}/my/unreads.json": { "put": { "description": "Mark specified items as read", @@ -14221,10 +14304,10 @@ } } }, - "/{accountId}/projects/{projectId}/status/active.json": { - "put": { - "description": "Restore a project to active status from trash as well as from the archive (returns 204 No Content).\nThis is the inverse of both ArchiveProject and TrashProject. Restoring counts against\nthe account's project limit, so it answers 507 when that limit is already reached.", - "operationId": "UnarchiveProject", + "/{accountId}/projects/{projectId}/recent_visit.json": { + "post": { + "description": "Record that the current user visited a project, moving it to the front of ListRecentProjects (returns 204 No Content).\nIdempotent: re-recording a visit refreshes the same entry. Visits to\narchived or trashed projects are accepted but not recorded, and an\ninaccessible project answers 404.", + "operationId": "RecordProjectVisit", "parameters": [ { "name": "accountId", @@ -14249,7 +14332,7 @@ ], "responses": { "204": { - "description": "UnarchiveProject 204 response" + "description": "RecordProjectVisit 204 response" }, "401": { "description": "UnauthorizedError 401 response", @@ -14281,22 +14364,22 @@ } } }, - "500": { - "description": "InternalServerError 500 response", + "429": { + "description": "RateLimitError 429 response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InternalServerErrorResponseContent" + "$ref": "#/components/schemas/RateLimitErrorResponseContent" } } } }, - "507": { - "description": "ProjectLimitError 507 response", + "500": { + "description": "InternalServerError 500 response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectLimitErrorResponseContent" + "$ref": "#/components/schemas/InternalServerErrorResponseContent" } } } @@ -14319,10 +14402,10 @@ } } }, - "/{accountId}/projects/{projectId}/status/archived.json": { + "/{accountId}/projects/{projectId}/status/active.json": { "put": { - "description": "Archive a project, removing it from the active project list (returns 204 No Content).\nAccounts on the admin pro pack may restrict archiving to admins and the project's\ncreator, which answers 403.", - "operationId": "ArchiveProject", + "description": "Restore a project to active status from trash as well as from the archive (returns 204 No Content).\nThis is the inverse of both ArchiveProject and TrashProject. Restoring counts against\nthe account's project limit, so it answers 507 when that limit is already reached.", + "operationId": "UnarchiveProject", "parameters": [ { "name": "accountId", @@ -14347,7 +14430,7 @@ ], "responses": { "204": { - "description": "ArchiveProject 204 response" + "description": "UnarchiveProject 204 response" }, "401": { "description": "UnauthorizedError 401 response", @@ -14388,6 +14471,16 @@ } } } + }, + "507": { + "description": "ProjectLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectLimitErrorResponseContent" + } + } + } } }, "tags": [ @@ -14407,10 +14500,10 @@ } } }, - "/{accountId}/projects/{projectId}/timeline.json": { - "get": { - "description": "Get project timeline", - "operationId": "GetProjectTimeline", + "/{accountId}/projects/{projectId}/status/archived.json": { + "put": { + "description": "Archive a project, removing it from the active project list (returns 204 No Content).\nAccounts on the admin pro pack may restrict archiving to admins and the project's\ncreator, which answers 403.", + "operationId": "ArchiveProject", "parameters": [ { "name": "accountId", @@ -14431,28 +14524,11 @@ "format": "int64" }, "required": true - }, - { - "name": "page", - "in": "query", - "description": "Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.", - "schema": { - "type": "integer", - "description": "Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.", - "format": "int32" - } } ], "responses": { - "200": { - "description": "GetProjectTimeline 200 response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetProjectTimelineResponseContent" - } - } - } + "204": { + "description": "ArchiveProject 204 response" }, "401": { "description": "UnauthorizedError 401 response", @@ -14484,16 +14560,6 @@ } } }, - "429": { - "description": "RateLimitError 429 response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RateLimitErrorResponseContent" - } - } - } - }, "500": { "description": "InternalServerError 500 response", "content": { @@ -14505,10 +14571,11 @@ } } }, - "x-basecamp-pagination": { - "style": "link", - "totalCountHeader": "X-Total-Count", - "maxPageSize": 50 + "tags": [ + "Projects" + ], + "x-basecamp-idempotent": { + "natural": true }, "x-basecamp-retry": { "maxAttempts": 3, @@ -14518,16 +14585,13 @@ 429, 503 ] - }, - "tags": [ - "Reports" - ] + } } }, - "/{accountId}/projects/{projectId}/timesheet.json": { + "/{accountId}/projects/{projectId}/timeline.json": { "get": { - "description": "Get timesheet for a specific project", - "operationId": "GetProjectTimesheet", + "description": "Get project timeline", + "operationId": "GetProjectTimeline", "parameters": [ { "name": "accountId", @@ -14549,28 +14613,6 @@ }, "required": true }, - { - "name": "from", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "to", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "person_id", - "in": "query", - "schema": { - "type": "integer", - "format": "int64" - } - }, { "name": "page", "in": "query", @@ -14584,11 +14626,11 @@ ], "responses": { "200": { - "description": "GetProjectTimesheet 200 response", + "description": "GetProjectTimeline 200 response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetProjectTimesheetResponseContent" + "$ref": "#/components/schemas/GetProjectTimelineResponseContent" } } } @@ -14623,6 +14665,16 @@ } } }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, "500": { "description": "InternalServerError 500 response", "content": { @@ -14634,9 +14686,6 @@ } } }, - "tags": [ - "Schedule" - ], "x-basecamp-pagination": { "style": "link", "totalCountHeader": "X-Total-Count", @@ -14650,13 +14699,16 @@ 429, 503 ] - } + }, + "tags": [ + "Reports" + ] } }, - "/{accountId}/question_answers/{answerId}": { + "/{accountId}/projects/{projectId}/timesheet.json": { "get": { - "description": "Get a single answer by id", - "operationId": "GetAnswer", + "description": "Get timesheet for a specific project", + "operationId": "GetProjectTimesheet", "parameters": [ { "name": "accountId", @@ -14670,22 +14722,151 @@ "required": true }, { - "name": "answerId", + "name": "projectId", "in": "path", "schema": { "type": "integer", "format": "int64" }, "required": true + }, + { + "name": "from", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "person_id", + "in": "query", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.", + "schema": { + "type": "integer", + "description": "Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.", + "format": "int32" + } } ], "responses": { "200": { - "description": "GetAnswer 200 response", + "description": "GetProjectTimesheet 200 response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetAnswerResponseContent" + "$ref": "#/components/schemas/GetProjectTimesheetResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Schedule" + ], + "x-basecamp-pagination": { + "style": "link", + "totalCountHeader": "X-Total-Count", + "maxPageSize": 50 + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, + "/{accountId}/question_answers/{answerId}": { + "get": { + "description": "Get a single answer by id", + "operationId": "GetAnswer", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "answerId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "GetAnswer 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAnswerResponseContent" } } } @@ -16823,6 +17004,209 @@ } } }, + "/{accountId}/recordings/{recordingId}/bubble_up.json": { + "delete": { + "description": "Remove the current user's bubble-up from a recording (returns 204 No Content).\nIdempotent: popping an absent bubble-up also returns 204.", + "operationId": "DeleteBubbleUp", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "recordingId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "DeleteBubbleUp 204 response" + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "BubbleUps" + ], + "x-basecamp-idempotent": { + "natural": true + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + }, + "post": { + "description": "Bubble up a recording for the current user, resurfacing it in the current\nuser's readings (the BC5 successor to \"save\"). Returns 204 No Content with\nno body.\n\nThe `at` field controls timing. Send `\"now\"` to bubble up immediately, or a\nscheduling keyword (`\"today\"`, `\"tomorrow\"`, `\"weekend\"`, `\"next_week\"`) or\nan ISO8601 date (e.g. `\"2026-09-10\"`) to schedule it to resurface later.\nNOTE: bc3 currently requires `at` \u2014 omitting it raises on the server\n(`Date.iso8601(nil)`), so send `\"now\"` for the immediate case. The field is\nmodeled optional (not `@required`) so a future bc3 default (`params[:at] ||=\n\"now\"`) makes omission mean \"now\" without an SDK change.\n\nIdempotent: bubbling up an already-bubbled recording is set-membership and\nstill returns 204.", + "operationId": "CreateBubbleUp", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBubbleUpRequestContent" + } + } + } + }, + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "recordingId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "CreateBubbleUp 204 response" + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "BubbleUps" + ], + "x-basecamp-idempotent": { + "natural": true + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, "/{accountId}/recordings/{recordingId}/client_visibility.json": { "put": { "description": "Set client visibility for a recording", @@ -17497,6 +17881,217 @@ } } }, + "/{accountId}/recordings/{recordingId}/spotlight.json": { + "delete": { + "description": "Remove a recording from the spotlight area.\nIdempotent: removing an absent spotlight also returns 204.", + "operationId": "UnspotlightRecording", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "recordingId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "UnspotlightRecording 204 response" + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Automation" + ], + "x-basecamp-idempotent": { + "natural": true + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + }, + "post": { + "description": "Put a recording's card in the spotlight area on its project or template home page.\nIdempotent: spotlighting an already-spotlighted recording still returns 201.", + "operationId": "SpotlightRecording", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "recordingId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "201": { + "description": "SpotlightRecording 201 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SpotlightRecordingResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "422": { + "description": "ValidationError 422 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Automation" + ], + "x-basecamp-idempotent": { + "natural": true + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, "/{accountId}/recordings/{recordingId}/status/active.json": { "put": { "description": "Unarchive a recording (restore to active status)", @@ -28081,6 +28676,15 @@ "CreateBookmarkResponseContent": { "$ref": "#/components/schemas/Bookmark" }, + "CreateBubbleUpRequestContent": { + "type": "object", + "properties": { + "at": { + "type": "string", + "description": "Timing for the bubble-up. `\"now\"` bubbles up immediately; a scheduling\nkeyword (`\"today\"`, `\"tomorrow\"`, `\"weekend\"`, `\"next_week\"`) or an ISO8601\ndate (e.g. `\"2026-09-10\"`) schedules it to resurface later. bc3 requires a\nvalue \u2014 omitting `at` errors server-side (`Date.iso8601(nil)`) \u2014 so send\n`\"now\"` for the immediate case." + } + } + }, "CreateCampfireLineRequestContent": { "type": "object", "properties": { @@ -31030,6 +31634,12 @@ "$ref": "#/components/schemas/Question" } }, + "ListRecentProjectsResponseContent": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + }, "ListRecordingBoostsResponseContent": { "type": "array", "items": { @@ -32058,6 +32668,10 @@ "bookmark_url": { "type": "string" }, + "star_url": { + "type": "string", + "description": "Bucket stars collection for this project (`/buckets/{id}/stars.json`)." + }, "url": { "type": "string" }, @@ -32072,7 +32686,12 @@ "x-go-type-skip-optional-pointer": true }, "bookmarked": { - "type": "boolean" + "type": "boolean", + "description": "True when the project is pinned on the current user's home page at all,\nwhether starred or filed into a stack." + }, + "starred": { + "type": "boolean", + "description": "True only when the project carries a star on the current user's home page." }, "client_company": { "$ref": "#/components/schemas/ClientCompany" @@ -33720,6 +34339,9 @@ "SetClientVisibilityResponseContent": { "$ref": "#/components/schemas/Recording" }, + "SpotlightRecordingResponseContent": { + "$ref": "#/components/schemas/Recording" + }, "StorageLimitErrorResponseContent": { "type": "object", "description": "The account has reached its file storage limit.\n\nRaised by ResourceLimits#ensure_account_can_upload_files ahead of any operation\nthat stores new bytes. No retry can satisfy it: the account needs more storage,\nso this maps to `limit_exceeded` rather than a retryable server error.", diff --git a/internal/mcpserver/testdata/catalog_snapshot.txt b/internal/mcpserver/testdata/catalog_snapshot.txt index a9a1024f2..0eedc297e 100644 --- a/internal/mcpserver/testdata/catalog_snapshot.txt +++ b/internal/mcpserver/testdata/catalog_snapshot.txt @@ -9,6 +9,8 @@ ACTIONS (RO = read-only): - create_project: Create a new project - get_project (RO): Get a single project by id - list_projects (RO, paginated): List projects (active by default; optionally archived/trashed) +- list_recent_projects (RO): List the projects the current user has most recently visited, most recent visit first +- record_project_visit: Record that the current user visited a project, moving it to the front of ListRecentProjects (returns 204 No Content) - trash_project: Trash a project (returns 204 No Content) - unarchive_project: Restore a project to active status from trash as well as from the archive (returns 204 No Content) - update_project: Update an existing project @@ -254,8 +256,10 @@ ACTIONS (RO = read-only): - reposition_tool: Reposition a tool on the project dock - resume_question: Resume a paused check-in question (resumes sending reminders) - search (RO, paginated): Search for content across the account +- spotlight_recording: Put a recording's card in the spotlight area on its project or template home page - trash_recording: Trash a recording - unarchive_recording: Unarchive a recording (restore to active status) +- unspotlight_recording: Remove a recording from the spotlight area - update_answer: Update an existing answer - update_lineup_marker: Update an existing lineup marker - update_question: Update an existing question @@ -332,15 +336,17 @@ ACTIONS (RO = read-only): - list_forwards (RO, paginated): List all forwards in an inbox == basecamp_account -Account info and your personal surface: gauges and needles, my assignments and priorities, notifications and bubble-ups, bookmarks, personal note, and drafts. +Account info and your personal surface: gauges and needles, my assignments and priorities, notifications and bubble-ups, bookmarks, bubbling recordings up, personal note, and drafts. Gateway tool: call with {"action": "...", "params": {...}}. Call {"action": "describe", "params": {"action": "NAME"}} for an action's full parameter schema. ACTIONS (RO = read-only): - create_bookmark: Bookmark a recording for the current user +- create_bubble_up: Bubble up a recording for the current user, resurfacing it in the current user's readings (the BC5 successor to "save") - create_gauge_needle: Create a gauge needle (progress update) for a project - delete_bookmark: Remove the current user's bookmark from a recording (returns 204 No Content) +- delete_bubble_up: Remove the current user's bubble-up from a recording (returns 204 No Content) - deprioritize_assignment: Remove a recording from Up Next (returns 204 No Content) - destroy_gauge_needle: Destroy a gauge needle - get_account (RO): Get the account for the current access token diff --git a/internal/tui/resolve/project.go b/internal/tui/resolve/project.go index df103cbda..12ea5edf6 100644 --- a/internal/tui/resolve/project.go +++ b/internal/tui/resolve/project.go @@ -112,7 +112,7 @@ func (r *Resolver) ProjectWithPersist(ctx context.Context) (*ResolvedValue, erro // projectToPickerItem converts a Basecamp project to a picker item. func projectToPickerItem(proj basecamp.Project) tui.PickerItem { title := proj.Name - if proj.Bookmarked { + if proj.Starred { title = "★ " + title } diff --git a/internal/tui/resolve/project_test.go b/internal/tui/resolve/project_test.go new file mode 100644 index 000000000..c6ac588f7 --- /dev/null +++ b/internal/tui/resolve/project_test.go @@ -0,0 +1,19 @@ +package resolve + +import ( + "strings" + "testing" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + "github.com/stretchr/testify/assert" +) + +// The ★ prefix marks a starred project, so it reflects Starred, not Bookmarked: +// a project filed into a stack is bookmarked but unstarred and gets no star. +func TestProjectToPickerItem_StarMarkerReflectsStarred(t *testing.T) { + stackedNotStarred := projectToPickerItem(basecamp.Project{ID: 1, Name: "Stacked", Bookmarked: true, Starred: false}) + assert.False(t, strings.HasPrefix(stackedNotStarred.Title, "★"), "a bookmarked-but-unstarred project gets no star") + + starred := projectToPickerItem(basecamp.Project{ID: 2, Name: "Starred", Bookmarked: true, Starred: true}) + assert.True(t, strings.HasPrefix(starred.Title, "★"), "a starred project gets a star prefix") +} diff --git a/internal/tui/workspace/chrome/quickjump.go b/internal/tui/workspace/chrome/quickjump.go index 4bb79d2ec..2cce6a942 100644 --- a/internal/tui/workspace/chrome/quickjump.go +++ b/internal/tui/workspace/chrome/quickjump.go @@ -195,10 +195,10 @@ func (q *QuickJump) populateItems(src QuickJumpSource) { }) } - // 3. Bookmarked projects + // 3. Starred projects (this category renders as "Starred") for _, p := range src.Projects { id := fmt.Sprintf("%d", p.ID) - if seen[id] || !p.Bookmarked { + if seen[id] || !p.Starred { continue } seen[id] = true diff --git a/internal/tui/workspace/chrome/quickjump_test.go b/internal/tui/workspace/chrome/quickjump_test.go index d25091246..a4f5d5383 100644 --- a/internal/tui/workspace/chrome/quickjump_test.go +++ b/internal/tui/workspace/chrome/quickjump_test.go @@ -49,6 +49,31 @@ func testQuickJumpSource() QuickJumpSource { } } +// The "bookmark" category renders as "Starred", so it must be populated from +// Starred, not Bookmarked: a project filed into a stack is bookmarked but +// unstarred and must not appear there. +func TestQuickJump_StarredCategoryUsesStarred(t *testing.T) { + styles := tui.NewStyles() + qj := NewQuickJump(styles) + qj.SetSize(80, 24) + + src := testQuickJumpSource() + src.Projects = []data.ProjectInfo{ + {ID: 300, Name: "Starred Project", AccountID: "acct1", Bookmarked: true, Starred: true}, + {ID: 301, Name: "Stacked Only", AccountID: "acct1", Bookmarked: true, Starred: false}, + } + qj.Focus(src) + + inStarred := map[string]bool{} + for _, item := range qj.items { + if item.Category == "bookmark" { + inStarred[item.Title] = true + } + } + assert.True(t, inStarred["Starred Project"], "a starred project belongs in the Starred category") + assert.False(t, inStarred["Stacked Only"], "a bookmarked-but-unstarred project must not appear as Starred") +} + func TestQuickJump_ToolItems_Appear(t *testing.T) { styles := tui.NewStyles() qj := NewQuickJump(styles) diff --git a/internal/tui/workspace/data/hub_global.go b/internal/tui/workspace/data/hub_global.go index f89caf693..6e9754826 100644 --- a/internal/tui/workspace/data/hub_global.go +++ b/internal/tui/workspace/data/hub_global.go @@ -779,6 +779,7 @@ func projectsToInfos(projects []basecamp.Project, acct AccountInfo) []ProjectInf Description: p.Description, Purpose: p.Purpose, Bookmarked: p.Bookmarked, + Starred: p.Starred, AccountID: acct.ID, AccountName: acct.Name, Dock: dock, diff --git a/internal/tui/workspace/data/types.go b/internal/tui/workspace/data/types.go index ce7ecf720..feec3554c 100644 --- a/internal/tui/workspace/data/types.go +++ b/internal/tui/workspace/data/types.go @@ -215,6 +215,7 @@ type ProjectInfo struct { Description string Purpose string Bookmarked bool + Starred bool AccountID string AccountName string Dock []DockToolInfo diff --git a/internal/tui/workspace/session.go b/internal/tui/workspace/session.go index 223160609..d0c5d7e4c 100644 --- a/internal/tui/workspace/session.go +++ b/internal/tui/workspace/session.go @@ -224,6 +224,19 @@ func NewTestSessionWithScope(scope Scope) *Session { return s } +// NewTestSessionWithClient returns a test Session whose AccountClient is backed +// by the given SDK client — typically wired to a stub transport — under the +// given scope, so tests can exercise view actions that issue account-scoped +// HTTP requests (e.g. toggling a project's star). +func NewTestSessionWithClient(sdk *basecamp.Client, scope Scope) *Session { + s := NewTestSession() + s.app = &appctx.App{SDK: sdk} + s.multiStore = data.NewMultiStore(sdk) + s.hub = data.NewHub(s.multiStore, scope.AccountID) + s.scope = scope + return s +} + // NewTestSessionWithRecents is like NewTestSession but includes a recents store. func NewTestSessionWithRecents(r *recents.Store) *Session { s := NewTestSession() diff --git a/internal/tui/workspace/views/dock.go b/internal/tui/workspace/views/dock.go index d4048f280..9edd76f49 100644 --- a/internal/tui/workspace/views/dock.go +++ b/internal/tui/workspace/views/dock.go @@ -195,6 +195,7 @@ func (v *Dock) Update(msg tea.Msg) (workspace.View, tea.Cmd) { Description: msg.Project.Description, Purpose: msg.Project.Purpose, Bookmarked: msg.Project.Bookmarked, + Starred: msg.Project.Starred, Dock: dock, } v.syncTools() diff --git a/internal/tui/workspace/views/projects.go b/internal/tui/workspace/views/projects.go index 4173cdaab..23fc1fb25 100644 --- a/internal/tui/workspace/views/projects.go +++ b/internal/tui/workspace/views/projects.go @@ -216,7 +216,7 @@ func (v *Projects) Update(msg tea.Msg) (workspace.View, tea.Cmd) { // Revert optimistic update p := v.findProject(msg.ProjectID) if p != nil { - p.Bookmarked = !msg.Bookmarked + p.Starred = !msg.Bookmarked v.syncProjectList() } return v, workspace.ReportError(msg.Err, "toggling bookmark") @@ -509,7 +509,7 @@ func projectInfoToListItem(p data.ProjectInfo) widget.ListItem { ID: fmt.Sprintf("%d", p.ID), Title: p.Name, Description: desc, - Marked: p.Bookmarked, + Marked: p.Starred, } } @@ -734,12 +734,14 @@ func (v *Projects) toggleBookmark() tea.Cmd { return nil } - newBookmarked := !p.Bookmarked + // The toggle drives /projects/{id}/star.json, so it tracks Starred — a + // project filed into a stack is Bookmarked but not necessarily Starred. + newStarred := !p.Starred // Optimistic: flip in local data, re-sort - p.Bookmarked = newBookmarked + p.Starred = newStarred v.syncProjectList() - return v.setBookmark(projectID, newBookmarked) + return v.setBookmark(projectID, newStarred) } func (v *Projects) setBookmark(projectID int64, bookmarked bool) tea.Cmd { diff --git a/internal/tui/workspace/views/projects_test.go b/internal/tui/workspace/views/projects_test.go index 7e58a61f1..d7160395b 100644 --- a/internal/tui/workspace/views/projects_test.go +++ b/internal/tui/workspace/views/projects_test.go @@ -1,18 +1,82 @@ package views import ( + "bytes" + "context" "fmt" + "io" + "net/http" + "sync" "testing" tea "charm.land/bubbletea/v2" + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/basecamp/basecamp-cli/internal/tui" + "github.com/basecamp/basecamp-cli/internal/tui/workspace" "github.com/basecamp/basecamp-cli/internal/tui/workspace/data" "github.com/basecamp/basecamp-cli/internal/tui/workspace/widget" ) +// starToggleTransport records the account-scoped requests the star toggle issues +// and answers each with a bare 204, matching bc3's /star.json contract. +type starToggleTransport struct { + mu sync.Mutex + requests []string +} + +func (s *starToggleTransport) RoundTrip(req *http.Request) (*http.Response, error) { + s.mu.Lock() + s.requests = append(s.requests, req.Method+" "+req.URL.Path) + s.mu.Unlock() + return &http.Response{ + StatusCode: http.StatusNoContent, + Body: io.NopCloser(bytes.NewReader(nil)), + Header: make(http.Header), + Request: req, + }, nil +} + +type starTestToken struct{} + +func (starTestToken) AccessToken(context.Context) (string, error) { return "test-token", nil } + +// Toggling a project filed into a stack (Bookmarked, not Starred) stars it: it +// flips Starred (not Bookmarked), POSTs /star.json (never DELETE), and rolls the +// star back on error. This guards the star-vs-bookmark field identity through +// the actual toggle path, not just the marker. +func TestProjects_ToggleStarsStackedProject(t *testing.T) { + transport := &starToggleTransport{} + sdk := basecamp.NewClient(&basecamp.Config{BaseURL: "https://3.basecampapi.com"}, starTestToken{}, + basecamp.WithTransport(transport), basecamp.WithMaxRetries(1)) + sess := workspace.NewTestSessionWithClient(sdk, workspace.Scope{AccountID: "99999"}) + + v := testProjectsView([]data.ProjectInfo{ + {ID: 42, Name: "Stacked", AccountID: "99999", Bookmarked: true, Starred: false}, + }) + v.session = sess + + cmd := v.toggleBookmark() + require.NotNil(t, cmd) + + p := v.findProject(42) + require.NotNil(t, p) + assert.True(t, p.Starred, "toggling a stacked-but-unstarred project stars it") + assert.True(t, p.Bookmarked, "the pinned/bookmark state is untouched") + + msg := cmd() + bm, ok := msg.(workspace.ProjectBookmarkedMsg) + require.True(t, ok, "toggle dispatch returns a ProjectBookmarkedMsg") + require.NoError(t, bm.Err) + assert.Equal(t, []string{"POST /99999/projects/42/star.json"}, transport.requests, + "starring POSTs to /star.json (a regression to Bookmarked would DELETE an already-unstarred project)") + + v.Update(workspace.ProjectBookmarkedMsg{ProjectID: 42, Bookmarked: true, Err: assert.AnError}) + assert.False(t, v.findProject(42).Starred, "a failed toggle rolls the star back") +} + // testProjectsView builds a Projects view with pre-populated data for unit // testing focus management and key routing. Session is nil — tests that // trigger navigation (openTool, navigateToTool) are not covered here. @@ -397,6 +461,16 @@ func TestProjectInfoToListItem_UnicodeDescription(t *testing.T) { assert.Equal(t, item.Description, string([]rune(item.Description))) } +// The list star marker reflects Starred, not Bookmarked: a project filed into a +// stack is Bookmarked but unstarred, and the /star.json toggle acts on the star. +func TestProjectInfoToListItem_MarkedReflectsStarred(t *testing.T) { + stackedNotStarred := projectInfoToListItem(data.ProjectInfo{ID: 1, Name: "Stacked", Bookmarked: true, Starred: false}) + assert.False(t, stackedNotStarred.Marked, "a bookmarked-but-unstarred project is not marked") + + starred := projectInfoToListItem(data.ProjectInfo{ID: 2, Name: "Starred", Bookmarked: true, Starred: true}) + assert.True(t, starred.Marked, "a starred project is marked") +} + func TestToolNameToView(t *testing.T) { tests := []struct { name string diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index 3ccb5bcf6..589d7f387 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.15.0", - "revision": "1dd547b3fd85", - "updated_at": "2026-08-22T09:57:22Z" + "version": "v0.15.1-0.20260902205921-f439f493caf2", + "revision": "f439f493caf2", + "updated_at": "2026-09-02T20:59:21Z" }, "api": { "repo": "basecamp/bc3", - "revision": "71b43f3d9fa90a6c26e5af21b02697e33e7f0bae", - "synced_at": "2026-08-11" + "revision": "88549ca619eafc43e88639d0533f0d02e442edec", + "synced_at": "2026-08-31" } } diff --git a/nix/package.nix b/nix/package.nix index 761141783..9b811eb51 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -8,7 +8,7 @@ buildGoModule.override { go = go_1_26; } (finalAttrs: { src = lib.cleanSource ./..; # To update: set to lib.fakeHash, run `nix build`, use the hash from the error. - vendorHash = "sha256-2N7ar+f9S2WjppdHFbeiOhoREM81+yhTPn4bJZBGG/U="; + vendorHash = "sha256-OFz67aZDTfQZMS+4MmZM960KquS9IoXXH3WN6cn778Y="; subPackages = [ "cmd/basecamp" ]; diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index ca867626c..a09c2b8c2 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -4,7 +4,7 @@ description: | Interact with Basecamp via the Basecamp CLI. Full API coverage: projects, todos, cards, messages, files, schedule, check-ins, timeline, recordings, templates, webhooks, subscriptions, lineup, chat, pings, gauges, assignments, notifications, bookmarks, - drafts, notes, calendars, and accounts. + bubble-up, drafts, notes, calendars, and accounts. Use for ANY Basecamp question or action. triggers: # Direct invocations @@ -20,6 +20,7 @@ triggers: - basecamp file - basecamp document - basecamp bookmarks + - basecamp bubble-up - basecamp drafts - basecamp notes - basecamp calendars @@ -215,6 +216,9 @@ basecamp --page 1 # First page only, no auto-pagination | My bookmarks | `basecamp bookmarks list --json` | | Bookmark something | `basecamp bookmarks add --json` | | Is it bookmarked? | `basecamp bookmarks check --json` (always exits 0) | +| Bubble a recording up | `basecamp bubble-up add --json` | +| Schedule a bubble-up | `basecamp bubble-up add --at tomorrow --json` | +| Pop a bubble-up | `basecamp bubble-up remove --json` | | My unpublished drafts | `basecamp drafts list --json` | | Read my personal note | `basecamp notes show --json` | | Replace my personal note | `basecamp notes set "" --json` | @@ -1084,7 +1088,7 @@ success while changing nothing. If two steps on one card are prioritized, the listing shows the card once with a single `priority_recording_id` and the siblings are not separately addressable. -### Personal (bookmarks, drafts, notes) +### Personal (bookmarks, bubble-up, drafts, notes) Private to you, spanning every project — no `--in `. @@ -1093,11 +1097,21 @@ basecamp bookmarks list --json basecamp bookmarks add --json basecamp bookmarks remove --json basecamp bookmarks check --json +basecamp bubble-up add --json +basecamp bubble-up add --at tomorrow --json +basecamp bubble-up remove --json basecamp drafts list --json basecamp notes show --json basecamp notes set "" --json ``` +`bubble-up add`/`remove` resurface a recording in your readings (the BC5 +successor to "save"), addressed by id or pasted URL. `add` bubbles up now by +default; `--at` schedules it — a keyword (`today`, `tomorrow`, `weekend`, +`next_week`) or a calendar date (`YYYY-MM-DD`). Both verbs are idempotent. There is no +per-recording status read (that GET is an unrenderable API gap); the full list +is `basecamp notifications bubbleups`. + `bookmarks add` and `remove` are idempotent — re-adding returns the existing bookmark, removing an absent one still succeeds. `check` reports `{"bookmarked": true|false}` and **always exits 0**: both answers are successes,