diff --git a/.surface b/.surface index e319c2617..9706d0210 100644 --- a/.surface +++ b/.surface @@ -336,6 +336,16 @@ ARG basecamp subscriptions remove 01 [person_ids] ARG basecamp subscriptions show 00 ARG basecamp subscriptions subscribe 00 ARG basecamp subscriptions unsubscribe 00 +ARG basecamp subtasks complete 00 +ARG basecamp subtasks create 00 +ARG basecamp subtasks create 01 +ARG basecamp subtasks delete 00 <id|url> +ARG basecamp subtasks list 00 <todo-or-card-id|url> +ARG basecamp subtasks move 00 <id|url> +ARG basecamp subtasks show 00 <id|url> +ARG basecamp subtasks uncomplete 00 <id|url> +ARG basecamp subtasks update 00 <id|url> +ARG basecamp subtasks update 01 [title] ARG basecamp templates construct 00 <template_id> ARG basecamp templates construction 00 <template_id> ARG basecamp templates construction 01 <construction_id> @@ -1007,6 +1017,15 @@ CMD basecamp subscriptions remove CMD basecamp subscriptions show CMD basecamp subscriptions subscribe CMD basecamp subscriptions unsubscribe +CMD basecamp subtasks +CMD basecamp subtasks complete +CMD basecamp subtasks create +CMD basecamp subtasks delete +CMD basecamp subtasks list +CMD basecamp subtasks move +CMD basecamp subtasks show +CMD basecamp subtasks uncomplete +CMD basecamp subtasks update CMD basecamp templates CMD basecamp templates construct CMD basecamp templates construction @@ -13589,6 +13608,208 @@ FLAG basecamp subscriptions unsubscribe --stats type=bool FLAG basecamp subscriptions unsubscribe --styled type=bool FLAG basecamp subscriptions unsubscribe --todolist type=string FLAG basecamp subscriptions unsubscribe --verbose type=count +FLAG basecamp subtasks --account type=string +FLAG basecamp subtasks --agent type=bool +FLAG basecamp subtasks --cache-dir type=string +FLAG basecamp subtasks --count type=bool +FLAG basecamp subtasks --help type=bool +FLAG basecamp subtasks --hints type=bool +FLAG basecamp subtasks --ids-only type=bool +FLAG basecamp subtasks --in type=string +FLAG basecamp subtasks --jq type=string +FLAG basecamp subtasks --json type=bool +FLAG basecamp subtasks --markdown type=bool +FLAG basecamp subtasks --md type=bool +FLAG basecamp subtasks --no-hints type=bool +FLAG basecamp subtasks --no-stats type=bool +FLAG basecamp subtasks --profile type=string +FLAG basecamp subtasks --project type=string +FLAG basecamp subtasks --quiet type=bool +FLAG basecamp subtasks --stats type=bool +FLAG basecamp subtasks --styled type=bool +FLAG basecamp subtasks --todolist type=string +FLAG basecamp subtasks --verbose type=count +FLAG basecamp subtasks complete --account type=string +FLAG basecamp subtasks complete --agent type=bool +FLAG basecamp subtasks complete --cache-dir type=string +FLAG basecamp subtasks complete --count type=bool +FLAG basecamp subtasks complete --help type=bool +FLAG basecamp subtasks complete --hints type=bool +FLAG basecamp subtasks complete --ids-only type=bool +FLAG basecamp subtasks complete --in type=string +FLAG basecamp subtasks complete --jq type=string +FLAG basecamp subtasks complete --json type=bool +FLAG basecamp subtasks complete --markdown type=bool +FLAG basecamp subtasks complete --md type=bool +FLAG basecamp subtasks complete --no-hints type=bool +FLAG basecamp subtasks complete --no-stats type=bool +FLAG basecamp subtasks complete --profile type=string +FLAG basecamp subtasks complete --project type=string +FLAG basecamp subtasks complete --quiet type=bool +FLAG basecamp subtasks complete --stats type=bool +FLAG basecamp subtasks complete --styled type=bool +FLAG basecamp subtasks complete --todolist type=string +FLAG basecamp subtasks complete --verbose type=count +FLAG basecamp subtasks create --account type=string +FLAG basecamp subtasks create --agent type=bool +FLAG basecamp subtasks create --assignees type=string +FLAG basecamp subtasks create --cache-dir type=string +FLAG basecamp subtasks create --count type=bool +FLAG basecamp subtasks create --due type=string +FLAG basecamp subtasks create --help type=bool +FLAG basecamp subtasks create --hints type=bool +FLAG basecamp subtasks create --ids-only type=bool +FLAG basecamp subtasks create --in type=string +FLAG basecamp subtasks create --jq type=string +FLAG basecamp subtasks create --json type=bool +FLAG basecamp subtasks create --markdown type=bool +FLAG basecamp subtasks create --md type=bool +FLAG basecamp subtasks create --no-hints type=bool +FLAG basecamp subtasks create --no-stats type=bool +FLAG basecamp subtasks create --profile type=string +FLAG basecamp subtasks create --project type=string +FLAG basecamp subtasks create --quiet type=bool +FLAG basecamp subtasks create --stats type=bool +FLAG basecamp subtasks create --styled type=bool +FLAG basecamp subtasks create --todolist type=string +FLAG basecamp subtasks create --verbose type=count +FLAG basecamp subtasks delete --account type=string +FLAG basecamp subtasks delete --agent type=bool +FLAG basecamp subtasks delete --cache-dir type=string +FLAG basecamp subtasks delete --count type=bool +FLAG basecamp subtasks delete --force type=bool +FLAG basecamp subtasks delete --help type=bool +FLAG basecamp subtasks delete --hints type=bool +FLAG basecamp subtasks delete --ids-only type=bool +FLAG basecamp subtasks delete --in type=string +FLAG basecamp subtasks delete --jq type=string +FLAG basecamp subtasks delete --json type=bool +FLAG basecamp subtasks delete --markdown type=bool +FLAG basecamp subtasks delete --md type=bool +FLAG basecamp subtasks delete --no-hints type=bool +FLAG basecamp subtasks delete --no-stats type=bool +FLAG basecamp subtasks delete --profile type=string +FLAG basecamp subtasks delete --project type=string +FLAG basecamp subtasks delete --quiet type=bool +FLAG basecamp subtasks delete --stats type=bool +FLAG basecamp subtasks delete --styled type=bool +FLAG basecamp subtasks delete --todolist type=string +FLAG basecamp subtasks delete --verbose type=count +FLAG basecamp subtasks list --account type=string +FLAG basecamp subtasks list --agent type=bool +FLAG basecamp subtasks list --all type=bool +FLAG basecamp subtasks list --cache-dir type=string +FLAG basecamp subtasks list --count type=bool +FLAG basecamp subtasks list --help type=bool +FLAG basecamp subtasks list --hints type=bool +FLAG basecamp subtasks list --ids-only type=bool +FLAG basecamp subtasks list --in type=string +FLAG basecamp subtasks list --jq type=string +FLAG basecamp subtasks list --json type=bool +FLAG basecamp subtasks list --limit type=int +FLAG basecamp subtasks list --markdown type=bool +FLAG basecamp subtasks list --md type=bool +FLAG basecamp subtasks list --no-hints type=bool +FLAG basecamp subtasks list --no-stats type=bool +FLAG basecamp subtasks list --page type=int +FLAG basecamp subtasks list --profile type=string +FLAG basecamp subtasks list --project type=string +FLAG basecamp subtasks list --quiet type=bool +FLAG basecamp subtasks list --stats type=bool +FLAG basecamp subtasks list --styled type=bool +FLAG basecamp subtasks list --todolist type=string +FLAG basecamp subtasks list --verbose type=count +FLAG basecamp subtasks move --account type=string +FLAG basecamp subtasks move --agent type=bool +FLAG basecamp subtasks move --cache-dir type=string +FLAG basecamp subtasks move --count type=bool +FLAG basecamp subtasks move --help type=bool +FLAG basecamp subtasks move --hints type=bool +FLAG basecamp subtasks move --ids-only type=bool +FLAG basecamp subtasks move --in type=string +FLAG basecamp subtasks move --jq type=string +FLAG basecamp subtasks move --json type=bool +FLAG basecamp subtasks move --markdown type=bool +FLAG basecamp subtasks move --md type=bool +FLAG basecamp subtasks move --no-hints type=bool +FLAG basecamp subtasks move --no-stats type=bool +FLAG basecamp subtasks move --pos type=int +FLAG basecamp subtasks move --position type=int +FLAG basecamp subtasks move --profile type=string +FLAG basecamp subtasks move --project type=string +FLAG basecamp subtasks move --quiet type=bool +FLAG basecamp subtasks move --stats type=bool +FLAG basecamp subtasks move --styled type=bool +FLAG basecamp subtasks move --todolist type=string +FLAG basecamp subtasks move --verbose type=count +FLAG basecamp subtasks show --account type=string +FLAG basecamp subtasks show --agent type=bool +FLAG basecamp subtasks show --cache-dir type=string +FLAG basecamp subtasks show --count type=bool +FLAG basecamp subtasks show --help type=bool +FLAG basecamp subtasks show --hints type=bool +FLAG basecamp subtasks show --ids-only type=bool +FLAG basecamp subtasks show --in type=string +FLAG basecamp subtasks show --jq type=string +FLAG basecamp subtasks show --json type=bool +FLAG basecamp subtasks show --markdown type=bool +FLAG basecamp subtasks show --md type=bool +FLAG basecamp subtasks show --no-hints type=bool +FLAG basecamp subtasks show --no-stats type=bool +FLAG basecamp subtasks show --profile type=string +FLAG basecamp subtasks show --project type=string +FLAG basecamp subtasks show --quiet type=bool +FLAG basecamp subtasks show --stats type=bool +FLAG basecamp subtasks show --styled type=bool +FLAG basecamp subtasks show --todolist type=string +FLAG basecamp subtasks show --verbose type=count +FLAG basecamp subtasks uncomplete --account type=string +FLAG basecamp subtasks uncomplete --agent type=bool +FLAG basecamp subtasks uncomplete --cache-dir type=string +FLAG basecamp subtasks uncomplete --count type=bool +FLAG basecamp subtasks uncomplete --help type=bool +FLAG basecamp subtasks uncomplete --hints type=bool +FLAG basecamp subtasks uncomplete --ids-only type=bool +FLAG basecamp subtasks uncomplete --in type=string +FLAG basecamp subtasks uncomplete --jq type=string +FLAG basecamp subtasks uncomplete --json type=bool +FLAG basecamp subtasks uncomplete --markdown type=bool +FLAG basecamp subtasks uncomplete --md type=bool +FLAG basecamp subtasks uncomplete --no-hints type=bool +FLAG basecamp subtasks uncomplete --no-stats type=bool +FLAG basecamp subtasks uncomplete --profile type=string +FLAG basecamp subtasks uncomplete --project type=string +FLAG basecamp subtasks uncomplete --quiet type=bool +FLAG basecamp subtasks uncomplete --stats type=bool +FLAG basecamp subtasks uncomplete --styled type=bool +FLAG basecamp subtasks uncomplete --todolist type=string +FLAG basecamp subtasks uncomplete --verbose type=count +FLAG basecamp subtasks update --account type=string +FLAG basecamp subtasks update --agent type=bool +FLAG basecamp subtasks update --assignees type=string +FLAG basecamp subtasks update --cache-dir type=string +FLAG basecamp subtasks update --count type=bool +FLAG basecamp subtasks update --due type=string +FLAG basecamp subtasks update --help type=bool +FLAG basecamp subtasks update --hints type=bool +FLAG basecamp subtasks update --ids-only type=bool +FLAG basecamp subtasks update --in type=string +FLAG basecamp subtasks update --jq type=string +FLAG basecamp subtasks update --json type=bool +FLAG basecamp subtasks update --markdown type=bool +FLAG basecamp subtasks update --md type=bool +FLAG basecamp subtasks update --no-assignees type=bool +FLAG basecamp subtasks update --no-due type=bool +FLAG basecamp subtasks update --no-hints type=bool +FLAG basecamp subtasks update --no-stats type=bool +FLAG basecamp subtasks update --profile type=string +FLAG basecamp subtasks update --project type=string +FLAG basecamp subtasks update --quiet type=bool +FLAG basecamp subtasks update --stats type=bool +FLAG basecamp subtasks update --styled type=bool +FLAG basecamp subtasks update --title type=string +FLAG basecamp subtasks update --todolist type=string +FLAG basecamp subtasks update --verbose type=count FLAG basecamp templates --account type=string FLAG basecamp templates --agent type=bool FLAG basecamp templates --cache-dir type=string @@ -18862,6 +19083,15 @@ SUB basecamp subscriptions remove SUB basecamp subscriptions show SUB basecamp subscriptions subscribe SUB basecamp subscriptions unsubscribe +SUB basecamp subtasks +SUB basecamp subtasks complete +SUB basecamp subtasks create +SUB basecamp subtasks delete +SUB basecamp subtasks list +SUB basecamp subtasks move +SUB basecamp subtasks show +SUB basecamp subtasks uncomplete +SUB basecamp subtasks update SUB basecamp templates SUB basecamp templates construct SUB basecamp templates construction diff --git a/API-COVERAGE.md b/API-COVERAGE.md index c42c64a97..8ea42a0d1 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -6,12 +6,15 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. | Status | Sections | Endpoints | |--------|----------|-----------| -| ✅ Implemented | 51 | 195 | +| ✅ Implemented | 52 | 203 | | ⚠️ Blocked | 0 | 0 | | ⏭️ Out of scope | 4 | 12 | -| **Total tracked** | **55** | **207** | +| **Total tracked** | **56** | **215** | -**195 of 195 tracked in-scope endpoints.** The client-admission endpoints +**203 of 203 tracked in-scope endpoints.** The eight subtask endpoints bc3 +#12659 documented — the flat `/recordings/:id/subtasks.json` and +`/subtasks/:id` routes, modelled by basecamp/basecamp-sdk#883 — land as +`subtasks`. The client-admission endpoints basecamp/bc3#13098 added — `PUT /projects/:id/people/client_users.json` and `POST`/`DELETE /projects/:id/client_enablement.json` — land as `people clients`. SDK v0.16.0 adds the three to-do @@ -50,7 +53,8 @@ Out-of-scope sections are excluded from parity totals and scripts: chatbots (dif **SDK version:** the pin in `go.mod`, with `internal/version/sdk-provenance.json` authoritative for the exact commit. That -pin is basecamp-sdk v0.19.0, the release that ships the event-feed operations. +pin is basecamp-sdk v0.21.0, which ships the Subtasks service +(basecamp/basecamp-sdk#883). v0.19.0 shipped the event-feed operations. 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, @@ -187,7 +191,8 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A |---------|-----------|-------------|--------|-------|----------|-------| | **Core** | | projects | 9 | `projects` | ✅ | BC4 | - | list, show, create, update, delete | -| todos | 12 | `todos`, `todo`, `done`, `reopen` | ✅ | BC4 | - | list, show, create, update, complete, uncomplete, position (BC5: `steps` shown on `todos show`; edit via `cards step`). `todos create --loose` creates on the to-do set, outside any list | +| todos | 12 | `todos`, `todo`, `done`, `reopen` | ✅ | BC4 | - | list, show, create, update, complete, uncomplete, position (BC5: `steps` shown on `todos show`, plus `subtasks_count`/`subtasks_completed_count`/`subtasks_url`; edit via `subtasks`). `todos create --loose` creates on the to-do set, outside any list | +| subtasks | 8 | `subtasks` | ✅ | BC5 | - | list (`GET /recordings/:id/subtasks.json`, paginated), show, create (`POST` on the same list route), update (partial; `--no-due`/`--no-assignees` clear), complete/uncomplete (`POST`/`DELETE /subtasks/:id/completion.json`), move (`PUT /subtasks/:id/position.json`, 1-based), delete. Account-scoped flat routes (bc3#12659), so no `--in`. Parents are to-dos and cards only. Same `Kanban::Step` records as `card_table_steps` | | todolists | 9 | `todolists` | ✅ | BC4 | - | list, show, create, update, position | | todosets | 3 | `todosets` | ✅ | BC4 | - | Container for todolists, accessed via project dock (BC5: `todos_count`, `completed_loose_todos_count`, `todos_url`, `app_todos_url`) | | todolist_groups | 8 | `todolistgroups` | ✅ | BC4 | - | list, show, create, update, position | @@ -208,7 +213,7 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A | card_tables | 3 | `cards` | ✅ | BC4 | - | Accessed via project dock | | card_table_cards | 9 | `cards` | ✅ | BC4 | - | list, show, create, update, move | | card_table_columns | 11 | `cards columns` | ✅ | BC4 | - | list columns. SDK v0.12.0 added `Subscribe`/`Unsubscribe`; `cards column watch\|unwatch` already performs the same action through the generic recording-subscription endpoint and returns the resulting subscription details the specific endpoint does not, so the CLI keeps one spelling | -| card_table_steps | 4 | `cards steps` | ✅ | BC4 | - | Workflow steps on cards | +| card_table_steps | 4 | `cards steps` | ✅ | BC4 | - | Workflow steps on cards. The card-scoped aliases of `subtasks`, which bc3 keeps serving; `cards step move --position` is 1-based (the SDK refuses 0 since basecamp/basecamp-sdk#883) | | card_table_wormholes | 3 | `cards wormholes` | ✅ | BC5 | - | list (via `wormholes[]` on card table), create, update, delete; `cards move --to-wormhole` teleports a card across projects (async, new id) | | **Personal (My)** | | my_bookmarks | 4 | `bookmarks` | ✅ | BC5 | - | list, check, add, remove. Private to the authenticated user; `add`/`remove` are idempotent, and `check` returns a bool reported in the payload rather than through the exit code. Bounded like the account-wide listings | diff --git a/e2e/smoke/run_smoke.sh b/e2e/smoke/run_smoke.sh index 834744f3d..ab624bcac 100755 --- a/e2e/smoke/run_smoke.sh +++ b/e2e/smoke/run_smoke.sh @@ -91,6 +91,7 @@ echo "" echo "--- Level 1: Mutation tests (parallel, $jobs jobs) ---" level1=( "$SMOKE_DIR"/smoke_todos_write.bats + "$SMOKE_DIR"/smoke_subtasks.bats "$SMOKE_DIR"/smoke_messages_write.bats "$SMOKE_DIR"/smoke_files_write.bats "$SMOKE_DIR"/smoke_cards_write.bats diff --git a/e2e/smoke/smoke_subtasks.bats b/e2e/smoke/smoke_subtasks.bats new file mode 100644 index 000000000..52bc61a46 --- /dev/null +++ b/e2e/smoke/smoke_subtasks.bats @@ -0,0 +1,92 @@ +#!/usr/bin/env bats +# smoke_subtasks.bats - Level 1: Subtask lifecycle on a to-do + +load smoke_helper + +setup_file() { + ensure_token || return 1 + ensure_todo || return 1 +} + +@test "subtasks create adds a subtask to a to-do" { + run_smoke basecamp subtasks create "$QA_TODO" "Smoke subtask $(date +%s)" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_not_null '.data.id' + + echo "$output" | jq -r '.data.id' > "$BATS_FILE_TMPDIR/subtask_id" +} + +@test "subtasks list returns the to-do's subtasks" { + run_smoke basecamp subtasks list "$QA_TODO" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data | type' 'array' +} + +@test "subtasks show returns subtask detail" { + local id_file="$BATS_FILE_TMPDIR/subtask_id" + [[ -f "$id_file" ]] || mark_unverifiable "No subtask created in prior test" + local subtask_id + subtask_id=$(<"$id_file") + + run_smoke basecamp subtasks show "$subtask_id" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.parent.id' "$QA_TODO" +} + +@test "subtasks update updates a subtask" { + local id_file="$BATS_FILE_TMPDIR/subtask_id" + [[ -f "$id_file" ]] || mark_unverifiable "No subtask created in prior test" + local subtask_id + subtask_id=$(<"$id_file") + + run_smoke basecamp subtasks update "$subtask_id" "Updated subtask $(date +%s)" --json + assert_success + assert_json_value '.ok' 'true' +} + +@test "subtasks complete completes a subtask" { + local id_file="$BATS_FILE_TMPDIR/subtask_id" + [[ -f "$id_file" ]] || mark_unverifiable "No subtask created in prior test" + local subtask_id + subtask_id=$(<"$id_file") + + run_smoke basecamp subtasks complete "$subtask_id" --json + assert_success + assert_json_value '.data.completed' 'true' +} + +@test "subtasks uncomplete reopens a subtask" { + local id_file="$BATS_FILE_TMPDIR/subtask_id" + [[ -f "$id_file" ]] || mark_unverifiable "No subtask created in prior test" + local subtask_id + subtask_id=$(<"$id_file") + + run_smoke basecamp subtasks uncomplete "$subtask_id" --json + assert_success + assert_json_value '.data.completed' 'false' +} + +@test "subtasks move moves a subtask to the top" { + local id_file="$BATS_FILE_TMPDIR/subtask_id" + [[ -f "$id_file" ]] || mark_unverifiable "No subtask created in prior test" + local subtask_id + subtask_id=$(<"$id_file") + + run_smoke basecamp subtasks move "$subtask_id" --position 1 --json + assert_success + assert_json_value '.ok' 'true' +} + +@test "subtasks delete deletes a subtask" { + local id_file="$BATS_FILE_TMPDIR/subtask_id" + [[ -f "$id_file" ]] || mark_unverifiable "No subtask created in prior test" + local subtask_id + subtask_id=$(<"$id_file") + + run_smoke basecamp subtasks delete "$subtask_id" --force --json + assert_success + assert_json_value '.ok' 'true' +} diff --git a/e2e/subtasks.bats b/e2e/subtasks.bats new file mode 100644 index 000000000..feae73599 --- /dev/null +++ b/e2e/subtasks.bats @@ -0,0 +1,185 @@ +#!/usr/bin/env bats +# subtasks.bats - Test subtasks command error handling + +load test_helper + + +# Help + +@test "subtasks without subcommand shows help" { + run basecamp subtasks + assert_success + assert_output_contains "COMMANDS" + assert_output_contains "uncomplete" +} + + +# List errors + +@test "subtasks list without parent shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp subtasks list + assert_failure + assert_json_value '.error' '<todo-or-card-id|url> required' + assert_json_value '.code' 'usage' +} + +@test "subtasks list rejects --all with --limit" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp subtasks list 123 --all --limit 5 + assert_failure + assert_output_contains "mutually exclusive" +} + + +# Show errors + +@test "subtasks show without id shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp subtasks show + assert_failure + assert_output_contains "ID required" +} + +@test "subtasks show rejects a non-id" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp subtasks show not-an-id + assert_failure + assert_json_value '.code' 'usage' + assert_output_contains "not a subtask id" +} + + +# Create errors + +@test "subtasks create without parent shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp subtasks create + assert_failure + assert_json_value '.error' '<todo-or-card-id|url> required' + assert_json_value '.code' 'usage' +} + +@test "subtasks create without title shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp subtasks create 123 + assert_failure + assert_json_value '.error' '<title> required' + assert_json_value '.code' 'usage' +} + + +# Update errors + +@test "subtasks update without id shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp subtasks update + assert_failure + assert_json_value '.error' '<id|url> required' +} + +@test "subtasks update without changes shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp subtasks update 456 + assert_failure + assert_output_contains "No update fields specified" +} + +@test "subtasks update rejects --due with --no-due" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp subtasks update 456 --due tomorrow --no-due + assert_failure + assert_output_contains "mutually exclusive" +} + + +# Complete/uncomplete errors + +@test "subtasks complete without id shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp subtasks complete + assert_failure + assert_output_contains "ID required" +} + +@test "subtasks uncomplete without id shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp subtasks uncomplete + assert_failure + assert_output_contains "ID required" +} + + +# Move errors + +@test "subtasks move without position shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp subtasks move 456 + assert_failure + assert_output_contains "--position is required (1-based)" +} + +@test "subtasks move rejects position 0" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp subtasks move 456 --position 0 + assert_failure + assert_output_contains "--position must be 1 or more" +} + + +# Delete errors + +@test "subtasks delete without id shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp subtasks delete + assert_failure + assert_output_contains "ID required" +} + +@test "subtasks delete in JSON mode needs --force" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp subtasks delete 456 --json + assert_failure + assert_json_value '.code' 'usage' + assert_output_contains "--force" +} + +@test "subtasks delete refuses a parent URL" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp subtasks delete https://3.basecamp.com/99999/buckets/89/todos/123 --force + assert_failure + assert_json_value '.code' 'usage' + assert_output_contains "does not name a subtask" +} diff --git a/internal/cli/root.go b/internal/cli/root.go index a68c83b4a..dcc749012 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -367,6 +367,7 @@ func Execute() { cmd.AddCommand(commands.NewAssignmentsCmd()) cmd.AddCommand(commands.NewBookmarksCmd()) cmd.AddCommand(commands.NewBubbleUpCmd()) + cmd.AddCommand(commands.NewSubtasksCmd()) cmd.AddCommand(commands.NewDraftsCmd()) cmd.AddCommand(commands.NewNotesCmd()) cmd.AddCommand(commands.NewCalendarsCmd()) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index 23e52bd29..12a57dd7a 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -3120,11 +3120,11 @@ func newCardsStepMoveCmd() *cobra.Command { cmd := &cobra.Command{ Use: "move <step_id|url>", Short: "Move a step", - Long: `Reposition a step within a card (0-indexed). + Long: `Reposition a step within a card (1-based: 1 is the top). You can pass either a step ID or a Basecamp URL: - basecamp cards step move 789 --card 456 --position 0 --in my-project - basecamp cards step move https://3.basecamp.com/123/buckets/456/card_tables/cards/steps/789 --card 456 --position 0`, + basecamp cards step move 789 --card 456 --position 1 --in my-project + basecamp cards step move https://3.basecamp.com/123/buckets/456/card_tables/cards/steps/789 --card 456 --position 1`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Show help when invoked with no card flag @@ -3144,8 +3144,13 @@ You can pass either a step ID or a Basecamp URL: if err != nil { return output.ErrUsage("Step ID must be numeric") } - if position < 0 { - return output.ErrUsage("--position is required (0-indexed)") + // bc3 has always counted step positions from 1; its docs once + // said "zero indexed", and the SDK now refuses 0. + if !cmd.Flags().Changed("position") && !cmd.Flags().Changed("pos") { + return output.ErrUsage("--position is required (1-based)") + } + if position < 1 { + return output.ErrUsage("--position must be 1 or more (1 = top)") } cardIDInt, err := strconv.ParseInt(cardID, 10, 64) @@ -3167,8 +3172,8 @@ You can pass either a step ID or a Basecamp URL: } cmd.Flags().StringVarP(&cardID, "card", "c", "", "Card ID (required)") - cmd.Flags().IntVar(&position, "position", -1, "Target position (0-indexed)") - cmd.Flags().IntVar(&position, "pos", -1, "Target position (alias for --position)") + cmd.Flags().IntVar(&position, "position", 0, "Target position (1-based)") + cmd.Flags().IntVar(&position, "pos", 0, "Target position (alias for --position)") return cmd } diff --git a/internal/commands/cards_test.go b/internal/commands/cards_test.go index 3b16f203a..822e995db 100644 --- a/internal/commands/cards_test.go +++ b/internal/commands/cards_test.go @@ -321,7 +321,21 @@ func TestCardsStepMoveRequiresPosition(t *testing.T) { var e *output.Error if assert.True(t, errors.As(err, &e), "expected *output.Error, got %T: %v", err, err) { - assert.Equal(t, "--position is required (0-indexed)", e.Message) + assert.Equal(t, "--position is required (1-based)", e.Message) + } +} + +// A --position that was given but is below 1 says so, rather than calling it missing. +func TestCardsStepMoveRejectsPositionZero(t *testing.T) { + app, _ := setupTestApp(t) + app.Config.ProjectID = "123" + + err := executeCommand(newCardsStepMoveCmd(), app, "456", "--card", "789", "--position", "0") + require.NotNil(t, err, "expected error, got nil") + + var e *output.Error + if assert.True(t, errors.As(err, &e), "expected *output.Error, got %T: %v", err, err) { + assert.Equal(t, "--position must be 1 or more (1 = top)", e.Message) } } diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 5fda07a5a..a2a013670 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -36,6 +36,7 @@ func CommandCategories() []CommandCategory { Commands: []CommandInfo{ {Name: "projects", Category: "core", Description: "Manage projects", Actions: []string{"list", "show", "create", "update", "delete"}}, {Name: "todos", Category: "core", Description: "Manage to-dos", Actions: []string{"list", "show", "create", "update", "complete", "uncomplete", "position", "trash", "archive", "restore"}}, + {Name: "subtasks", Category: "core", Description: "Manage subtasks on to-dos and cards", Actions: []string{"list", "show", "create", "update", "complete", "uncomplete", "move", "delete"}}, {Name: "todolists", Category: "core", Description: "Manage to-do lists", Actions: []string{"list", "show", "create", "update", "position", "trash", "archive", "restore"}}, {Name: "todosets", Category: "core", Description: "Manage to-do set containers", Actions: []string{"list", "show"}}, {Name: "hillcharts", Category: "core", Description: "Manage hill charts", Actions: []string{"show", "track", "untrack"}}, diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index 359c56083..64c345418 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -117,6 +117,7 @@ func buildRootWithAllCommands() *cobra.Command { root.AddCommand(commands.NewAssignmentsCmd()) root.AddCommand(commands.NewBookmarksCmd()) root.AddCommand(commands.NewBubbleUpCmd()) + root.AddCommand(commands.NewSubtasksCmd()) root.AddCommand(commands.NewDraftsCmd()) root.AddCommand(commands.NewNotesCmd()) root.AddCommand(commands.NewCalendarsCmd()) diff --git a/internal/commands/subtasks.go b/internal/commands/subtasks.go new file mode 100644 index 000000000..42d462ace --- /dev/null +++ b/internal/commands/subtasks.go @@ -0,0 +1,615 @@ +package commands + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "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/dateparse" + "github.com/basecamp/basecamp-cli/internal/output" + "github.com/basecamp/basecamp-cli/internal/tui" + "github.com/basecamp/basecamp-cli/internal/urlarg" +) + +// NewSubtasksCmd creates the subtasks command group: checklist items under a +// to-do or a card. +// +// Subtasks are the canonical, flat successor to card steps. bc3 routes them by +// account alone — /recordings/:id/subtasks.json for a parent's list and +// /subtasks/:id for one subtask — so no verb needs a project, and none takes +// --in. The wire still calls a subtask "Kanban::Step", and the records are the +// same ones `cards steps` reads through its card-scoped aliases. +func NewSubtasksCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "subtasks", + Short: "Manage subtasks on to-dos and cards", + Long: `Manage subtasks: the checklist items under a to-do or a card. + +A subtask belongs to one parent, addressed by the parent's id or pasted +Basecamp URL. Every other verb addresses the subtask itself, by id or by its +URL (a parent URL ending in #__recording_<id>). + + basecamp subtasks list 123 + basecamp subtasks create 123 "Book the venue" --due friday --assignees me + basecamp subtasks complete 456 + basecamp subtasks move 456 --position 1`, + Annotations: map[string]string{ + "agent_notes": "Parents are to-dos and cards only; any other recording answers 403.\n" + + "Account-scoped — no --in <project> needed.\n" + + "Same records as `cards steps`/`cards step` (the wire type is Kanban::Step); subtasks also cover to-dos.\n" + + "A parent embeds at most 100 subtasks under steps; `subtasks list --all` reads every one.\n" + + "move --position is 1-based (1 = top).", + }, + } + + cmd.AddCommand( + newSubtasksListCmd(), + newSubtasksShowCmd(), + newSubtasksCreateCmd(), + newSubtasksUpdateCmd(), + newSubtasksCompleteCmd(), + newSubtasksUncompleteCmd(), + newSubtasksMoveCmd(), + newSubtasksDeleteCmd(), + ) + + return cmd +} + +func newSubtasksListCmd() *cobra.Command { + var limit, page int + var all bool + + cmd := &cobra.Command{ + Use: "list <todo-or-card-id|url>", + Short: "List a to-do's or card's subtasks", + Long: `List the subtasks of a to-do or a card, in position order. + + basecamp subtasks list 123 + basecamp subtasks list https://3.basecamp.com/123/buckets/456/todos/789 --all`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return missingArg(cmd, "<todo-or-card-id|url>") + } + if len(args) > 1 { + return output.ErrUsage("subtasks list takes one parent id or URL") + } + if limit < 0 { + return output.ErrUsage("--limit must be zero or positive") + } + if all && limit > 0 { + return output.ErrUsage("--all and --limit are mutually exclusive") + } + if page > 0 && (all || limit > 0) { + return output.ErrUsage("--page cannot be combined with --all or --limit") + } + if cmd.Flags().Changed("page") && page < 1 { + return output.ErrUsage("--page must be 1 or greater") + } + + parentID, err := subtaskParentID(args[0]) + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + opts := &basecamp.SubtaskListOptions{} + switch { + case all: + opts.Limit = -1 // SDK treats -1 as unlimited + case limit > 0: + opts.Limit = limit + } + if page > 0 { + opts.Page = page + } + + result, err := app.Account().Subtasks().List(cmd.Context(), parentID, opts) + if err != nil { + return convertSDKError(err) + } + subtasks := result.Subtasks + if subtasks == nil { + subtasks = []basecamp.CardStep{} + } + + respOpts := []output.ResponseOption{ + output.WithSummary(fmt.Sprintf("%d subtasks on #%d", len(subtasks), parentID)), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "create", + Cmd: fmt.Sprintf("basecamp subtasks create %d <title>", parentID), + Description: "Add a subtask", + }, + output.Breadcrumb{ + Action: "show", + Cmd: "basecamp subtasks show <id>", + Description: "Show a subtask", + }, + ), + } + if notice := output.TruncationNoticeWithTotal(len(subtasks), result.Meta.TotalCount); notice != "" { + respOpts = append(respOpts, output.WithNotice(notice)) + } + + return app.OK(subtasks, respOpts...) + }, + } + + cmd.Flags().IntVarP(&limit, "limit", "n", 0, "Maximum number of subtasks to fetch (0 = default 100)") + cmd.Flags().BoolVar(&all, "all", false, "Fetch all subtasks (no limit)") + cmd.Flags().IntVar(&page, "page", 0, "Fetch a single page (use --all for everything)") + + return cmd +} + +func newSubtasksShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show <id|url>", + Short: "Show a subtask", + Long: `Show one subtask: its title, completion, due date, assignees, and parent. + + basecamp subtasks show 456 + basecamp subtasks show https://3.basecamp.com/123/buckets/456/todos/789#__recording_1011`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + subtaskID, err := subtaskIDArg(args[0]) + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + subtask, err := app.Account().Subtasks().Get(cmd.Context(), subtaskID) + if err != nil { + return convertSDKError(err) + } + + return app.OK(subtask, + output.WithSummary(fmt.Sprintf("Subtask #%d: %s", subtask.ID, subtask.Title)), + output.WithBreadcrumbs(subtaskBreadcrumbs(subtask)...), + ) + }, + } +} + +func newSubtasksCreateCmd() *cobra.Command { + var dueOn string + var assignees string + + cmd := &cobra.Command{ + Use: "create <todo-or-card-id|url> <title>", + Short: "Add a subtask to a to-do or card", + Long: `Add a subtask to a to-do or a card. It lands at the bottom of the list. + + basecamp subtasks create 123 "Book the venue" + basecamp subtasks create 123 "Send invites" --due "next friday" --assignees me,annie@example.com`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) < 2 { + if len(args) == 0 { + return missingArg(cmd, "<todo-or-card-id|url>") + } + return missingArg(cmd, "<title>") + } + if len(args) > 2 { + return output.ErrUsageHint("subtasks create takes one parent and one title", + `Quote a title with spaces: basecamp subtasks create 123 "Book the venue"`) + } + + parentID, err := subtaskParentID(args[0]) + if err != nil { + return err + } + title := args[1] + if title == "" { + return output.ErrUsage("subtask title cannot be empty") + } + + app := appctx.FromContext(cmd.Context()) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + req := &basecamp.CreateSubtaskRequest{Title: title} + if dueOn != "" { + req.DueOn = dateparse.Parse(dueOn) + } + if assignees != "" { + ids, err := resolveAssigneeIDs(cmd.Context(), app, assignees) + if err != nil { + return err + } + req.AssigneeIDs = ids + } + + subtask, err := app.Account().Subtasks().Create(cmd.Context(), parentID, req) + if err != nil { + return convertSDKError(err) + } + + return app.OK(subtask, + output.WithSummary(fmt.Sprintf("Created subtask #%d: %s", subtask.ID, subtask.Title)), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "complete", + Cmd: fmt.Sprintf("basecamp subtasks complete %d", subtask.ID), + Description: "Complete subtask", + }, + output.Breadcrumb{ + Action: "list", + Cmd: fmt.Sprintf("basecamp subtasks list %d", parentID), + Description: "List subtasks", + }, + ), + ) + }, + } + + cmd.Flags().StringVarP(&dueOn, "due", "d", "", "Due date (natural language or YYYY-MM-DD)") + cmd.Flags().StringVar(&assignees, "assignees", "", "Assignees (IDs, names, or emails, comma-separated)") + + return cmd +} + +func newSubtasksUpdateCmd() *cobra.Command { + var title, dueOn, assignees string + var noDue, noAssignees bool + + cmd := &cobra.Command{ + Use: "update <id|url> [title]", + Short: "Update a subtask", + Long: `Update a subtask's title, due date, or assignees. Anything not given is +left as it is. + + basecamp subtasks update 456 "Book the bigger venue" + basecamp subtasks update 456 --due tomorrow + basecamp subtasks update 456 --no-due --no-assignees`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return missingArg(cmd, "<id|url>") + } + if len(args) > 2 { + return output.ErrUsageHint("subtasks update takes one subtask and at most one title", + `Quote a title with spaces: basecamp subtasks update 456 "Book the venue"`) + } + + subtaskID, err := subtaskIDArg(args[0]) + if err != nil { + return err + } + if len(args) == 2 { + if cmd.Flags().Changed("title") { + return output.ErrUsage("pass the title as an argument or with --title, not both") + } + title = args[1] + } + if (cmd.Flags().Changed("title") || len(args) == 2) && title == "" { + return output.ErrUsage("subtask title cannot be empty") + } + if noDue && dueOn != "" { + return output.ErrUsage("--due and --no-due are mutually exclusive") + } + if noAssignees && assignees != "" { + return output.ErrUsage("--assignees and --no-assignees are mutually exclusive") + } + if title == "" && dueOn == "" && !noDue && assignees == "" && !noAssignees { + return noChanges(cmd) + } + + app := appctx.FromContext(cmd.Context()) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + req := &basecamp.UpdateSubtaskRequest{Title: title} + switch { + case noDue: + req.DueOn = basecamp.Ptr("") + case dueOn != "": + req.DueOn = basecamp.Ptr(dateparse.Parse(dueOn)) + } + switch { + case noAssignees: + req.AssigneeIDs = []int64{} + case assignees != "": + ids, err := resolveAssigneeIDs(cmd.Context(), app, assignees) + if err != nil { + return err + } + req.AssigneeIDs = ids + } + + subtask, err := app.Account().Subtasks().Update(cmd.Context(), subtaskID, req) + if err != nil { + return convertSDKError(err) + } + + return app.OK(subtask, + output.WithSummary(fmt.Sprintf("Updated subtask #%d", subtask.ID)), + output.WithBreadcrumbs(subtaskBreadcrumbs(subtask)...), + ) + }, + } + + cmd.Flags().StringVarP(&title, "title", "t", "", "New title") + cmd.Flags().StringVarP(&dueOn, "due", "d", "", "Due date (natural language or YYYY-MM-DD)") + cmd.Flags().BoolVar(&noDue, "no-due", false, "Clear the due date") + cmd.Flags().StringVar(&assignees, "assignees", "", "Replace assignees (IDs, names, or emails, comma-separated)") + cmd.Flags().BoolVar(&noAssignees, "no-assignees", false, "Remove every assignee") + + return cmd +} + +func newSubtasksCompleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "complete <id|url>", + Short: "Complete a subtask", + Long: `Mark a subtask as completed. + + basecamp subtasks complete 456`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runSubtaskCompletion(cmd, args[0], true) + }, + } +} + +func newSubtasksUncompleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "uncomplete <id|url>", + Short: "Uncomplete a subtask", + Long: `Mark a subtask as not completed. + + basecamp subtasks uncomplete 456`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runSubtaskCompletion(cmd, args[0], false) + }, + } +} + +// runSubtaskCompletion drives complete and uncomplete. bc3 answers both with a +// bare 204, so the payload reports the state that was asked for. +func runSubtaskCompletion(cmd *cobra.Command, arg string, complete bool) error { + subtaskID, err := subtaskIDArg(arg) + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + summary := fmt.Sprintf("Completed subtask #%d", subtaskID) + undo := output.Breadcrumb{ + Action: "uncomplete", + Cmd: fmt.Sprintf("basecamp subtasks uncomplete %d", subtaskID), + Description: "Uncomplete subtask", + } + if complete { + err = app.Account().Subtasks().Complete(cmd.Context(), subtaskID) + } else { + err = app.Account().Subtasks().Uncomplete(cmd.Context(), subtaskID) + summary = fmt.Sprintf("Uncompleted subtask #%d", subtaskID) + undo = output.Breadcrumb{ + Action: "complete", + Cmd: fmt.Sprintf("basecamp subtasks complete %d", subtaskID), + Description: "Complete subtask", + } + } + if err != nil { + return convertSDKError(err) + } + + return app.OK(map[string]any{"id": subtaskID, "completed": complete}, + output.WithSummary(summary), + output.WithBreadcrumbs( + undo, + output.Breadcrumb{ + Action: "show", + Cmd: fmt.Sprintf("basecamp subtasks show %d", subtaskID), + Description: "Show subtask", + }, + ), + ) +} + +func newSubtasksMoveCmd() *cobra.Command { + var position int + + cmd := &cobra.Command{ + Use: "move <id|url>", + Short: "Move a subtask", + Long: `Move a subtask to a new position among its siblings. Positions are +1-based: 1 is the top. + + basecamp subtasks move 456 --position 1`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + subtaskID, err := subtaskIDArg(args[0]) + if err != nil { + return err + } + if !cmd.Flags().Changed("position") && !cmd.Flags().Changed("pos") { + return output.ErrUsage("--position is required (1-based)") + } + if position < 1 { + return output.ErrUsage("--position must be 1 or more (1 = top)") + } + + app := appctx.FromContext(cmd.Context()) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + if err := app.Account().Subtasks().Reposition(cmd.Context(), subtaskID, position); err != nil { + return convertSDKError(err) + } + + return app.OK(map[string]any{"id": subtaskID, "position": position, "moved": true}, + output.WithSummary(fmt.Sprintf("Moved subtask #%d to position %d", subtaskID, position)), + output.WithBreadcrumbs(output.Breadcrumb{ + Action: "show", + Cmd: fmt.Sprintf("basecamp subtasks show %d", subtaskID), + Description: "Show subtask", + }), + ) + }, + } + + cmd.Flags().IntVar(&position, "position", 0, "Target position (1-based)") + cmd.Flags().IntVar(&position, "pos", 0, "Target position (alias for --position)") + + return cmd +} + +func newSubtasksDeleteCmd() *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "delete <id|url>", + Short: "Delete a subtask", + Long: `Permanently delete a subtask. It is not trashed and cannot be undone, so +this asks for confirmation; machine-output modes and non-interactive sessions +cannot answer one and need --force instead. On accounts that limit deleting to +admins and the creator, everyone else is refused. + + basecamp subtasks delete 456 + basecamp subtasks delete 456 --json --force`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + subtaskID, err := subtaskIDArg(args[0]) + if err != nil { + return err + } + + // Permanent, so only with --force or a confirmation somebody can + // answer — refused before any account lookup or request. + if err := ensureDeleteConfirmable(cmd, force); err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + // ensureDeleteConfirmable established that without --force a + // prompt will be shown and can be answered. + if !force { + confirmed, err := tui.ConfirmDangerous("Permanently delete this subtask?") + switch { + case errors.Is(err, tui.ErrCanceled): + return nil + case err != nil: + return fmt.Errorf("confirming the delete: %w", err) + case !confirmed: + return nil + } + } + + if err := app.Account().Subtasks().Delete(cmd.Context(), subtaskID); err != nil { + return convertSDKError(err) + } + + return app.OK(map[string]any{"id": subtaskID, "deleted": true}, + output.WithSummary(fmt.Sprintf("Deleted subtask #%d", subtaskID)), + ) + }, + } + + cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt") + + return cmd +} + +// subtaskBreadcrumbs are the follow-ups for a single subtask: toggle its +// completion, and go back to its parent's list. +func subtaskBreadcrumbs(subtask *basecamp.CardStep) []output.Breadcrumb { + crumbs := []output.Breadcrumb{} + if subtask.Completed { + crumbs = append(crumbs, output.Breadcrumb{ + Action: "uncomplete", + Cmd: fmt.Sprintf("basecamp subtasks uncomplete %d", subtask.ID), + Description: "Uncomplete subtask", + }) + } else { + crumbs = append(crumbs, output.Breadcrumb{ + Action: "complete", + Cmd: fmt.Sprintf("basecamp subtasks complete %d", subtask.ID), + Description: "Complete subtask", + }) + } + if subtask.Parent != nil && subtask.Parent.ID != 0 { + crumbs = append(crumbs, output.Breadcrumb{ + Action: "list", + Cmd: fmt.Sprintf("basecamp subtasks list %d", subtask.Parent.ID), + Description: "List sibling subtasks", + }) + } + return crumbs +} + +// subtaskParentID resolves the <todo-or-card-id|url> positional: the id of the +// to-do or card itself, never a fragment inside its URL. +func subtaskParentID(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 to-do or card id or Basecamp URL", arg), + "Pass the numeric id of the to-do or card, or paste its Basecamp URL", + ) + } + return id, nil +} + +// subtaskIDArg resolves the <id|url> positional the per-subtask verbs take: a +// bare id, a subtask's app URL — its to-do's or card's URL ending in +// #__recording_<id>, where the fragment names the subtask — or a single +// subtask or card-step URL, which names it in the path. Anything else is +// refused rather than read as a subtask id: a parent URL without the fragment, +// a fragment on any other kind of recording, and a subtasks or steps +// collection URL all carry an id that is not the subtask's, and `subtasks +// delete` must never aim at one of those. +func subtaskIDArg(arg string) (int64, error) { + raw := arg + if parsed := urlarg.Parse(arg); parsed != nil { + parentType := parsed.Type == "todos" || parsed.Type == "cards" + subtaskType := parsed.Type == "subtasks" || parsed.Type == "steps" + switch { + case parentType && parsed.CommentID != "" && strings.HasSuffix(arg, "#__recording_"+parsed.CommentID): + raw = parsed.CommentID + case subtaskType && parsed.CommentID == "" && !parsed.IsCollection: + raw = parsed.RecordingID + default: + return 0, output.ErrUsageHint( + fmt.Sprintf("%q does not name a subtask", arg), + "A to-do or card URL names the parent. Pass the subtask id, or its URL ending in #__recording_<id> "+ + "(basecamp subtasks list <parent> shows the ids)", + ) + } + } else if strings.Contains(arg, "://") { + raw = "" + } + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil || id <= 0 { + return 0, output.ErrUsageHint( + fmt.Sprintf("%q is not a subtask id or Basecamp URL", arg), + "Pass the numeric subtask id, or paste its URL (the parent's URL ending in #__recording_<id>)", + ) + } + return id, nil +} diff --git a/internal/commands/subtasks_test.go b/internal/commands/subtasks_test.go new file mode 100644 index 000000000..c6c5b3462 --- /dev/null +++ b/internal/commands/subtasks_test.go @@ -0,0 +1,366 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const subtaskFixture = `{ + "id": 456, + "status": "active", + "title": "Book the venue", + "type": "Kanban::Step", + "position": 1, + "completed": false, + "due_on": null, + "parent": {"id": 123, "title": "Plan the offsite", "type": "Todo"}, + "bucket": {"id": 89, "name": "Offsite", "type": "Project"}, + "assignees": [], + "completion_url": "https://3.basecampapi.com/99999/subtasks/456/completion.json" +}` + +func subtasksListPath(parentID int64) string { + return fmt.Sprintf("/99999/recordings/%d/subtasks.json", parentID) +} + +func subtaskPath(id int64) string { + return fmt.Sprintf("/99999/subtasks/%d", id) +} + +func subtaskRoute(method, path string, status int, body string) stubRoute { + return stubRoute{method: method, path: path, status: status, body: body} +} + +// subtaskEnvelope reads the success envelope a subtasks verb emits. +func subtaskEnvelope(t *testing.T, out *bytes.Buffer) (data json.RawMessage, summary string) { + t.Helper() + var envelope struct { + Data json.RawMessage `json:"data"` + Summary string `json:"summary"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &envelope), out.String()) + return envelope.Data, envelope.Summary +} + +func decodeSubtaskBody(t *testing.T, call recordedCall) map[string]any { + t.Helper() + var body map[string]any + require.NoError(t, json.Unmarshal([]byte(call.Body), &body), call.Body) + return body +} + +func TestSubtasksListReadsTheParentsSubtasks(t *testing.T) { + app, transport, out := setupPersonalFeedApp(t, + subtaskRoute(http.MethodGet, subtasksListPath(123), http.StatusOK, "["+subtaskFixture+"]")) + + require.NoError(t, executeRecordingCommand(NewSubtasksCmd(), app, "list", "123")) + + call := transport.last(t) + assert.Equal(t, http.MethodGet, call.Method) + assert.Equal(t, subtasksListPath(123), call.Path) + + data, summary := subtaskEnvelope(t, out) + var subtasks []map[string]any + require.NoError(t, json.Unmarshal(data, &subtasks)) + require.Len(t, subtasks, 1) + assert.Equal(t, "Book the venue", subtasks[0]["title"]) + assert.Equal(t, "1 subtasks on #123", summary) +} + +// A to-do or card URL names the parent by its path, not by a fragment. +func TestSubtasksListAcceptsAParentURL(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, + subtaskRoute(http.MethodGet, subtasksListPath(123), http.StatusOK, "[]")) + + require.NoError(t, executeRecordingCommand(NewSubtasksCmd(), app, "list", + "https://3.basecamp.com/99999/buckets/89/card_tables/cards/123")) + + assert.Equal(t, subtasksListPath(123), transport.last(t).Path) +} + +// An empty list is an empty array, not null. +func TestSubtasksListEmptyIsAnArray(t *testing.T) { + app, _, out := setupPersonalFeedApp(t, + subtaskRoute(http.MethodGet, subtasksListPath(123), http.StatusOK, "[]")) + + require.NoError(t, executeRecordingCommand(NewSubtasksCmd(), app, "list", "123")) + + data, _ := subtaskEnvelope(t, out) + assert.JSONEq(t, "[]", string(data)) +} + +func TestSubtasksListPageSelectsOnePage(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, + subtaskRoute(http.MethodGet, subtasksListPath(123), http.StatusOK, "[]")) + + require.NoError(t, executeRecordingCommand(NewSubtasksCmd(), app, "list", "123", "--page", "2")) + + assert.Equal(t, "page=2", transport.last(t).Query) +} + +func TestSubtasksListRejectsConflictingPagination(t *testing.T) { + for _, args := range [][]string{ + {"list", "123", "--all", "--limit", "5"}, + {"list", "123", "--page", "2", "--all"}, + {"list", "123", "--page", "0"}, + {"list", "123", "--limit", "-1"}, + } { + app, transport, _ := setupPersonalFeedApp(t) + + err := executeRecordingCommand(NewSubtasksCmd(), app, args...) + requireBookmarksUsageError(t, err) + assert.Empty(t, transport.recorded(), "no request for %v", args) + } +} + +func TestSubtasksShowGetsTheSubtask(t *testing.T) { + app, transport, out := setupPersonalFeedApp(t, + subtaskRoute(http.MethodGet, subtaskPath(456), http.StatusOK, subtaskFixture)) + + require.NoError(t, executeRecordingCommand(NewSubtasksCmd(), app, "show", "456")) + + assert.Equal(t, subtaskPath(456), transport.last(t).Path) + _, summary := subtaskEnvelope(t, out) + assert.Equal(t, "Subtask #456: Book the venue", summary) +} + +// A subtask's app URL is its parent's with a #__recording_<id> fragment; the +// fragment names the subtask. +func TestSubtasksShowAcceptsTheSubtaskURL(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, + subtaskRoute(http.MethodGet, subtaskPath(456), http.StatusOK, subtaskFixture)) + + require.NoError(t, executeRecordingCommand(NewSubtasksCmd(), app, "show", + "https://3.basecamp.com/99999/buckets/89/todos/123#__recording_456")) + + assert.Equal(t, subtaskPath(456), transport.last(t).Path) +} + +func TestSubtasksCreatePostsTitleDueAndAssignees(t *testing.T) { + app, transport, out := setupPersonalFeedApp(t, + subtaskRoute(http.MethodPost, subtasksListPath(123), http.StatusCreated, subtaskFixture)) + + require.NoError(t, executeRecordingCommand(NewSubtasksCmd(), app, "create", "123", "Book the venue", + "--due", "2026-10-02", "--assignees", "7,8")) + + call := transport.last(t) + assert.Equal(t, http.MethodPost, call.Method) + assert.Equal(t, subtasksListPath(123), call.Path) + + body := decodeSubtaskBody(t, call) + assert.Equal(t, "Book the venue", body["title"]) + assert.Equal(t, "2026-10-02", body["due_on"]) + assert.Equal(t, []any{float64(7), float64(8)}, body["assignee_ids"]) + + _, summary := subtaskEnvelope(t, out) + assert.Equal(t, "Created subtask #456: Book the venue", summary) +} + +func TestSubtasksCreateOmitsUnsetFields(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, + subtaskRoute(http.MethodPost, subtasksListPath(123), http.StatusCreated, subtaskFixture)) + + require.NoError(t, executeRecordingCommand(NewSubtasksCmd(), app, "create", "123", "Book the venue")) + + body := decodeSubtaskBody(t, transport.last(t)) + assert.Equal(t, map[string]any{"title": "Book the venue"}, body) +} + +func TestSubtasksCreateRejectsAnEmptyTitle(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t) + + err := executeRecordingCommand(NewSubtasksCmd(), app, "create", "123", "") + requireBookmarksUsageError(t, err) + assert.Empty(t, transport.recorded()) +} + +func TestSubtasksUpdateSendsOnlyWhatChanged(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, + subtaskRoute(http.MethodPut, subtaskPath(456), http.StatusOK, subtaskFixture)) + + require.NoError(t, executeRecordingCommand(NewSubtasksCmd(), app, "update", "456", "Book the bigger venue")) + + call := transport.last(t) + assert.Equal(t, http.MethodPut, call.Method) + assert.Equal(t, subtaskPath(456), call.Path) + assert.Equal(t, map[string]any{"title": "Book the bigger venue"}, decodeSubtaskBody(t, call)) +} + +// --no-due and --no-assignees clear: an empty due_on and an empty assignee +// list are sent, not omitted. +func TestSubtasksUpdateClearsDueAndAssignees(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, + subtaskRoute(http.MethodPut, subtaskPath(456), http.StatusOK, subtaskFixture)) + + require.NoError(t, executeRecordingCommand(NewSubtasksCmd(), app, "update", "456", "--no-due", "--no-assignees")) + + assert.Equal(t, map[string]any{"due_on": "", "assignee_ids": []any{}}, decodeSubtaskBody(t, transport.last(t))) +} + +func TestSubtasksUpdateRejectsContradictions(t *testing.T) { + for _, args := range [][]string{ + {"update", "456", "--due", "tomorrow", "--no-due"}, + {"update", "456", "--assignees", "7", "--no-assignees"}, + {"update", "456", "A title", "--title", "Another"}, + {"update", "456", "--title", ""}, + } { + app, transport, _ := setupPersonalFeedApp(t) + + err := executeRecordingCommand(NewSubtasksCmd(), app, args...) + requireBookmarksUsageError(t, err) + assert.Empty(t, transport.recorded(), "no request for %v", args) + } +} + +func TestSubtasksCompleteAndUncomplete(t *testing.T) { + completion := fmt.Sprintf("/99999/subtasks/%d/completion.json", 456) + for _, tc := range []struct { + verb string + method string + summary string + }{ + {"complete", http.MethodPost, "Completed subtask #456"}, + {"uncomplete", http.MethodDelete, "Uncompleted subtask #456"}, + } { + t.Run(tc.verb, func(t *testing.T) { + app, transport, out := setupPersonalFeedApp(t, + subtaskRoute(tc.method, completion, http.StatusNoContent, "")) + + require.NoError(t, executeRecordingCommand(NewSubtasksCmd(), app, tc.verb, "456")) + + call := transport.last(t) + assert.Equal(t, tc.method, call.Method) + assert.Equal(t, completion, call.Path) + + data, summary := subtaskEnvelope(t, out) + assert.Equal(t, tc.summary, summary) + var payload map[string]any + require.NoError(t, json.Unmarshal(data, &payload)) + assert.Equal(t, tc.verb == "complete", payload["completed"]) + }) + } +} + +func TestSubtasksMoveSendsTheOneBasedPosition(t *testing.T) { + position := fmt.Sprintf("/99999/subtasks/%d/position.json", 456) + app, transport, _ := setupPersonalFeedApp(t, + subtaskRoute(http.MethodPut, position, http.StatusNoContent, "")) + + require.NoError(t, executeRecordingCommand(NewSubtasksCmd(), app, "move", "456", "--position", "1")) + + call := transport.last(t) + assert.Equal(t, http.MethodPut, call.Method) + assert.Equal(t, position, call.Path) + assert.Equal(t, map[string]any{"position": float64(1)}, decodeSubtaskBody(t, call)) +} + +// Positions are 1-based, so 0 (or no --position at all) is a local usage +// error rather than a request the SDK would refuse. +func TestSubtasksMoveRequiresAPositivePosition(t *testing.T) { + for _, args := range [][]string{ + {"move", "456"}, + {"move", "456", "--position", "0"}, + } { + app, transport, _ := setupPersonalFeedApp(t) + + err := executeRecordingCommand(NewSubtasksCmd(), app, args...) + requireBookmarksUsageError(t, err) + assert.Empty(t, transport.recorded()) + } +} + +func TestSubtasksDeleteDeletesTheSubtask(t *testing.T) { + app, transport, out := setupPersonalFeedApp(t, + subtaskRoute(http.MethodDelete, subtaskPath(456), http.StatusNoContent, "")) + + require.NoError(t, executeRecordingCommand(NewSubtasksCmd(), app, "delete", "456", "--force")) + + call := transport.last(t) + assert.Equal(t, http.MethodDelete, call.Method) + assert.Equal(t, subtaskPath(456), call.Path) + _, summary := subtaskEnvelope(t, out) + assert.Equal(t, "Deleted subtask #456", summary) +} + +func TestSubtasksVerbsRejectANonID(t *testing.T) { + for _, args := range [][]string{ + {"list", "not-an-id"}, + {"show", "not-an-id"}, + {"create", "not-an-id", "Title"}, + {"update", "not-an-id", "Title"}, + {"complete", "not-an-id"}, + {"uncomplete", "0"}, + {"move", "not-an-id", "--position", "1"}, + {"delete", "not-an-id"}, + } { + app, transport, _ := setupPersonalFeedApp(t) + + err := executeRecordingCommand(NewSubtasksCmd(), app, args...) + requireBookmarksUsageError(t, err) + assert.Empty(t, transport.recorded(), "no request for %v", args) + } +} + +// A permanent delete in machine-output mode has nobody to confirm it, so it +// needs --force and is refused before any request without it. +func TestSubtasksDeleteNeedsForceWhenNothingCanConfirm(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t) + + err := executeRecordingCommand(NewSubtasksCmd(), app, "delete", "456") + outErr := requireBookmarksUsageError(t, err) + assert.Contains(t, outErr.Message, "--force") + assert.Empty(t, transport.recorded()) +} + +// A to-do or card URL without a #__recording_ fragment names the parent, not a +// subtask, so it must not be read as a subtask id — least of all by delete. +func TestSubtasksPerSubtaskVerbsRefuseAParentURL(t *testing.T) { + parentURL := "https://3.basecamp.com/99999/buckets/89/todos/123" + for _, args := range [][]string{ + {"show", parentURL}, + {"complete", parentURL}, + {"delete", parentURL, "--force"}, + {"show", "https://example.com/not/basecamp"}, + // Collection URLs carry the parent's id, not a subtask's. + {"delete", "https://3.basecampapi.com/99999/recordings/123/subtasks.json", "--force"}, + {"delete", "https://3.basecampapi.com/99999/buckets/89/card_tables/cards/123/steps.json", "--force"}, + // A fragment names a subtask only on a to-do or card, and only as #__recording_<id>. + {"show", "https://3.basecamp.com/99999/buckets/89/messages/123#__recording_456"}, + {"show", "https://3.basecamp.com/99999/buckets/89/todos/123#456"}, + {"show", "https://3.basecampapi.com/99999/subtasks/789#__recording_456"}, + } { + app, transport, _ := setupPersonalFeedApp(t) + + err := executeRecordingCommand(NewSubtasksCmd(), app, args...) + requireBookmarksUsageError(t, err) + assert.Empty(t, transport.recorded(), "no request for %v", args) + } +} + +// The API URL a subtask payload carries names the subtask in its path. +func TestSubtasksShowAcceptsTheSubtaskAPIURL(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, + subtaskRoute(http.MethodGet, subtaskPath(456), http.StatusOK, subtaskFixture)) + + require.NoError(t, executeRecordingCommand(NewSubtasksCmd(), app, "show", + "https://3.basecampapi.com/99999/buckets/89/subtasks/456.json")) + + assert.Equal(t, subtaskPath(456), transport.last(t).Path) +} + +// A card's URL carries its subtasks' fragments the same way a to-do's does. +func TestSubtasksShowAcceptsACardSubtaskURL(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, + subtaskRoute(http.MethodGet, subtaskPath(456), http.StatusOK, subtaskFixture)) + + require.NoError(t, executeRecordingCommand(NewSubtasksCmd(), app, "show", + "https://3.basecamp.com/99999/buckets/89/card_tables/cards/123#__recording_456")) + + assert.Equal(t, subtaskPath(456), transport.last(t).Path) +} diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index c5f3409fd..bb3d73a70 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, - bubble-up, drafts, notes, calendars, and accounts. + bubble-up, drafts, notes, calendars, subtasks, and accounts. Use for ANY Basecamp question or action. triggers: # Direct invocations @@ -12,6 +12,7 @@ triggers: - /basecamp # Resource actions - basecamp todos + - basecamp subtasks - basecamp project - basecamp cards - basecamp chat @@ -81,7 +82,7 @@ argument-hint: "[action] [args...]" # /basecamp - Basecamp Workflow Command -Full CLI coverage: 195 tracked in-scope endpoints across todos, cards, messages, files, schedule, check-ins, timeline, recordings, templates, webhooks, subscriptions, lineup, chat, pings, gauges, assignments, notifications, the account event feed, and accounts. +Full CLI coverage: 203 tracked in-scope endpoints across todos, subtasks, cards, messages, files, schedule, check-ins, timeline, recordings, templates, webhooks, subscriptions, lineup, chat, pings, gauges, assignments, notifications, the account event feed, and accounts. ## Agent Invariants @@ -143,7 +144,7 @@ Full CLI coverage: 195 tracked in-scope endpoints across todos, cards, messages, renders as an empty bullet list. When the CLI version is unknown, check `basecamp --version` first, or pass the content portably as `"$(cat file.md)"` and verify the posted `content` when it matters. -6. **Project scope is mandatory for most commands** — via `--in <project>` or `.basecamp/config.json`. Cross-project exceptions: `basecamp reports assigned` for assigned work, `basecamp assignments` for structured assignment views, `basecamp reports overdue` for overdue todos, `basecamp reports schedule` for upcoming schedule across all projects, `basecamp recordings <type>` for browsing by type, `basecamp notifications` for notifications, `basecamp gauges list` for account-wide gauges, `basecamp events poll` and `basecamp inbox` for the account event feed, and the seven list commands covered in item 7. +6. **Project scope is mandatory for most commands** — via `--in <project>` or `.basecamp/config.json`. Cross-project exceptions: `basecamp reports assigned` for assigned work, `basecamp assignments` for structured assignment views, `basecamp reports overdue` for overdue todos, `basecamp reports schedule` for upcoming schedule across all projects, `basecamp recordings <type>` for browsing by type, `basecamp subtasks` for subtasks on a to-do or card, `basecamp notifications` for notifications, `basecamp gauges list` for account-wide gauges, `basecamp events poll` and `basecamp inbox` for the account event feed, and the seven list commands covered in item 7. 7. **Account-wide listing.** `basecamp todos list --all-projects --json` lists across every project; the same flag does the same on `cards list`, `messages list`, `comments list`, `files list`, `forwards list`, and `checkins answers`. It overrides a configured project, and with no project in scope those commands already list account-wide rather than prompting. Flags that name something inside a single project are rejected there rather than silently ignored. Account-wide listings return **the first 100 items by default** — account-wide "all" is the whole account, not one project's worth. Use `--limit N` to raise the cap (it walks pages until N are collected) or `--all` for everything. `--page N` fetches exactly one page, but only on the paginated listings. The two overdue variants — `basecamp todos list --all-projects --overdue` and `basecamp cards list --all-projects --overdue` — come from unpaginated endpoints. They accept `--limit` and `--all` but **reject `--page`**, so do not generate `--page` against them. @@ -205,7 +206,7 @@ basecamp <cmd> --page 1 # First page only, no auto-pagination ## Quick Reference -> **Note:** Most queries require project scope (via `--in <project>` or `.basecamp/config.json`). Cross-project exceptions: `basecamp reports assigned`, `basecamp assignments`, `basecamp reports overdue`, `basecamp reports schedule`, `basecamp recordings <type>`, `basecamp notifications`, `basecamp gauges list`, `basecamp events poll`, `basecamp inbox`. +> **Note:** Most queries require project scope (via `--in <project>` or `.basecamp/config.json`). Cross-project exceptions: `basecamp reports assigned`, `basecamp assignments`, `basecamp reports overdue`, `basecamp reports schedule`, `basecamp recordings <type>`, `basecamp subtasks`, `basecamp notifications`, `basecamp gauges list`, `basecamp events poll`, `basecamp inbox`. > > Seven list commands also list account-wide: `basecamp todos list --all-projects --json`, and likewise `cards list`, `messages list`, `comments list`, `files list`, `forwards list`, and `checkins answers`. @@ -242,6 +243,9 @@ basecamp <cmd> --page 1 # First page only, no auto-pagination | Create todo | `basecamp todos create "Task" --in <project> --list <list> --json` | | Create todolist | `basecamp todolists create "Name" --in <project> --json` | | Complete todo | `basecamp todos complete <id> --json` | +| List a to-do's or card's subtasks | `basecamp subtasks list <todo-or-card-id> --json` | +| Add a subtask | `basecamp subtasks create <todo-or-card-id> "Title" --json` | +| Complete a subtask | `basecamp subtasks complete <subtask-id> --json` | | List cards | `basecamp cards list --in <project> --json` | | Create card | `basecamp cards create "Title" --in <project> --json` | | Complete card | `basecamp cards done <id|url> --in <project> --json` | @@ -568,89 +572,31 @@ basecamp todos update <id> --no-notify-on-completion # Clear completion not `todos update`; clear with `--no-notify-on-completion` on `todos update`. Plain updates (title, due date, etc.) preserve existing completion subscribers. -**Todo Subtasks (checklist steps):** Basecamp to-do subtasks are stored as -`Kanban::Step` records, even when their parent is a normal `Todo`. The regular -`basecamp todos show` response may not include them; use -`basecamp recordings list --in <project> --type Kanban::Step` and filter by -`parent.id` to list/check subtasks for a todo. - -```bash -# Create a subtask under a todo. -# Use the numeric project ID and todo ID in this card-style path. -basecamp api post /buckets/<project_id>/card_tables/cards/<parent_todo_id>/steps.json \ - --data '{"title":"Subtask title"}' \ - --json - -# Read or edit a subtask -basecamp api get /buckets/<project_id>/card_tables/steps/<step_id>.json --json -basecamp api put /buckets/<project_id>/card_tables/steps/<step_id>.json \ - --data '{"title":"Updated subtask title"}' \ - --json - -# List subtasks for a todo -PARENT_TODO_ID=<parent_todo_id> \ -basecamp recordings list --in <project> --type Kanban::Step --all \ - --jq '.data[] | select(.parent.id==(env.PARENT_TODO_ID | tonumber)) | {id,title,status,parent:.parent.id,url}' - -# Assign or set a due date. Send only what you're changing — omitted fields are -# left alone. `assignee_ids` replaces the whole list, so name everyone who stays. -basecamp api put /buckets/<project_id>/card_tables/steps/<step_id>.json \ - --data '{"assignee_ids":[<person_id>,<existing_person_id>],"due_on":"<YYYY-MM-DD>"}' \ - --json - -# Complete or reopen a subtask -basecamp api put /buckets/<project_id>/card_tables/steps/<step_id>/completions.json \ - --data '{"completion":"on"}' \ - --json -basecamp api put /buckets/<project_id>/card_tables/steps/<step_id>/completions.json \ - --data '{"completion":"off"}' \ - --json - -# Trash a subtask from the todo UI by trashing the step record (Kanban::Step) -basecamp recordings trash <step_id> --in <project> --json -``` - -Key points: replace numeric placeholders such as `<project_id>`, -`<parent_todo_id>`, and `<person_id>` before running the examples. Bucket-scoped -API paths require a numeric project/bucket ID; `--in <project>` can still accept -a project name where CLI commands support name resolution. For creating todo -subtasks, Basecamp accepts the parent todo ID in the -`/buckets/<project_id>/card_tables/cards/<parent_todo_id>/steps.json` path. To -list subtasks under a todo, use -`basecamp recordings list --in <project> --type Kanban::Step` with the -`parent.id` filter shown above. - -Completed subtasks have `completed: true` and a `completion` object with -`created_at` and `creator`. Open subtasks have `completed: false` and no -`completion` object. Trashed subtasks may still be readable directly with -`status: "trashed"` and `inherits_status: false`, but they no longer appear in -the todo UI. - -In testing with todo-backed steps, these bucket-scoped direct `GET` requests -returned `not_found`: -`/buckets/<project_id>/card_tables/cards/<parent_todo_id>/steps.json`, -`/buckets/<project_id>/card_tables/cards/<parent_todo_id>.json`, and -`/buckets/<project_id>/todos/<parent_todo_id>/steps.json`. To inspect trashed -subtasks, add `--status trashed`; archived parents may require -`--status archived`. - -**Raw step updates are partial.** `PUT .../card_tables/steps/<id>.json` leaves -every parameter you omit unchanged, so send only the fields you are changing. -Echoing back a `title` you did not mean to change is not merely redundant — it -reverts anyone who edited the title between your read and your write. To clear a -value, say so explicitly: `"due_on": null` clears the due date, `"assignee_ids": -[]` removes everyone. `assignee_ids` always replaces the whole list rather than -adding to it, so name every person who should remain assigned. - -(This is bc3#12521. Before it, an omitted field *was* cleared and a title-less -update was rejected, which is why older guidance said to resend the title. Todo -subtasks and card steps share one endpoint and one contract — `PUT -card_tables/steps/:id` routes to the same controller for both.) - -The generic -`basecamp assign <step_id> --step ...` command is intended for card steps and -may fail with `Bad Request` for todo-backed steps, so prefer `assignee_ids` on -the raw step update endpoint for todo subtasks. +**Subtasks (checklist items on to-dos and cards):** use `basecamp subtasks`. +Subtasks are account-scoped — no `--in <project>` — and address the parent +to-do or card by id or URL. They are the same `Kanban::Step` records +`cards steps` reads, so `subtasks` covers card steps too. + +```bash +basecamp subtasks list <todo-or-card-id|url> --json # In position order (--all past 100) +basecamp subtasks show <subtask-id|url> --json # URL: parent URL ending in #__recording_<id> +basecamp subtasks create <todo-or-card-id|url> "Title" --due tomorrow --assignees me --json +basecamp subtasks update <subtask-id> "New title" --json # Partial: only what you pass changes +basecamp subtasks update <subtask-id> --due 2026-10-02 --assignees "Ann,Bob" --json +basecamp subtasks update <subtask-id> --no-due --no-assignees --json +basecamp subtasks complete <subtask-id> --json +basecamp subtasks uncomplete <subtask-id> --json +basecamp subtasks move <subtask-id> --position 1 --json # 1-based (1 = top) +basecamp subtasks delete <subtask-id> --force --json # Permanent; --force where nothing can confirm +``` + +Key points: only to-dos and cards hold subtasks; any other parent is refused +with 403. `update` is partial — omitted fields are left alone, so there is no +need to resend the title. `--assignees` replaces the whole list, so name +everyone who should stay assigned; `--no-assignees` removes everyone and +`--no-due` clears the due date. A to-do or card embeds at most 100 subtasks +under `steps` and reports the real total as `subtasks_count` (with +`subtasks_completed_count`); `subtasks list --all` reads every one. ### Todolists @@ -720,12 +666,15 @@ records an `adopted` event for every column move, and a card crossing into or out of a Done column pairs that with `completed`/`uncompleted`. See [Events](#events-change-history). -**Card Steps (checklists):** +**Card Steps (checklists):** card steps are subtasks; `basecamp subtasks` +(see [Todos](#todos)) covers them without a project. The card-scoped forms +remain: ```bash basecamp cards steps <card_id> --in <project> # List steps basecamp cards step create "Step" --card <id> --in <project> basecamp cards step complete <step_id> --in <project> basecamp cards step uncomplete <step_id> +basecamp cards step move <step_id> --card <id> --position 1 # 1-based ``` **Column management:**