diff --git a/.surface b/.surface index 46458088c..41bf791e4 100644 --- a/.surface +++ b/.surface @@ -356,6 +356,7 @@ ARG basecamp schedule update 00 ARG basecamp search 00 ARG basecamp show 00 [type] ARG basecamp show 01 +ARG basecamp spotlight 00 ARG basecamp subscriptions add 00 ARG basecamp subscriptions add 01 [person_ids] ARG basecamp subscriptions remove 00 @@ -463,6 +464,7 @@ ARG basecamp tools update 00 ARG basecamp tools update 01 ARG basecamp tui 00 [url] ARG basecamp unassign 00 <id|url>... +ARG basecamp unspotlight 00 <id|url> ARG basecamp upload 00 <file> ARG basecamp uploads create 00 <file> ARG basecamp uploads show 00 <id|url> @@ -1066,6 +1068,7 @@ CMD basecamp setup grok CMD basecamp show CMD basecamp skill CMD basecamp skill install +CMD basecamp spotlight CMD basecamp subscriptions CMD basecamp subscriptions add CMD basecamp subscriptions remove @@ -1183,6 +1186,7 @@ CMD basecamp tools trash CMD basecamp tools update CMD basecamp tui CMD basecamp unassign +CMD basecamp unspotlight CMD basecamp upgrade CMD basecamp upload CMD basecamp uploads @@ -14171,6 +14175,27 @@ FLAG basecamp skill install --stats type=bool FLAG basecamp skill install --styled type=bool FLAG basecamp skill install --todolist type=string FLAG basecamp skill install --verbose type=count +FLAG basecamp spotlight --account type=string +FLAG basecamp spotlight --agent type=bool +FLAG basecamp spotlight --cache-dir type=string +FLAG basecamp spotlight --count type=bool +FLAG basecamp spotlight --help type=bool +FLAG basecamp spotlight --hints type=bool +FLAG basecamp spotlight --ids-only type=bool +FLAG basecamp spotlight --in type=string +FLAG basecamp spotlight --jq type=string +FLAG basecamp spotlight --json type=bool +FLAG basecamp spotlight --markdown type=bool +FLAG basecamp spotlight --md type=bool +FLAG basecamp spotlight --no-hints type=bool +FLAG basecamp spotlight --no-stats type=bool +FLAG basecamp spotlight --profile type=string +FLAG basecamp spotlight --project type=string +FLAG basecamp spotlight --quiet type=bool +FLAG basecamp spotlight --stats type=bool +FLAG basecamp spotlight --styled type=bool +FLAG basecamp spotlight --todolist type=string +FLAG basecamp spotlight --verbose type=count FLAG basecamp subscriptions --account type=string FLAG basecamp subscriptions --agent type=bool FLAG basecamp subscriptions --cache-dir type=string @@ -16845,6 +16870,27 @@ FLAG basecamp unassign --step type=bool FLAG basecamp unassign --styled type=bool FLAG basecamp unassign --todolist type=string FLAG basecamp unassign --verbose type=count +FLAG basecamp unspotlight --account type=string +FLAG basecamp unspotlight --agent type=bool +FLAG basecamp unspotlight --cache-dir type=string +FLAG basecamp unspotlight --count type=bool +FLAG basecamp unspotlight --help type=bool +FLAG basecamp unspotlight --hints type=bool +FLAG basecamp unspotlight --ids-only type=bool +FLAG basecamp unspotlight --in type=string +FLAG basecamp unspotlight --jq type=string +FLAG basecamp unspotlight --json type=bool +FLAG basecamp unspotlight --markdown type=bool +FLAG basecamp unspotlight --md type=bool +FLAG basecamp unspotlight --no-hints type=bool +FLAG basecamp unspotlight --no-stats type=bool +FLAG basecamp unspotlight --profile type=string +FLAG basecamp unspotlight --project type=string +FLAG basecamp unspotlight --quiet type=bool +FLAG basecamp unspotlight --stats type=bool +FLAG basecamp unspotlight --styled type=bool +FLAG basecamp unspotlight --todolist type=string +FLAG basecamp unspotlight --verbose type=count FLAG basecamp upgrade --account type=string FLAG basecamp upgrade --agent type=bool FLAG basecamp upgrade --cache-dir type=string @@ -19865,6 +19911,7 @@ SUB basecamp setup grok SUB basecamp show SUB basecamp skill SUB basecamp skill install +SUB basecamp spotlight SUB basecamp subscriptions SUB basecamp subscriptions add SUB basecamp subscriptions remove @@ -19982,6 +20029,7 @@ SUB basecamp tools trash SUB basecamp tools update SUB basecamp tui SUB basecamp unassign +SUB basecamp unspotlight SUB basecamp upgrade SUB basecamp upload SUB basecamp uploads diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 58733df26..5a8bc5452 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -6,12 +6,14 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. | Status | Sections | Endpoints | |--------|----------|-----------| -| ✅ Implemented | 54 | 209 | +| ✅ Implemented | 55 | 211 | | ⚠️ Blocked | 0 | 0 | | ⏭️ Out of scope | 4 | 12 | -| **Total tracked** | **58** | **221** | +| **Total tracked** | **59** | **223** | -**209 of 209 tracked in-scope endpoints.** The two embed endpoints bc3 documents +**211 of 211 tracked in-scope endpoints.** Spotlighting a recording on its +project or template home page (`POST`/`DELETE /recordings/:id/spotlight.json`, +SDK v0.27.0) lands as `spotlight` and `unspotlight`. The two embed endpoints bc3 documents in `embeds.md` — `POST /embeds.json` and `GET /embeds/:sgid.json` — land as `embeds`. The three Pings endpoints bc3#13943 documented — `GET /circles.json`, `GET /circles/:id.json` and `POST @@ -218,6 +220,7 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A | boosts | 7 | `boost`, `react` | ✅ | BC4 | - | list (recording + event), show, create (recording + event), update (`PUT /boosts/{id}`, the creator only; same 16-character cap as create), 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 | 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`. | +| spotlights | 2 | `spotlight`, `unspotlight` | ✅ | BC5 | - | `POST`/`DELETE /recordings/{id}/spotlight.json` put a recording's card on, or take it off, its project or template home page; both idempotent. No endpoint reads spotlight state or lists spotlights, so there is no `check` or list. Reordering spotlights (`PUT /recordings/:id/spotlight/position`) has no SDK operation, so it is not covered | | **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/smoke/smoke_misc_write.bats b/e2e/smoke/smoke_misc_write.bats index b0fd3d05b..2243c68b3 100644 --- a/e2e/smoke/smoke_misc_write.bats +++ b/e2e/smoke/smoke_misc_write.bats @@ -1,5 +1,5 @@ #!/usr/bin/env bats -# smoke_misc_write.bats - Level 1: Schedule settings, recordings trash/restore +# smoke_misc_write.bats - Level 1: Schedule settings, recordings trash/restore, spotlight load smoke_helper @@ -82,3 +82,30 @@ setup_file() { assert_success assert_json_value '.ok' 'true' } + +@test "spotlight and unspotlight a recording" { + ensure_todolist || mark_unverifiable "Cannot discover todolist for spotlight" + + # A throwaway todo: unspotlight would otherwise remove a spotlight someone + # set, and there is no read API to put it back. + local todo_out + todo_out=$(basecamp todos create "Spotlight target $(date +%s)" --list "$QA_TODOLIST" -p "$QA_PROJECT" --json 2>/dev/null) || { + mark_unverifiable "Cannot create todo for spotlight test" + return + } + local todo_id + todo_id=$(echo "$todo_out" | jq -r '.data.id // empty') + [[ -n "$todo_id" ]] || mark_unverifiable "No todo ID returned" + + run_smoke basecamp spotlight "$todo_id" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.id' "$todo_id" + + run_smoke basecamp unspotlight "$todo_id" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.spotlighted' 'false' + + basecamp recordings trash "$todo_id" -p "$QA_PROJECT" --json >/dev/null 2>&1 || true +} diff --git a/e2e/spotlight.bats b/e2e/spotlight.bats new file mode 100644 index 000000000..5ab91ec42 --- /dev/null +++ b/e2e/spotlight.bats @@ -0,0 +1,23 @@ +#!/usr/bin/env bats +# spotlight.bats - Test spotlight and unspotlight error handling + +load test_helper + + +@test "spotlight without recording id shows error" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp spotlight + assert_failure + assert_output_contains "ID required" +} + +@test "unspotlight without recording id shows error" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp unspotlight + assert_failure + assert_output_contains "ID required" +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 1069c8a3b..218ce71b7 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -319,6 +319,8 @@ func Execute() { cmd.AddCommand(commands.NewCommentsCmd()) cmd.AddCommand(commands.NewAssignCmd()) cmd.AddCommand(commands.NewUnassignCmd()) + cmd.AddCommand(commands.NewSpotlightCmd()) + cmd.AddCommand(commands.NewUnspotlightCmd()) cmd.AddCommand(commands.NewMessagesCmd()) cmd.AddCommand(commands.NewCardsCmd()) cmd.AddCommand(commands.NewURLCmd()) diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 84f57d6f2..ca6c799cc 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -56,6 +56,8 @@ func CommandCategories() []CommandCategory { Commands: []CommandInfo{ {Name: "assign", Category: "shortcut", Description: "Assign someone to a to-do, card, or step"}, {Name: "unassign", Category: "shortcut", Description: "Remove assignment from a to-do, card, or step"}, + {Name: "spotlight", Category: "shortcut", Description: "Spotlight a recording on its project page"}, + {Name: "unspotlight", Category: "shortcut", Description: "Remove a recording's spotlight"}, {Name: "attach", Category: "shortcut", Description: "Upload and stage an attachment"}, {Name: "upload", Category: "shortcut", Description: "Upload a file to Docs & Files"}, }, diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index 25de382b7..0339ea7a3 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -70,6 +70,8 @@ func buildRootWithAllCommands() *cobra.Command { root.AddCommand(commands.NewCommentsCmd()) root.AddCommand(commands.NewAssignCmd()) root.AddCommand(commands.NewUnassignCmd()) + root.AddCommand(commands.NewSpotlightCmd()) + root.AddCommand(commands.NewUnspotlightCmd()) root.AddCommand(commands.NewMessagesCmd()) root.AddCommand(commands.NewCardsCmd()) root.AddCommand(commands.NewURLCmd()) diff --git a/internal/commands/messages.go b/internal/commands/messages.go index eb57f937a..013fb3e0a 100644 --- a/internal/commands/messages.go +++ b/internal/commands/messages.go @@ -814,6 +814,9 @@ You can pass either a message ID or a Basecamp URL: return cmd } +const messagesPinAgentNotes = "Pinning is not spotlighting: a pin keeps a message at the top of its message board; " + + "basecamp spotlight puts any recording on its project's home page, and basecamp unspotlight takes it off" + func newMessagesPinCmd() *cobra.Command { cmd := &cobra.Command{ Use: "pin <id|url>", @@ -823,7 +826,8 @@ func newMessagesPinCmd() *cobra.Command { You can pass either a message ID or a Basecamp URL: basecamp messages pin 789 basecamp messages pin https://3.basecamp.com/123/buckets/456/messages/789`, - Args: cobra.ExactArgs(1), + Annotations: map[string]string{"agent_notes": messagesPinAgentNotes}, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) @@ -876,7 +880,8 @@ func newMessagesUnpinCmd() *cobra.Command { You can pass either a message ID or a Basecamp URL: basecamp messages unpin 789 basecamp messages unpin https://3.basecamp.com/123/buckets/456/messages/789`, - Args: cobra.ExactArgs(1), + Annotations: map[string]string{"agent_notes": messagesPinAgentNotes}, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) diff --git a/internal/commands/spotlight.go b/internal/commands/spotlight.go new file mode 100644 index 000000000..1b0919baf --- /dev/null +++ b/internal/commands/spotlight.go @@ -0,0 +1,196 @@ +package commands + +import ( + "errors" + "fmt" + "slices" + "strconv" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + "github.com/spf13/cobra" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/hostutil" + "github.com/basecamp/basecamp-cli/internal/output" + "github.com/basecamp/basecamp-cli/internal/urlarg" +) + +// NewSpotlightCmd creates the spotlight command, which puts a recording's card +// on its project or template home page. +func NewSpotlightCmd() *cobra.Command { + return &cobra.Command{ + Use: "spotlight <id|url>", + Short: "Spotlight a recording on its project page", + Long: `Put a recording's card in the Spotlights area of its project (or template) +home page. Same as "Spotlight on project page" in Basecamp. + +You can pass either an ID or a Basecamp URL: + basecamp spotlight 789 + basecamp spotlight https://3.basecamp.com/123/buckets/456/messages/789 + +Messages, documents, folders, files, cloud files, Google docs, to-do lists, +to-dos, card table columns, cards, check-in questions and schedule entries can +be spotlighted while they are active and in a project or template. A comment +cannot, so a comment's URL (…#__recording_<id>) spotlights the item the +comment sits on. + +Spotlighting a recording that is already spotlighted succeeds and changes +nothing. Requires edit access to the project.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + persistentAccount := hasPersistentAccount(app.Config) + recordingID, err := spotlightTarget(cmd, app, args[0]) + if err != nil { + return err + } + + recording, err := app.Account().Recordings().Spotlight(cmd.Context(), recordingID) + if err != nil { + return spotlightError(err) + } + + accountArg := replyAccountArg(persistentAccount, app.Config.AccountID) + return app.OK(recording, + output.WithSummary(fmt.Sprintf("Spotlighted #%d on its project page", recordingID)), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "unspotlight", + Cmd: fmt.Sprintf("basecamp unspotlight %d%s", recordingID, accountArg), + Description: "Remove the spotlight", + }, + output.Breadcrumb{ + Action: "show", + Cmd: fmt.Sprintf("basecamp show %d%s", recordingID, accountArg), + Description: "View item", + }, + ), + ) + }, + } +} + +// NewUnspotlightCmd creates the unspotlight command, which takes a recording's +// card off its project or template home page. +func NewUnspotlightCmd() *cobra.Command { + return &cobra.Command{ + Use: "unspotlight <id|url>", + Short: "Remove a recording's spotlight", + Long: `Remove a recording's card from the Spotlights area of its project (or +template) home page. Same as "Remove Spotlight" in Basecamp. + +You can pass either an ID or a Basecamp URL: + basecamp unspotlight 789 + basecamp unspotlight https://3.basecamp.com/123/buckets/456/messages/789 + +A comment's URL (…#__recording_<id>) acts on the item the comment sits on. + +Removing the spotlight from a recording that has none succeeds and changes +nothing. Requires edit access to the project.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + persistentAccount := hasPersistentAccount(app.Config) + recordingID, err := spotlightTarget(cmd, app, args[0]) + if err != nil { + return err + } + + if err := app.Account().Recordings().Unspotlight(cmd.Context(), recordingID); err != nil { + return convertSDKError(err) + } + + accountArg := replyAccountArg(persistentAccount, app.Config.AccountID) + return app.OK(map[string]any{"id": recordingID, "spotlighted": false}, + output.WithSummary(fmt.Sprintf("Spotlight removed from #%d", recordingID)), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "spotlight", + Cmd: fmt.Sprintf("basecamp spotlight %d%s", recordingID, accountArg), + Description: "Spotlight it again", + }, + output.Breadcrumb{ + Action: "show", + Cmd: fmt.Sprintf("basecamp show %d%s", recordingID, accountArg), + Description: "View item", + }, + ), + ) + }, + } +} + +// spotlightKinds are the URL types that name a recording bc3 may spotlight: +// one of the spotlightable kinds, or a generic recordings/<id> URL, which is +// as good as its bare ID. bc3 still decides each case; the list keeps a URL +// for something else (a person, a Campfire, a path the router only matched by +// shape) from sending its ID out as a recording's. +var spotlightKinds = []string{ + "cards", "cloud_files", "columns", "documents", "google_documents", "messages", + "questions", "recordings", "schedule_entries", "todolists", "todos", "uploads", "vaults", +} + +// spotlightTarget reads the recording a spotlight command acts on: an ID, or +// the URL of a single item on a trusted host. The URL router is host-agnostic, +// so the host check keeps a look-alike URL from retargeting the configured +// account. A URL's account is adopted when none is configured and refused when +// it differs, so a same-numbered recording in another account is never +// touched. A comment's URL names the item it sits on, since comments cannot be +// spotlighted. +func spotlightTarget(cmd *cobra.Command, app *appctx.App, arg string) (int64, error) { + idStr, urlAccountID := arg, "" + if urlarg.IsURL(arg) { + if !hostutil.IsTrustedBasecampHost(arg, app.Config.BaseURL) { + return 0, spotlightUsage(cmd, "not a Basecamp URL", spotlightHint) + } + parsed := urlarg.Parse(arg) + if parsed == nil || parsed.IsCollection || !slices.Contains(spotlightKinds, parsed.Type) { + return 0, spotlightUsage(cmd, "the URL names something else", spotlightHint) + } + // The URL's ID is the recurring event's, so acting on it would spotlight + // the whole series. The occurrence is a recording of its own. + if parsed.OccurrenceDate != "" { + return 0, spotlightUsage(cmd, "the URL names one occurrence of a recurring event", + "Pass the occurrence's own ID, which basecamp schedule show <url> prints, or the event's ID to act on the whole series") + } + idStr, urlAccountID = parsed.RecordingID, parsed.AccountID + } + recordingID, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return 0, output.ErrUsage("Invalid ID") + } + if urlAccountID != "" { + switch { + case app.Config.AccountID == "": + app.Config.AccountID = urlAccountID + case urlAccountID != app.Config.AccountID: + return 0, output.ErrUsage(fmt.Sprintf("URL account %s does not match the configured account %s", urlAccountID, app.Config.AccountID)) + } + } + if err := ensureAccount(cmd, app); err != nil { + return 0, err + } + return recordingID, nil +} + +const spotlightHint = "Pass its ID or its own URL (…/messages/<id>, …/todos/<id>, …); basecamp spotlight --help lists the types" + +func spotlightUsage(cmd *cobra.Command, reason, hint string) error { + return output.ErrUsageHint(cmd.Name()+" takes a single item: "+reason, hint) +} + +// spotlightError says why bc3 refused a spotlight. Its 422 carries no reason +// the SDK surfaces, and the usual cause is a comment or a trashed item. +func spotlightError(err error) error { + var sdkErr *basecamp.Error + if !errors.As(err, &sdkErr) || sdkErr.Code != basecamp.CodeValidation { + return convertSDKError(err) + } + return &output.Error{ + Code: output.CodeValidation, + Message: "Basecamp refused the spotlight", + Hint: "Only an active recording in a project or template can be spotlighted, and not a comment; see basecamp spotlight --help for the types", + HTTPStatus: sdkErr.HTTPStatus, + Cause: sdkErr, + } +} diff --git a/internal/commands/spotlight_test.go b/internal/commands/spotlight_test.go new file mode 100644 index 000000000..80c3e728e --- /dev/null +++ b/internal/commands/spotlight_test.go @@ -0,0 +1,189 @@ +package commands + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/output" +) + +const spotlightPath = "/99999/recordings/456/spotlight.json" + +// Each argument names recording 456: a bare ID, the item's URL, its generic +// recording URL, and a comment's URL on it, which must reach the item because +// comments cannot be spotlighted. +var spotlightArgs = []string{ + "456", + "https://app.basecamp.com/99999/buckets/123/messages/456", + "https://app.basecamp.com/99999/buckets/123/recordings/456", + "https://app.basecamp.com/99999/buckets/123/messages/456#__recording_789", +} + +func TestSpotlightPostsTheRecording(t *testing.T) { + for _, arg := range spotlightArgs { + t.Run(arg, func(t *testing.T) { + app, transport := setupRecordingTestApp(t, stubRoute{ + method: http.MethodPost, path: spotlightPath, status: http.StatusCreated, + body: `{"id":456,"type":"Message","title":"Kickoff"}`, + }) + buf := captureSpotlightOutput(app) + + require.NoError(t, executeRecordingCommand(NewSpotlightCmd(), app, arg)) + + calls := transport.recorded() + require.Len(t, calls, 1) + assert.Equal(t, http.MethodPost, calls[0].Method) + assert.Equal(t, spotlightPath, calls[0].Path) + + env := decodeSpotlightEnvelope(t, buf) + assert.Equal(t, float64(456), env.Data["id"]) + assert.Equal(t, "Kickoff", env.Data["title"]) + assert.Equal(t, "Spotlighted #456 on its project page", env.Summary) + assert.Contains(t, env.Breadcrumbs, output.Breadcrumb{ + Action: "unspotlight", Cmd: "basecamp unspotlight 456", Description: "Remove the spotlight", + }) + }) + } +} + +func TestUnspotlightDeletesTheSpotlight(t *testing.T) { + for _, arg := range spotlightArgs { + t.Run(arg, func(t *testing.T) { + app, transport := setupRecordingTestApp(t, stubRoute{ + method: http.MethodDelete, path: spotlightPath, status: http.StatusNoContent, + }) + buf := captureSpotlightOutput(app) + + require.NoError(t, executeRecordingCommand(NewUnspotlightCmd(), app, arg)) + + calls := transport.recorded() + require.Len(t, calls, 1) + assert.Equal(t, http.MethodDelete, calls[0].Method) + assert.Equal(t, spotlightPath, calls[0].Path) + + env := decodeSpotlightEnvelope(t, buf) + assert.Equal(t, map[string]any{"id": float64(456), "spotlighted": false}, env.Data) + assert.Equal(t, "Spotlight removed from #456", env.Summary) + assert.Contains(t, env.Breadcrumbs, output.Breadcrumb{ + Action: "spotlight", Cmd: "basecamp spotlight 456", Description: "Spotlight it again", + }) + }) + } +} + +// No ID from these may go out as a recording's: a project, a look-alike host, +// a to-do list's to-dos, a person, one occurrence of a recurring event, whose +// URL carries the whole series' ID, and an item in another account than the +// configured 99999. +func TestSpotlightRefusesANonRecordingArgument(t *testing.T) { + cmds := map[string]func() *cobra.Command{ + "spotlight": NewSpotlightCmd, + "unspotlight": NewUnspotlightCmd, + } + for name, newCmd := range cmds { + for _, arg := range []string{ + "abc", + "https://app.basecamp.com/99999/projects/123", + "https://evil.example/99999/buckets/123/messages/456", + "https://app.basecamp.com/99999/buckets/123/todolists/456/todos", + "https://app.basecamp.com/99999/people/456", + "https://app.basecamp.com/99999/buckets/123/schedule_entries/456/occurrences/20261022", + "https://app.basecamp.com/88888/buckets/123/messages/456", + } { + t.Run(name+" "+arg, func(t *testing.T) { + app, transport := setupRecordingTestApp(t) + + err := executeRecordingCommand(newCmd(), app, arg) + + var e *output.Error + require.True(t, errors.As(err, &e), "expected *output.Error, got %T: %v", err, err) + assert.Equal(t, output.CodeUsage, e.Code) + assert.Empty(t, transport.recorded()) + }) + } + } +} + +// With no account configured, the URL's account is the one acted on, and the +// follow-ups carry it so they run in a fresh process. +func TestSpotlightAdoptsTheURLAccount(t *testing.T) { + cmds := map[string]struct { + newCmd func() *cobra.Command + route stubRoute + followUp string + }{ + "spotlight": { + newCmd: NewSpotlightCmd, + route: stubRoute{method: http.MethodPost, path: spotlightPath, status: http.StatusCreated, body: `{"id":456}`}, + followUp: "basecamp unspotlight 456 --account 99999", + }, + "unspotlight": { + newCmd: NewUnspotlightCmd, + route: stubRoute{method: http.MethodDelete, path: spotlightPath, status: http.StatusNoContent}, + followUp: "basecamp spotlight 456 --account 99999", + }, + } + for name, c := range cmds { + t.Run(name, func(t *testing.T) { + app, transport := setupRecordingTestApp(t, c.route) + app.Config.AccountID = "" + buf := captureSpotlightOutput(app) + + require.NoError(t, executeRecordingCommand(c.newCmd(), app, + "https://app.basecamp.com/99999/buckets/123/messages/456")) + + calls := transport.recorded() + require.Len(t, calls, 1) + assert.Equal(t, c.route.method, calls[0].Method) + assert.Equal(t, spotlightPath, calls[0].Path) + + var followUps []string + for _, b := range decodeSpotlightEnvelope(t, buf).Breadcrumbs { + followUps = append(followUps, b.Cmd) + } + assert.Contains(t, followUps, c.followUp) + }) + } +} + +func TestSpotlightSurfacesARefusal(t *testing.T) { + app, _ := setupRecordingTestApp(t, stubRoute{ + method: http.MethodPost, path: spotlightPath, status: http.StatusUnprocessableEntity, + body: `{"error":"Recordable can't be spotlighted"}`, + }) + + err := executeRecordingCommand(NewSpotlightCmd(), app, "456") + + var e *output.Error + require.True(t, errors.As(err, &e), "expected *output.Error, got %T: %v", err, err) + assert.Equal(t, output.CodeValidation, e.Code) + assert.Contains(t, e.Hint, "not a comment") +} + +func captureSpotlightOutput(app *appctx.App) *bytes.Buffer { + buf := &bytes.Buffer{} + app.Flags.Hints = true + app.Output = output.New(output.Options{Format: output.FormatJSON, Writer: buf}) + return buf +} + +type spotlightEnvelope struct { + Data map[string]any `json:"data"` + Summary string `json:"summary"` + Breadcrumbs []output.Breadcrumb `json:"breadcrumbs"` +} + +func decodeSpotlightEnvelope(t *testing.T, buf *bytes.Buffer) spotlightEnvelope { + t.Helper() + var env spotlightEnvelope + require.NoError(t, json.Unmarshal(buf.Bytes(), &env), buf.String()) + return env +} diff --git a/skill-evals/cases/spotlight-message.yml b/skill-evals/cases/spotlight-message.yml new file mode 100644 index 000000000..9f94275db --- /dev/null +++ b/skill-evals/cases/spotlight-message.yml @@ -0,0 +1,6 @@ +task: Spotlight message 789 on its project page +tags: [semantics] +accept: + - '^spotlight (?!.*--help)(?:-\S+ )*789(?:\s|$)' +reject: + - '^messages (un)?pin(?!.*--help)' diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 8b6ac48af..ffd4197ba 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -20,6 +20,7 @@ triggers: - basecamp document - basecamp bookmarks - basecamp bubble-up + - basecamp spotlight - basecamp drafts - basecamp notes - basecamp calendars @@ -216,6 +217,20 @@ basecamp pings messages "Jane Smith" --agent `chat line`; edit or delete such a message with `chat update` or `chat delete` on that URL. +## Spotlights + +A spotlight puts a recording's card in the Spotlights area of its project's home +page. It is not a pin: `messages pin` only keeps a message at the top of its +Message Board. `spotlight` is a top-level command, outside every recording +type's group, and takes the ID or URL of a message, document, file, to-do list, +to-do, card, card column, check-in question, schedule entry and more. +`unspotlight` removes it, as "Remove Spotlight" does in Basecamp. + +```bash +basecamp spotlight <id|url> --agent +basecamp unspotlight <id|url> --agent +``` + ## Content and stdin Command help declares content arguments and stdin support. Titles are generally