diff --git a/.surface b/.surface index 1b6b8238d..7931f86a4 100644 --- a/.surface +++ b/.surface @@ -51,6 +51,7 @@ ARG basecamp campfire update 01 [content] ARG basecamp campfire upload 00 ARG basecamp cards archive 00 ARG basecamp cards column color 00 +ARG basecamp cards column copy 00 ARG basecamp cards column create 00 ARG basecamp cards column move 00 <id|url> ARG basecamp cards column no-on-hold 00 <id|url> @@ -59,6 +60,7 @@ ARG basecamp cards column show 00 <id|url> ARG basecamp cards column unwatch 00 <id|url> ARG basecamp cards column update 00 <id|url> ARG basecamp cards column watch 00 <id|url> +ARG basecamp cards copy 00 <id|url> ARG basecamp cards create 00 <title> ARG basecamp cards create 01 [body] ARG basecamp cards done 00 <id|url> @@ -95,7 +97,9 @@ ARG basecamp checkin answer update 01 <content> ARG basecamp checkin answers 00 [question_id|url] ARG basecamp checkin question 00 <id|url> ARG basecamp checkin question answerers 00 <id|url> +ARG basecamp checkin question copy 00 <id|url> ARG basecamp checkin question create 00 <title> +ARG basecamp checkin question move 00 <id|url> ARG basecamp checkin question notify 00 <id|url> ARG basecamp checkin question pause 00 <id|url> ARG basecamp checkin question resume 00 <id|url> @@ -111,7 +115,9 @@ ARG basecamp checkins answer update 01 <content> ARG basecamp checkins answers 00 [question_id|url] ARG basecamp checkins question 00 <id|url> ARG basecamp checkins question answerers 00 <id|url> +ARG basecamp checkins question copy 00 <id|url> ARG basecamp checkins question create 00 <title> +ARG basecamp checkins question move 00 <id|url> ARG basecamp checkins question notify 00 <id|url> ARG basecamp checkins question pause 00 <id|url> ARG basecamp checkins question resume 00 <id|url> @@ -135,6 +141,7 @@ ARG basecamp config trust 00 [path] ARG basecamp config unset 00 <key> ARG basecamp config untrust 00 [path] ARG basecamp docs archive 00 <id|url> +ARG basecamp docs copy 00 <id|url>... ARG basecamp docs doc create 00 <title> ARG basecamp docs doc create 01 [content] ARG basecamp docs document create 00 <title> @@ -144,7 +151,7 @@ ARG basecamp docs documents create 01 [content] ARG basecamp docs download 00 <upload-id|url> ARG basecamp docs folder create 00 <name> ARG basecamp docs folders create 00 <name> -ARG basecamp docs move 00 <id|url> +ARG basecamp docs move 00 <id|url>... ARG basecamp docs new-version 00 <upload-id|url> ARG basecamp docs new-version 01 <file> ARG basecamp docs replace 00 <upload-id|url> @@ -159,6 +166,7 @@ ARG basecamp docs vault create 00 <name> ARG basecamp docs vaults create 00 <name> ARG basecamp docs versions 00 <upload_id|url> ARG basecamp documents archive 00 <id|url> +ARG basecamp documents copy 00 <id|url>... ARG basecamp documents doc create 00 <title> ARG basecamp documents doc create 01 [content] ARG basecamp documents document create 00 <title> @@ -168,7 +176,7 @@ ARG basecamp documents documents create 01 [content] ARG basecamp documents download 00 <upload-id|url> ARG basecamp documents folder create 00 <name> ARG basecamp documents folders create 00 <name> -ARG basecamp documents move 00 <id|url> +ARG basecamp documents move 00 <id|url>... ARG basecamp documents new-version 00 <upload-id|url> ARG basecamp documents new-version 01 <file> ARG basecamp documents replace 00 <upload-id|url> @@ -186,6 +194,7 @@ ARG basecamp embeds create 00 <url> ARG basecamp embeds show 00 <sgid> ARG basecamp events 00 [id|url] ARG basecamp file archive 00 <id|url> +ARG basecamp file copy 00 <id|url>... ARG basecamp file doc create 00 <title> ARG basecamp file doc create 01 [content] ARG basecamp file document create 00 <title> @@ -195,7 +204,7 @@ ARG basecamp file documents create 01 [content] ARG basecamp file download 00 <upload-id|url> ARG basecamp file folder create 00 <name> ARG basecamp file folders create 00 <name> -ARG basecamp file move 00 <id|url> +ARG basecamp file move 00 <id|url>... ARG basecamp file new-version 00 <upload-id|url> ARG basecamp file new-version 01 <file> ARG basecamp file replace 00 <upload-id|url> @@ -210,6 +219,7 @@ ARG basecamp file vault create 00 <name> ARG basecamp file vaults create 00 <name> ARG basecamp file versions 00 <upload_id|url> ARG basecamp files archive 00 <id|url> +ARG basecamp files copy 00 <id|url>... ARG basecamp files doc create 00 <title> ARG basecamp files doc create 01 [content] ARG basecamp files document create 00 <title> @@ -219,7 +229,7 @@ ARG basecamp files documents create 01 [content] ARG basecamp files download 00 <upload-id|url> ARG basecamp files folder create 00 <name> ARG basecamp files folders create 00 <name> -ARG basecamp files move 00 <id|url> +ARG basecamp files move 00 <id|url>... ARG basecamp files new-version 00 <upload-id|url> ARG basecamp files new-version 01 <file> ARG basecamp files replace 00 <upload-id|url> @@ -233,7 +243,9 @@ ARG basecamp files uploads create 00 <file> ARG basecamp files vault create 00 <name> ARG basecamp files vaults create 00 <name> ARG basecamp files versions 00 <upload_id|url> +ARG basecamp filings show 00 <filing_id> ARG basecamp folders archive 00 <id|url> +ARG basecamp folders copy 00 <id|url>... ARG basecamp folders doc create 00 <title> ARG basecamp folders doc create 01 [content] ARG basecamp folders document create 00 <title> @@ -243,7 +255,7 @@ ARG basecamp folders documents create 01 [content] ARG basecamp folders download 00 <upload-id|url> ARG basecamp folders folder create 00 <name> ARG basecamp folders folders create 00 <name> -ARG basecamp folders move 00 <id|url> +ARG basecamp folders move 00 <id|url>... ARG basecamp folders new-version 00 <upload-id|url> ARG basecamp folders new-version 01 <file> ARG basecamp folders replace 00 <upload-id|url> @@ -257,6 +269,8 @@ ARG basecamp folders uploads create 00 <file> ARG basecamp folders vault create 00 <name> ARG basecamp folders vaults create 00 <name> ARG basecamp folders versions 00 <upload_id|url> +ARG basecamp forwards copy 00 <id|url> +ARG basecamp forwards move 00 <id|url> ARG basecamp forwards replies 00 <forward_id|url> ARG basecamp forwards reply 00 <forward_id|url> ARG basecamp forwards reply 01 <reply_id|url> @@ -275,8 +289,10 @@ ARG basecamp lineup update 01 [name] ARG basecamp lineup update 02 [date] ARG basecamp messageboards show 00 [id] ARG basecamp messages archive 00 <id|url> +ARG basecamp messages copy 00 <id|url> ARG basecamp messages create 00 <title> ARG basecamp messages create 01 [body] +ARG basecamp messages move 00 <id|url> ARG basecamp messages pin 00 <id|url> ARG basecamp messages publish 00 <id|url> ARG basecamp messages restore 00 <id|url> @@ -289,8 +305,10 @@ ARG basecamp messagetypes delete 00 <id> ARG basecamp messagetypes show 00 <id> ARG basecamp messagetypes update 00 <id> ARG basecamp msgs archive 00 <id|url> +ARG basecamp msgs copy 00 <id|url> ARG basecamp msgs create 00 <title> ARG basecamp msgs create 01 [body] +ARG basecamp msgs move 00 <id|url> ARG basecamp msgs pin 00 <id|url> ARG basecamp msgs publish 00 <id|url> ARG basecamp msgs restore 00 <id|url> @@ -331,6 +349,7 @@ ARG basecamp profile set-default 00 <name> ARG basecamp profile show 00 [name] ARG basecamp project create 00 <name> ARG basecamp project delete 00 <id> +ARG basecamp project logo set 00 <file> ARG basecamp project show 00 <id> ARG basecamp project trash 00 <id> ARG basecamp project update 00 <id> @@ -338,6 +357,7 @@ ARG basecamp project wallpaper mode 00 <fill|tile> ARG basecamp project wallpaper set 00 <file> ARG basecamp projects create 00 <name> ARG basecamp projects delete 00 <id> +ARG basecamp projects logo set 00 <file> ARG basecamp projects show 00 <id> ARG basecamp projects trash 00 <id> ARG basecamp projects update 00 <id> @@ -354,7 +374,9 @@ ARG basecamp recordings trash 00 <id|url> ARG basecamp recordings trashed 00 <id|url> ARG basecamp recordings visibility 00 <id|url> ARG basecamp reports assigned 00 [person] +ARG basecamp schedule copy 00 <id|url> ARG basecamp schedule create 00 <summary> +ARG basecamp schedule move 00 <id|url> ARG basecamp schedule show 00 <id|url> ARG basecamp schedule update 00 <id|url> ARG basecamp search 00 <query> @@ -390,6 +412,7 @@ ARG basecamp templates update 00 <id> ARG basecamp timeline 00 [me] ARG basecamp timesheet item 00 <id> ARG basecamp timesheet recording 00 <id> +ARG basecamp tlgroup copy 00 <id|url> ARG basecamp tlgroup create 00 <name> ARG basecamp tlgroup move 00 <id> ARG basecamp tlgroup position 00 <id> @@ -398,6 +421,7 @@ ARG basecamp tlgroup rename 01 <name> ARG basecamp tlgroup show 00 <id> ARG basecamp tlgroup update 00 <id> ARG basecamp tlgroup update 01 <name> +ARG basecamp tlgroups copy 00 <id|url> ARG basecamp tlgroups create 00 <name> ARG basecamp tlgroups move 00 <id> ARG basecamp tlgroups position 00 <id> @@ -407,6 +431,7 @@ ARG basecamp tlgroups show 00 <id> ARG basecamp tlgroups update 00 <id> ARG basecamp tlgroups update 01 <name> ARG basecamp todolist archive 00 <id|url> +ARG basecamp todolist copy 00 <id|url> ARG basecamp todolist create 00 <name> ARG basecamp todolist move 00 <id|url>... ARG basecamp todolist position 00 <id|url>... @@ -415,6 +440,7 @@ ARG basecamp todolist restore 00 <id|url> ARG basecamp todolist show 00 <id|url> ARG basecamp todolist trash 00 <id|url> ARG basecamp todolist update 00 <id|url> +ARG basecamp todolistgroup copy 00 <id|url> ARG basecamp todolistgroup create 00 <name> ARG basecamp todolistgroup move 00 <id> ARG basecamp todolistgroup position 00 <id> @@ -423,6 +449,7 @@ ARG basecamp todolistgroup rename 01 <name> ARG basecamp todolistgroup show 00 <id> ARG basecamp todolistgroup update 00 <id> ARG basecamp todolistgroup update 01 <name> +ARG basecamp todolistgroups copy 00 <id|url> ARG basecamp todolistgroups create 00 <name> ARG basecamp todolistgroups move 00 <id> ARG basecamp todolistgroups position 00 <id> @@ -432,6 +459,7 @@ ARG basecamp todolistgroups show 00 <id> ARG basecamp todolistgroups update 00 <id> ARG basecamp todolistgroups update 01 <name> ARG basecamp todolists archive 00 <id|url> +ARG basecamp todolists copy 00 <id|url> ARG basecamp todolists create 00 <name> ARG basecamp todolists move 00 <id|url>... ARG basecamp todolists position 00 <id|url>... @@ -442,11 +470,12 @@ ARG basecamp todolists trash 00 <id|url> ARG basecamp todolists update 00 <id|url> ARG basecamp todos archive 00 <id|url> ARG basecamp todos complete 00 <id|url>... +ARG basecamp todos copy 00 <id|url>... ARG basecamp todos create 00 <content> -ARG basecamp todos move 00 <id|url> -ARG basecamp todos position 00 <id|url> +ARG basecamp todos move 00 <id|url>... +ARG basecamp todos position 00 <id|url>... ARG basecamp todos reopen 00 <id|url>... -ARG basecamp todos reorder 00 <id|url> +ARG basecamp todos reorder 00 <id|url>... ARG basecamp todos restore 00 <id|url> ARG basecamp todos show 00 <id|url> ARG basecamp todos trash 00 <id|url> @@ -458,7 +487,7 @@ ARG basecamp tools create 00 [title] ARG basecamp tools delete 00 <id> ARG basecamp tools disable 00 <id> ARG basecamp tools enable 00 <id> -ARG basecamp tools move 00 <id> +ARG basecamp tools move 00 <id|url> ARG basecamp tools rename 00 <id> ARG basecamp tools rename 01 <title> ARG basecamp tools reposition 00 <id> @@ -470,11 +499,14 @@ 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 copy 00 <id|url>... ARG basecamp uploads create 00 <file> +ARG basecamp uploads move 00 <id|url>... ARG basecamp uploads show 00 <id|url> ARG basecamp url 00 <url> ARG basecamp url parse 00 <url> ARG basecamp vault archive 00 <id|url> +ARG basecamp vault copy 00 <id|url>... ARG basecamp vault doc create 00 <title> ARG basecamp vault doc create 01 [content] ARG basecamp vault document create 00 <title> @@ -484,7 +516,7 @@ ARG basecamp vault documents create 01 [content] ARG basecamp vault download 00 <upload-id|url> ARG basecamp vault folder create 00 <name> ARG basecamp vault folders create 00 <name> -ARG basecamp vault move 00 <id|url> +ARG basecamp vault move 00 <id|url>... ARG basecamp vault new-version 00 <upload-id|url> ARG basecamp vault new-version 01 <file> ARG basecamp vault replace 00 <upload-id|url> @@ -499,6 +531,7 @@ ARG basecamp vault vault create 00 <name> ARG basecamp vault vaults create 00 <name> ARG basecamp vault versions 00 <upload_id|url> ARG basecamp vaults archive 00 <id|url> +ARG basecamp vaults copy 00 <id|url>... ARG basecamp vaults doc create 00 <title> ARG basecamp vaults doc create 01 [content] ARG basecamp vaults document create 00 <title> @@ -508,7 +541,7 @@ ARG basecamp vaults documents create 01 [content] ARG basecamp vaults download 00 <upload-id|url> ARG basecamp vaults folder create 00 <name> ARG basecamp vaults folders create 00 <name> -ARG basecamp vaults move 00 <id|url> +ARG basecamp vaults move 00 <id|url>... ARG basecamp vaults new-version 00 <upload-id|url> ARG basecamp vaults new-version 01 <file> ARG basecamp vaults replace 00 <upload-id|url> @@ -615,6 +648,7 @@ CMD basecamp cards CMD basecamp cards archive CMD basecamp cards column CMD basecamp cards column color +CMD basecamp cards column copy CMD basecamp cards column create CMD basecamp cards column move CMD basecamp cards column no-on-hold @@ -624,6 +658,7 @@ CMD basecamp cards column unwatch CMD basecamp cards column update CMD basecamp cards column watch CMD basecamp cards columns +CMD basecamp cards copy CMD basecamp cards create CMD basecamp cards done CMD basecamp cards list @@ -665,7 +700,9 @@ CMD basecamp checkin answer update CMD basecamp checkin answers CMD basecamp checkin question CMD basecamp checkin question answerers +CMD basecamp checkin question copy CMD basecamp checkin question create +CMD basecamp checkin question move CMD basecamp checkin question notify CMD basecamp checkin question pause CMD basecamp checkin question resume @@ -681,7 +718,9 @@ CMD basecamp checkins answer update CMD basecamp checkins answers CMD basecamp checkins question CMD basecamp checkins question answerers +CMD basecamp checkins question copy CMD basecamp checkins question create +CMD basecamp checkins question move CMD basecamp checkins question notify CMD basecamp checkins question pause CMD basecamp checkins question resume @@ -717,6 +756,7 @@ CMD basecamp config unset CMD basecamp config untrust CMD basecamp docs CMD basecamp docs archive +CMD basecamp docs copy CMD basecamp docs doc CMD basecamp docs doc create CMD basecamp docs doc list @@ -757,6 +797,7 @@ CMD basecamp docs versions CMD basecamp doctor CMD basecamp documents CMD basecamp documents archive +CMD basecamp documents copy CMD basecamp documents doc CMD basecamp documents doc create CMD basecamp documents doc list @@ -804,6 +845,7 @@ CMD basecamp events poll CMD basecamp events ticket CMD basecamp file CMD basecamp file archive +CMD basecamp file copy CMD basecamp file doc CMD basecamp file doc create CMD basecamp file doc list @@ -843,6 +885,7 @@ CMD basecamp file vaults list CMD basecamp file versions CMD basecamp files CMD basecamp files archive +CMD basecamp files copy CMD basecamp files doc CMD basecamp files doc create CMD basecamp files doc list @@ -880,8 +923,11 @@ CMD basecamp files vaults CMD basecamp files vaults create CMD basecamp files vaults list CMD basecamp files versions +CMD basecamp filings +CMD basecamp filings show CMD basecamp folders CMD basecamp folders archive +CMD basecamp folders copy CMD basecamp folders doc CMD basecamp folders doc create CMD basecamp folders doc list @@ -920,8 +966,10 @@ CMD basecamp folders vaults create CMD basecamp folders vaults list CMD basecamp folders versions CMD basecamp forwards +CMD basecamp forwards copy CMD basecamp forwards inbox CMD basecamp forwards list +CMD basecamp forwards move CMD basecamp forwards replies CMD basecamp forwards reply CMD basecamp forwards show @@ -953,8 +1001,10 @@ CMD basecamp messageboards CMD basecamp messageboards show CMD basecamp messages CMD basecamp messages archive +CMD basecamp messages copy CMD basecamp messages create CMD basecamp messages list +CMD basecamp messages move CMD basecamp messages pin CMD basecamp messages publish CMD basecamp messages restore @@ -971,8 +1021,10 @@ CMD basecamp messagetypes update CMD basecamp migrate CMD basecamp msgs CMD basecamp msgs archive +CMD basecamp msgs copy CMD basecamp msgs create CMD basecamp msgs list +CMD basecamp msgs move CMD basecamp msgs pin CMD basecamp msgs publish CMD basecamp msgs restore @@ -1029,6 +1081,9 @@ CMD basecamp project CMD basecamp project create CMD basecamp project delete CMD basecamp project list +CMD basecamp project logo +CMD basecamp project logo remove +CMD basecamp project logo set CMD basecamp project show CMD basecamp project trash CMD basecamp project update @@ -1040,6 +1095,9 @@ CMD basecamp projects CMD basecamp projects create CMD basecamp projects delete CMD basecamp projects list +CMD basecamp projects logo +CMD basecamp projects logo remove +CMD basecamp projects logo set CMD basecamp projects show CMD basecamp projects trash CMD basecamp projects update @@ -1063,9 +1121,11 @@ CMD basecamp reports assigned CMD basecamp reports overdue CMD basecamp reports schedule CMD basecamp schedule +CMD basecamp schedule copy CMD basecamp schedule create CMD basecamp schedule entries CMD basecamp schedule info +CMD basecamp schedule move CMD basecamp schedule settings CMD basecamp schedule show CMD basecamp schedule update @@ -1114,6 +1174,7 @@ CMD basecamp timesheet project CMD basecamp timesheet recording CMD basecamp timesheet report CMD basecamp tlgroup +CMD basecamp tlgroup copy CMD basecamp tlgroup create CMD basecamp tlgroup list CMD basecamp tlgroup move @@ -1122,6 +1183,7 @@ CMD basecamp tlgroup rename CMD basecamp tlgroup show CMD basecamp tlgroup update CMD basecamp tlgroups +CMD basecamp tlgroups copy CMD basecamp tlgroups create CMD basecamp tlgroups list CMD basecamp tlgroups move @@ -1131,6 +1193,7 @@ CMD basecamp tlgroups show CMD basecamp tlgroups update CMD basecamp todolist CMD basecamp todolist archive +CMD basecamp todolist copy CMD basecamp todolist create CMD basecamp todolist list CMD basecamp todolist move @@ -1141,6 +1204,7 @@ CMD basecamp todolist show CMD basecamp todolist trash CMD basecamp todolist update CMD basecamp todolistgroup +CMD basecamp todolistgroup copy CMD basecamp todolistgroup create CMD basecamp todolistgroup list CMD basecamp todolistgroup move @@ -1149,6 +1213,7 @@ CMD basecamp todolistgroup rename CMD basecamp todolistgroup show CMD basecamp todolistgroup update CMD basecamp todolistgroups +CMD basecamp todolistgroups copy CMD basecamp todolistgroups create CMD basecamp todolistgroups list CMD basecamp todolistgroups move @@ -1158,6 +1223,7 @@ CMD basecamp todolistgroups show CMD basecamp todolistgroups update CMD basecamp todolists CMD basecamp todolists archive +CMD basecamp todolists copy CMD basecamp todolists create CMD basecamp todolists list CMD basecamp todolists move @@ -1170,6 +1236,7 @@ CMD basecamp todolists update CMD basecamp todos CMD basecamp todos archive CMD basecamp todos complete +CMD basecamp todos copy CMD basecamp todos create CMD basecamp todos list CMD basecamp todos move @@ -1202,13 +1269,16 @@ CMD basecamp unspotlight CMD basecamp upgrade CMD basecamp upload CMD basecamp uploads +CMD basecamp uploads copy CMD basecamp uploads create CMD basecamp uploads list +CMD basecamp uploads move CMD basecamp uploads show CMD basecamp url CMD basecamp url parse CMD basecamp vault CMD basecamp vault archive +CMD basecamp vault copy CMD basecamp vault doc CMD basecamp vault doc create CMD basecamp vault doc list @@ -1248,6 +1318,7 @@ CMD basecamp vault vaults list CMD basecamp vault versions CMD basecamp vaults CMD basecamp vaults archive +CMD basecamp vaults copy CMD basecamp vaults doc CMD basecamp vaults doc create CMD basecamp vaults doc list @@ -3155,6 +3226,31 @@ FLAG basecamp cards column color --stats type=bool FLAG basecamp cards column color --styled type=bool FLAG basecamp cards column color --todolist type=string FLAG basecamp cards column color --verbose type=count +FLAG basecamp cards column copy --account type=string +FLAG basecamp cards column copy --agent type=bool +FLAG basecamp cards column copy --cache-dir type=string +FLAG basecamp cards column copy --card-table type=string +FLAG basecamp cards column copy --count type=bool +FLAG basecamp cards column copy --help type=bool +FLAG basecamp cards column copy --hints type=bool +FLAG basecamp cards column copy --ids-only type=bool +FLAG basecamp cards column copy --in type=string +FLAG basecamp cards column copy --include-comments type=bool +FLAG basecamp cards column copy --jq type=string +FLAG basecamp cards column copy --json type=bool +FLAG basecamp cards column copy --markdown type=bool +FLAG basecamp cards column copy --md type=bool +FLAG basecamp cards column copy --no-hints type=bool +FLAG basecamp cards column copy --no-stats type=bool +FLAG basecamp cards column copy --profile type=string +FLAG basecamp cards column copy --project type=string +FLAG basecamp cards column copy --quiet type=bool +FLAG basecamp cards column copy --stats type=bool +FLAG basecamp cards column copy --styled type=bool +FLAG basecamp cards column copy --to-card-table type=string +FLAG basecamp cards column copy --to-project type=string +FLAG basecamp cards column copy --todolist type=string +FLAG basecamp cards column copy --verbose type=count FLAG basecamp cards column create --account type=string FLAG basecamp cards column create --agent type=bool FLAG basecamp cards column create --cache-dir type=string @@ -3200,6 +3296,8 @@ FLAG basecamp cards column move --project type=string FLAG basecamp cards column move --quiet type=bool FLAG basecamp cards column move --stats type=bool FLAG basecamp cards column move --styled type=bool +FLAG basecamp cards column move --to-card-table type=string +FLAG basecamp cards column move --to-project type=string FLAG basecamp cards column move --todolist type=string FLAG basecamp cards column move --verbose type=count FLAG basecamp cards column no-on-hold --account type=string @@ -3358,6 +3456,31 @@ FLAG basecamp cards columns --stats type=bool FLAG basecamp cards columns --styled type=bool FLAG basecamp cards columns --todolist type=string FLAG basecamp cards columns --verbose type=count +FLAG basecamp cards copy --account type=string +FLAG basecamp cards copy --agent type=bool +FLAG basecamp cards copy --cache-dir type=string +FLAG basecamp cards copy --card-table type=string +FLAG basecamp cards copy --count type=bool +FLAG basecamp cards copy --help type=bool +FLAG basecamp cards copy --hints type=bool +FLAG basecamp cards copy --ids-only type=bool +FLAG basecamp cards copy --in type=string +FLAG basecamp cards copy --include-comments type=bool +FLAG basecamp cards copy --jq type=string +FLAG basecamp cards copy --json type=bool +FLAG basecamp cards copy --markdown type=bool +FLAG basecamp cards copy --md type=bool +FLAG basecamp cards copy --no-hints type=bool +FLAG basecamp cards copy --no-stats type=bool +FLAG basecamp cards copy --profile type=string +FLAG basecamp cards copy --project type=string +FLAG basecamp cards copy --quiet type=bool +FLAG basecamp cards copy --stats type=bool +FLAG basecamp cards copy --styled type=bool +FLAG basecamp cards copy --to-card-table type=string +FLAG basecamp cards copy --to-project type=string +FLAG basecamp cards copy --todolist type=string +FLAG basecamp cards copy --verbose type=count FLAG basecamp cards create --account type=string FLAG basecamp cards create --agent type=bool FLAG basecamp cards create --assignee type=string @@ -3468,6 +3591,8 @@ FLAG basecamp cards move --quiet type=bool FLAG basecamp cards move --stats type=bool FLAG basecamp cards move --styled type=bool FLAG basecamp cards move --to type=string +FLAG basecamp cards move --to-card-table type=string +FLAG basecamp cards move --to-project type=string FLAG basecamp cards move --to-wormhole type=string FLAG basecamp cards move --todolist type=string FLAG basecamp cards move --verbose type=count @@ -3495,6 +3620,8 @@ FLAG basecamp cards mv --quiet type=bool FLAG basecamp cards mv --stats type=bool FLAG basecamp cards mv --styled type=bool FLAG basecamp cards mv --to type=string +FLAG basecamp cards mv --to-card-table type=string +FLAG basecamp cards mv --to-project type=string FLAG basecamp cards mv --to-wormhole type=string FLAG basecamp cards mv --todolist type=string FLAG basecamp cards mv --verbose type=count @@ -4344,6 +4471,31 @@ FLAG basecamp checkin question answerers --stats type=bool FLAG basecamp checkin question answerers --styled type=bool FLAG basecamp checkin question answerers --todolist type=string FLAG basecamp checkin question answerers --verbose type=count +FLAG basecamp checkin question copy --account type=string +FLAG basecamp checkin question copy --agent type=bool +FLAG basecamp checkin question copy --cache-dir type=string +FLAG basecamp checkin question copy --count type=bool +FLAG basecamp checkin question copy --help type=bool +FLAG basecamp checkin question copy --hints type=bool +FLAG basecamp checkin question copy --ids-only type=bool +FLAG basecamp checkin question copy --in type=string +FLAG basecamp checkin question copy --include-comments type=bool +FLAG basecamp checkin question copy --jq type=string +FLAG basecamp checkin question copy --json type=bool +FLAG basecamp checkin question copy --markdown type=bool +FLAG basecamp checkin question copy --md type=bool +FLAG basecamp checkin question copy --no-hints type=bool +FLAG basecamp checkin question copy --no-stats type=bool +FLAG basecamp checkin question copy --profile type=string +FLAG basecamp checkin question copy --project type=string +FLAG basecamp checkin question copy --questionnaire type=string +FLAG basecamp checkin question copy --quiet type=bool +FLAG basecamp checkin question copy --stats type=bool +FLAG basecamp checkin question copy --styled type=bool +FLAG basecamp checkin question copy --to-project type=string +FLAG basecamp checkin question copy --to-questionnaire type=string +FLAG basecamp checkin question copy --todolist type=string +FLAG basecamp checkin question copy --verbose type=count FLAG basecamp checkin question create --account type=string FLAG basecamp checkin question create --agent type=bool FLAG basecamp checkin question create --cache-dir type=string @@ -4370,6 +4522,30 @@ FLAG basecamp checkin question create --time type=string FLAG basecamp checkin question create --todolist type=string FLAG basecamp checkin question create --verbose type=count FLAG basecamp checkin question create --visible-to-clients type=bool +FLAG basecamp checkin question move --account type=string +FLAG basecamp checkin question move --agent type=bool +FLAG basecamp checkin question move --cache-dir type=string +FLAG basecamp checkin question move --count type=bool +FLAG basecamp checkin question move --help type=bool +FLAG basecamp checkin question move --hints type=bool +FLAG basecamp checkin question move --ids-only type=bool +FLAG basecamp checkin question move --in type=string +FLAG basecamp checkin question move --jq type=string +FLAG basecamp checkin question move --json type=bool +FLAG basecamp checkin question move --markdown type=bool +FLAG basecamp checkin question move --md type=bool +FLAG basecamp checkin question move --no-hints type=bool +FLAG basecamp checkin question move --no-stats type=bool +FLAG basecamp checkin question move --profile type=string +FLAG basecamp checkin question move --project type=string +FLAG basecamp checkin question move --questionnaire type=string +FLAG basecamp checkin question move --quiet type=bool +FLAG basecamp checkin question move --stats type=bool +FLAG basecamp checkin question move --styled type=bool +FLAG basecamp checkin question move --to-project type=string +FLAG basecamp checkin question move --to-questionnaire type=string +FLAG basecamp checkin question move --todolist type=string +FLAG basecamp checkin question move --verbose type=count FLAG basecamp checkin question notify --account type=string FLAG basecamp checkin question notify --agent type=bool FLAG basecamp checkin question notify --cache-dir type=string @@ -4733,6 +4909,31 @@ FLAG basecamp checkins question answerers --stats type=bool FLAG basecamp checkins question answerers --styled type=bool FLAG basecamp checkins question answerers --todolist type=string FLAG basecamp checkins question answerers --verbose type=count +FLAG basecamp checkins question copy --account type=string +FLAG basecamp checkins question copy --agent type=bool +FLAG basecamp checkins question copy --cache-dir type=string +FLAG basecamp checkins question copy --count type=bool +FLAG basecamp checkins question copy --help type=bool +FLAG basecamp checkins question copy --hints type=bool +FLAG basecamp checkins question copy --ids-only type=bool +FLAG basecamp checkins question copy --in type=string +FLAG basecamp checkins question copy --include-comments type=bool +FLAG basecamp checkins question copy --jq type=string +FLAG basecamp checkins question copy --json type=bool +FLAG basecamp checkins question copy --markdown type=bool +FLAG basecamp checkins question copy --md type=bool +FLAG basecamp checkins question copy --no-hints type=bool +FLAG basecamp checkins question copy --no-stats type=bool +FLAG basecamp checkins question copy --profile type=string +FLAG basecamp checkins question copy --project type=string +FLAG basecamp checkins question copy --questionnaire type=string +FLAG basecamp checkins question copy --quiet type=bool +FLAG basecamp checkins question copy --stats type=bool +FLAG basecamp checkins question copy --styled type=bool +FLAG basecamp checkins question copy --to-project type=string +FLAG basecamp checkins question copy --to-questionnaire type=string +FLAG basecamp checkins question copy --todolist type=string +FLAG basecamp checkins question copy --verbose type=count FLAG basecamp checkins question create --account type=string FLAG basecamp checkins question create --agent type=bool FLAG basecamp checkins question create --cache-dir type=string @@ -4759,6 +4960,30 @@ FLAG basecamp checkins question create --time type=string FLAG basecamp checkins question create --todolist type=string FLAG basecamp checkins question create --verbose type=count FLAG basecamp checkins question create --visible-to-clients type=bool +FLAG basecamp checkins question move --account type=string +FLAG basecamp checkins question move --agent type=bool +FLAG basecamp checkins question move --cache-dir type=string +FLAG basecamp checkins question move --count type=bool +FLAG basecamp checkins question move --help type=bool +FLAG basecamp checkins question move --hints type=bool +FLAG basecamp checkins question move --ids-only type=bool +FLAG basecamp checkins question move --in type=string +FLAG basecamp checkins question move --jq type=string +FLAG basecamp checkins question move --json type=bool +FLAG basecamp checkins question move --markdown type=bool +FLAG basecamp checkins question move --md type=bool +FLAG basecamp checkins question move --no-hints type=bool +FLAG basecamp checkins question move --no-stats type=bool +FLAG basecamp checkins question move --profile type=string +FLAG basecamp checkins question move --project type=string +FLAG basecamp checkins question move --questionnaire type=string +FLAG basecamp checkins question move --quiet type=bool +FLAG basecamp checkins question move --stats type=bool +FLAG basecamp checkins question move --styled type=bool +FLAG basecamp checkins question move --to-project type=string +FLAG basecamp checkins question move --to-questionnaire type=string +FLAG basecamp checkins question move --todolist type=string +FLAG basecamp checkins question move --verbose type=count FLAG basecamp checkins question notify --account type=string FLAG basecamp checkins question notify --agent type=bool FLAG basecamp checkins question notify --cache-dir type=string @@ -5532,6 +5757,32 @@ FLAG basecamp docs archive --styled type=bool FLAG basecamp docs archive --todolist type=string FLAG basecamp docs archive --vault type=string FLAG basecamp docs archive --verbose type=count +FLAG basecamp docs copy --account type=string +FLAG basecamp docs copy --agent type=bool +FLAG basecamp docs copy --cache-dir type=string +FLAG basecamp docs copy --count type=bool +FLAG basecamp docs copy --folder type=string +FLAG basecamp docs copy --help type=bool +FLAG basecamp docs copy --hints type=bool +FLAG basecamp docs copy --ids-only type=bool +FLAG basecamp docs copy --in type=string +FLAG basecamp docs copy --include-comments type=bool +FLAG basecamp docs copy --jq type=string +FLAG basecamp docs copy --json type=bool +FLAG basecamp docs copy --markdown type=bool +FLAG basecamp docs copy --md type=bool +FLAG basecamp docs copy --no-hints type=bool +FLAG basecamp docs copy --no-stats type=bool +FLAG basecamp docs copy --profile type=string +FLAG basecamp docs copy --project type=string +FLAG basecamp docs copy --quiet type=bool +FLAG basecamp docs copy --stats type=bool +FLAG basecamp docs copy --styled type=bool +FLAG basecamp docs copy --to-folder type=string +FLAG basecamp docs copy --to-project type=string +FLAG basecamp docs copy --todolist type=string +FLAG basecamp docs copy --vault type=string +FLAG basecamp docs copy --verbose type=count FLAG basecamp docs doc --account type=string FLAG basecamp docs doc --agent type=bool FLAG basecamp docs doc --all type=bool @@ -6000,6 +6251,8 @@ FLAG basecamp docs move --quiet type=bool FLAG basecamp docs move --stats type=bool FLAG basecamp docs move --styled type=bool FLAG basecamp docs move --to type=string +FLAG basecamp docs move --to-folder type=string +FLAG basecamp docs move --to-project type=string FLAG basecamp docs move --todolist type=string FLAG basecamp docs move --vault type=string FLAG basecamp docs move --verbose type=count @@ -6555,6 +6808,32 @@ FLAG basecamp documents archive --styled type=bool FLAG basecamp documents archive --todolist type=string FLAG basecamp documents archive --vault type=string FLAG basecamp documents archive --verbose type=count +FLAG basecamp documents copy --account type=string +FLAG basecamp documents copy --agent type=bool +FLAG basecamp documents copy --cache-dir type=string +FLAG basecamp documents copy --count type=bool +FLAG basecamp documents copy --folder type=string +FLAG basecamp documents copy --help type=bool +FLAG basecamp documents copy --hints type=bool +FLAG basecamp documents copy --ids-only type=bool +FLAG basecamp documents copy --in type=string +FLAG basecamp documents copy --include-comments type=bool +FLAG basecamp documents copy --jq type=string +FLAG basecamp documents copy --json type=bool +FLAG basecamp documents copy --markdown type=bool +FLAG basecamp documents copy --md type=bool +FLAG basecamp documents copy --no-hints type=bool +FLAG basecamp documents copy --no-stats type=bool +FLAG basecamp documents copy --profile type=string +FLAG basecamp documents copy --project type=string +FLAG basecamp documents copy --quiet type=bool +FLAG basecamp documents copy --stats type=bool +FLAG basecamp documents copy --styled type=bool +FLAG basecamp documents copy --to-folder type=string +FLAG basecamp documents copy --to-project type=string +FLAG basecamp documents copy --todolist type=string +FLAG basecamp documents copy --vault type=string +FLAG basecamp documents copy --verbose type=count FLAG basecamp documents doc --account type=string FLAG basecamp documents doc --agent type=bool FLAG basecamp documents doc --all type=bool @@ -7023,6 +7302,8 @@ FLAG basecamp documents move --quiet type=bool FLAG basecamp documents move --stats type=bool FLAG basecamp documents move --styled type=bool FLAG basecamp documents move --to type=string +FLAG basecamp documents move --to-folder type=string +FLAG basecamp documents move --to-project type=string FLAG basecamp documents move --todolist type=string FLAG basecamp documents move --vault type=string FLAG basecamp documents move --verbose type=count @@ -7742,6 +8023,32 @@ FLAG basecamp file archive --styled type=bool FLAG basecamp file archive --todolist type=string FLAG basecamp file archive --vault type=string FLAG basecamp file archive --verbose type=count +FLAG basecamp file copy --account type=string +FLAG basecamp file copy --agent type=bool +FLAG basecamp file copy --cache-dir type=string +FLAG basecamp file copy --count type=bool +FLAG basecamp file copy --folder type=string +FLAG basecamp file copy --help type=bool +FLAG basecamp file copy --hints type=bool +FLAG basecamp file copy --ids-only type=bool +FLAG basecamp file copy --in type=string +FLAG basecamp file copy --include-comments type=bool +FLAG basecamp file copy --jq type=string +FLAG basecamp file copy --json type=bool +FLAG basecamp file copy --markdown type=bool +FLAG basecamp file copy --md type=bool +FLAG basecamp file copy --no-hints type=bool +FLAG basecamp file copy --no-stats type=bool +FLAG basecamp file copy --profile type=string +FLAG basecamp file copy --project type=string +FLAG basecamp file copy --quiet type=bool +FLAG basecamp file copy --stats type=bool +FLAG basecamp file copy --styled type=bool +FLAG basecamp file copy --to-folder type=string +FLAG basecamp file copy --to-project type=string +FLAG basecamp file copy --todolist type=string +FLAG basecamp file copy --vault type=string +FLAG basecamp file copy --verbose type=count FLAG basecamp file doc --account type=string FLAG basecamp file doc --agent type=bool FLAG basecamp file doc --all type=bool @@ -8210,6 +8517,8 @@ FLAG basecamp file move --quiet type=bool FLAG basecamp file move --stats type=bool FLAG basecamp file move --styled type=bool FLAG basecamp file move --to type=string +FLAG basecamp file move --to-folder type=string +FLAG basecamp file move --to-project type=string FLAG basecamp file move --todolist type=string FLAG basecamp file move --vault type=string FLAG basecamp file move --verbose type=count @@ -8744,6 +9053,32 @@ FLAG basecamp files archive --styled type=bool FLAG basecamp files archive --todolist type=string FLAG basecamp files archive --vault type=string FLAG basecamp files archive --verbose type=count +FLAG basecamp files copy --account type=string +FLAG basecamp files copy --agent type=bool +FLAG basecamp files copy --cache-dir type=string +FLAG basecamp files copy --count type=bool +FLAG basecamp files copy --folder type=string +FLAG basecamp files copy --help type=bool +FLAG basecamp files copy --hints type=bool +FLAG basecamp files copy --ids-only type=bool +FLAG basecamp files copy --in type=string +FLAG basecamp files copy --include-comments type=bool +FLAG basecamp files copy --jq type=string +FLAG basecamp files copy --json type=bool +FLAG basecamp files copy --markdown type=bool +FLAG basecamp files copy --md type=bool +FLAG basecamp files copy --no-hints type=bool +FLAG basecamp files copy --no-stats type=bool +FLAG basecamp files copy --profile type=string +FLAG basecamp files copy --project type=string +FLAG basecamp files copy --quiet type=bool +FLAG basecamp files copy --stats type=bool +FLAG basecamp files copy --styled type=bool +FLAG basecamp files copy --to-folder type=string +FLAG basecamp files copy --to-project type=string +FLAG basecamp files copy --todolist type=string +FLAG basecamp files copy --vault type=string +FLAG basecamp files copy --verbose type=count FLAG basecamp files doc --account type=string FLAG basecamp files doc --agent type=bool FLAG basecamp files doc --all type=bool @@ -9212,6 +9547,8 @@ FLAG basecamp files move --quiet type=bool FLAG basecamp files move --stats type=bool FLAG basecamp files move --styled type=bool FLAG basecamp files move --to type=string +FLAG basecamp files move --to-folder type=string +FLAG basecamp files move --to-project type=string FLAG basecamp files move --todolist type=string FLAG basecamp files move --vault type=string FLAG basecamp files move --verbose type=count @@ -9700,6 +10037,48 @@ FLAG basecamp files versions --styled type=bool FLAG basecamp files versions --todolist type=string FLAG basecamp files versions --vault type=string FLAG basecamp files versions --verbose type=count +FLAG basecamp filings --account type=string +FLAG basecamp filings --agent type=bool +FLAG basecamp filings --cache-dir type=string +FLAG basecamp filings --count type=bool +FLAG basecamp filings --help type=bool +FLAG basecamp filings --hints type=bool +FLAG basecamp filings --ids-only type=bool +FLAG basecamp filings --in type=string +FLAG basecamp filings --jq type=string +FLAG basecamp filings --json type=bool +FLAG basecamp filings --markdown type=bool +FLAG basecamp filings --md type=bool +FLAG basecamp filings --no-hints type=bool +FLAG basecamp filings --no-stats type=bool +FLAG basecamp filings --profile type=string +FLAG basecamp filings --project type=string +FLAG basecamp filings --quiet type=bool +FLAG basecamp filings --stats type=bool +FLAG basecamp filings --styled type=bool +FLAG basecamp filings --todolist type=string +FLAG basecamp filings --verbose type=count +FLAG basecamp filings show --account type=string +FLAG basecamp filings show --agent type=bool +FLAG basecamp filings show --cache-dir type=string +FLAG basecamp filings show --count type=bool +FLAG basecamp filings show --help type=bool +FLAG basecamp filings show --hints type=bool +FLAG basecamp filings show --ids-only type=bool +FLAG basecamp filings show --in type=string +FLAG basecamp filings show --jq type=string +FLAG basecamp filings show --json type=bool +FLAG basecamp filings show --markdown type=bool +FLAG basecamp filings show --md type=bool +FLAG basecamp filings show --no-hints type=bool +FLAG basecamp filings show --no-stats type=bool +FLAG basecamp filings show --profile type=string +FLAG basecamp filings show --project type=string +FLAG basecamp filings show --quiet type=bool +FLAG basecamp filings show --stats type=bool +FLAG basecamp filings show --styled type=bool +FLAG basecamp filings show --todolist type=string +FLAG basecamp filings show --verbose type=count FLAG basecamp folders --account type=string FLAG basecamp folders --agent type=bool FLAG basecamp folders --cache-dir type=string @@ -9746,6 +10125,32 @@ FLAG basecamp folders archive --styled type=bool FLAG basecamp folders archive --todolist type=string FLAG basecamp folders archive --vault type=string FLAG basecamp folders archive --verbose type=count +FLAG basecamp folders copy --account type=string +FLAG basecamp folders copy --agent type=bool +FLAG basecamp folders copy --cache-dir type=string +FLAG basecamp folders copy --count type=bool +FLAG basecamp folders copy --folder type=string +FLAG basecamp folders copy --help type=bool +FLAG basecamp folders copy --hints type=bool +FLAG basecamp folders copy --ids-only type=bool +FLAG basecamp folders copy --in type=string +FLAG basecamp folders copy --include-comments type=bool +FLAG basecamp folders copy --jq type=string +FLAG basecamp folders copy --json type=bool +FLAG basecamp folders copy --markdown type=bool +FLAG basecamp folders copy --md type=bool +FLAG basecamp folders copy --no-hints type=bool +FLAG basecamp folders copy --no-stats type=bool +FLAG basecamp folders copy --profile type=string +FLAG basecamp folders copy --project type=string +FLAG basecamp folders copy --quiet type=bool +FLAG basecamp folders copy --stats type=bool +FLAG basecamp folders copy --styled type=bool +FLAG basecamp folders copy --to-folder type=string +FLAG basecamp folders copy --to-project type=string +FLAG basecamp folders copy --todolist type=string +FLAG basecamp folders copy --vault type=string +FLAG basecamp folders copy --verbose type=count FLAG basecamp folders doc --account type=string FLAG basecamp folders doc --agent type=bool FLAG basecamp folders doc --all type=bool @@ -10214,6 +10619,8 @@ FLAG basecamp folders move --quiet type=bool FLAG basecamp folders move --stats type=bool FLAG basecamp folders move --styled type=bool FLAG basecamp folders move --to type=string +FLAG basecamp folders move --to-folder type=string +FLAG basecamp folders move --to-project type=string FLAG basecamp folders move --todolist type=string FLAG basecamp folders move --vault type=string FLAG basecamp folders move --verbose type=count @@ -10724,6 +11131,31 @@ FLAG basecamp forwards --stats type=bool FLAG basecamp forwards --styled type=bool FLAG basecamp forwards --todolist type=string FLAG basecamp forwards --verbose type=count +FLAG basecamp forwards copy --account type=string +FLAG basecamp forwards copy --agent type=bool +FLAG basecamp forwards copy --cache-dir type=string +FLAG basecamp forwards copy --count type=bool +FLAG basecamp forwards copy --help type=bool +FLAG basecamp forwards copy --hints type=bool +FLAG basecamp forwards copy --ids-only type=bool +FLAG basecamp forwards copy --in type=string +FLAG basecamp forwards copy --inbox type=string +FLAG basecamp forwards copy --include-comments type=bool +FLAG basecamp forwards copy --jq type=string +FLAG basecamp forwards copy --json type=bool +FLAG basecamp forwards copy --markdown type=bool +FLAG basecamp forwards copy --md type=bool +FLAG basecamp forwards copy --no-hints type=bool +FLAG basecamp forwards copy --no-stats type=bool +FLAG basecamp forwards copy --profile type=string +FLAG basecamp forwards copy --project type=string +FLAG basecamp forwards copy --quiet type=bool +FLAG basecamp forwards copy --stats type=bool +FLAG basecamp forwards copy --styled type=bool +FLAG basecamp forwards copy --to-inbox type=string +FLAG basecamp forwards copy --to-project type=string +FLAG basecamp forwards copy --todolist type=string +FLAG basecamp forwards copy --verbose type=count FLAG basecamp forwards inbox --account type=string FLAG basecamp forwards inbox --agent type=bool FLAG basecamp forwards inbox --cache-dir type=string @@ -10772,6 +11204,30 @@ FLAG basecamp forwards list --stats type=bool FLAG basecamp forwards list --styled type=bool FLAG basecamp forwards list --todolist type=string FLAG basecamp forwards list --verbose type=count +FLAG basecamp forwards move --account type=string +FLAG basecamp forwards move --agent type=bool +FLAG basecamp forwards move --cache-dir type=string +FLAG basecamp forwards move --count type=bool +FLAG basecamp forwards move --help type=bool +FLAG basecamp forwards move --hints type=bool +FLAG basecamp forwards move --ids-only type=bool +FLAG basecamp forwards move --in type=string +FLAG basecamp forwards move --inbox type=string +FLAG basecamp forwards move --jq type=string +FLAG basecamp forwards move --json type=bool +FLAG basecamp forwards move --markdown type=bool +FLAG basecamp forwards move --md type=bool +FLAG basecamp forwards move --no-hints type=bool +FLAG basecamp forwards move --no-stats type=bool +FLAG basecamp forwards move --profile type=string +FLAG basecamp forwards move --project type=string +FLAG basecamp forwards move --quiet type=bool +FLAG basecamp forwards move --stats type=bool +FLAG basecamp forwards move --styled type=bool +FLAG basecamp forwards move --to-inbox type=string +FLAG basecamp forwards move --to-project type=string +FLAG basecamp forwards move --todolist type=string +FLAG basecamp forwards move --verbose type=count FLAG basecamp forwards replies --account type=string FLAG basecamp forwards replies --agent type=bool FLAG basecamp forwards replies --all type=bool @@ -11464,6 +11920,31 @@ FLAG basecamp messages archive --stats type=bool FLAG basecamp messages archive --styled type=bool FLAG basecamp messages archive --todolist type=string FLAG basecamp messages archive --verbose type=count +FLAG basecamp messages copy --account type=string +FLAG basecamp messages copy --agent type=bool +FLAG basecamp messages copy --cache-dir type=string +FLAG basecamp messages copy --count type=bool +FLAG basecamp messages copy --help type=bool +FLAG basecamp messages copy --hints type=bool +FLAG basecamp messages copy --ids-only type=bool +FLAG basecamp messages copy --in type=string +FLAG basecamp messages copy --include-comments type=bool +FLAG basecamp messages copy --jq type=string +FLAG basecamp messages copy --json type=bool +FLAG basecamp messages copy --markdown type=bool +FLAG basecamp messages copy --md type=bool +FLAG basecamp messages copy --message-board type=string +FLAG basecamp messages copy --no-hints type=bool +FLAG basecamp messages copy --no-stats type=bool +FLAG basecamp messages copy --profile type=string +FLAG basecamp messages copy --project type=string +FLAG basecamp messages copy --quiet type=bool +FLAG basecamp messages copy --stats type=bool +FLAG basecamp messages copy --styled type=bool +FLAG basecamp messages copy --to-board type=string +FLAG basecamp messages copy --to-project type=string +FLAG basecamp messages copy --todolist type=string +FLAG basecamp messages copy --verbose type=count FLAG basecamp messages create --account type=string FLAG basecamp messages create --agent type=bool FLAG basecamp messages create --attach type=stringArray @@ -11522,6 +12003,30 @@ FLAG basecamp messages list --stats type=bool FLAG basecamp messages list --styled type=bool FLAG basecamp messages list --todolist type=string FLAG basecamp messages list --verbose type=count +FLAG basecamp messages move --account type=string +FLAG basecamp messages move --agent type=bool +FLAG basecamp messages move --cache-dir type=string +FLAG basecamp messages move --count type=bool +FLAG basecamp messages move --help type=bool +FLAG basecamp messages move --hints type=bool +FLAG basecamp messages move --ids-only type=bool +FLAG basecamp messages move --in type=string +FLAG basecamp messages move --jq type=string +FLAG basecamp messages move --json type=bool +FLAG basecamp messages move --markdown type=bool +FLAG basecamp messages move --md type=bool +FLAG basecamp messages move --message-board type=string +FLAG basecamp messages move --no-hints type=bool +FLAG basecamp messages move --no-stats type=bool +FLAG basecamp messages move --profile type=string +FLAG basecamp messages move --project type=string +FLAG basecamp messages move --quiet type=bool +FLAG basecamp messages move --stats type=bool +FLAG basecamp messages move --styled type=bool +FLAG basecamp messages move --to-board type=string +FLAG basecamp messages move --to-project type=string +FLAG basecamp messages move --todolist type=string +FLAG basecamp messages move --verbose type=count FLAG basecamp messages pin --account type=string FLAG basecamp messages pin --agent type=bool FLAG basecamp messages pin --cache-dir type=string @@ -11881,6 +12386,31 @@ FLAG basecamp msgs archive --stats type=bool FLAG basecamp msgs archive --styled type=bool FLAG basecamp msgs archive --todolist type=string FLAG basecamp msgs archive --verbose type=count +FLAG basecamp msgs copy --account type=string +FLAG basecamp msgs copy --agent type=bool +FLAG basecamp msgs copy --cache-dir type=string +FLAG basecamp msgs copy --count type=bool +FLAG basecamp msgs copy --help type=bool +FLAG basecamp msgs copy --hints type=bool +FLAG basecamp msgs copy --ids-only type=bool +FLAG basecamp msgs copy --in type=string +FLAG basecamp msgs copy --include-comments type=bool +FLAG basecamp msgs copy --jq type=string +FLAG basecamp msgs copy --json type=bool +FLAG basecamp msgs copy --markdown type=bool +FLAG basecamp msgs copy --md type=bool +FLAG basecamp msgs copy --message-board type=string +FLAG basecamp msgs copy --no-hints type=bool +FLAG basecamp msgs copy --no-stats type=bool +FLAG basecamp msgs copy --profile type=string +FLAG basecamp msgs copy --project type=string +FLAG basecamp msgs copy --quiet type=bool +FLAG basecamp msgs copy --stats type=bool +FLAG basecamp msgs copy --styled type=bool +FLAG basecamp msgs copy --to-board type=string +FLAG basecamp msgs copy --to-project type=string +FLAG basecamp msgs copy --todolist type=string +FLAG basecamp msgs copy --verbose type=count FLAG basecamp msgs create --account type=string FLAG basecamp msgs create --agent type=bool FLAG basecamp msgs create --attach type=stringArray @@ -11939,6 +12469,30 @@ FLAG basecamp msgs list --stats type=bool FLAG basecamp msgs list --styled type=bool FLAG basecamp msgs list --todolist type=string FLAG basecamp msgs list --verbose type=count +FLAG basecamp msgs move --account type=string +FLAG basecamp msgs move --agent type=bool +FLAG basecamp msgs move --cache-dir type=string +FLAG basecamp msgs move --count type=bool +FLAG basecamp msgs move --help type=bool +FLAG basecamp msgs move --hints type=bool +FLAG basecamp msgs move --ids-only type=bool +FLAG basecamp msgs move --in type=string +FLAG basecamp msgs move --jq type=string +FLAG basecamp msgs move --json type=bool +FLAG basecamp msgs move --markdown type=bool +FLAG basecamp msgs move --md type=bool +FLAG basecamp msgs move --message-board type=string +FLAG basecamp msgs move --no-hints type=bool +FLAG basecamp msgs move --no-stats type=bool +FLAG basecamp msgs move --profile type=string +FLAG basecamp msgs move --project type=string +FLAG basecamp msgs move --quiet type=bool +FLAG basecamp msgs move --stats type=bool +FLAG basecamp msgs move --styled type=bool +FLAG basecamp msgs move --to-board type=string +FLAG basecamp msgs move --to-project type=string +FLAG basecamp msgs move --todolist type=string +FLAG basecamp msgs move --verbose type=count FLAG basecamp msgs pin --account type=string FLAG basecamp msgs pin --agent type=bool FLAG basecamp msgs pin --cache-dir type=string @@ -13182,6 +13736,69 @@ FLAG basecamp project list --status type=string FLAG basecamp project list --styled type=bool FLAG basecamp project list --todolist type=string FLAG basecamp project list --verbose type=count +FLAG basecamp project logo --account type=string +FLAG basecamp project logo --agent type=bool +FLAG basecamp project logo --cache-dir type=string +FLAG basecamp project logo --count type=bool +FLAG basecamp project logo --help type=bool +FLAG basecamp project logo --hints type=bool +FLAG basecamp project logo --ids-only type=bool +FLAG basecamp project logo --in type=string +FLAG basecamp project logo --jq type=string +FLAG basecamp project logo --json type=bool +FLAG basecamp project logo --markdown type=bool +FLAG basecamp project logo --md type=bool +FLAG basecamp project logo --no-hints type=bool +FLAG basecamp project logo --no-stats type=bool +FLAG basecamp project logo --profile type=string +FLAG basecamp project logo --project type=string +FLAG basecamp project logo --quiet type=bool +FLAG basecamp project logo --stats type=bool +FLAG basecamp project logo --styled type=bool +FLAG basecamp project logo --todolist type=string +FLAG basecamp project logo --verbose type=count +FLAG basecamp project logo remove --account type=string +FLAG basecamp project logo remove --agent type=bool +FLAG basecamp project logo remove --cache-dir type=string +FLAG basecamp project logo remove --count type=bool +FLAG basecamp project logo remove --help type=bool +FLAG basecamp project logo remove --hints type=bool +FLAG basecamp project logo remove --ids-only type=bool +FLAG basecamp project logo remove --in type=string +FLAG basecamp project logo remove --jq type=string +FLAG basecamp project logo remove --json type=bool +FLAG basecamp project logo remove --markdown type=bool +FLAG basecamp project logo remove --md type=bool +FLAG basecamp project logo remove --no-hints type=bool +FLAG basecamp project logo remove --no-stats type=bool +FLAG basecamp project logo remove --profile type=string +FLAG basecamp project logo remove --project type=string +FLAG basecamp project logo remove --quiet type=bool +FLAG basecamp project logo remove --stats type=bool +FLAG basecamp project logo remove --styled type=bool +FLAG basecamp project logo remove --todolist type=string +FLAG basecamp project logo remove --verbose type=count +FLAG basecamp project logo set --account type=string +FLAG basecamp project logo set --agent type=bool +FLAG basecamp project logo set --cache-dir type=string +FLAG basecamp project logo set --count type=bool +FLAG basecamp project logo set --help type=bool +FLAG basecamp project logo set --hints type=bool +FLAG basecamp project logo set --ids-only type=bool +FLAG basecamp project logo set --in type=string +FLAG basecamp project logo set --jq type=string +FLAG basecamp project logo set --json type=bool +FLAG basecamp project logo set --markdown type=bool +FLAG basecamp project logo set --md type=bool +FLAG basecamp project logo set --no-hints type=bool +FLAG basecamp project logo set --no-stats type=bool +FLAG basecamp project logo set --profile type=string +FLAG basecamp project logo set --project type=string +FLAG basecamp project logo set --quiet type=bool +FLAG basecamp project logo set --stats type=bool +FLAG basecamp project logo set --styled type=bool +FLAG basecamp project logo set --todolist type=string +FLAG basecamp project logo set --verbose type=count FLAG basecamp project show --account type=string FLAG basecamp project show --agent type=bool FLAG basecamp project show --all type=bool @@ -13424,6 +14041,69 @@ FLAG basecamp projects list --status type=string FLAG basecamp projects list --styled type=bool FLAG basecamp projects list --todolist type=string FLAG basecamp projects list --verbose type=count +FLAG basecamp projects logo --account type=string +FLAG basecamp projects logo --agent type=bool +FLAG basecamp projects logo --cache-dir type=string +FLAG basecamp projects logo --count type=bool +FLAG basecamp projects logo --help type=bool +FLAG basecamp projects logo --hints type=bool +FLAG basecamp projects logo --ids-only type=bool +FLAG basecamp projects logo --in type=string +FLAG basecamp projects logo --jq type=string +FLAG basecamp projects logo --json type=bool +FLAG basecamp projects logo --markdown type=bool +FLAG basecamp projects logo --md type=bool +FLAG basecamp projects logo --no-hints type=bool +FLAG basecamp projects logo --no-stats type=bool +FLAG basecamp projects logo --profile type=string +FLAG basecamp projects logo --project type=string +FLAG basecamp projects logo --quiet type=bool +FLAG basecamp projects logo --stats type=bool +FLAG basecamp projects logo --styled type=bool +FLAG basecamp projects logo --todolist type=string +FLAG basecamp projects logo --verbose type=count +FLAG basecamp projects logo remove --account type=string +FLAG basecamp projects logo remove --agent type=bool +FLAG basecamp projects logo remove --cache-dir type=string +FLAG basecamp projects logo remove --count type=bool +FLAG basecamp projects logo remove --help type=bool +FLAG basecamp projects logo remove --hints type=bool +FLAG basecamp projects logo remove --ids-only type=bool +FLAG basecamp projects logo remove --in type=string +FLAG basecamp projects logo remove --jq type=string +FLAG basecamp projects logo remove --json type=bool +FLAG basecamp projects logo remove --markdown type=bool +FLAG basecamp projects logo remove --md type=bool +FLAG basecamp projects logo remove --no-hints type=bool +FLAG basecamp projects logo remove --no-stats type=bool +FLAG basecamp projects logo remove --profile type=string +FLAG basecamp projects logo remove --project type=string +FLAG basecamp projects logo remove --quiet type=bool +FLAG basecamp projects logo remove --stats type=bool +FLAG basecamp projects logo remove --styled type=bool +FLAG basecamp projects logo remove --todolist type=string +FLAG basecamp projects logo remove --verbose type=count +FLAG basecamp projects logo set --account type=string +FLAG basecamp projects logo set --agent type=bool +FLAG basecamp projects logo set --cache-dir type=string +FLAG basecamp projects logo set --count type=bool +FLAG basecamp projects logo set --help type=bool +FLAG basecamp projects logo set --hints type=bool +FLAG basecamp projects logo set --ids-only type=bool +FLAG basecamp projects logo set --in type=string +FLAG basecamp projects logo set --jq type=string +FLAG basecamp projects logo set --json type=bool +FLAG basecamp projects logo set --markdown type=bool +FLAG basecamp projects logo set --md type=bool +FLAG basecamp projects logo set --no-hints type=bool +FLAG basecamp projects logo set --no-stats type=bool +FLAG basecamp projects logo set --profile type=string +FLAG basecamp projects logo set --project type=string +FLAG basecamp projects logo set --quiet type=bool +FLAG basecamp projects logo set --stats type=bool +FLAG basecamp projects logo set --styled type=bool +FLAG basecamp projects logo set --todolist type=string +FLAG basecamp projects logo set --verbose type=count FLAG basecamp projects show --account type=string FLAG basecamp projects show --agent type=bool FLAG basecamp projects show --all type=bool @@ -13937,6 +14617,31 @@ FLAG basecamp schedule --stats type=bool FLAG basecamp schedule --styled type=bool FLAG basecamp schedule --todolist type=string FLAG basecamp schedule --verbose type=count +FLAG basecamp schedule copy --account type=string +FLAG basecamp schedule copy --agent type=bool +FLAG basecamp schedule copy --cache-dir type=string +FLAG basecamp schedule copy --count type=bool +FLAG basecamp schedule copy --help type=bool +FLAG basecamp schedule copy --hints type=bool +FLAG basecamp schedule copy --ids-only type=bool +FLAG basecamp schedule copy --in type=string +FLAG basecamp schedule copy --include-comments type=bool +FLAG basecamp schedule copy --jq type=string +FLAG basecamp schedule copy --json type=bool +FLAG basecamp schedule copy --markdown type=bool +FLAG basecamp schedule copy --md type=bool +FLAG basecamp schedule copy --no-hints type=bool +FLAG basecamp schedule copy --no-stats type=bool +FLAG basecamp schedule copy --profile type=string +FLAG basecamp schedule copy --project type=string +FLAG basecamp schedule copy --quiet type=bool +FLAG basecamp schedule copy --schedule type=string +FLAG basecamp schedule copy --stats type=bool +FLAG basecamp schedule copy --styled type=bool +FLAG basecamp schedule copy --to-project type=string +FLAG basecamp schedule copy --to-schedule type=string +FLAG basecamp schedule copy --todolist type=string +FLAG basecamp schedule copy --verbose type=count FLAG basecamp schedule create --account type=string FLAG basecamp schedule create --agent type=bool FLAG basecamp schedule create --all-day type=bool @@ -14026,6 +14731,30 @@ FLAG basecamp schedule info --stats type=bool FLAG basecamp schedule info --styled type=bool FLAG basecamp schedule info --todolist type=string FLAG basecamp schedule info --verbose type=count +FLAG basecamp schedule move --account type=string +FLAG basecamp schedule move --agent type=bool +FLAG basecamp schedule move --cache-dir type=string +FLAG basecamp schedule move --count type=bool +FLAG basecamp schedule move --help type=bool +FLAG basecamp schedule move --hints type=bool +FLAG basecamp schedule move --ids-only type=bool +FLAG basecamp schedule move --in type=string +FLAG basecamp schedule move --jq type=string +FLAG basecamp schedule move --json type=bool +FLAG basecamp schedule move --markdown type=bool +FLAG basecamp schedule move --md type=bool +FLAG basecamp schedule move --no-hints type=bool +FLAG basecamp schedule move --no-stats type=bool +FLAG basecamp schedule move --profile type=string +FLAG basecamp schedule move --project type=string +FLAG basecamp schedule move --quiet type=bool +FLAG basecamp schedule move --schedule type=string +FLAG basecamp schedule move --stats type=bool +FLAG basecamp schedule move --styled type=bool +FLAG basecamp schedule move --to-project type=string +FLAG basecamp schedule move --to-schedule type=string +FLAG basecamp schedule move --todolist type=string +FLAG basecamp schedule move --verbose type=count FLAG basecamp schedule settings --account type=string FLAG basecamp schedule settings --agent type=bool FLAG basecamp schedule settings --cache-dir type=string @@ -15131,6 +15860,32 @@ FLAG basecamp tlgroup --stats type=bool FLAG basecamp tlgroup --styled type=bool FLAG basecamp tlgroup --todolist type=string FLAG basecamp tlgroup --verbose type=count +FLAG basecamp tlgroup copy --account type=string +FLAG basecamp tlgroup copy --agent type=bool +FLAG basecamp tlgroup copy --cache-dir type=string +FLAG basecamp tlgroup copy --count type=bool +FLAG basecamp tlgroup copy --help type=bool +FLAG basecamp tlgroup copy --hints type=bool +FLAG basecamp tlgroup copy --ids-only type=bool +FLAG basecamp tlgroup copy --in type=string +FLAG basecamp tlgroup copy --include-comments type=bool +FLAG basecamp tlgroup copy --jq type=string +FLAG basecamp tlgroup copy --json type=bool +FLAG basecamp tlgroup copy --list type=string +FLAG basecamp tlgroup copy --markdown type=bool +FLAG basecamp tlgroup copy --md type=bool +FLAG basecamp tlgroup copy --no-hints type=bool +FLAG basecamp tlgroup copy --no-stats type=bool +FLAG basecamp tlgroup copy --profile type=string +FLAG basecamp tlgroup copy --project type=string +FLAG basecamp tlgroup copy --quiet type=bool +FLAG basecamp tlgroup copy --stats type=bool +FLAG basecamp tlgroup copy --styled type=bool +FLAG basecamp tlgroup copy --to-list type=string +FLAG basecamp tlgroup copy --to-project type=string +FLAG basecamp tlgroup copy --to-todoset type=string +FLAG basecamp tlgroup copy --todolist type=string +FLAG basecamp tlgroup copy --verbose type=count FLAG basecamp tlgroup create --account type=string FLAG basecamp tlgroup create --agent type=bool FLAG basecamp tlgroup create --cache-dir type=string @@ -15197,6 +15952,9 @@ FLAG basecamp tlgroup move --project type=string FLAG basecamp tlgroup move --quiet type=bool FLAG basecamp tlgroup move --stats type=bool FLAG basecamp tlgroup move --styled type=bool +FLAG basecamp tlgroup move --to-list type=string +FLAG basecamp tlgroup move --to-project type=string +FLAG basecamp tlgroup move --to-todoset type=string FLAG basecamp tlgroup move --todolist type=string FLAG basecamp tlgroup move --verbose type=count FLAG basecamp tlgroup position --account type=string @@ -15221,6 +15979,9 @@ FLAG basecamp tlgroup position --project type=string FLAG basecamp tlgroup position --quiet type=bool FLAG basecamp tlgroup position --stats type=bool FLAG basecamp tlgroup position --styled type=bool +FLAG basecamp tlgroup position --to-list type=string +FLAG basecamp tlgroup position --to-project type=string +FLAG basecamp tlgroup position --to-todoset type=string FLAG basecamp tlgroup position --todolist type=string FLAG basecamp tlgroup position --verbose type=count FLAG basecamp tlgroup rename --account type=string @@ -15311,6 +16072,32 @@ FLAG basecamp tlgroups --stats type=bool FLAG basecamp tlgroups --styled type=bool FLAG basecamp tlgroups --todolist type=string FLAG basecamp tlgroups --verbose type=count +FLAG basecamp tlgroups copy --account type=string +FLAG basecamp tlgroups copy --agent type=bool +FLAG basecamp tlgroups copy --cache-dir type=string +FLAG basecamp tlgroups copy --count type=bool +FLAG basecamp tlgroups copy --help type=bool +FLAG basecamp tlgroups copy --hints type=bool +FLAG basecamp tlgroups copy --ids-only type=bool +FLAG basecamp tlgroups copy --in type=string +FLAG basecamp tlgroups copy --include-comments type=bool +FLAG basecamp tlgroups copy --jq type=string +FLAG basecamp tlgroups copy --json type=bool +FLAG basecamp tlgroups copy --list type=string +FLAG basecamp tlgroups copy --markdown type=bool +FLAG basecamp tlgroups copy --md type=bool +FLAG basecamp tlgroups copy --no-hints type=bool +FLAG basecamp tlgroups copy --no-stats type=bool +FLAG basecamp tlgroups copy --profile type=string +FLAG basecamp tlgroups copy --project type=string +FLAG basecamp tlgroups copy --quiet type=bool +FLAG basecamp tlgroups copy --stats type=bool +FLAG basecamp tlgroups copy --styled type=bool +FLAG basecamp tlgroups copy --to-list type=string +FLAG basecamp tlgroups copy --to-project type=string +FLAG basecamp tlgroups copy --to-todoset type=string +FLAG basecamp tlgroups copy --todolist type=string +FLAG basecamp tlgroups copy --verbose type=count FLAG basecamp tlgroups create --account type=string FLAG basecamp tlgroups create --agent type=bool FLAG basecamp tlgroups create --cache-dir type=string @@ -15377,6 +16164,9 @@ FLAG basecamp tlgroups move --project type=string FLAG basecamp tlgroups move --quiet type=bool FLAG basecamp tlgroups move --stats type=bool FLAG basecamp tlgroups move --styled type=bool +FLAG basecamp tlgroups move --to-list type=string +FLAG basecamp tlgroups move --to-project type=string +FLAG basecamp tlgroups move --to-todoset type=string FLAG basecamp tlgroups move --todolist type=string FLAG basecamp tlgroups move --verbose type=count FLAG basecamp tlgroups position --account type=string @@ -15401,6 +16191,9 @@ FLAG basecamp tlgroups position --project type=string FLAG basecamp tlgroups position --quiet type=bool FLAG basecamp tlgroups position --stats type=bool FLAG basecamp tlgroups position --styled type=bool +FLAG basecamp tlgroups position --to-list type=string +FLAG basecamp tlgroups position --to-project type=string +FLAG basecamp tlgroups position --to-todoset type=string FLAG basecamp tlgroups position --todolist type=string FLAG basecamp tlgroups position --verbose type=count FLAG basecamp tlgroups rename --account type=string @@ -15511,6 +16304,31 @@ FLAG basecamp todolist archive --stats type=bool FLAG basecamp todolist archive --styled type=bool FLAG basecamp todolist archive --todolist type=string FLAG basecamp todolist archive --verbose type=count +FLAG basecamp todolist copy --account type=string +FLAG basecamp todolist copy --agent type=bool +FLAG basecamp todolist copy --cache-dir type=string +FLAG basecamp todolist copy --count type=bool +FLAG basecamp todolist copy --help type=bool +FLAG basecamp todolist copy --hints type=bool +FLAG basecamp todolist copy --ids-only type=bool +FLAG basecamp todolist copy --in type=string +FLAG basecamp todolist copy --include-comments type=bool +FLAG basecamp todolist copy --jq type=string +FLAG basecamp todolist copy --json type=bool +FLAG basecamp todolist copy --markdown type=bool +FLAG basecamp todolist copy --md type=bool +FLAG basecamp todolist copy --no-hints type=bool +FLAG basecamp todolist copy --no-stats type=bool +FLAG basecamp todolist copy --profile type=string +FLAG basecamp todolist copy --project type=string +FLAG basecamp todolist copy --quiet type=bool +FLAG basecamp todolist copy --stats type=bool +FLAG basecamp todolist copy --styled type=bool +FLAG basecamp todolist copy --to-list type=string +FLAG basecamp todolist copy --to-project type=string +FLAG basecamp todolist copy --to-todoset type=string +FLAG basecamp todolist copy --todolist type=string +FLAG basecamp todolist copy --verbose type=count FLAG basecamp todolist create --account type=string FLAG basecamp todolist create --agent type=bool FLAG basecamp todolist create --cache-dir type=string @@ -15584,6 +16402,9 @@ FLAG basecamp todolist move --quiet type=bool FLAG basecamp todolist move --stats type=bool FLAG basecamp todolist move --styled type=bool FLAG basecamp todolist move --to type=int +FLAG basecamp todolist move --to-list type=string +FLAG basecamp todolist move --to-project type=string +FLAG basecamp todolist move --to-todoset type=string FLAG basecamp todolist move --todolist type=string FLAG basecamp todolist move --verbose type=count FLAG basecamp todolist position --account type=string @@ -15607,6 +16428,9 @@ FLAG basecamp todolist position --quiet type=bool FLAG basecamp todolist position --stats type=bool FLAG basecamp todolist position --styled type=bool FLAG basecamp todolist position --to type=int +FLAG basecamp todolist position --to-list type=string +FLAG basecamp todolist position --to-project type=string +FLAG basecamp todolist position --to-todoset type=string FLAG basecamp todolist position --todolist type=string FLAG basecamp todolist position --verbose type=count FLAG basecamp todolist reorder --account type=string @@ -15630,6 +16454,9 @@ FLAG basecamp todolist reorder --quiet type=bool FLAG basecamp todolist reorder --stats type=bool FLAG basecamp todolist reorder --styled type=bool FLAG basecamp todolist reorder --to type=int +FLAG basecamp todolist reorder --to-list type=string +FLAG basecamp todolist reorder --to-project type=string +FLAG basecamp todolist reorder --to-todoset type=string FLAG basecamp todolist reorder --todolist type=string FLAG basecamp todolist reorder --verbose type=count FLAG basecamp todolist restore --account type=string @@ -15743,6 +16570,32 @@ FLAG basecamp todolistgroup --stats type=bool FLAG basecamp todolistgroup --styled type=bool FLAG basecamp todolistgroup --todolist type=string FLAG basecamp todolistgroup --verbose type=count +FLAG basecamp todolistgroup copy --account type=string +FLAG basecamp todolistgroup copy --agent type=bool +FLAG basecamp todolistgroup copy --cache-dir type=string +FLAG basecamp todolistgroup copy --count type=bool +FLAG basecamp todolistgroup copy --help type=bool +FLAG basecamp todolistgroup copy --hints type=bool +FLAG basecamp todolistgroup copy --ids-only type=bool +FLAG basecamp todolistgroup copy --in type=string +FLAG basecamp todolistgroup copy --include-comments type=bool +FLAG basecamp todolistgroup copy --jq type=string +FLAG basecamp todolistgroup copy --json type=bool +FLAG basecamp todolistgroup copy --list type=string +FLAG basecamp todolistgroup copy --markdown type=bool +FLAG basecamp todolistgroup copy --md type=bool +FLAG basecamp todolistgroup copy --no-hints type=bool +FLAG basecamp todolistgroup copy --no-stats type=bool +FLAG basecamp todolistgroup copy --profile type=string +FLAG basecamp todolistgroup copy --project type=string +FLAG basecamp todolistgroup copy --quiet type=bool +FLAG basecamp todolistgroup copy --stats type=bool +FLAG basecamp todolistgroup copy --styled type=bool +FLAG basecamp todolistgroup copy --to-list type=string +FLAG basecamp todolistgroup copy --to-project type=string +FLAG basecamp todolistgroup copy --to-todoset type=string +FLAG basecamp todolistgroup copy --todolist type=string +FLAG basecamp todolistgroup copy --verbose type=count FLAG basecamp todolistgroup create --account type=string FLAG basecamp todolistgroup create --agent type=bool FLAG basecamp todolistgroup create --cache-dir type=string @@ -15809,6 +16662,9 @@ FLAG basecamp todolistgroup move --project type=string FLAG basecamp todolistgroup move --quiet type=bool FLAG basecamp todolistgroup move --stats type=bool FLAG basecamp todolistgroup move --styled type=bool +FLAG basecamp todolistgroup move --to-list type=string +FLAG basecamp todolistgroup move --to-project type=string +FLAG basecamp todolistgroup move --to-todoset type=string FLAG basecamp todolistgroup move --todolist type=string FLAG basecamp todolistgroup move --verbose type=count FLAG basecamp todolistgroup position --account type=string @@ -15833,6 +16689,9 @@ FLAG basecamp todolistgroup position --project type=string FLAG basecamp todolistgroup position --quiet type=bool FLAG basecamp todolistgroup position --stats type=bool FLAG basecamp todolistgroup position --styled type=bool +FLAG basecamp todolistgroup position --to-list type=string +FLAG basecamp todolistgroup position --to-project type=string +FLAG basecamp todolistgroup position --to-todoset type=string FLAG basecamp todolistgroup position --todolist type=string FLAG basecamp todolistgroup position --verbose type=count FLAG basecamp todolistgroup rename --account type=string @@ -15923,6 +16782,32 @@ FLAG basecamp todolistgroups --stats type=bool FLAG basecamp todolistgroups --styled type=bool FLAG basecamp todolistgroups --todolist type=string FLAG basecamp todolistgroups --verbose type=count +FLAG basecamp todolistgroups copy --account type=string +FLAG basecamp todolistgroups copy --agent type=bool +FLAG basecamp todolistgroups copy --cache-dir type=string +FLAG basecamp todolistgroups copy --count type=bool +FLAG basecamp todolistgroups copy --help type=bool +FLAG basecamp todolistgroups copy --hints type=bool +FLAG basecamp todolistgroups copy --ids-only type=bool +FLAG basecamp todolistgroups copy --in type=string +FLAG basecamp todolistgroups copy --include-comments type=bool +FLAG basecamp todolistgroups copy --jq type=string +FLAG basecamp todolistgroups copy --json type=bool +FLAG basecamp todolistgroups copy --list type=string +FLAG basecamp todolistgroups copy --markdown type=bool +FLAG basecamp todolistgroups copy --md type=bool +FLAG basecamp todolistgroups copy --no-hints type=bool +FLAG basecamp todolistgroups copy --no-stats type=bool +FLAG basecamp todolistgroups copy --profile type=string +FLAG basecamp todolistgroups copy --project type=string +FLAG basecamp todolistgroups copy --quiet type=bool +FLAG basecamp todolistgroups copy --stats type=bool +FLAG basecamp todolistgroups copy --styled type=bool +FLAG basecamp todolistgroups copy --to-list type=string +FLAG basecamp todolistgroups copy --to-project type=string +FLAG basecamp todolistgroups copy --to-todoset type=string +FLAG basecamp todolistgroups copy --todolist type=string +FLAG basecamp todolistgroups copy --verbose type=count FLAG basecamp todolistgroups create --account type=string FLAG basecamp todolistgroups create --agent type=bool FLAG basecamp todolistgroups create --cache-dir type=string @@ -15989,6 +16874,9 @@ FLAG basecamp todolistgroups move --project type=string FLAG basecamp todolistgroups move --quiet type=bool FLAG basecamp todolistgroups move --stats type=bool FLAG basecamp todolistgroups move --styled type=bool +FLAG basecamp todolistgroups move --to-list type=string +FLAG basecamp todolistgroups move --to-project type=string +FLAG basecamp todolistgroups move --to-todoset type=string FLAG basecamp todolistgroups move --todolist type=string FLAG basecamp todolistgroups move --verbose type=count FLAG basecamp todolistgroups position --account type=string @@ -16013,6 +16901,9 @@ FLAG basecamp todolistgroups position --project type=string FLAG basecamp todolistgroups position --quiet type=bool FLAG basecamp todolistgroups position --stats type=bool FLAG basecamp todolistgroups position --styled type=bool +FLAG basecamp todolistgroups position --to-list type=string +FLAG basecamp todolistgroups position --to-project type=string +FLAG basecamp todolistgroups position --to-todoset type=string FLAG basecamp todolistgroups position --todolist type=string FLAG basecamp todolistgroups position --verbose type=count FLAG basecamp todolistgroups rename --account type=string @@ -16123,6 +17014,31 @@ FLAG basecamp todolists archive --stats type=bool FLAG basecamp todolists archive --styled type=bool FLAG basecamp todolists archive --todolist type=string FLAG basecamp todolists archive --verbose type=count +FLAG basecamp todolists copy --account type=string +FLAG basecamp todolists copy --agent type=bool +FLAG basecamp todolists copy --cache-dir type=string +FLAG basecamp todolists copy --count type=bool +FLAG basecamp todolists copy --help type=bool +FLAG basecamp todolists copy --hints type=bool +FLAG basecamp todolists copy --ids-only type=bool +FLAG basecamp todolists copy --in type=string +FLAG basecamp todolists copy --include-comments type=bool +FLAG basecamp todolists copy --jq type=string +FLAG basecamp todolists copy --json type=bool +FLAG basecamp todolists copy --markdown type=bool +FLAG basecamp todolists copy --md type=bool +FLAG basecamp todolists copy --no-hints type=bool +FLAG basecamp todolists copy --no-stats type=bool +FLAG basecamp todolists copy --profile type=string +FLAG basecamp todolists copy --project type=string +FLAG basecamp todolists copy --quiet type=bool +FLAG basecamp todolists copy --stats type=bool +FLAG basecamp todolists copy --styled type=bool +FLAG basecamp todolists copy --to-list type=string +FLAG basecamp todolists copy --to-project type=string +FLAG basecamp todolists copy --to-todoset type=string +FLAG basecamp todolists copy --todolist type=string +FLAG basecamp todolists copy --verbose type=count FLAG basecamp todolists create --account type=string FLAG basecamp todolists create --agent type=bool FLAG basecamp todolists create --cache-dir type=string @@ -16196,6 +17112,9 @@ FLAG basecamp todolists move --quiet type=bool FLAG basecamp todolists move --stats type=bool FLAG basecamp todolists move --styled type=bool FLAG basecamp todolists move --to type=int +FLAG basecamp todolists move --to-list type=string +FLAG basecamp todolists move --to-project type=string +FLAG basecamp todolists move --to-todoset type=string FLAG basecamp todolists move --todolist type=string FLAG basecamp todolists move --verbose type=count FLAG basecamp todolists position --account type=string @@ -16219,6 +17138,9 @@ FLAG basecamp todolists position --quiet type=bool FLAG basecamp todolists position --stats type=bool FLAG basecamp todolists position --styled type=bool FLAG basecamp todolists position --to type=int +FLAG basecamp todolists position --to-list type=string +FLAG basecamp todolists position --to-project type=string +FLAG basecamp todolists position --to-todoset type=string FLAG basecamp todolists position --todolist type=string FLAG basecamp todolists position --verbose type=count FLAG basecamp todolists reorder --account type=string @@ -16242,6 +17164,9 @@ FLAG basecamp todolists reorder --quiet type=bool FLAG basecamp todolists reorder --stats type=bool FLAG basecamp todolists reorder --styled type=bool FLAG basecamp todolists reorder --to type=int +FLAG basecamp todolists reorder --to-list type=string +FLAG basecamp todolists reorder --to-project type=string +FLAG basecamp todolists reorder --to-todoset type=string FLAG basecamp todolists reorder --todolist type=string FLAG basecamp todolists reorder --verbose type=count FLAG basecamp todolists restore --account type=string @@ -16396,6 +17321,30 @@ FLAG basecamp todos complete --stats type=bool FLAG basecamp todos complete --styled type=bool FLAG basecamp todos complete --todolist type=string FLAG basecamp todos complete --verbose type=count +FLAG basecamp todos copy --account type=string +FLAG basecamp todos copy --agent type=bool +FLAG basecamp todos copy --cache-dir type=string +FLAG basecamp todos copy --count type=bool +FLAG basecamp todos copy --help type=bool +FLAG basecamp todos copy --hints type=bool +FLAG basecamp todos copy --ids-only type=bool +FLAG basecamp todos copy --in type=string +FLAG basecamp todos copy --include-comments type=bool +FLAG basecamp todos copy --jq type=string +FLAG basecamp todos copy --json type=bool +FLAG basecamp todos copy --markdown type=bool +FLAG basecamp todos copy --md type=bool +FLAG basecamp todos copy --no-hints type=bool +FLAG basecamp todos copy --no-stats type=bool +FLAG basecamp todos copy --profile type=string +FLAG basecamp todos copy --project type=string +FLAG basecamp todos copy --quiet type=bool +FLAG basecamp todos copy --stats type=bool +FLAG basecamp todos copy --styled type=bool +FLAG basecamp todos copy --to-list type=string +FLAG basecamp todos copy --to-project type=string +FLAG basecamp todos copy --todolist type=string +FLAG basecamp todos copy --verbose type=count FLAG basecamp todos create --account type=string FLAG basecamp todos create --agent type=bool FLAG basecamp todos create --assignee type=string @@ -16485,6 +17434,8 @@ FLAG basecamp todos move --quiet type=bool FLAG basecamp todos move --stats type=bool FLAG basecamp todos move --styled type=bool FLAG basecamp todos move --to type=int +FLAG basecamp todos move --to-list type=string +FLAG basecamp todos move --to-project type=string FLAG basecamp todos move --todolist type=string FLAG basecamp todos move --verbose type=count FLAG basecamp todos position --account type=string @@ -16509,6 +17460,8 @@ FLAG basecamp todos position --quiet type=bool FLAG basecamp todos position --stats type=bool FLAG basecamp todos position --styled type=bool FLAG basecamp todos position --to type=int +FLAG basecamp todos position --to-list type=string +FLAG basecamp todos position --to-project type=string FLAG basecamp todos position --todolist type=string FLAG basecamp todos position --verbose type=count FLAG basecamp todos reopen --account type=string @@ -16554,6 +17507,8 @@ FLAG basecamp todos reorder --quiet type=bool FLAG basecamp todos reorder --stats type=bool FLAG basecamp todos reorder --styled type=bool FLAG basecamp todos reorder --to type=int +FLAG basecamp todos reorder --to-list type=string +FLAG basecamp todos reorder --to-project type=string FLAG basecamp todos reorder --todolist type=string FLAG basecamp todos reorder --verbose type=count FLAG basecamp todos restore --account type=string @@ -16899,6 +17854,7 @@ FLAG basecamp tools move --project type=string FLAG basecamp tools move --quiet type=bool FLAG basecamp tools move --stats type=bool FLAG basecamp tools move --styled type=bool +FLAG basecamp tools move --to-project type=string FLAG basecamp tools move --todolist type=string FLAG basecamp tools move --verbose type=count FLAG basecamp tools rename --account type=string @@ -17145,6 +18101,32 @@ FLAG basecamp uploads --styled type=bool FLAG basecamp uploads --todolist type=string FLAG basecamp uploads --vault type=string FLAG basecamp uploads --verbose type=count +FLAG basecamp uploads copy --account type=string +FLAG basecamp uploads copy --agent type=bool +FLAG basecamp uploads copy --cache-dir type=string +FLAG basecamp uploads copy --count type=bool +FLAG basecamp uploads copy --folder type=string +FLAG basecamp uploads copy --help type=bool +FLAG basecamp uploads copy --hints type=bool +FLAG basecamp uploads copy --ids-only type=bool +FLAG basecamp uploads copy --in type=string +FLAG basecamp uploads copy --include-comments type=bool +FLAG basecamp uploads copy --jq type=string +FLAG basecamp uploads copy --json type=bool +FLAG basecamp uploads copy --markdown type=bool +FLAG basecamp uploads copy --md type=bool +FLAG basecamp uploads copy --no-hints type=bool +FLAG basecamp uploads copy --no-stats type=bool +FLAG basecamp uploads copy --profile type=string +FLAG basecamp uploads copy --project type=string +FLAG basecamp uploads copy --quiet type=bool +FLAG basecamp uploads copy --stats type=bool +FLAG basecamp uploads copy --styled type=bool +FLAG basecamp uploads copy --to-folder type=string +FLAG basecamp uploads copy --to-project type=string +FLAG basecamp uploads copy --todolist type=string +FLAG basecamp uploads copy --vault type=string +FLAG basecamp uploads copy --verbose type=count FLAG basecamp uploads create --account type=string FLAG basecamp uploads create --agent type=bool FLAG basecamp uploads create --cache-dir type=string @@ -17197,6 +18179,33 @@ FLAG basecamp uploads list --styled type=bool FLAG basecamp uploads list --todolist type=string FLAG basecamp uploads list --vault type=string FLAG basecamp uploads list --verbose type=count +FLAG basecamp uploads move --account type=string +FLAG basecamp uploads move --agent type=bool +FLAG basecamp uploads move --cache-dir type=string +FLAG basecamp uploads move --count type=bool +FLAG basecamp uploads move --folder type=string +FLAG basecamp uploads move --help type=bool +FLAG basecamp uploads move --hints type=bool +FLAG basecamp uploads move --ids-only type=bool +FLAG basecamp uploads move --in type=string +FLAG basecamp uploads move --jq type=string +FLAG basecamp uploads move --json type=bool +FLAG basecamp uploads move --markdown type=bool +FLAG basecamp uploads move --md type=bool +FLAG basecamp uploads move --no-hints type=bool +FLAG basecamp uploads move --no-stats type=bool +FLAG basecamp uploads move --position type=int +FLAG basecamp uploads move --profile type=string +FLAG basecamp uploads move --project type=string +FLAG basecamp uploads move --quiet type=bool +FLAG basecamp uploads move --stats type=bool +FLAG basecamp uploads move --styled type=bool +FLAG basecamp uploads move --to type=string +FLAG basecamp uploads move --to-folder type=string +FLAG basecamp uploads move --to-project type=string +FLAG basecamp uploads move --todolist type=string +FLAG basecamp uploads move --vault type=string +FLAG basecamp uploads move --verbose type=count FLAG basecamp uploads show --account type=string FLAG basecamp uploads show --agent type=bool FLAG basecamp uploads show --all-comments type=bool @@ -17313,6 +18322,32 @@ FLAG basecamp vault archive --styled type=bool FLAG basecamp vault archive --todolist type=string FLAG basecamp vault archive --vault type=string FLAG basecamp vault archive --verbose type=count +FLAG basecamp vault copy --account type=string +FLAG basecamp vault copy --agent type=bool +FLAG basecamp vault copy --cache-dir type=string +FLAG basecamp vault copy --count type=bool +FLAG basecamp vault copy --folder type=string +FLAG basecamp vault copy --help type=bool +FLAG basecamp vault copy --hints type=bool +FLAG basecamp vault copy --ids-only type=bool +FLAG basecamp vault copy --in type=string +FLAG basecamp vault copy --include-comments type=bool +FLAG basecamp vault copy --jq type=string +FLAG basecamp vault copy --json type=bool +FLAG basecamp vault copy --markdown type=bool +FLAG basecamp vault copy --md type=bool +FLAG basecamp vault copy --no-hints type=bool +FLAG basecamp vault copy --no-stats type=bool +FLAG basecamp vault copy --profile type=string +FLAG basecamp vault copy --project type=string +FLAG basecamp vault copy --quiet type=bool +FLAG basecamp vault copy --stats type=bool +FLAG basecamp vault copy --styled type=bool +FLAG basecamp vault copy --to-folder type=string +FLAG basecamp vault copy --to-project type=string +FLAG basecamp vault copy --todolist type=string +FLAG basecamp vault copy --vault type=string +FLAG basecamp vault copy --verbose type=count FLAG basecamp vault doc --account type=string FLAG basecamp vault doc --agent type=bool FLAG basecamp vault doc --all type=bool @@ -17781,6 +18816,8 @@ FLAG basecamp vault move --quiet type=bool FLAG basecamp vault move --stats type=bool FLAG basecamp vault move --styled type=bool FLAG basecamp vault move --to type=string +FLAG basecamp vault move --to-folder type=string +FLAG basecamp vault move --to-project type=string FLAG basecamp vault move --todolist type=string FLAG basecamp vault move --vault type=string FLAG basecamp vault move --verbose type=count @@ -18315,6 +19352,32 @@ FLAG basecamp vaults archive --styled type=bool FLAG basecamp vaults archive --todolist type=string FLAG basecamp vaults archive --vault type=string FLAG basecamp vaults archive --verbose type=count +FLAG basecamp vaults copy --account type=string +FLAG basecamp vaults copy --agent type=bool +FLAG basecamp vaults copy --cache-dir type=string +FLAG basecamp vaults copy --count type=bool +FLAG basecamp vaults copy --folder type=string +FLAG basecamp vaults copy --help type=bool +FLAG basecamp vaults copy --hints type=bool +FLAG basecamp vaults copy --ids-only type=bool +FLAG basecamp vaults copy --in type=string +FLAG basecamp vaults copy --include-comments type=bool +FLAG basecamp vaults copy --jq type=string +FLAG basecamp vaults copy --json type=bool +FLAG basecamp vaults copy --markdown type=bool +FLAG basecamp vaults copy --md type=bool +FLAG basecamp vaults copy --no-hints type=bool +FLAG basecamp vaults copy --no-stats type=bool +FLAG basecamp vaults copy --profile type=string +FLAG basecamp vaults copy --project type=string +FLAG basecamp vaults copy --quiet type=bool +FLAG basecamp vaults copy --stats type=bool +FLAG basecamp vaults copy --styled type=bool +FLAG basecamp vaults copy --to-folder type=string +FLAG basecamp vaults copy --to-project type=string +FLAG basecamp vaults copy --todolist type=string +FLAG basecamp vaults copy --vault type=string +FLAG basecamp vaults copy --verbose type=count FLAG basecamp vaults doc --account type=string FLAG basecamp vaults doc --agent type=bool FLAG basecamp vaults doc --all type=bool @@ -18783,6 +19846,8 @@ FLAG basecamp vaults move --quiet type=bool FLAG basecamp vaults move --stats type=bool FLAG basecamp vaults move --styled type=bool FLAG basecamp vaults move --to type=string +FLAG basecamp vaults move --to-folder type=string +FLAG basecamp vaults move --to-project type=string FLAG basecamp vaults move --todolist type=string FLAG basecamp vaults move --vault type=string FLAG basecamp vaults move --verbose type=count @@ -19638,6 +20703,7 @@ SUB basecamp cards SUB basecamp cards archive SUB basecamp cards column SUB basecamp cards column color +SUB basecamp cards column copy SUB basecamp cards column create SUB basecamp cards column move SUB basecamp cards column no-on-hold @@ -19647,6 +20713,7 @@ SUB basecamp cards column unwatch SUB basecamp cards column update SUB basecamp cards column watch SUB basecamp cards columns +SUB basecamp cards copy SUB basecamp cards create SUB basecamp cards done SUB basecamp cards list @@ -19688,7 +20755,9 @@ SUB basecamp checkin answer update SUB basecamp checkin answers SUB basecamp checkin question SUB basecamp checkin question answerers +SUB basecamp checkin question copy SUB basecamp checkin question create +SUB basecamp checkin question move SUB basecamp checkin question notify SUB basecamp checkin question pause SUB basecamp checkin question resume @@ -19704,7 +20773,9 @@ SUB basecamp checkins answer update SUB basecamp checkins answers SUB basecamp checkins question SUB basecamp checkins question answerers +SUB basecamp checkins question copy SUB basecamp checkins question create +SUB basecamp checkins question move SUB basecamp checkins question notify SUB basecamp checkins question pause SUB basecamp checkins question resume @@ -19740,6 +20811,7 @@ SUB basecamp config unset SUB basecamp config untrust SUB basecamp docs SUB basecamp docs archive +SUB basecamp docs copy SUB basecamp docs doc SUB basecamp docs doc create SUB basecamp docs doc list @@ -19780,6 +20852,7 @@ SUB basecamp docs versions SUB basecamp doctor SUB basecamp documents SUB basecamp documents archive +SUB basecamp documents copy SUB basecamp documents doc SUB basecamp documents doc create SUB basecamp documents doc list @@ -19827,6 +20900,7 @@ SUB basecamp events poll SUB basecamp events ticket SUB basecamp file SUB basecamp file archive +SUB basecamp file copy SUB basecamp file doc SUB basecamp file doc create SUB basecamp file doc list @@ -19866,6 +20940,7 @@ SUB basecamp file vaults list SUB basecamp file versions SUB basecamp files SUB basecamp files archive +SUB basecamp files copy SUB basecamp files doc SUB basecamp files doc create SUB basecamp files doc list @@ -19903,8 +20978,11 @@ SUB basecamp files vaults SUB basecamp files vaults create SUB basecamp files vaults list SUB basecamp files versions +SUB basecamp filings +SUB basecamp filings show SUB basecamp folders SUB basecamp folders archive +SUB basecamp folders copy SUB basecamp folders doc SUB basecamp folders doc create SUB basecamp folders doc list @@ -19943,8 +21021,10 @@ SUB basecamp folders vaults create SUB basecamp folders vaults list SUB basecamp folders versions SUB basecamp forwards +SUB basecamp forwards copy SUB basecamp forwards inbox SUB basecamp forwards list +SUB basecamp forwards move SUB basecamp forwards replies SUB basecamp forwards reply SUB basecamp forwards show @@ -19976,8 +21056,10 @@ SUB basecamp messageboards SUB basecamp messageboards show SUB basecamp messages SUB basecamp messages archive +SUB basecamp messages copy SUB basecamp messages create SUB basecamp messages list +SUB basecamp messages move SUB basecamp messages pin SUB basecamp messages publish SUB basecamp messages restore @@ -19994,8 +21076,10 @@ SUB basecamp messagetypes update SUB basecamp migrate SUB basecamp msgs SUB basecamp msgs archive +SUB basecamp msgs copy SUB basecamp msgs create SUB basecamp msgs list +SUB basecamp msgs move SUB basecamp msgs pin SUB basecamp msgs publish SUB basecamp msgs restore @@ -20052,6 +21136,9 @@ SUB basecamp project SUB basecamp project create SUB basecamp project delete SUB basecamp project list +SUB basecamp project logo +SUB basecamp project logo remove +SUB basecamp project logo set SUB basecamp project show SUB basecamp project trash SUB basecamp project update @@ -20063,6 +21150,9 @@ SUB basecamp projects SUB basecamp projects create SUB basecamp projects delete SUB basecamp projects list +SUB basecamp projects logo +SUB basecamp projects logo remove +SUB basecamp projects logo set SUB basecamp projects show SUB basecamp projects trash SUB basecamp projects update @@ -20086,9 +21176,11 @@ SUB basecamp reports assigned SUB basecamp reports overdue SUB basecamp reports schedule SUB basecamp schedule +SUB basecamp schedule copy SUB basecamp schedule create SUB basecamp schedule entries SUB basecamp schedule info +SUB basecamp schedule move SUB basecamp schedule settings SUB basecamp schedule show SUB basecamp schedule update @@ -20137,6 +21229,7 @@ SUB basecamp timesheet project SUB basecamp timesheet recording SUB basecamp timesheet report SUB basecamp tlgroup +SUB basecamp tlgroup copy SUB basecamp tlgroup create SUB basecamp tlgroup list SUB basecamp tlgroup move @@ -20145,6 +21238,7 @@ SUB basecamp tlgroup rename SUB basecamp tlgroup show SUB basecamp tlgroup update SUB basecamp tlgroups +SUB basecamp tlgroups copy SUB basecamp tlgroups create SUB basecamp tlgroups list SUB basecamp tlgroups move @@ -20154,6 +21248,7 @@ SUB basecamp tlgroups show SUB basecamp tlgroups update SUB basecamp todolist SUB basecamp todolist archive +SUB basecamp todolist copy SUB basecamp todolist create SUB basecamp todolist list SUB basecamp todolist move @@ -20164,6 +21259,7 @@ SUB basecamp todolist show SUB basecamp todolist trash SUB basecamp todolist update SUB basecamp todolistgroup +SUB basecamp todolistgroup copy SUB basecamp todolistgroup create SUB basecamp todolistgroup list SUB basecamp todolistgroup move @@ -20172,6 +21268,7 @@ SUB basecamp todolistgroup rename SUB basecamp todolistgroup show SUB basecamp todolistgroup update SUB basecamp todolistgroups +SUB basecamp todolistgroups copy SUB basecamp todolistgroups create SUB basecamp todolistgroups list SUB basecamp todolistgroups move @@ -20181,6 +21278,7 @@ SUB basecamp todolistgroups show SUB basecamp todolistgroups update SUB basecamp todolists SUB basecamp todolists archive +SUB basecamp todolists copy SUB basecamp todolists create SUB basecamp todolists list SUB basecamp todolists move @@ -20193,6 +21291,7 @@ SUB basecamp todolists update SUB basecamp todos SUB basecamp todos archive SUB basecamp todos complete +SUB basecamp todos copy SUB basecamp todos create SUB basecamp todos list SUB basecamp todos move @@ -20225,13 +21324,16 @@ SUB basecamp unspotlight SUB basecamp upgrade SUB basecamp upload SUB basecamp uploads +SUB basecamp uploads copy SUB basecamp uploads create SUB basecamp uploads list +SUB basecamp uploads move SUB basecamp uploads show SUB basecamp url SUB basecamp url parse SUB basecamp vault SUB basecamp vault archive +SUB basecamp vault copy SUB basecamp vault doc SUB basecamp vault doc create SUB basecamp vault doc list @@ -20271,6 +21373,7 @@ SUB basecamp vault vaults list SUB basecamp vault versions SUB basecamp vaults SUB basecamp vaults archive +SUB basecamp vaults copy SUB basecamp vaults doc SUB basecamp vaults doc create SUB basecamp vaults doc list diff --git a/.surface-breaking b/.surface-breaking index 9018a159c..4ca0e78c5 100644 --- a/.surface-breaking +++ b/.surface-breaking @@ -19,6 +19,9 @@ ARG basecamp message 01 [body] ARG basecamp react 00 <content> ARG basecamp reopen 00 <id|url>... ARG basecamp todo 00 <content> +ARG basecamp todos move 00 <id|url> +ARG basecamp todos position 00 <id|url> +ARG basecamp todos reorder 00 <id|url> ARG basecamp unassign 00 <id> ARG basecamp unassign 00 <todo_id> ARG basecamp upload archive 00 <id|url> @@ -1502,3 +1505,11 @@ FLAG basecamp checkin question notify --digest-include-unanswered type=bool FLAG basecamp checkin question notify --no-digest-include-unanswered type=bool FLAG basecamp checkins question notify --digest-include-unanswered type=bool FLAG basecamp checkins question notify --no-digest-include-unanswered type=bool +ARG basecamp docs move 00 <id|url> +ARG basecamp documents move 00 <id|url> +ARG basecamp file move 00 <id|url> +ARG basecamp files move 00 <id|url> +ARG basecamp folders move 00 <id|url> +ARG basecamp vault move 00 <id|url> +ARG basecamp vaults move 00 <id|url> +ARG basecamp tools move 00 <id> diff --git a/API-COVERAGE.md b/API-COVERAGE.md index bb0d99b41..c07aadd28 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -6,12 +6,23 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. | Status | Sections | Endpoints | |--------|----------|-----------| -| ✅ Implemented | 55 | 213 | +| ✅ Implemented | 57 | 218 | | ⚠️ Blocked | 0 | 0 | | ⏭️ Out of scope | 4 | 12 | -| **Total tracked** | **59** | **225** | - -**213 of 213 tracked in-scope endpoints.** Uploading and removing a project's +| **Total tracked** | **61** | **230** | + +**218 of 218 tracked in-scope endpoints.** Uploading and removing a project's +logo (`PUT`/`DELETE /projects/:id/logo.json`, added by +[bc3#14174](https://github.com/basecamp/bc3/pull/14174) and shipped in +[SDK v0.30.0](https://github.com/basecamp/basecamp-sdk/releases/tag/v0.30.0)) +land as `projects logo`. Moving and copying a recording +into another project (`POST /recordings/:id/moves.json`, +`POST /recordings/:id/copies.json`) and reading the background filing they +start (`GET /filings/:id.json`, also read on its own by `filings show`), modelled by +[basecamp/basecamp-sdk#1053](https://github.com/basecamp/basecamp-sdk/pull/1053), +land for cards as `cards move --to-project`/`--to-card-table` and `cards copy`, and for +to-dos, to-do lists and groups as `todos move|copy`, `todolists move|copy` and +`todolistgroups move|copy` with `--to-project`/`--to-list`/`--to-todoset`. Uploading and removing a project's wallpaper (`PUT`/`DELETE /projects/:id/wallpaper.json`, added by [bc3#14062](https://github.com/basecamp/bc3/pull/14062) and shipped in [SDK v0.28.0](https://github.com/basecamp/basecamp-sdk/releases/tag/v0.28.0)) @@ -205,18 +216,19 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A |---------|-----------|-------------|--------|-------|----------|-------| | **Core** | | projects | 9 | `projects` | ✅ | BC4 | - | list, show, create, update, delete | +| projects (logo) | 2 | `projects logo` | ✅ | BC5 | - | set (`PUT /projects/:id/logo.json`, multipart `logo`), remove (`DELETE` on the same route; 204 with or without a logo). PNG, JPEG, GIF, WebP, AVIF, HEIF or HEIC up to 5 MB, checked before the upload using a local MIME hint; the server applies its shared image validations. People and agents who can edit the project may change it; others get 403. Project JSON carries `logo {url}` when one is attached, so `projects show` shows it. `GET /projects/:id/logo.json` only redirects to the image `logo.url` already names, so the CLI does not wrap it. Project-scoped via `--in`/`--project` | | projects (wallpaper) | 2 | `projects wallpaper` | ✅ | BC5 | - | set (`PUT /projects/:id/wallpaper.json`, multipart `wallpaper` with an optional `mode`), remove (`DELETE` on the same route; 204 with or without a wallpaper). `mode` sends `wallpaper_mode` through the existing `PUT /projects/:id.json`, with the current name the SDK requires. PNG, JPEG, GIF, WebP, AVIF, HEIF or HEIC up to 10 MB, checked before the upload using a local MIME hint; the server applies its shared image validations. People and personal assistants follow project-edit permissions; shared agents are refused with 403. Project JSON carries `wallpaper {url, mode}` when one is attached, so `projects show` shows it. Project-scoped via `--in`/`--project` | -| 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 | +| todos | 12 | `todos`, `todo`, `done`, `reopen` | ✅ | BC4 | - | list, show, create, update, complete, uncomplete, position, copy (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 | +| todolists | 9 | `todolists` | ✅ | BC4 | - | list, show, create, update, position, copy | | todosets | 3 | `todosets` | ✅ | BC4 | - | Container for todolists, accessed via project dock (BC5: `todos_count`, `completed_loose_todos_count`, `todos_url`, `app_todos_url`) | -| todolist_groups | 8 | `todolistgroups` | ✅ | BC4 | - | list, show, create, update, position | -| dock_tools | 7 | `tools` | ✅ | BC4 | - | Dock tool management: show, update, trash, enable, disable, reposition. `create` is BC5-only (create-by-type: `POST /buckets/{id}/dock/tools.json`), replacing the removed clone call; create-time `visible_to_clients` behind `tools create --visible-to-clients` (chat/kanban only) | +| todolist_groups | 8 | `todolistgroups` | ✅ | BC4 | - | list, show, create, update, position, copy | +| dock_tools | 7 | `tools` | ✅ | BC4 | - | Dock tool management: show, update, trash, enable, disable, reposition (also `tools move --position`; `tools move --to-project` moves a tool to another project through recordings (moves, copies)). `create` is BC5-only (create-by-type: `POST /buckets/{id}/dock/tools.json`), replacing the removed clone call; create-time `visible_to_clients` behind `tools create --visible-to-clients` (chat/kanban only) | | **Hill Charts** | | hill_charts | 2 | `hillcharts` | ✅ | BC4 | - | show, track/untrack todolists | | gauges | 7 | `gauges` | ✅ | BC4 | - | list, needles, needle, create, update, delete, enable/disable | | **Communication** | -| messages | 10 | `messages`, `message` | ✅ | BC4 | - | list, show, create, update, publish, pin, unpin. Create supports `--subscribe`/`--no-subscribe` and `--draft`. Publish promotes drafts to active | +| messages | 10 | `messages`, `message` | ✅ | BC4 | - | list, show, create, update, publish, pin, unpin. Create supports `--subscribe`/`--no-subscribe` and `--draft`. Publish promotes drafts to active. `move`/`copy` file a message onto a message board in another project (see recordings (moves, copies)) | | message_boards | 3 | `messageboards` | ✅ | BC4 | - | Container, accessed via project dock | | message_types | 5 | `messagetypes` | ✅ | BC4 | - | list, show, create, update, delete. Bucket-scoped (`/buckets/{id}/categories…`); commands are project-scoped via `--in`/`--project` | | campfires | 14 | `chat` | ✅ | BC4 | - | list, messages, post, line show/update/delete. @mentions in content | @@ -231,7 +243,8 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A | 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. 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) | +| 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 and reports the new card once it lands | +| recordings (moves, copies) | 3 | `cards move --to-project`, `cards copy`, `todos move --to-project`/`--to-list`, `todos copy`, `todolists move --to-project`, `todolists copy`, `todolistgroups move --to-list`, `todolistgroups copy`, `messages move`, `messages copy`, `files move --to-project`, `files copy`, `cards column move --to-project`, `cards column copy`, `schedule move`, `schedule copy`, `checkins question move`, `checkins question copy`, `forwards move`, `forwards copy`, `tools move --to-project`, `filings show` | ✅ | BC5 | - | `POST /recordings/:id/moves.json` and `POST /recordings/:id/copies.json` file a card into a card table in this or another project, landing in Triage (Done if completed); `GET /filings/:id.json` reports the background filing, which the commands wait on before showing the new card, and which `filings show <id>` reads when a command's wait ends first (status, type, source, and once completed the recording it made). `--to-project` picks the project's only card table on its dock, `--to-card-table` one by ID, URL or name. `cards copy --include-comments` sends `include_comments`; a copy is never retried. To-dos file into a to-do list in any project (several IDs file one by one, and any failure fails the command naming what moved); a to-do list files into a project's To-dos (`--to-todoset` when it has several); a group files into a to-do list and stays a group. `--to-list`/`--to-todoset` take an ID, URL or a name looked up in `--to-project`; Basecamp refuses a group or an archived list as the destination. `messages move`/`copy` file a message onto a message board (`--to-project`, `--to-board`); a copy with no destination goes onto the message's own board. `files move --to-project`/`--to-folder` and `files copy` (also on `uploads`, `docs`, `vaults` and their aliases) file documents, uploads, cloud files, Google documents and folders into a project's Docs & Files, a folder inside it as `parent_id`; several IDs are filed one by one, as the web's bulk Move/Copy does, and `files show` reads the cloud files and Google documents they make. `cards column move --to-project`/`--to-card-table` and `cards column copy` file a column with its cards onto a card table; Triage, Not now, Done and On hold are refused before the request. `schedule move`/`copy` file an event onto a schedule (`--to-project`, `--to-schedule`); a recurring event, whose read answers with an occurrence, files as its whole series. `checkins question move`/`copy` file a question, with its answers on a move, into a project's Automatic Check-ins (`--to-project`, `--to-questionnaire`). `forwards move`/`copy` file an email forward into a project's Email Forwards (`--to-project`, `--to-inbox`), which must be turned on there; a turned-off one is named with the `tools enable` command. `tools move --to-project` moves a whole dock tool to another project with `destination_project_id`; tools are not copied, as on the web, and doors are refused | | **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 | | drafts | 1 | `drafts` | ✅ | BC5 | - | list unpublished drafts across projects (server caps at 250). Bounded like the account-wide listings; publishing happens through the command for the draft's type | @@ -245,7 +258,7 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A | **Files & Documents** | | uploads | 8 | `files`, `uploads` | ✅ | BC4 | - | list, show, create, update, download, versions (`files versions <id>`), replace (`files replace <id> <file>`); trash/archive/restore go through `recordings`. Create supports `--visible-to-clients` (root vault only) | | vaults | 8 | `files`, `vaults` | ✅ | BC4 | - | list, show, create | -| vaults (move) | 1 | `files move` | ✅ | BC5 | - | `POST /recordings/:id/filing.json`: moves a document, upload or folder into another folder in the same project, in place (same id and comments). `--to` takes a folder ID or URL; `--position` is 1-indexed. Flat route, so no `--in` | +| vaults (move) | 1 | `files move` | ✅ | BC5 | - | `POST /recordings/:id/filing.json`: moves a document, upload or folder into another folder in the same project, in place (same id and comments). `--to` takes a folder ID or URL; `--position` is 1-indexed. Flat route, so no `--in`. Moves and copies into another project go through recordings (moves, copies) with `--to-project`/`--to-folder` | | documents | 8 | `files`, `docs` | ✅ | BC4 | - | list, show, create, update. Create supports `--subscribe`/`--no-subscribe`, `--visible-to-clients` (root vault only) | | attachments | 1 | `uploads`, `attachments` | ✅ | BC4 | - | Upload via `attach`; list embedded attachments via `attachments list` (parses `<bc-attachment>` from content) | | embeds | 2 | `embeds` | ✅ | BC5 | - | create, show. Rich-text commands also embed a URL that stands alone on its line in Markdown content, through the same create | diff --git a/e2e/cards_columns_steps.bats b/e2e/cards_columns_steps.bats index 7611b2a4b..ad68f44a8 100644 --- a/e2e/cards_columns_steps.bats +++ b/e2e/cards_columns_steps.bats @@ -67,15 +67,6 @@ load test_helper assert_output_contains "ID required" } -@test "cards column show without project shows error" { - create_credentials - create_global_config '{"account_id": 99999}' - - run basecamp cards column show 456 - assert_failure - assert_output_contains "project" -} - # Column create errors diff --git a/e2e/errors.bats b/e2e/errors.bats index 7142751c5..6f24a8669 100644 --- a/e2e/errors.bats +++ b/e2e/errors.bats @@ -335,7 +335,7 @@ load test_helper run basecamp todos position --to 1 assert_failure - assert_json_value '.error' '<id|url> required' + assert_json_value '.error' '<id|url>... required' assert_json_value '.code' 'usage' } diff --git a/e2e/projects.bats b/e2e/projects.bats index 973aeba72..7b98e9954 100644 --- a/e2e/projects.bats +++ b/e2e/projects.bats @@ -91,3 +91,57 @@ load test_helper run basecamp projects wallpaper remove skyline.png assert_failure } + + +@test "projects logo without subcommand shows help" { + run basecamp projects logo + assert_success + assert_output_contains "COMMANDS" + assert_output_contains "set" + assert_output_contains "remove" +} + +@test "projects logo set help names the formats and the size cap" { + run basecamp projects logo set --help + assert_success + assert_output_contains "PNG, JPEG, GIF, WebP, AVIF, HEIF, or HEIC files up to 5 MB" +} + +@test "projects logo set without a file shows error" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp projects logo set + assert_failure + assert_json_value '.error' '<file> required' + assert_json_value '.code' 'usage' +} + +@test "projects logo set refuses an SVG" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + printf '<svg></svg>' > brand.svg + + run basecamp projects logo set brand.svg + assert_failure + assert_json_value '.error' 'brand.svg: unsupported image type image/svg+xml (use PNG, JPEG, GIF, WebP, AVIF, HEIF, or HEIC)' + assert_json_value '.code' 'usage' +} + +@test "projects logo set refuses a file over 5 MB" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + dd if=/dev/zero of=big.png bs=1 count=1 seek=5242880 2>/dev/null + + run basecamp projects logo set big.png + assert_failure + assert_json_value '.error' 'big.png exceeds maximum logo size of 5 MB' +} + +@test "projects logo remove takes no arguments" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp projects logo remove brand.png + assert_failure +} diff --git a/e2e/smoke/smoke_aliases.bats b/e2e/smoke/smoke_aliases.bats index d60eea23b..84ef4f15f 100644 --- a/e2e/smoke/smoke_aliases.bats +++ b/e2e/smoke/smoke_aliases.bats @@ -265,10 +265,6 @@ load smoke_helper mark_out_of_scope "Alias for tools trash — tested via canonical form" } -@test "tools move is out of scope" { - mark_out_of_scope "Alias for tools reposition — tested via canonical form" -} - @test "tools rename is out of scope" { mark_out_of_scope "Alias for tools update — tested via canonical form" } diff --git a/e2e/smoke/smoke_cards_column_write.bats b/e2e/smoke/smoke_cards_column_write.bats index e9f924080..1d35cde27 100644 --- a/e2e/smoke/smoke_cards_column_write.bats +++ b/e2e/smoke/smoke_cards_column_write.bats @@ -69,6 +69,30 @@ setup_file() { assert_json_value '.ok' 'true' } +@test "cards column copy copies a column onto its own card table" { + local id_file="$BATS_FILE_TMPDIR/column_id" + [[ -f "$id_file" ]] || mark_unverifiable "No column created in prior test" + local col_id + col_id=$(<"$id_file") + + run_smoke basecamp cards column copy "$col_id" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.source_id' "$col_id" + assert_json_not_null '.data.id' +} + +@test "cards column move refuses the card table the column is on" { + local id_file="$BATS_FILE_TMPDIR/column_id" + [[ -f "$id_file" ]] || mark_unverifiable "No column created in prior test" + local col_id + col_id=$(<"$id_file") + + run_smoke basecamp cards column move "$col_id" --to-card-table "$QA_CARDTABLE" --json + assert_failure + assert_json_value '.ok' 'false' +} + @test "cards column watch watches a column" { local id_file="$BATS_FILE_TMPDIR/column_id" [[ -f "$id_file" ]] || mark_unverifiable "No column created in prior test" diff --git a/e2e/smoke/smoke_cards_write.bats b/e2e/smoke/smoke_cards_write.bats index 69cd87f11..1092f2ad7 100644 --- a/e2e/smoke/smoke_cards_write.bats +++ b/e2e/smoke/smoke_cards_write.bats @@ -45,6 +45,19 @@ setup_file() { assert_json_not_null '.data.id' } +@test "cards copy copies a card onto its own card table" { + local id_file="$BATS_FILE_TMPDIR/card_id" + [[ -f "$id_file" ]] || mark_unverifiable "No card created in prior test" + local card_id + card_id=$(<"$id_file") + + run_smoke basecamp cards copy "$card_id" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.source_id' "$card_id" + assert_json_not_null '.data.id' +} + @test "cards columns lists columns" { run_smoke basecamp cards columns --card-table "$QA_CARDTABLE" -p "$QA_PROJECT" --json assert_success diff --git a/e2e/smoke/smoke_checkins_write.bats b/e2e/smoke/smoke_checkins_write.bats index 1dac6ecd2..20de92e87 100644 --- a/e2e/smoke/smoke_checkins_write.bats +++ b/e2e/smoke/smoke_checkins_write.bats @@ -57,3 +57,29 @@ setup_file() { assert_success assert_json_value '.ok' 'true' } + +@test "checkins question copy copies a question into its own check-ins" { + local id_file="$BATS_FILE_TMPDIR/question_id" + [[ -f "$id_file" ]] || mark_unverifiable "No question created in prior test" + local qid + qid=$(<"$id_file") + + run_smoke basecamp checkins question copy "$qid" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.source_id' "$qid" + assert_json_not_null '.data.id' +} + +@test "checkins question move refuses the check-ins the question is in" { + local id_file="$BATS_FILE_TMPDIR/question_id" + [[ -f "$id_file" ]] || mark_unverifiable "No question created in prior test" + local qid + qid=$(<"$id_file") + + # A cross-project move would take the QA question away; the move itself + # is covered against local bc3. + run_smoke basecamp checkins question move "$qid" --to-project "$QA_PROJECT" --json + assert_failure + assert_json_value '.ok' 'false' +} diff --git a/e2e/smoke/smoke_communication_write.bats b/e2e/smoke/smoke_communication_write.bats index e813102b0..3ebd520ba 100644 --- a/e2e/smoke/smoke_communication_write.bats +++ b/e2e/smoke/smoke_communication_write.bats @@ -84,3 +84,33 @@ setup_file() { assert_success assert_json_value '.ok' 'true' } + +# --- Email forwards --- + +@test "forwards copy copies a forward into its own inbox" { + ensure_inbox || return 0 + run basecamp forwards list --inbox "$QA_INBOX" -p "$QA_PROJECT" --jq '.data[0].id // empty' + local fwd_id="$output" + [[ -n "$fwd_id" ]] || mark_unverifiable "No forwards in project inbox" + + run_smoke basecamp forwards copy "$fwd_id" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.source_id' "$fwd_id" + + run_smoke basecamp recordings trash "$(echo "$output" | jq -r '.data.id')" -p "$QA_PROJECT" --json + assert_success +} + +@test "forwards move refuses the inbox the forward is in" { + ensure_inbox || return 0 + run basecamp forwards list --inbox "$QA_INBOX" -p "$QA_PROJECT" --jq '.data[0].id // empty' + local fwd_id="$output" + [[ -n "$fwd_id" ]] || mark_unverifiable "No forwards in project inbox" + + # A cross-project move would take the QA forward away; the move itself is + # covered against local bc3. + run_smoke basecamp forwards move "$fwd_id" --to-inbox "$QA_INBOX" --json + assert_failure + assert_json_value '.ok' 'false' +} diff --git a/e2e/smoke/smoke_files_write.bats b/e2e/smoke/smoke_files_write.bats index c68de9dbd..ac15d72a3 100644 --- a/e2e/smoke/smoke_files_write.bats +++ b/e2e/smoke/smoke_files_write.bats @@ -154,6 +154,56 @@ setup_file() { assert_json_value '.data.folder_id' "$folder_id" } +@test "files copy copies a file into its own folder" { + local tmpfile="$BATS_FILE_TMPDIR/smoke_files_copy.txt" + echo "smoke files copy content $(date +%s)" > "$tmpfile" + + run_smoke basecamp files uploads create "$tmpfile" -p "$QA_PROJECT" --json + assert_success + local file_id + file_id=$(echo "$output" | jq -r '.data.id') + + run_smoke basecamp files copy "$file_id" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.source_id' "$file_id" + assert_json_not_null '.data.id' +} + +@test "uploads copy copies an upload into its own folder" { + local tmpfile="$BATS_FILE_TMPDIR/smoke_uploads_copy.txt" + echo "smoke uploads copy content $(date +%s)" > "$tmpfile" + + run_smoke basecamp uploads create "$tmpfile" -p "$QA_PROJECT" --json + assert_success + local file_id + file_id=$(echo "$output" | jq -r '.data.id') + + run_smoke basecamp uploads copy "$file_id" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.source_id' "$file_id" +} + +@test "uploads move moves an upload into a folder" { + local tmpfile="$BATS_FILE_TMPDIR/smoke_uploads_move.txt" + echo "smoke uploads move content $(date +%s)" > "$tmpfile" + + run_smoke basecamp files folders create "Smoke uploads move target $(date +%s)" -p "$QA_PROJECT" --json + assert_success + local folder_id + folder_id=$(echo "$output" | jq -r '.data.id') + + run_smoke basecamp uploads create "$tmpfile" -p "$QA_PROJECT" --json + assert_success + local file_id + file_id=$(echo "$output" | jq -r '.data.id') + + run_smoke basecamp uploads move "$file_id" --to "$folder_id" --json + assert_success + assert_json_value '.data.id' "$file_id" +} + @test "files archive archives a file" { local id_file="$BATS_FILE_TMPDIR/upload_id" [[ -f "$id_file" ]] || mark_unverifiable "No upload created in prior test" diff --git a/e2e/smoke/smoke_filings.bats b/e2e/smoke/smoke_filings.bats new file mode 100644 index 000000000..202aed9de --- /dev/null +++ b/e2e/smoke/smoke_filings.bats @@ -0,0 +1,15 @@ +#!/usr/bin/env bats +# smoke_filings.bats - Level 0: Reading a move's or copy's filing + +load smoke_helper + +setup_file() { + ensure_token || return 1 +} + +@test "filings show reports a filing it cannot read as not found" { + run_smoke basecamp filings show 999999999999 --json + assert_failure + assert_json_value '.code' 'not_found' + assert_output_contains "Only the person who started a move or copy" +} diff --git a/e2e/smoke/smoke_lifecycle.bats b/e2e/smoke/smoke_lifecycle.bats index 6ea591f4d..c425a190c 100644 --- a/e2e/smoke/smoke_lifecycle.bats +++ b/e2e/smoke/smoke_lifecycle.bats @@ -235,6 +235,10 @@ load smoke_helper mark_out_of_scope "Shares implementation with files group (tested)" } +@test "docs copy is out of scope" { + mark_out_of_scope "Shares implementation with files group (tested)" +} + @test "docs versions is out of scope" { mark_out_of_scope "Shares implementation with files group (tested)" } @@ -297,6 +301,10 @@ load smoke_helper mark_out_of_scope "Shares implementation with files group (tested)" } +@test "vaults copy is out of scope" { + mark_out_of_scope "Shares implementation with files group (tested)" +} + @test "vaults versions is out of scope" { mark_out_of_scope "Shares implementation with files group (tested)" } diff --git a/e2e/smoke/smoke_messages_write.bats b/e2e/smoke/smoke_messages_write.bats index 06daa473b..c25414c73 100644 --- a/e2e/smoke/smoke_messages_write.bats +++ b/e2e/smoke/smoke_messages_write.bats @@ -60,6 +60,35 @@ setup_file() { assert_json_value '.ok' 'true' } +@test "messages copy copies a message onto its own board" { + local id_file="$BATS_FILE_TMPDIR/message_id" + [[ -f "$id_file" ]] || mark_unverifiable "No message created in prior test" + local msg_id + msg_id=$(<"$id_file") + + run_smoke basecamp messages copy "$msg_id" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.source_id' "$msg_id" + assert_json_not_null '.data.id' + + run_smoke basecamp messages trash "$(echo "$output" | jq -r '.data.id')" -p "$QA_PROJECT" --json + assert_success +} + +@test "messages move refuses the board the message is already on" { + local id_file="$BATS_FILE_TMPDIR/message_id" + [[ -f "$id_file" ]] || mark_unverifiable "No message created in prior test" + local msg_id + msg_id=$(<"$id_file") + + # A move across projects would take the QA message away, so this only + # checks the refusal; the cross-project move is covered against local bc3. + run_smoke basecamp messages move "$msg_id" --to-board "$QA_MESSAGEBOARD" --json + assert_failure + assert_json_value '.ok' 'false' +} + @test "messages pin pins a message" { local id_file="$BATS_FILE_TMPDIR/message_id" [[ -f "$id_file" ]] || mark_unverifiable "No message created in prior test" diff --git a/e2e/smoke/smoke_projects_write.bats b/e2e/smoke/smoke_projects_write.bats index c9a9fe784..421e75bdc 100644 --- a/e2e/smoke/smoke_projects_write.bats +++ b/e2e/smoke/smoke_projects_write.bats @@ -46,6 +46,34 @@ setup_file() { touch "$BATS_FILE_TMPDIR/wallpaper_set" } +@test "projects logo set uploads a logo without changing wallpaper" { + [[ -f "$BATS_FILE_TMPDIR/wallpaper_set" ]] || mark_unverifiable "No wallpaper set in prior test" + local id_file="$BATS_FILE_TMPDIR/created_project_id" + [[ -f "$id_file" ]] || mark_unverifiable "No project created in prior test" + local proj_id + proj_id=$(<"$id_file") + local png="$BATS_FILE_TMPDIR/logo.png" + printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' | + base64 -d > "$png" + + run_smoke basecamp projects logo set "$png" --project "$proj_id" --json + assert_success + assert_json_not_null '.data.logo.url' + assert_json_value '.data.wallpaper.mode' 'tile' + touch "$BATS_FILE_TMPDIR/logo_set" +} + +@test "projects logo remove clears only the logo" { + [[ -f "$BATS_FILE_TMPDIR/logo_set" ]] || mark_unverifiable "No logo set in prior test" + local proj_id + proj_id=$(<"$BATS_FILE_TMPDIR/created_project_id") + + run_smoke basecamp projects logo remove --project "$proj_id" --json + assert_success + assert_json_value '.data.logo' 'null' + assert_json_value '.data.wallpaper.mode' 'tile' +} + @test "projects wallpaper mode changes the layout" { [[ -f "$BATS_FILE_TMPDIR/wallpaper_set" ]] || mark_unverifiable "No wallpaper set in prior test" local proj_id diff --git a/e2e/smoke/smoke_schedule_write.bats b/e2e/smoke/smoke_schedule_write.bats index 6aa0d0bc2..e67eb506e 100644 --- a/e2e/smoke/smoke_schedule_write.bats +++ b/e2e/smoke/smoke_schedule_write.bats @@ -34,3 +34,29 @@ setup_file() { assert_success assert_json_value '.ok' 'true' } + +@test "schedule copy copies a schedule entry onto its own schedule" { + local id_file="$BATS_FILE_TMPDIR/entry_id" + [[ -f "$id_file" ]] || mark_unverifiable "No schedule entry created in prior test" + local eid + eid=$(<"$id_file") + + run_smoke basecamp schedule copy "$eid" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.source_id' "$eid" + assert_json_not_null '.data.id' +} + +@test "schedule move refuses the schedule the entry is already on" { + local id_file="$BATS_FILE_TMPDIR/entry_id" + [[ -f "$id_file" ]] || mark_unverifiable "No schedule entry created in prior test" + local eid + eid=$(<"$id_file") + + # A cross-project move would take the QA entry away; the move itself is + # covered against local bc3. + run_smoke basecamp schedule move "$eid" --to-schedule "$QA_SCHEDULE" --json + assert_failure + assert_json_value '.ok' 'false' +} diff --git a/e2e/smoke/smoke_todolistgroups_write.bats b/e2e/smoke/smoke_todolistgroups_write.bats index 9c00cfa00..f2c97dbc8 100644 --- a/e2e/smoke/smoke_todolistgroups_write.bats +++ b/e2e/smoke/smoke_todolistgroups_write.bats @@ -41,3 +41,16 @@ setup_file() { assert_success assert_json_value '.ok' 'true' } + +@test "todolistgroups copy copies a group into its own list" { + local id_file="$BATS_FILE_TMPDIR/group_id" + [[ -f "$id_file" ]] || mark_unverifiable "No group created in prior test" + local gid + gid=$(<"$id_file") + + run_smoke basecamp todolistgroups copy "$gid" -p "$QA_PROJECT" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.source_id' "$gid" + assert_json_not_null '.data.id' +} diff --git a/e2e/smoke/smoke_todos_write.bats b/e2e/smoke/smoke_todos_write.bats index 750980630..972b4df9a 100644 --- a/e2e/smoke/smoke_todos_write.bats +++ b/e2e/smoke/smoke_todos_write.bats @@ -52,6 +52,23 @@ setup_file() { assert_json_value '.ok' 'true' } +@test "todos copy copies a todo into its own list" { + local id_file="$BATS_FILE_TMPDIR/direct_todo_id" + [[ -f "$id_file" ]] || mark_unverifiable "No direct todo created in prior test" + local todo_id + todo_id=$(<"$id_file") + + run_smoke basecamp todos copy "$todo_id" -p "$QA_PROJECT" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.source_id' "$todo_id" + assert_json_not_null '.data.id' + + local copy_id + copy_id=$(echo "$output" | jq -r '.data.id') + basecamp todos trash "$copy_id" -p "$QA_PROJECT" --json >/dev/null 2>&1 || true +} + @test "todos update updates a todo" { local id_file="$BATS_FILE_TMPDIR/direct_todo_id" [[ -f "$id_file" ]] || mark_unverifiable "No direct todo created in prior test" @@ -249,6 +266,23 @@ setup_file() { assert_json_value '.ok' 'true' } +@test "todolists copy copies a todolist into its own To-dos" { + local id_file="$BATS_FILE_TMPDIR/todolist_target_id" + [[ -f "$id_file" ]] || mark_unverifiable "No todolist created in prior test" + local tl_id + tl_id=$(<"$id_file") + + run_smoke basecamp todolists copy "$tl_id" -p "$QA_PROJECT" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.source_id' "$tl_id" + assert_json_not_null '.data.id' + + local copy_id + copy_id=$(echo "$output" | jq -r '.data.id') + basecamp todolists trash "$copy_id" -p "$QA_PROJECT" --json >/dev/null 2>&1 || true +} + @test "todolists archive archives a todolist" { local id_file="$BATS_FILE_TMPDIR/todolist_target_id" [[ -f "$id_file" ]] || mark_unverifiable "No todolist created in prior test" diff --git a/e2e/smoke/smoke_tools.bats b/e2e/smoke/smoke_tools.bats index 70cd51b16..201882b78 100644 --- a/e2e/smoke/smoke_tools.bats +++ b/e2e/smoke/smoke_tools.bats @@ -49,6 +49,30 @@ setup_file() { assert_json_value '.ok' 'true' } +@test "tools move repositions a tool in its dock" { + local id_file="$BATS_FILE_TMPDIR/tool_id" + [[ -f "$id_file" ]] || mark_unverifiable "No tool created in prior test" + local tool_id + tool_id=$(<"$id_file") + + run_smoke basecamp tools move "$tool_id" --position 1 -p "$QA_PROJECT" --json + assert_success + assert_json_value '.ok' 'true' +} + +@test "tools move refuses moving a tool to the project it is in" { + local id_file="$BATS_FILE_TMPDIR/tool_id" + [[ -f "$id_file" ]] || mark_unverifiable "No tool created in prior test" + local tool_id + tool_id=$(<"$id_file") + + # A cross-project move would take the tool out of the QA project; the move + # itself is covered against local bc3. + run_smoke basecamp tools move "$tool_id" --to-project "$QA_PROJECT" --json + assert_failure + assert_json_value '.ok' 'false' +} + @test "tools disable disables a tool" { local id_file="$BATS_FILE_TMPDIR/tool_id" [[ -f "$id_file" ]] || mark_unverifiable "No tool created in prior test" diff --git a/go.mod b/go.mod index a66172eca..99384080d 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.29.1 + github.com/basecamp/basecamp-sdk/go v0.31.0 github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9 github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d github.com/basecamp/surfguard/go v0.1.0 diff --git a/go.sum b/go.sum index 7aa92ed2c..6bf1e7cda 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg= github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/basecamp-sdk/go v0.29.1 h1:zYEtED8WnEKcfRXKujKHqYqbFc3h2DbE9Z+scHuH1J8= -github.com/basecamp/basecamp-sdk/go v0.29.1/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU= +github.com/basecamp/basecamp-sdk/go v0.31.0 h1:+gAcD0hMiEt4VWP4VOSOr/U3crjVa6dfT/NjmPN2JDo= +github.com/basecamp/basecamp-sdk/go v0.31.0/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU= github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9 h1:3NfGs+zx23Tp6cf+97+UewrJHlzZMLzmvEXOmOJyQAk= github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE= github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4= diff --git a/internal/appctx/context.go b/internal/appctx/context.go index 2faa4b86a..b367fc814 100644 --- a/internal/appctx/context.go +++ b/internal/appctx/context.go @@ -283,7 +283,7 @@ func (a *App) Err(err error) error { } // Print the error response - if outputErr := a.Output.Err(a.withAuthRemedy(err), opts...); outputErr != nil { + if outputErr := a.Output.Err(a.WithAuthRemedy(err), opts...); outputErr != nil { return outputErr } @@ -295,7 +295,7 @@ func (a *App) Err(err error) error { return nil } -// withAuthRemedy replaces the generic login hint on an API 401 with one that +// WithAuthRemedy replaces the generic login hint on an API 401 with one that // fits the credential the request actually sent. The SDK classifies a 401 // far from the profile and the environment, so its conversion can only say // "basecamp auth login": under an active profile that command would store @@ -305,7 +305,7 @@ func (a *App) Err(err error) error { // the credential manager's own failures name the profile themselves, and // come from stored-credential operations (auth refresh, auth token // --stored) that ignore the environment token by design. -func (a *App) withAuthRemedy(err error) error { +func (a *App) WithAuthRemedy(err error) error { var sdkErr *basecamp.Error e := output.AsError(err) if e.Code != output.CodeAuth || !errors.As(err, &sdkErr) || (e.Hint != "" && !strings.HasPrefix(e.Hint, output.DefaultAuthHint)) { diff --git a/internal/cli/root.go b/internal/cli/root.go index 218ce71b7..566f604f4 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -326,6 +326,7 @@ func Execute() { cmd.AddCommand(commands.NewURLCmd()) cmd.AddCommand(commands.NewSearchCmd()) cmd.AddCommand(commands.NewRecordingsCmd()) + cmd.AddCommand(commands.NewFilingsCmd()) cmd.AddCommand(commands.NewChatCmd()) cmd.AddCommand(commands.NewPingsCmd()) cmd.AddCommand(commands.NewScheduleCmd()) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index 14f3ee93f..d2dec904c 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "net/http" "strconv" "strings" @@ -42,6 +43,7 @@ func NewCardsCmd() *cobra.Command { newCardsCreateCmd(&project, &cardTable), newCardsUpdateCmd(), newCardsMoveCmd(&project, &cardTable), + newCardsCopyCmd(&project), newCardsDoneCmd(&project, &cardTable), newCardsColumnsCmd(&project, &cardTable), newCardsColumnCmd(&project, &cardTable), @@ -810,6 +812,12 @@ You can pass either a card ID or a Basecamp URL: if err != nil { return convertSDKError(err) } + // A moved card's old id redirects to the card at its new id, and the + // old id 404s for everything after this read. + if card.ID != 0 && card.ID != cardID { + cardID = card.ID + cardIDStr = strconv.FormatInt(cardID, 10) + } if card.Steps, err = everySubtask(cmd.Context(), app, cardID, card.Steps, card.SubtasksCount); err != nil { return err } @@ -1308,11 +1316,13 @@ func newCardsMoveCmd(project, cardTable *string) *cobra.Command { var position int var onHold bool var toWormhole string + var toProject, toCardTable string cmd := &cobra.Command{ Use: "move <id|url>", - Short: "Move a card to another column", - Long: `Move a card to another column in its project. + Short: "Move a card to another column, card table or project", + Long: `Move a card to another column in its project, or into a card table in +another project. --to accepts any column in the card's project, including a column on another card table in the same project; the card moves there directly, with no wormhole @@ -1332,10 +1342,20 @@ through a wormhole already on the card's card table. Pass a wormhole ID, or the URL of the destination column it targets: basecamp cards move 789 --to-wormhole 1069480287 --in my-project basecamp cards move 789 --to-wormhole https://3.basecamp.com/123/buckets/456/card_tables/columns/999 -This is asynchronous: a new card appears in the destination project and the -original card 404s once the server finishes filing it away. A wormhole is a -standing shortcut ("cards wormholes"), not a step of each move; a move within -the project doesn't need one.`, +The teleport mints a new card id. The command waits for the card to land and +shows it; if it is still teleporting when the wait runs out, basecamp cards show +<old id> returns the card at its new id once it lands. A wormhole is a standing +shortcut ("cards wormholes"), not a step of each move; a move within the project +doesn't need one. + +--to-project and --to-card-table move a card into any card table, as the web's +Move… does, with no wormhole. The card lands in that table's Triage, or in Done +if it is completed, under a new id; move it on with --to afterward: + basecamp cards move 789 --to-project "Marketing" + basecamp cards move 789 --to-project "Marketing" --to-card-table "Roadmap" + basecamp cards move 789 --to-card-table https://3.basecamp.com/123/buckets/456/card_tables/999 +--to-project alone needs a project with one card table. The command waits for +Basecamp to finish the move and shows the card where it landed.`, Args: cobra.ExactArgs(1), Aliases: []string{"mv"}, Annotations: map[string]string{ @@ -1345,14 +1365,28 @@ the project doesn't need one.`, "When --on-hold is used with --to, the card moves to the on-hold section of the target column. " + "--position cannot be combined with --on-hold. " + "--to-wormhole sends the card to another project's card table through a wormhole already on its card table; it is mutually exclusive with --to/--on-hold/--position. " + - "The teleport is asynchronous and mints a new card id — the original id 404s afterward, so do not reuse it. " + - "Do not create a wormhole just to move a card, and do not delete one afterward.", + "The teleport mints a new card id; the output is the card where it landed (data.id new, data.source_id old). Write to the new id, not the old. " + + "Do not create a wormhole just to move a card, and do not delete one afterward. " + + "--to-project/--to-card-table move the card into another card table (usually another project) with no wormhole, landing in Triage (Done if completed) under a new id; " + + "--to-project alone needs a project with one card table, otherwise add --to-card-table. They are mutually exclusive with --to/--on-hold/--position/--to-wormhole, and --card-table there is refused because it names the card's own table. " + + "An in_progress error names its filing: do not move it again; basecamp filings show <filing id> gives its new id once it lands.", }, RunE: func(cmd *cobra.Command, args []string) error { positionSet := cmd.Flags().Changed("position") || cmd.Flags().Changed("pos") wormholeSet := cmd.Flags().Changed("to-wormhole") + filingSet := cmd.Flags().Changed("to-project") || cmd.Flags().Changed("to-card-table") - if wormholeSet { + if filingSet { + if targetColumn != "" || onHold || positionSet || wormholeSet { + return output.ErrUsage("--to-project and --to-card-table cannot be combined with --to, --on-hold, --position, or --to-wormhole") + } + if err := refuseOwnCardTable(*cardTable, "card"); err != nil { + return err + } + if err := validateFilingFlags(toProject, toCardTable); err != nil { + return err + } + } else if wormholeSet { if targetColumn != "" || onHold || positionSet { return output.ErrUsage("--to-wormhole cannot be combined with --to, --on-hold, or --position") } @@ -1372,11 +1406,20 @@ the project doesn't need one.`, } app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) if err := ensureAccount(cmd, app); err != nil { return err } + if filingSet { + card, err := parseFilingRef(app, args[0], "card", "cards", "…/card_tables/cards/<id>") + if err != nil { + return err + } + return moveCardToCardTable(cmd, app, scope, card.id, *project, card.project, toProject, toCardTable) + } + cardIDStr, urlProjectID, err := extractWithProject(args[0]) if err != nil { return err @@ -1478,6 +1521,13 @@ the project doesn't need one.`, } else { err = app.Account().Cards().Move(cmd.Context(), cardID, columnID, nil) } + if isNumericColumn && httpStatus(err) == http.StatusNotFound { + // Basecamp looks the column up in the card's project only, so a + // column from another project is indistinguishable from a typo. + return output.ErrNotFoundHint("card or column", fmt.Sprintf("card %s, column %s", cardIDStr, targetColumn), fmt.Sprintf( + "--to takes a column in the card's own project. To send the card to another project, use: basecamp cards move %s --to-project <project> (or --to-wormhole)", + cardIDStr)) + } if err != nil { return convertSDKError(err) } @@ -1520,8 +1570,9 @@ the project doesn't need one.`, cmd.Flags().IntVar(&position, "pos", 0, "Position in column (alias for --position)") cmd.Flags().BoolVar(&onHold, "on-hold", false, "Move card to the on-hold section of its current (or target) column") cmd.Flags().StringVar(&toWormhole, "to-wormhole", "", "Send the card to another project's card table through an existing wormhole (wormhole ID or destination-column URL)") + addFilingDestinationFlags(cmd, &toProject, &toCardTable, "Move") - return cmd + return guardFilingFlags(cmd, "card-table") } // moveCardThroughWormhole teleports a card across projects through a wormhole to @@ -1533,15 +1584,15 @@ the project doesn't need one.`, // // The teleport is destructive and asynchronous: Cards().Move returns once it is // accepted, then the server copies the card into the destination column under a -// new id and deletes the original — so the original id 404s once filing -// completes. The move endpoint resolves the column_id against the whole project -// bucket, so a sibling board's wormhole would happily teleport the card -// elsewhere. Because that is irreversible, this fails closed: it always fetches +// new id and deletes the original. Once that is done, reading the original id +// redirects to the new card, which is how the command learns where it landed. +// The move endpoint resolves the column_id against the whole project bucket, so +// a sibling board's wormhole would happily teleport the card elsewhere. Because that is irreversible, this fails closed: it always fetches // the card, derives the source project and table from the card itself, rejects // conflicting caller context, and requires the referenced wormhole to be present -// and linked on that exact table before issuing the move. The output reports the -// source under source_id (not id) and omits a same-id "view card" breadcrumb, -// because that id is about to 404. +// and linked on that exact table before issuing the move. The output is the new +// card; if it has not landed when the wait runs out, the output reports the +// source under source_id (not id) and omits a same-id "view card" breadcrumb. func moveCardThroughWormhole(cmd *cobra.Command, app *appctx.App, cardID int64, cardIDStr, flagProject, urlProjectID, cardTableFlag, wormholeRef string) error { // Validate the reference before any network call. byID := isNumericID(wormholeRef) @@ -1571,7 +1622,7 @@ func moveCardThroughWormhole(cmd *cobra.Command, app *appctx.App, cardID int64, // Reject an explicit project (flag or card URL) that contradicts the card's // own project — the card's bucket is authoritative for a cross-project move. - if err := rejectWormholeProjectConflict(cmd, app, flagProject, urlProjectID, card.Bucket.ID); err != nil { + if err := rejectProjectConflict(cmd, app, "card", flagProject, urlProjectID, card.Bucket.ID); err != nil { return err } @@ -1612,13 +1663,28 @@ func moveCardThroughWormhole(cmd *cobra.Command, app *appctx.App, cardID int64, return convertSDKError(err) } + var landed *basecamp.Card + if moved, _ := pollUntil(cmd.Context(), func(ctx context.Context) (bool, error) { + card, err := app.Account().Cards().Get(ctx, cardID) + if httpStatus(err) == http.StatusNotFound { + return false, nil + } + if err != nil { + return false, err + } + landed = card + return card.ID != cardID, nil + }); moved { + return filedOK(app, "card", "Teleported", cardID, filedCard(landed, cardID)) + } + result := map[string]any{ "source_id": cardIDStr, "status": "teleporting", "wormhole": wormhole.ID, "destination": wormhole.Title, } - summary := fmt.Sprintf("Teleporting card #%s through wormhole %d (%s) — asynchronous; a new card appears in the destination and the original 404s once filing completes", cardIDStr, wormhole.ID, wormhole.Title) + summary := fmt.Sprintf("Teleporting card #%s through wormhole %d (%s) — still in progress; once it lands, basecamp cards show %s returns the card at its new id", cardIDStr, wormhole.ID, wormhole.Title, cardIDStr) return app.OK(result, output.WithSummary(summary), @@ -1649,41 +1715,6 @@ func wormholeMoveBreadcrumbs(sourceProjectID, cardTableID string) []output.Bread }} } -// rejectWormholeProjectConflict fails when an explicitly supplied project (the -// --in/--project flag or a card URL's bucket) resolves to a different project -// than the card's own bucket. Config/interactive defaults are not checked — only -// explicit caller context can conflict. -func rejectWormholeProjectConflict(cmd *cobra.Command, app *appctx.App, flagProject, urlProjectID string, cardBucketID int64) error { - want := strconv.FormatInt(cardBucketID, 10) - check := func(value, label string) error { - if value == "" { - return nil - } - resolved, _, err := app.Names.ResolveProject(cmd.Context(), value) - if err != nil { - return err - } - if resolved != want { - return output.ErrUsageHint( - fmt.Sprintf("The card is in project %s, but %s points at project %s", want, label, resolved), - "Omit the conflicting project — a wormhole move always uses the card's own project", - ) - } - return nil - } - if err := check(flagProject, "--in/--project"); err != nil { - return err - } - // app.Flags.Project carries a root-level --project (e.g. - // `basecamp --project 999 cards move …`), which lands here rather than in the - // card command's own flag — check it too so a root-level conflict can't slip - // past into a destructive move. - if err := check(app.Flags.Project, "--project"); err != nil { - return err - } - return check(urlProjectID, "the card URL") -} - // requireCardInTable confirms the card's parent column belongs to the resolved // card table, so an explicit --card-table can't point the wormhole search at a // table the card isn't on. The teleport is irreversible, so it fails closed: if @@ -1775,6 +1806,243 @@ func parseColumnID(ref string) (int64, error) { return id, nil } +func newCardsCopyCmd(project *string) *cobra.Command { + var toProject, toCardTable string + var includeComments bool + + cmd := &cobra.Command{ + Use: "copy <id|url>", + Short: "Copy a card to a card table in this or another project", + Long: `Copy a card into a card table, as the web's Copy… does. The original stays +where it is. The copy lands in the card table's Triage, or in Done if the card +is completed. + +With no destination, the copy goes onto the card's own card table: + basecamp cards copy 789 + basecamp cards copy 789 --to-project "Marketing" + basecamp cards copy 789 --to-project "Marketing" --to-card-table "Roadmap" + basecamp cards copy 789 --to-card-table 1069479999 --include-comments + +Basecamp makes the copy in the background; the command waits for it and shows +the new card.`, + Args: cobra.ExactArgs(1), + Annotations: map[string]string{ + "agent_notes": "Without --to-project/--to-card-table the copy goes onto the card's own card table. " + + "--to-project alone uses that project's only card table and fails as ambiguous when it has several; add --to-card-table. " + + "The copy lands in Triage, or Done if the card is completed; move it on with cards move --to. " + + "Output is the new card; its id is data.id and the original's is data.source_id. " + + "Never re-run a copy that reported in_progress: each run makes another copy; check an in_progress one with basecamp filings show <filing id>.", + }, + RunE: func(cmd *cobra.Command, args []string) error { + filingSet := cmd.Flags().Changed("to-project") || cmd.Flags().Changed("to-card-table") + if filingSet { + if err := validateFilingFlags(toProject, toCardTable); err != nil { + return err + } + } + + app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + ref, err := parseFilingRef(app, args[0], "card", "cards", "…/card_tables/cards/<id>") + if err != nil { + return err + } + + var table *basecamp.CardTable + if filingSet { + if table, err = resolveFilingTable(cmd, app, toProject, toCardTable); err != nil { + return err + } + } + + card, err := app.Account().Cards().Get(cmd.Context(), ref.id) + if err != nil { + return convertSDKError(err) + } + if err := requireFilableCard(card, ref.id); err != nil { + return err + } + if err := rejectProjectConflict(cmd, app, "card", *project, ref.project, card.Bucket.ID); err != nil { + return err + } + + if table == nil { + if _, table, err = resolveCardTableForCard(cmd, app, strconv.FormatInt(card.Bucket.ID, 10), "", card); err != nil { + return err + } + } + + return runFiling(cmd, app, scope, cardFiling(app, basecamp.FilingCopy, card, table, includeComments)) + }, + } + + addFilingDestinationFlags(cmd, &toProject, &toCardTable, "Copy") + cmd.Flags().BoolVar(&includeComments, "include-comments", false, "Copy the card's comments too") + + return guardFilingFlags(cmd) +} + +// addFilingDestinationFlags adds --to-project and --to-card-table, which name +// the card table a card is moved or copied into. +func addFilingDestinationFlags(cmd *cobra.Command, toProject, toCardTable *string, verb string) { + cmd.Flags().StringVar(toProject, "to-project", "", verb+" the card into this project's card table (ID or name); it lands in Triage, or Done if completed") + cmd.Flags().StringVar(toCardTable, "to-card-table", "", verb+" the card into this card table (ID, URL, or a name with --to-project)") +} + +// refuseOwnCardTable refuses the group's --card-table on a move to another +// card table. cards move and cards column move read it to find a column or +// card table in the same project, so the guard on unread flags lets it through, but +// there it would read as the destination. +func refuseOwnCardTable(cardTable, noun string) error { + if cardTable == "" { + return nil + } + return output.ErrUsageHint( + fmt.Sprintf("--card-table names the %s's own card table, not where it goes", noun), + "Use --to-card-table for the destination card table", + ) +} + +// validateFilingFlags checks --to-project and --to-card-table before any +// request. +func validateFilingFlags(toProject, toCardTable string) error { + if toProject == "" && toCardTable == "" { + return output.ErrUsage("--to-project or --to-card-table needs a value") + } + if toProject == "" && !isNumericID(toCardTable) && !urlarg.IsURL(toCardTable) { + return output.ErrUsageHint( + fmt.Sprintf("Card table %q is a name, which needs a project to look it up in", toCardTable), + "Add --to-project <project>, or pass the card table's ID or URL", + ) + } + return nil +} + +// requireFilableCard refuses what the card read returned when it is not a +// card. A moved message's old id redirects through the card endpoint to the +// message, so the read alone does not prove the id names a card. +func requireFilableCard(card *basecamp.Card, requestedID int64) error { + if card.Bucket == nil { + return output.ErrNotFound("card", strconv.FormatInt(requestedID, 10)) + } + if card.Type != "" && card.Type != "Kanban::Card" { + return output.ErrUsage(fmt.Sprintf("%d is a %s, not a card", requestedID, card.Type)) + } + return nil +} + +// moveCardToCardTable moves a card into another card table, usually in +// another project, as the web's Move… dialog does. A move within the card's +// own table is a column change, which --to makes directly. +func moveCardToCardTable(cmd *cobra.Command, app *appctx.App, scope followUpScope, cardID int64, flagProject, urlProjectID, toProject, toCardTable string) error { + table, err := resolveFilingTable(cmd, app, toProject, toCardTable) + if err != nil { + return err + } + card, err := app.Account().Cards().Get(cmd.Context(), cardID) + if err != nil { + return convertSDKError(err) + } + if err := requireFilableCard(card, cardID); err != nil { + return err + } + if err := rejectProjectConflict(cmd, app, "card", flagProject, urlProjectID, card.Bucket.ID); err != nil { + return err + } + if card.Parent != nil && cardTableContainsColumn(table.Lists, card.Parent.ID) { + return output.ErrUsageHint( + fmt.Sprintf("Card #%d is already on card table %d (%s)", cardID, table.ID, table.Title), + fmt.Sprintf("Move it between that table's columns with: basecamp cards move %d --to <column> --card-table %d --in %d", cardID, table.ID, cardTableProjectID(table)), + ) + } + return runFiling(cmd, app, scope, cardFiling(app, basecamp.FilingMove, card, table, false)) +} + +// resolveFilingTable finds the card table that --to-project and +// --to-card-table name, as resolveFilingTarget finds any other container, +// and reads it in full: a move checks whether the card is already on it. +func resolveFilingTable(cmd *cobra.Command, app *appctx.App, toProject, toCardTable string) (*basecamp.CardTable, error) { + target, err := resolveFilingTarget(cmd, app, cardTableContainer, toProject, toCardTable) + if err != nil { + return nil, err + } + return getCardTable(cmd, app, target.id) +} + +func getCardTable(cmd *cobra.Command, app *appctx.App, tableID int64) (*basecamp.CardTable, error) { + table, err := app.Account().CardTables().Get(cmd.Context(), tableID) + if err != nil { + return nil, convertSDKError(err) + } + return table, nil +} + +func cardTableProjectID(table *basecamp.CardTable) int64 { + if table.Bucket == nil { + return 0 + } + return table.Bucket.ID +} + +// cardFiling is a Move… or Copy… of a card into a card table, as the web's +// dialogs make it: the card lands in the table's Triage, or in Done if it is +// completed. +func cardFiling(app *appctx.App, kind basecamp.FilingType, card *basecamp.Card, table *basecamp.CardTable, includeComments bool) recordingFiling { + return recordingFiling{ + kind: kind, + sourceID: card.ID, + noun: "card", + destinationID: table.ID, + includeComments: includeComments, + destination: fmt.Sprintf("card table %d (%s)", table.ID, table.Title), + showCmd: "basecamp cards show", + lookFor: copyLookFor(filingSource{id: card.ID, typ: "Kanban::Card", status: card.Status, title: card.Title}, filingTarget{id: table.ID, title: table.Title, appURL: table.AppURL, projectID: cardTableProjectID(table)}), + read: func(ctx context.Context, id int64) (filedRecording, error) { + filed, err := app.Account().Cards().Get(ctx, id) + if err != nil { + return filedRecording{}, err + } + return filedCard(filed, card.ID), nil + }, + } +} + +// filedCardResult is a card that a move, copy or teleport made, with the id of +// the card it came from. +type filedCardResult struct { + *basecamp.Card + SourceID int64 `json:"source_id"` +} + +func filedCard(card *basecamp.Card, sourceID int64) filedRecording { + var projectFlag string + if card.Bucket != nil { + projectFlag = fmt.Sprintf(" --in %d", card.Bucket.ID) + } + return filedRecording{ + result: filedCardResult{Card: card, SourceID: sourceID}, + id: card.ID, + bucket: card.Bucket, + parent: card.Parent, + breadcrumbs: []output.Breadcrumb{ + { + Action: "view", + Cmd: fmt.Sprintf("basecamp cards show %d%s", card.ID, projectFlag), + Description: "View the card", + }, + { + Action: "move", + Cmd: fmt.Sprintf("basecamp cards move %d --to <column>%s", card.ID, projectFlag), + Description: "Move it to a column", + }, + }, + } +} + func newCardsDoneCmd(project, cardTable *string) *cobra.Command { cmd := &cobra.Command{ Use: "done <id|url>", @@ -2071,10 +2339,11 @@ func newCardsColumnCmd(project, cardTable *string) *cobra.Command { } cmd.AddCommand( - newCardsColumnShowCmd(project), + newCardsColumnShowCmd(), newCardsColumnCreateCmd(project, cardTable), newCardsColumnUpdateCmd(), newCardsColumnMoveCmd(project, cardTable), + newFilingCmd(columnFamily(), basecamp.FilingCopy, cardsColumnCopyHelp, false), newCardsColumnWatchCmd(), newCardsColumnUnwatchCmd(), newCardsColumnOnHoldCmd(project), @@ -2085,14 +2354,14 @@ func newCardsColumnCmd(project, cardTable *string) *cobra.Command { return cmd } -func newCardsColumnShowCmd(project *string) *cobra.Command { +func newCardsColumnShowCmd() *cobra.Command { cmd := &cobra.Command{ Use: "show <id|url>", Short: "Show column details", Long: `Display detailed information about a column. You can pass either a column ID or a Basecamp URL: - basecamp cards column show 789 --in my-project + basecamp cards column show 789 basecamp cards column show https://3.basecamp.com/123/buckets/456/card_tables/columns/789`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -2102,8 +2371,8 @@ You can pass either a column ID or a Basecamp URL: return err } - // Extract ID and project from URL if provided - columnIDStr, urlProjectID, err := extractWithProject(args[0]) + // The column is read by its account-wide ID, so no project is needed. + columnIDStr, _, err := extractWithProject(args[0]) if err != nil { return err } @@ -2112,45 +2381,25 @@ You can pass either a column ID or a Basecamp URL: return output.ErrUsage("Invalid column ID") } - // Resolve project - use URL > flag > config, with interactive fallback - projectID := *project - if projectID == "" && urlProjectID != "" { - projectID = urlProjectID - } - if projectID == "" { - projectID = app.Flags.Project - } - if projectID == "" { - projectID = app.Config.ProjectID - } - if projectID == "" { - if err := ensureProject(cmd, app); err != nil { - return err - } - projectID = app.Config.ProjectID - } - - resolvedProjectID, _, err := app.Names.ResolveProject(cmd.Context(), projectID) - if err != nil { - return err - } - col, err := app.Account().CardColumns().Get(cmd.Context(), columnID) if err != nil { return convertSDKError(err) } + // A moved column's old id redirects to the column at its new id, + // and only the new id takes writes. + columnIDStr = strconv.FormatInt(col.ID, 10) return app.OK(col, output.WithSummary(fmt.Sprintf("%s (%d cards)", col.Title, col.CardsCount)), output.WithBreadcrumbs( output.Breadcrumb{ Action: "update", - Cmd: fmt.Sprintf("basecamp cards column update %s --in %s", columnIDStr, resolvedProjectID), + Cmd: fmt.Sprintf("basecamp cards column update %s --in %s", columnIDStr, recordProject(col.Bucket)), Description: "Update column", }, output.Breadcrumb{ Action: "columns", - Cmd: fmt.Sprintf("basecamp cards columns --in %s", resolvedProjectID), + Cmd: fmt.Sprintf("basecamp cards columns --in %s", recordProject(col.Bucket)), Description: "List all columns", }, ), @@ -2322,17 +2571,43 @@ You can pass either a column ID or a Basecamp URL: func newCardsColumnMoveCmd(project, cardTable *string) *cobra.Command { var position int + var toProject, toCardTable string cmd := &cobra.Command{ Use: "move <id|url>", - Short: "Move a column", - Long: `Reposition a column within the card table. + Short: "Move a column within its card table or to another project's card table", + Long: `Reposition a column within its card table, or move it with its cards to a +card table in another project. -You can pass either a column ID or a Basecamp URL: +--position repositions it within its card table. You can pass either a column +ID or a Basecamp URL: basecamp cards column move 789 --position 2 --in my-project - basecamp cards column move https://3.basecamp.com/123/buckets/456/card_tables/columns/789 --position 2`, + basecamp cards column move https://3.basecamp.com/123/buckets/456/card_tables/columns/789 --position 2 + +--to-project and --to-card-table move it, cards and all, onto another card +table, as the web's Move… does. It lands under a new ID: + basecamp cards column move 789 --to-project "Marketing" + basecamp cards column move 789 --to-project "Marketing" --to-card-table "Roadmap" + basecamp cards column move 789 --to-card-table https://3.basecamp.com/123/buckets/999/card_tables/555 +Only the columns you add move this way; Basecamp refuses Triage, Not now, Done +and On hold, which every card table has.`, Args: cobra.ExactArgs(1), + Annotations: map[string]string{"agent_notes": "--position repositions the column within its card table and keeps its ID. " + + "--to-project/--to-card-table move the column with its cards onto another card table (usually in another project) under a NEW id: output data.id is the new id and data.source_id the old. " + + "--to-project alone needs a project with one card table, otherwise add --to-card-table. --position and --card-table (the column's own table) cannot be combined with them; the column's own table is refused. " + + "Triage, Not now, Done and On hold columns cannot be moved or copied. " + + "An in_progress error names its filing: do not move it again; basecamp filings show <filing id> gives its new id once it lands."}, RunE: func(cmd *cobra.Command, args []string) error { + if familyFilingSet(cmd, columnFamily()) { + if cmd.Flags().Changed("position") || cmd.Flags().Changed("pos") { + return output.ErrUsageHint("--position repositions the column within its card table, and cannot be combined with --to-project or --to-card-table", + "Move it to the other card table first, then reposition it there with --position") + } + if err := refuseOwnCardTable(*cardTable, "column"); err != nil { + return err + } + return runFamilyFiling(cmd, columnFamily(), basecamp.FilingMove, args, toProject, toCardTable, false) + } app := appctx.FromContext(cmd.Context()) if err := ensureAccount(cmd, app); err != nil { @@ -2395,7 +2670,13 @@ You can pass either a column ID or a Basecamp URL: err = app.Account().CardColumns().Move(cmd.Context(), cardTableIDInt, req) if err != nil { - return convertSDKError(err) + e := output.AsError(convertSDKError(err)) + if e.Code == output.CodeNotFound { + notFound := *e + notFound.Hint = fmt.Sprintf("--position reorders a column within its own card table, which may not be card table %d. To move it to a card table in another project use: basecamp cards column move %d --to-card-table %d", cardTableIDInt, columnID, cardTableIDInt) + return ¬Found + } + return e } return app.OK(map[string]any{ @@ -2408,8 +2689,89 @@ You can pass either a column ID or a Basecamp URL: cmd.Flags().IntVar(&position, "position", 0, "Target position (1-indexed)") cmd.Flags().IntVar(&position, "pos", 0, "Target position (alias for --position)") + addFamilyFilingFlags(cmd, columnFamily(), "Move", &toProject, &toCardTable) - return cmd + return guardFilingFlags(cmd, "card-table") +} + +var cardsColumnCopyHelp = filingHelp{ + use: "copy <id|url>", + short: "Copy a column with its cards to a card table in this or another project", + long: `Copy a column, with its cards, onto a card table, as the web's Copy… does. +The original stays where it is. + +With no destination, the copy goes onto the column's own card table: + basecamp cards column copy 789 + basecamp cards column copy 789 --to-project "Marketing" + basecamp cards column copy 789 --to-project "Marketing" --to-card-table "Roadmap" --include-comments + +Only the columns you add copy this way; Basecamp refuses Triage, Not now, Done +and On hold. The command waits for Basecamp to make the copy and shows it.`, + notes: "Without --to-project/--to-card-table the copy goes onto the column's own card table. " + + "--to-project alone uses that project's only card table and fails as ambiguous when it has several; add --to-card-table. " + + "Output is the new column; its id is data.id and the original's is data.source_id. Triage, Not now, Done and On hold columns cannot be copied. " + + "Never re-run a copy that reported in_progress or a may-have-started error: each run makes another copy; check an in_progress one with basecamp filings show <filing id>.", +} + +// cardTableContainer is a project's card table, where cards and columns are +// filed. +var cardTableContainer = filingContainer{ + dockName: "kanban_board", + noun: "card table", + plural: "card tables", + flag: "--to-card-table", + urlKind: "card_tables", + urlShape: "…/card_tables/<id>", + get: func(ctx context.Context, app *appctx.App, id int64) (filingTarget, error) { + table, err := app.Account().CardTables().Get(ctx, id) + if err != nil { + return filingTarget{}, err + } + return bucketTarget(table.ID, table.Title, table.AppURL, table.Bucket), nil + }, +} + +// columnTypes are the columns a card table has: Kanban::Column is one a +// person added, the rest are the ones every card table has. +var columnTypes = []string{standardColumnType, "Kanban::Triage", "Kanban::NotNowColumn", "Kanban::DoneColumn", "Kanban::OnHold"} + +// columnFamily is how a card table's columns move and copy between card +// tables, cards and all. +func columnFamily() filingFamily { + return filingFamily{ + noun: "column", + group: "basecamp cards column", + types: columnTypes, + urlKinds: []string{"columns"}, + urlShape: "…/card_tables/columns/<id>", + into: cardTableContainer, + get: func(ctx context.Context, app *appctx.App, id int64) (any, filingSource, error) { + c, err := app.Account().CardColumns().Get(ctx, id) + if err != nil { + return nil, filingSource{}, err + } + return c, filingSource{id: c.ID, typ: c.Type, status: c.Status, title: c.Title, bucket: c.Bucket, parent: c.Parent}, nil + }, + check: func(src filingSource) error { + if src.typ == standardColumnType { + return nil + } + return output.ErrUsageHint( + fmt.Sprintf("Column #%d (%s) is one every card table has, which Basecamp does not move or copy", src.id, src.title), + "Only the columns you add move and copy; move its cards instead with: basecamp cards move <card> --to-project <project>", + ) + }, + sameHint: func(src filingSource, target filingTarget) string { + return fmt.Sprintf("Reposition it within that card table with: basecamp cards column move %d --position <n> --card-table %d --in %d", src.id, target.id, target.projectID) + }, + crumbs: func(id, projectID int64) []output.Breadcrumb { + return []output.Breadcrumb{{ + Action: "show", + Cmd: fmt.Sprintf("basecamp cards column show %d --in %d", id, projectID), + Description: "View the column", + }} + }, + } } func newCardsColumnWatchCmd() *cobra.Command { @@ -2653,19 +3015,20 @@ You can pass either a column ID or a Basecamp URL: // newCardsWormholesCmd creates the wormhole management group. A wormhole is a // standing shortcut on a card table that teleports any card dropped into it to a -// column on another card table, usually in another project — the only way to -// move a card across projects. Within a project, cards move --to reaches any -// column directly. +// column on another card table, usually in another project. A one-off move to +// another project uses cards move --to-project, and within a project cards move +// --to reaches any column directly. func newCardsWormholesCmd(project, cardTable *string) *cobra.Command { cmd := &cobra.Command{ Use: "wormholes", Short: "Manage card-table wormholes", Long: `Manage wormholes — standing shortcuts that teleport cards to a column on another card table, usually in another project. -A wormhole is the only way to move a card across projects. Drop a card into one -(with "cards move --to-wormhole") and the server teleports it to the wormhole's -destination column, minting a new card and deleting the original. A card table -holds at most four wormholes. +Drop a card into one (with "cards move --to-wormhole") and the server teleports +it to the wormhole's destination column, minting a new card and deleting the +original. A card table holds at most four wormholes. A one-off move to another +project needs no wormhole: "cards move --to-project" sends the card to that +project's card table. A move within a project needs no wormhole: "cards move --to" accepts any column in the card's project, including one on another card table. Create a wormhole @@ -2965,6 +3328,15 @@ func newCardsStepsCmd(project *string) *cobra.Command { if err != nil { return convertSDKError(err) } + // A moved card's old id redirects to the card at its new id, in + // another project, and the old id 404s for everything after this. + if card.ID != 0 && card.ID != cardIDInt { + cardIDInt = card.ID + cardID = strconv.FormatInt(card.ID, 10) + if card.Bucket != nil { + resolvedProjectID = strconv.FormatInt(card.Bucket.ID, 10) + } + } steps, err := everySubtask(cmd.Context(), app, cardIDInt, card.Steps, card.SubtasksCount) if err != nil { return err @@ -3380,8 +3752,9 @@ You can pass either a step ID or a Basecamp URL: } type projectCardTable struct { - ID int64 - Title string + ID int64 + Title string + Enabled bool } // resolveCardsProjectID resolves the project for a card command from the @@ -3513,9 +3886,10 @@ func listProjectCardTables(cmd *cobra.Command, app *appctx.App, projectID string var project struct { Dock []struct { - Name string `json:"name"` - ID int64 `json:"id"` - Title string `json:"title"` + Name string `json:"name"` + ID int64 `json:"id"` + Title string `json:"title"` + Enabled bool `json:"enabled"` } `json:"dock"` } if err := resp.UnmarshalData(&project); err != nil { @@ -3525,7 +3899,7 @@ func listProjectCardTables(cmd *cobra.Command, app *appctx.App, projectID string var cardTables []projectCardTable for _, item := range project.Dock { if item.Name == "kanban_board" { - cardTables = append(cardTables, projectCardTable{ID: item.ID, Title: item.Title}) + cardTables = append(cardTables, projectCardTable{ID: item.ID, Title: item.Title, Enabled: item.Enabled}) } } @@ -3647,6 +4021,10 @@ func findDoneColumn(columns []basecamp.CardColumn) *basecamp.CardColumn { } func ambiguousCardTablesError(cardTables []projectCardTable) error { + return ambiguousCardTablesErrorFor("--card-table", cardTables) +} + +func ambiguousCardTablesErrorFor(flag string, cardTables []projectCardTable) error { lines := make([]string, 0, len(cardTables)) for _, ct := range cardTables { title := ct.Title @@ -3658,7 +4036,7 @@ func ambiguousCardTablesError(cardTables []projectCardTable) error { return &output.Error{ Code: output.CodeAmbiguous, Message: "Multiple card tables found", - Hint: fmt.Sprintf("Specify one with --card-table <id>:\n%s", strings.Join(lines, "\n")), + Hint: fmt.Sprintf("Specify one with %s <id>:\n%s", flag, strings.Join(lines, "\n")), } } diff --git a/internal/commands/cards_column_filing_test.go b/internal/commands/cards_column_filing_test.go new file mode 100644 index 000000000..9d51749bc --- /dev/null +++ b/internal/commands/cards_column_filing_test.go @@ -0,0 +1,101 @@ +package commands + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" +) + +// columnsTransport has column 456 ("Doing") and Triage 457 on card table 555 +// in Source (123), and Dest (999) with card table 900, which the filed +// column 4560 is on. +func columnsTransport() *familyTransport { + return &familyTransport{gets: map[string]string{ + "/projects": filingProjects, + "/projects/123": `{"id":123,"name":"Source","dock":[{"name":"kanban_board","id":555,"title":"Board","enabled":true}]}`, + "/projects/999": `{"id":999,"name":"Dest","dock":[{"name":"kanban_board","id":900,"title":"Roadmap","enabled":true,"app_url":"https://3.basecamp.com/99999/buckets/999/card_tables/900"}]}`, + "/card_tables/555": `{"id":555,"title":"Board","bucket":{"id":123,"name":"Source"}}`, + "/card_tables/900": `{"id":900,"title":"Roadmap","bucket":{"id":999,"name":"Dest"}}`, + "/card_tables/columns/456": `{"id":456,"type":"Kanban::Column","title":"Doing","bucket":{"id":123,"name":"Source"},"parent":{"id":555,"title":"Board","type":"Kanban::Board"}}`, + "/card_tables/columns/457": `{"id":457,"type":"Kanban::Triage","title":"Triage","bucket":{"id":123,"name":"Source"},"parent":{"id":555,"title":"Board","type":"Kanban::Board"}}`, + "/card_tables/columns/4560": `{"id":4560,"type":"Kanban::Column","title":"Doing","bucket":{"id":999,"name":"Dest"},"parent":{"id":900,"title":"Roadmap","type":"Kanban::Board"}}`, + }} +} + +func TestCardsColumnMoveToProject(t *testing.T) { + transport := columnsTransport() + run := runFamilyCommand(t, transport, NewCardsCmd(), "column", "move", "456", "--to-project", "Dest") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/99999/recordings/456/moves"}, transport.posts) + assert.Equal(t, float64(900), transport.bodies[0]["filing_destination_id"]) + data := run.data(t) + assert.Equal(t, float64(4560), data["id"]) + assert.Equal(t, float64(456), data["source_id"]) + assert.Equal(t, "Moved column #456 to Dest › Roadmap as column #4560", run.envelope.Summary) +} + +func TestCardsColumnMoveRefusesTriageAndTheLike(t *testing.T) { + transport := columnsTransport() + run := runFamilyCommand(t, transport, NewCardsCmd(), "column", "move", "457", "--to-project", "Dest") + e := run.failure(t) + assert.Equal(t, output.CodeUsage, e.Code) + assert.Equal(t, "Column #457 (Triage) is one every card table has, which Basecamp does not move or copy", e.Message) + assert.Empty(t, transport.posts) +} + +func TestCardsColumnMoveOntoItsOwnTablePointsAtPosition(t *testing.T) { + transport := columnsTransport() + run := runFamilyCommand(t, transport, NewCardsCmd(), "column", "move", "456", "--to-card-table", "555") + e := run.failure(t) + assert.Equal(t, "Reposition it within that card table with: basecamp cards column move 456 --position <n> --card-table 555 --in 123", e.Hint) + assert.Empty(t, transport.posts) +} + +func TestCardsColumnMoveRefusesPositionBesideToProject(t *testing.T) { + transport := columnsTransport() + run := runFamilyCommand(t, transport, NewCardsCmd(), "column", "move", "456", "--position", "2", "--to-project", "Dest") + e := run.failure(t) + assert.Contains(t, e.Message, "cannot be combined with --to-project") + assert.Empty(t, transport.posts) +} + +func TestCardsColumnMoveRefusesTheOwnCardTableFlag(t *testing.T) { + transport := columnsTransport() + run := runFamilyCommand(t, transport, NewCardsCmd(), "column", "move", "456", "--card-table", "900", "--to-project", "Dest") + e := run.failure(t) + assert.Contains(t, e.Hint, "--to-card-table") + assert.Empty(t, transport.posts) +} + +func TestCardsColumnCopyWithoutDestinationGoesOntoItsOwnTable(t *testing.T) { + transport := columnsTransport() + transport.gets["/card_tables/columns/4560"] = `{"id":4560,"type":"Kanban::Column","title":"Doing","bucket":{"id":123,"name":"Source"},"parent":{"id":555,"title":"Board","type":"Kanban::Board"}}` + run := runFamilyCommand(t, transport, NewCardsCmd(), "column", "copy", "456", "--include-comments") + require.NoError(t, run.err) + assert.Equal(t, []string{"/99999/recordings/456/copies"}, transport.posts) + assert.Equal(t, float64(555), transport.bodies[0]["filing_destination_id"]) + assert.Equal(t, true, transport.bodies[0]["include_comments"]) +} + +func TestCardsColumnCopyRefusesDone(t *testing.T) { + transport := columnsTransport() + transport.gets["/card_tables/columns/457"] = `{"id":457,"type":"Kanban::DoneColumn","title":"Done","bucket":{"id":123,"name":"Source"},"parent":{"id":555,"title":"Board","type":"Kanban::Board"}}` + run := runFamilyCommand(t, transport, NewCardsCmd(), "column", "copy", "457", "--to-project", "Dest") + e := run.failure(t) + assert.Contains(t, e.Message, "one every card table has") + assert.Empty(t, transport.posts) +} + +func TestCardsColumnRepositionThatBasecampCannotFindPointsAtToCardTable(t *testing.T) { + transport := columnsTransport() + // Basecamp finds the reposition's card table only in the column's project. + transport.refuse = map[int64]int{900: 404} + run := runFamilyCommand(t, transport, NewCardsCmd(), "column", "move", "456", "--position", "1", "--card-table", "900", "--in", "Dest") + e := run.failure(t) + assert.Equal(t, output.CodeNotFound, e.Code) + assert.Contains(t, e.Hint, "basecamp cards column move 456 --to-card-table 900") +} diff --git a/internal/commands/cards_filing_test.go b/internal/commands/cards_filing_test.go new file mode 100644 index 000000000..1b240da66 --- /dev/null +++ b/internal/commands/cards_filing_test.go @@ -0,0 +1,621 @@ +package commands + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" + "github.com/basecamp/basecamp-cli/internal/resilience" +) + +func shortFilingWait(t *testing.T) { + t.Helper() + saved := filingWait + filingWait.first = time.Millisecond + filingWait.max = 2 * time.Millisecond + filingWait.total = 50 * time.Millisecond + t.Cleanup(func() { filingWait = saved }) +} + +func TestPollUntilStopsWhenDone(t *testing.T) { + shortFilingWait(t) + checks := 0 + done, err := pollUntil(context.Background(), func(context.Context) (bool, error) { + checks++ + return checks == 3, nil + }) + require.NoError(t, err) + assert.True(t, done) + assert.Equal(t, 3, checks) +} + +func TestPollUntilGivesUpAfterTotal(t *testing.T) { + shortFilingWait(t) + start := time.Now() + done, err := pollUntil(context.Background(), func(context.Context) (bool, error) { return false, nil }) + require.NoError(t, err) + assert.False(t, done) + assert.Less(t, time.Since(start), time.Second) +} + +func TestPollUntilHonoursCancellation(t *testing.T) { + shortFilingWait(t) + filingWait.total = time.Minute + ctx, cancel := context.WithCancel(context.Background()) + checks := 0 + done, err := pollUntil(ctx, func(context.Context) (bool, error) { + checks++ + if checks == 2 { + cancel() + } + return false, nil + }) + assert.ErrorIs(t, err, context.Canceled) + assert.False(t, done) + assert.Equal(t, 2, checks) +} + +// mockFilingTransport serves a card in one project and two other projects to +// move or copy it into, and plays back a filing's statuses. +// +// - Card 456 is in project 123 ("Source"), column 777 on card table 555. +// - Project 999 ("Dest") has card table 900 (Triage 901); with twoTables it +// also has card table 950 ("Roadmap"), and always a disabled card table 960. +// - Project 888 ("Bare") has only a card table turned off, 880; project 777 +// ("Chat only") has none. +// - A move to a column 404s, as for a column in another project. +// - A move or copy POST answers filing 70, pending. Each GET of the filing +// takes the next of statuses, holding the last; "completed" names card 4560. +type mockFilingTransport struct { + twoTables bool + duplicateTitle bool + numericTitle bool + postStatus int + postErr error + postBody string + statuses []string + + mu sync.Mutex + requests int + posts []string + body map[string]any + filingGet int +} + +func (m *mockFilingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.requests++ + path := strings.TrimSuffix(req.URL.Path, ".json") + respond := func(status int, body string) (*http.Response, error) { + header := make(http.Header) + header.Set("Content-Type", "application/json") + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: header}, nil + } + + if req.Method == http.MethodPost { + m.posts = append(m.posts, path) + if req.Body != nil { + raw, _ := io.ReadAll(req.Body) + m.body = map[string]any{} + _ = json.Unmarshal(raw, &m.body) + } + if m.postErr != nil { + return nil, m.postErr + } + if m.postBody != "" { + return respond(201, m.postBody) + } + if m.postStatus != 0 { + return respond(m.postStatus, `{"error":"upstream"}`) + } + if strings.HasSuffix(path, "/card_tables/cards/456/moves") { + // Basecamp finds column_id only in the card's own project. + return respond(404, `{"error":"not found"}`) + } + kind := "Move" + if strings.HasSuffix(path, "/copies") { + kind = "Copy" + } + return respond(201, m.filingJSON("pending", kind)) + } + + switch { + case strings.HasSuffix(path, "/projects"): + return respond(200, `[{"id":123,"name":"Source"},{"id":999,"name":"Dest"},{"id":888,"name":"Bare"},{"id":777,"name":"Chat only"}]`) + case strings.HasSuffix(path, "/projects/888"): + return respond(200, `{"id":888,"dock":[{"name":"kanban_board","id":880,"title":"Off","enabled":false}]}`) + case strings.HasSuffix(path, "/projects/777"): + return respond(200, `{"id":777,"dock":[{"name":"chat","id":770,"title":"Chat","enabled":true}]}`) + case strings.HasSuffix(path, "/projects/123"): + return respond(200, `{"id":123,"dock":[{"name":"kanban_board","id":555,"title":"Board","enabled":true}]}`) + case strings.HasSuffix(path, "/projects/999"): + dock := `{"name":"kanban_board","id":900,"title":"Ideas","enabled":true},{"name":"kanban_board","id":960,"title":"Old","enabled":false}` + if m.twoTables { + dock += `,{"name":"kanban_board","id":950,"title":"Roadmap","enabled":true}` + } + if m.duplicateTitle { + dock += `,{"name":"kanban_board","id":970,"title":"roadmap","enabled":true}` + } + if m.numericTitle { + dock += `,{"name":"kanban_board","id":980,"title":"2026","enabled":true}` + } + return respond(200, `{"id":999,"dock":[`+dock+`]}`) + case strings.HasSuffix(path, "/card_tables/555"): + return respond(200, `{"id":555,"title":"Board","bucket":{"id":123,"name":"Source"},"lists":[{"id":777,"title":"Developing"}]}`) + case strings.HasSuffix(path, "/card_tables/900"): + return respond(200, `{"id":900,"title":"Ideas","bucket":{"id":999,"name":"Dest"},"lists":[{"id":901,"title":"Triage","type":"Kanban::Triage"}]}`) + case m.numericTitle && strings.HasSuffix(path, "/card_tables/980"): + return respond(200, `{"id":980,"title":"2026","bucket":{"id":999,"name":"Dest"},"lists":[{"id":981,"title":"Triage","type":"Kanban::Triage"}]}`) + case strings.HasSuffix(path, "/card_tables/950"): + return respond(200, `{"id":950,"title":"Roadmap","bucket":{"id":999,"name":"Dest"},"lists":[{"id":951,"title":"Triage","type":"Kanban::Triage"}]}`) + case strings.HasSuffix(path, "/card_tables/columns/777"): + return respond(200, `{"id":777,"title":"Developing","parent":{"id":555,"title":"Board","type":"Kanban::Board"}}`) + case strings.HasSuffix(path, "/card_tables/cards/458"): + // A moved message's old id redirects through the card endpoint to the message. + return respond(200, `{"id":459,"title":"Kickoff","type":"Message","bucket":{"id":123,"name":"Source","type":"Project"},"parent":{"id":555,"title":"Message Board","type":"Message::Board"}}`) + case strings.HasSuffix(path, "/card_tables/cards/456"): + return respond(200, `{"id":456,"title":"Test Card","bucket":{"id":123,"name":"Source","type":"Project"},"parent":{"id":777,"title":"Developing","type":"Kanban::Column"}}`) + case strings.HasSuffix(path, "/card_tables/cards/4560"): + return respond(200, `{"id":4560,"title":"Test Card","bucket":{"id":999,"name":"Dest","type":"Project"},"parent":{"id":901,"title":"Triage","type":"Kanban::Triage"}}`) + case strings.Contains(path, "/filings/70"): + status := "processing" + if len(m.statuses) > 0 { + status = m.statuses[min(m.filingGet, len(m.statuses)-1)] + } + m.filingGet++ + return respond(200, m.filingJSON(status, "Move")) + } + return respond(404, `{"error":"not found"}`) +} + +func (m *mockFilingTransport) filingJSON(status, kind string) string { + dest := "" + if status == "completed" { + dest = `,"destination_recording":{"id":4560,"type":"Kanban::Card","url":"https://3.basecampapi.com/99999/buckets/999/card_tables/cards/4560.json","app_url":"https://3.basecamp.com/99999/buckets/999/card_tables/cards/4560"}` + } + return fmt.Sprintf(`{"id":70,"status":%q,"type":%q,"source_recording_id":456,"url":"https://3.basecampapi.com/99999/filings/70.json"%s}`, status, kind, dest) +} + +type filingRun struct { + err error + envelope struct { + Data map[string]any `json:"data"` + Summary string `json:"summary"` + } + output string +} + +func runFilingCommand(t *testing.T, transport *mockFilingTransport, args ...string) filingRun { + t.Helper() + shortFilingWait(t) + app, buf := newTestAppWithTransport(t, transport) + app.Flags.Hints = true + var run filingRun + run.err = executeCommand(NewCardsCmd(), app, args...) + run.output = buf.String() + if run.err == nil { + require.NoError(t, json.Unmarshal(buf.Bytes(), &run.envelope)) + } + return run +} + +func requireFilingError(t *testing.T, run filingRun) *output.Error { + t.Helper() + var e *output.Error + require.True(t, errors.As(run.err, &e), "expected *output.Error, got %T: %v", run.err, run.err) + return e +} + +func TestCardsMoveToProjectWithOneCardTable(t *testing.T) { + transport := &mockFilingTransport{statuses: []string{"processing", "completed"}} + run := runFilingCommand(t, transport, "move", "456", "--to-project", "Dest") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/99999/recordings/456/moves"}, transport.posts) + assert.Equal(t, float64(900), transport.body["filing_destination_id"]) + assert.Equal(t, float64(4560), run.envelope.Data["id"]) + assert.Equal(t, float64(456), run.envelope.Data["source_id"]) + assert.Equal(t, "Moved card #456 to Dest › Triage as card #4560", run.envelope.Summary) + assert.Contains(t, run.output, "basecamp cards show 4560 --in 999") +} + +func TestCardsMoveToProjectWithSeveralCardTablesIsAmbiguous(t *testing.T) { + transport := &mockFilingTransport{twoTables: true} + run := runFilingCommand(t, transport, "move", "456", "--to-project", "999") + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeAmbiguous, e.Code) + assert.Contains(t, e.Hint, "--to-card-table") + assert.Contains(t, e.Hint, "950 Roadmap") + assert.NotContains(t, e.Hint, "960", "a card table off the dock is not a destination") + assert.Empty(t, transport.posts) +} + +// A card table turned off is turned back on, never replaced by a second one, +// so the refusal says which it is, as every other family's does. +func TestCardsMoveToProjectWithCardTableTurnedOffSaysHowToTurnItOn(t *testing.T) { + for _, verb := range []string{"move", "copy"} { + transport := &mockFilingTransport{} + run := runFilingCommand(t, transport, verb, "456", "--to-project", "Bare") + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeNotFound, e.Code) + assert.Equal(t, "Bare's card table (Off) is turned off, so nothing can be moved or copied into it", e.Message) + assert.Equal(t, "Turn it on first with: basecamp tools enable 880 --in 888", e.Hint) + assert.Empty(t, transport.posts) + } +} + +func TestCardsMoveToProjectWithoutCardTableNamesTheProject(t *testing.T) { + transport := &mockFilingTransport{} + run := runFilingCommand(t, transport, "move", "456", "--to-project", "Chat only") + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeNotFound, e.Code) + assert.Equal(t, "Project Chat only has no card table", e.Message) + assert.Equal(t, "Pick a project that has a card table", e.Hint) + assert.Empty(t, transport.posts) +} + +func TestCardsMoveToCardTableNameSharedByTwoTablesIsAmbiguous(t *testing.T) { + transport := &mockFilingTransport{twoTables: true, duplicateTitle: true} + run := runFilingCommand(t, transport, "move", "456", "--to-project", "Dest", "--to-card-table", "Roadmap") + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeAmbiguous, e.Code) + assert.Contains(t, e.Hint, "950 Roadmap") + assert.Contains(t, e.Hint, "970 roadmap") + assert.NotContains(t, e.Hint, "900", "only the tables with that name are candidates") + assert.Empty(t, transport.posts) +} + +func TestCardsCopyThatFailsUpstreamIsNotRetryable(t *testing.T) { + transport := &mockFilingTransport{postStatus: 502} + run := runFilingCommand(t, transport, "copy", "456", "--to-project", "Dest") + e := requireFilingError(t, run) + + assert.False(t, e.Retryable, "a 502 can follow a copy Basecamp already started") + assert.Contains(t, e.Hint, "may have started the copy") + assert.Contains(t, e.Hint, "basecamp recordings list Kanban::Card --in 999 --status active --all") + assert.Len(t, transport.posts, 1) +} + +func TestCardsCopyWithNoReadableAnswerMayHaveStarted(t *testing.T) { + for name, transport := range map[string]*mockFilingTransport{ + "dropped connection": {postErr: errors.New("connection reset by peer")}, + "unreadable 201": {postBody: `{"id":70,"status":`}, + } { + t.Run(name, func(t *testing.T) { + run := runFilingCommand(t, transport, "copy", "456", "--to-project", "Dest") + e := requireFilingError(t, run) + + assert.False(t, e.Retryable, "Basecamp may have started the copy") + assert.Contains(t, e.Hint, "may have started the copy") + assert.Contains(t, e.Hint, "basecamp recordings list Kanban::Card --in 999 --status active --all") + assert.Len(t, transport.posts, 1) + }) + } +} + +func TestCardsCopyThatBasecampRefusesKeepsItsError(t *testing.T) { + transport := &mockFilingTransport{postStatus: 403} + run := runFilingCommand(t, transport, "copy", "456", "--to-project", "Dest") + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeForbidden, e.Code) + assert.NotContains(t, e.Hint, "may have started the copy") + assert.Len(t, transport.posts, 1) +} + +func TestCardsMoveWithNoReadableAnswerKeepsItsError(t *testing.T) { + transport := &mockFilingTransport{postErr: errors.New("connection reset by peer")} + run := runFilingCommand(t, transport, "move", "456", "--to-project", "Dest") + require.Error(t, run.err) + + assert.ErrorContains(t, run.err, "connection reset by peer") + assert.NotContains(t, output.AsError(run.err).Hint, "may have started the copy") + assert.Len(t, transport.posts, 1) +} + +func TestCardsCopyThatIsRateLimitedStaysRetryable(t *testing.T) { + transport := &mockFilingTransport{postStatus: 429} + run := runFilingCommand(t, transport, "copy", "456", "--to-project", "Dest") + e := requireFilingError(t, run) + + assert.True(t, e.Retryable, "Basecamp refuses a rate-limited request before copying") + assert.NotContains(t, e.Hint, "may have started the copy") +} + +func TestAcceptedCopyStaysNotRetryableWhenTheWaitHitsTheRateLimitGate(t *testing.T) { + f := cardFiling(nil, basecamp.FilingCopy, + &basecamp.Card{ID: 456}, + &basecamp.CardTable{ID: 900, Title: "Ideas", Bucket: &basecamp.Bucket{ID: 999}}, + false) + gate := &resilience.GateError{Message: "rate limited by a recent 429", Hint: "Re-run in 30s"} + + e := output.AsError(stillFilingError(f, 70, gate)) + + assert.Equal(t, output.CodeInProgress, e.Code) + assert.False(t, e.Retryable, "the copy was accepted, so re-running makes a second one") + assert.Contains(t, e.Hint, "Do not copy it again") +} + +func TestCardsFilingRefusesARecordThatIsNotACard(t *testing.T) { + for name, args := range map[string][]string{ + "move": {"move", "458", "--to-card-table", "900"}, + "copy": {"copy", "458", "--to-card-table", "900"}, + } { + t.Run(name, func(t *testing.T) { + transport := &mockFilingTransport{} + run := runFilingCommand(t, transport, args...) + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeUsage, e.Code) + assert.Equal(t, "458 is a Message, not a card", e.Message) + assert.Empty(t, transport.posts) + }) + } +} + +func TestCardsMoveToExplicitCardTable(t *testing.T) { + for name, args := range map[string][]string{ + "id": {"--to-card-table", "950"}, + "url": {"--to-card-table", "https://3.basecamp.com/99999/buckets/999/card_tables/950"}, + "name in project": {"--to-project", "Dest", "--to-card-table", "roadmap"}, + } { + t.Run(name, func(t *testing.T) { + transport := &mockFilingTransport{twoTables: true, statuses: []string{"completed"}} + run := runFilingCommand(t, transport, append([]string{"move", "456"}, args...)...) + require.NoError(t, run.err) + assert.Equal(t, float64(950), transport.body["filing_destination_id"]) + assert.Equal(t, float64(4560), run.envelope.Data["id"]) + }) + } +} + +// A flat API URL names no project, and its id can be a card table's title +// too. A URL that names no card table of the project is refused, never read as +// that title. +func TestCardsCardTableURLThatNamesNoTableIsNeverATitle(t *testing.T) { + transport := &mockFilingTransport{numericTitle: true, statuses: []string{"completed"}} + url := "https://3.basecampapi.com/99999/card_tables/2026.json" + e := requireFilingError(t, runFilingCommand(t, transport, "copy", "456", "--to-project", "Dest", "--to-card-table", url)) + assert.Equal(t, output.CodeNotFound, e.Code) + assert.Contains(t, e.Message, url) + assert.Empty(t, transport.posts) +} + +func TestCardsMoveRefusesItsOwnCardTable(t *testing.T) { + transport := &mockFilingTransport{} + run := runFilingCommand(t, transport, "move", "456", "--to-card-table", "555") + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeUsage, e.Code) + assert.Contains(t, e.Message, "already on card table 555") + assert.Contains(t, e.Hint, "basecamp cards move 456 --to <column>") + assert.Empty(t, transport.posts) +} + +func TestCardsMoveToCardTableRejectsBadFlags(t *testing.T) { + for name, args := range map[string][]string{ + "with --to": {"--to-project", "Dest", "--to", "Done"}, + "with --to-wormhole": {"--to-project", "Dest", "--to-wormhole", "111"}, + "with --card-table": {"--to-project", "Dest", "--card-table", "555"}, + "name without project": {"--to-card-table", "Roadmap"}, + } { + t.Run(name, func(t *testing.T) { + transport := &mockFilingTransport{} + run := runFilingCommand(t, transport, append([]string{"move", "456"}, args...)...) + assert.Equal(t, output.CodeUsage, requireFilingError(t, run).Code) + assert.Empty(t, transport.posts) + }) + } +} + +func TestCardsMoveReportsAFailedFiling(t *testing.T) { + transport := &mockFilingTransport{statuses: []string{"failed"}} + run := runFilingCommand(t, transport, "move", "456", "--to-project", "Dest") + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeAPI, e.Code) + assert.Contains(t, e.Message, "could not move card #456") + assert.Len(t, transport.posts, 1) +} + +func TestCardsMoveStillRunningWhenTheWaitRunsOut(t *testing.T) { + transport := &mockFilingTransport{statuses: []string{"processing"}} + run := runFilingCommand(t, transport, "move", "456", "--to-project", "Dest") + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeInProgress, e.Code) + assert.Contains(t, e.Message, "still moving card #456") + assert.Contains(t, e.Hint, "basecamp filings show 70") + assert.Len(t, transport.posts, 1, "the move is never sent twice") + assert.Greater(t, transport.filingGet, 1) +} + +func TestCardsCopyDefaultsToItsOwnCardTable(t *testing.T) { + transport := &mockFilingTransport{statuses: []string{"completed"}} + run := runFilingCommand(t, transport, "copy", "456") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/99999/recordings/456/copies"}, transport.posts) + assert.Equal(t, float64(555), transport.body["filing_destination_id"]) + assert.NotContains(t, transport.body, "include_comments") + assert.Equal(t, float64(4560), run.envelope.Data["id"]) + assert.Equal(t, float64(456), run.envelope.Data["source_id"]) + assert.True(t, strings.HasPrefix(run.envelope.Summary, "Copied card #456 to "), run.envelope.Summary) +} + +func TestCardsCopyToProjectIncludingComments(t *testing.T) { + transport := &mockFilingTransport{statuses: []string{"completed"}} + run := runFilingCommand(t, transport, "copy", "456", "--to-project", "Dest", "--include-comments") + require.NoError(t, run.err) + + assert.Equal(t, float64(900), transport.body["filing_destination_id"]) + assert.Equal(t, true, transport.body["include_comments"]) + assert.Equal(t, float64(4560), run.envelope.Data["id"]) +} + +func TestCardsCopyStillRunningSaysNotToCopyAgain(t *testing.T) { + transport := &mockFilingTransport{statuses: []string{"pending"}} + run := runFilingCommand(t, transport, "copy", "456", "--to-project", "Dest") + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeInProgress, e.Code) + assert.Contains(t, e.Hint, "Do not copy it again") + assert.Contains(t, e.Hint, "basecamp filings show 70") + assert.Len(t, transport.posts, 1) +} + +func TestCardsMoveToAForeignColumnPointsAtCrossProjectMoves(t *testing.T) { + transport := &mockFilingTransport{} + run := runFilingCommand(t, transport, "move", "456", "--to", "8888", "--in", "123") + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeNotFound, e.Code) + assert.Contains(t, e.Message, "column 8888") + assert.Contains(t, e.Hint, "basecamp cards move 456 --to-project <project>") + assert.Contains(t, e.Hint, "--to-wormhole") +} + +func TestCardsFilingRefusesAProjectTheCardIsNotIn(t *testing.T) { + foreignURL := "https://3.basecamp.com/99999/buckets/888/card_tables/cards/456" + for name, args := range map[string][]string{ + "move with --in": {"move", "456", "--in", "888", "--to-project", "Dest"}, + "move by a card URL": {"move", foreignURL, "--to-project", "Dest"}, + "copy with --in": {"copy", "456", "--in", "888", "--to-project", "Dest"}, + "copy onto its own table": {"copy", "456", "--in", "Bare"}, + "copy by a card URL": {"copy", foreignURL}, + } { + t.Run(name, func(t *testing.T) { + transport := &mockFilingTransport{statuses: []string{"completed"}} + run := runFilingCommand(t, transport, args...) + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeUsage, e.Code) + assert.Contains(t, e.Message, "The card is in project 123") + assert.Contains(t, e.Message, "project 888") + assert.Empty(t, transport.posts) + }) + } +} + +func TestCardsFilingAcceptsTheCardsOwnProject(t *testing.T) { + ownURL := "https://3.basecamp.com/99999/buckets/123/card_tables/cards/456" + for name, args := range map[string][]string{ + "move with --in": {"move", "456", "--in", "Source", "--to-project", "Dest"}, + "move by a card URL": {"move", ownURL, "--to-project", "Dest"}, + "copy with --in": {"copy", "456", "--in", "123"}, + "copy by a card URL": {"copy", ownURL, "--to-project", "Dest"}, + } { + t.Run(name, func(t *testing.T) { + transport := &mockFilingTransport{statuses: []string{"completed"}} + run := runFilingCommand(t, transport, args...) + require.NoError(t, run.err) + assert.Len(t, transport.posts, 1) + assert.Equal(t, float64(4560), run.envelope.Data["id"]) + }) + } +} + +func TestCardsFilingRefusesASourceURLThatIsNotThisAccountsCard(t *testing.T) { + for name, tc := range map[string]struct{ url, message string }{ + "untrusted host": {"https://3.basecamp.com.evil.example/99999/buckets/123/card_tables/cards/456", "not a Basecamp URL"}, + "another account": {"https://3.basecamp.com/11111/buckets/123/card_tables/cards/456", "URL is for account 11111"}, + "a to-do": {"https://3.basecamp.com/99999/buckets/123/todos/456", "the URL names something else"}, + "a cards listing": {"https://3.basecamp.com/99999/buckets/123/card_tables/lists/456/cards", "the URL names something else"}, + "a card table": {"https://3.basecamp.com/99999/buckets/123/card_tables/456", "the URL names something else"}, + } { + for _, verb := range []string{"move", "copy"} { + t.Run(verb+" "+name, func(t *testing.T) { + transport := &mockFilingTransport{statuses: []string{"completed"}} + run := runFilingCommand(t, transport, verb, tc.url, "--to-project", "Dest") + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeUsage, e.Code) + assert.Contains(t, e.Message, tc.message) + assert.Zero(t, transport.requests, "refused before any request") + }) + } + } +} + +func TestCardsFilingRefusesADestinationURLThatIsNotThisAccountsCardTable(t *testing.T) { + for name, tc := range map[string]struct{ url, message string }{ + "untrusted host": {"https://3.basecamp.com.evil.example/99999/buckets/999/card_tables/900", "not a Basecamp URL"}, + "another account": {"https://3.basecamp.com/11111/buckets/999/card_tables/900", "URL is for account 11111"}, + "a column": {"https://3.basecamp.com/99999/buckets/999/card_tables/columns/901", "the URL names something else"}, + } { + for _, verb := range []string{"move", "copy"} { + t.Run(verb+" "+name, func(t *testing.T) { + transport := &mockFilingTransport{statuses: []string{"completed"}} + run := runFilingCommand(t, transport, verb, "456", "--to-card-table", tc.url) + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeUsage, e.Code) + assert.Contains(t, e.Message, tc.message) + assert.Zero(t, transport.requests, "refused before any request") + }) + } + } +} + +func TestCardsFilingRefusesACardTableURLInAnotherProjectThanToProject(t *testing.T) { + for _, verb := range []string{"move", "copy"} { + t.Run(verb, func(t *testing.T) { + transport := &mockFilingTransport{statuses: []string{"completed"}} + run := runFilingCommand(t, transport, verb, "456", + "--to-project", "Dest", "--to-card-table", "https://3.basecamp.com/99999/buckets/123/card_tables/900") + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeUsage, e.Code) + assert.Contains(t, e.Message, "URL is in project 123, but --to-project points at project 999") + assert.Empty(t, transport.posts) + }) + } +} + +func TestCardsFilingRefusesACardTableURLWhoseProjectIsNotTheTables(t *testing.T) { + for _, verb := range []string{"move", "copy"} { + t.Run(verb, func(t *testing.T) { + transport := &mockFilingTransport{statuses: []string{"completed"}} + run := runFilingCommand(t, transport, verb, "456", + "--to-card-table", "https://3.basecamp.com/99999/buckets/123/card_tables/900") + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeUsage, e.Code) + assert.Equal(t, "The --to-card-table URL names project 123, but card table 900 is in project 999", e.Message) + assert.Empty(t, transport.posts) + }) + } +} + +func TestCardsFilingAcceptsACardTableURLOnItsOwn(t *testing.T) { + transport := &mockFilingTransport{statuses: []string{"completed"}} + run := runFilingCommand(t, transport, "copy", "456", + "--to-card-table", "https://3.basecamp.com/99999/buckets/999/card_tables/900") + require.NoError(t, run.err) + assert.Equal(t, float64(900), transport.body["filing_destination_id"]) +} + +func TestCardsFilingAcceptsACardTableURLInToProject(t *testing.T) { + transport := &mockFilingTransport{twoTables: true, statuses: []string{"completed"}} + run := runFilingCommand(t, transport, "copy", "456", + "--to-project", "Dest", "--to-card-table", "https://3.basecamp.com/99999/buckets/999/card_tables/950") + require.NoError(t, run.err) + assert.Equal(t, float64(950), transport.body["filing_destination_id"]) +} diff --git a/internal/commands/cards_test.go b/internal/commands/cards_test.go index bfe29cd35..8d80a7694 100644 --- a/internal/commands/cards_test.go +++ b/internal/commands/cards_test.go @@ -673,23 +673,6 @@ func TestCardsColumnsRequiresProject(t *testing.T) { } } -// TestCardsColumnShowRequiresProject tests that Project ID required for column show. -func TestCardsColumnShowRequiresProject(t *testing.T) { - app, _ := setupTestApp(t) - // No project in config - - project := "" - cmd := newCardsColumnShowCmd(&project) - - err := executeCommand(cmd, app, "456") - 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, "Project ID required", e.Message) - } -} - // ============================================================================= // Numeric Column ID Shortcut Tests // ============================================================================= @@ -2062,6 +2045,8 @@ type mockWormholeTransport struct { linked *bool secondTable bool cardNoParent bool // card 456 comes back without a parent column + stuck bool // a teleported card never lands + moved bool method string path string body []byte @@ -2096,6 +2081,9 @@ func (t *mockWormholeTransport) RoundTrip(req *http.Request) (*http.Response, er } else { body = `{"id": 123, "dock": [{"name": "kanban_board", "id": 555, "title": "Board"}]}` } + case strings.Contains(req.URL.Path, "/card_tables/cards/456") && t.moved && !t.stuck: + // Reading a teleported card's old id follows the redirect to its new one. + body = `{"id": 4560, "title": "Test Card", "bucket": {"id": 999, "name": "Anniversary", "type": "Project"}, "parent": {"id": 888, "title": "Triage", "type": "Kanban::Triage"}}` case strings.Contains(req.URL.Path, "/card_tables/cards/456"): if t.cardNoParent { body = `{"id": 456, "title": "Test Card", "bucket": {"id": 123, "name": "Test Project", "type": "Project"}}` @@ -2124,6 +2112,7 @@ func (t *mockWormholeTransport) RoundTrip(req *http.Request) (*http.Response, er switch req.Method { case "POST": if strings.Contains(req.URL.Path, "/moves.json") { + t.moved = true return &http.Response{StatusCode: 204, Body: io.NopCloser(strings.NewReader("")), Header: header}, nil } return &http.Response{StatusCode: 201, Body: io.NopCloser(strings.NewReader(t.wormholeJSON())), Header: header}, nil @@ -2171,16 +2160,46 @@ func TestCardsMoveToWormholeByID(t *testing.T) { require.NoError(t, json.Unmarshal(transport.body, &body)) assert.Equal(t, float64(111), body["column_id"]) - // Output reports source_id (not id) and status teleporting, and carries no - // same-id "view card" breadcrumb (that id is about to 404). + // Output is the card where it landed, read through the old id's redirect. var envelope struct { - Data map[string]any `json:"data"` + Data map[string]any `json:"data"` + Summary string `json:"summary"` + } + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope)) + assert.Equal(t, float64(4560), envelope.Data["id"]) + assert.Equal(t, float64(456), envelope.Data["source_id"]) + assert.Equal(t, "Teleported card #456 to Anniversary › Triage as card #4560", envelope.Summary) +} + +// TestCardsMoveToWormholeStillTeleporting verifies that when the card has not +// landed by the end of the wait, the output reports the source under source_id +// (not id), says the teleport is still in progress, and carries no same-id +// "view card" breadcrumb. +func TestCardsMoveToWormholeStillTeleporting(t *testing.T) { + shortFilingWait(t) + transport := &mockWormholeTransport{stuck: true} + app, buf := newTestAppWithTransport(t, transport) + app.Flags.Hints = true + + project := "" + cardTable := "" + err := executeCommand(newCardsMoveCmd(&project, &cardTable), app, "456", "--to-wormhole", "111") + require.NoError(t, err) + + var envelope struct { + Data map[string]any `json:"data"` + Summary string `json:"summary"` + Breadcrumbs []output.Breadcrumb `json:"breadcrumbs"` } require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope)) assert.Equal(t, "456", envelope.Data["source_id"]) assert.Equal(t, "teleporting", envelope.Data["status"]) assert.NotContains(t, envelope.Data, "id") - assert.NotContains(t, buf.String(), "cards show 456") + assert.Contains(t, envelope.Summary, "still in progress") + require.NotEmpty(t, envelope.Breadcrumbs) + for _, crumb := range envelope.Breadcrumbs { + assert.NotContains(t, crumb.Cmd, "cards show 456") + } } // TestCardsMoveToWormholeByDestinationURL verifies a destination-column URL is diff --git a/internal/commands/checkins.go b/internal/commands/checkins.go index b5e01e377..7972ab55b 100644 --- a/internal/commands/checkins.go +++ b/internal/commands/checkins.go @@ -1,6 +1,7 @@ package commands import ( + "context" "fmt" "strconv" "strings" @@ -257,17 +258,19 @@ func newCheckinsQuestionCmd(project *string) *cobra.Command { if len(args) == 0 { return missingArg(cmd, "<id|url>") } - return runCheckinsQuestionShow(cmd, *project, args[0], cf) + return runCheckinsQuestionShow(cmd, args[0], cf) } cmd.AddCommand( - newCheckinsQuestionShowCmd(project), + newCheckinsQuestionShowCmd(), newCheckinsQuestionCreateCmd(project), newCheckinsQuestionUpdateCmd(project), newCheckinsQuestionPauseCmd(), newCheckinsQuestionResumeCmd(), newCheckinsQuestionNotifyCmd(), newCheckinsQuestionAnswerersCmd(), + newFilingCmd(questionFamily(), basecamp.FilingMove, checkinsQuestionMoveHelp, false), + newFilingCmd(questionFamily(), basecamp.FilingCopy, checkinsQuestionCopyHelp, false), ) return cmd @@ -523,14 +526,14 @@ func checkinsQuestionID(arg string) (int64, error) { return id, nil } -func newCheckinsQuestionShowCmd(project *string) *cobra.Command { +func newCheckinsQuestionShowCmd() *cobra.Command { cmd := &cobra.Command{ Use: "show <id|url>", Short: "Show question details", Long: `Display details of a check-in question. You can pass either a question ID or a Basecamp URL: - basecamp checkins question show 789 --in my-project + basecamp checkins question show 789 basecamp checkins question show https://3.basecamp.com/123/buckets/456/questionnaires/questions/789`, Args: cobra.ExactArgs(1), } @@ -538,44 +541,21 @@ You can pass either a question ID or a Basecamp URL: cf := addCommentFlags(cmd, false) cmd.RunE = func(cmd *cobra.Command, args []string) error { - return runCheckinsQuestionShow(cmd, *project, args[0], cf) + return runCheckinsQuestionShow(cmd, args[0], cf) } return cmd } -func runCheckinsQuestionShow(cmd *cobra.Command, project, questionIDStr string, cf *commentFlags) error { +func runCheckinsQuestionShow(cmd *cobra.Command, questionIDStr string, cf *commentFlags) error { app := appctx.FromContext(cmd.Context()) if err := ensureAccount(cmd, app); err != nil { return err } - // Extract ID and project from URL if provided - questionIDStr, urlProjectID, err := extractWithProject(questionIDStr) - if err != nil { - return err - } - - // Resolve project - use URL > flag > config, with interactive fallback - projectID := project - if projectID == "" && urlProjectID != "" { - projectID = urlProjectID - } - if projectID == "" { - projectID = app.Flags.Project - } - if projectID == "" { - projectID = app.Config.ProjectID - } - if projectID == "" { - if err := ensureProject(cmd, app); err != nil { - return err - } - projectID = app.Config.ProjectID - } - - resolvedProjectID, _, err := app.Names.ResolveProject(cmd.Context(), projectID) + // The question is read by its account-wide ID, so no project is needed. + questionIDStr, _, err := extractWithProject(questionIDStr) if err != nil { return err } @@ -589,6 +569,9 @@ func runCheckinsQuestionShow(cmd *cobra.Command, project, questionIDStr string, if err != nil { return convertSDKError(err) } + // A moved question's old id redirects to the question at its new id, and + // only the new id reaches its answers and comments. + questionIDStr = strconv.FormatInt(question.ID, 10) enrichment := fetchCommentsForRecording(cmd.Context(), app, questionIDStr, cf) data, commentOpts := enrichment.apply(question, "") @@ -600,12 +583,12 @@ func runCheckinsQuestionShow(cmd *cobra.Command, project, questionIDStr string, output.WithBreadcrumbs( output.Breadcrumb{ Action: "answers", - Cmd: fmt.Sprintf("basecamp checkins answers %s --in %s", questionIDStr, resolvedProjectID), + Cmd: fmt.Sprintf("basecamp checkins answers %s --in %s", questionIDStr, recordProject(question.Bucket)), Description: "View answers", }, output.Breadcrumb{ Action: "questions", - Cmd: fmt.Sprintf("basecamp checkins questions --in %s", resolvedProjectID), + Cmd: fmt.Sprintf("basecamp checkins questions --in %s", recordProject(question.Bucket)), Description: "View all questions", }, ), @@ -1646,3 +1629,80 @@ func parseTimeOfDay(t string) (int, int, error) { return hour, minute, nil } + +var checkinsQuestionMoveHelp = filingHelp{ + use: "move <id|url>", + short: "Move a check-in question to another project's Automatic Check-ins", + long: `Move a check-in question, with its answers, to the Automatic Check-ins of +another project, as the web's Move… does. + + basecamp checkins question move 789 --to-project "Marketing" + basecamp checkins question move https://3.basecamp.com/123/buckets/456/questions/789 --to-questionnaire 1069479999 + +--to-project alone needs a project with one Automatic Check-ins. The command +waits for Basecamp to finish the move and shows the question where it landed, +under a new ID.`, + notes: "--to-project <project> (ID or name) moves the question and its answers to that project's Automatic Check-ins; --to-questionnaire <id|url> names one directly. " + + "Output is the moved question: data.id is its NEW id and data.source_id the old one. Write to the new id; the old id stops taking changes, and reading it follows the move. " + + "--questionnaire names the question's own Automatic Check-ins and is refused; moving onto the one it is in is refused. Basecamp refuses moving an answer on its own. " + + "An in_progress error names its filing: do not move it again; basecamp filings show <filing id> gives its new id once it lands.", +} + +var checkinsQuestionCopyHelp = filingHelp{ + use: "copy <id|url>", + short: "Copy a check-in question to Automatic Check-ins in this or another project", + long: `Copy a check-in question into a project's Automatic Check-ins, as the web's +Copy… does. The original stays where it is. + +With no destination, the copy goes into the question's own Automatic Check-ins: + basecamp checkins question copy 789 + basecamp checkins question copy 789 --to-project "Marketing" + basecamp checkins question copy 789 --to-questionnaire 1069479999 --include-comments + +Basecamp makes the copy in the background; the command waits for it and shows +the new question.`, + notes: "Without --to-project/--to-questionnaire the copy goes into the question's own Automatic Check-ins. " + + "Output is the new question; its id is data.id and the original's is data.source_id. " + + "Never re-run a copy that reported in_progress or a may-have-started error: each run makes another copy; check an in_progress one with basecamp filings show <filing id>.", +} + +// questionFamily is how check-in questions move and copy between projects' +// Automatic Check-ins. +func questionFamily() filingFamily { + return filingFamily{ + noun: "question", + group: "basecamp checkins question", + types: []string{"Question"}, + urlKinds: []string{"questions"}, + urlShape: "…/questions/<id>", + into: filingContainer{ + dockName: "questionnaire", + noun: "Automatic Check-ins", + plural: "Automatic Check-ins tools", + flag: "--to-questionnaire", + urlKind: "questionnaires", + urlShape: "…/questionnaires/<id>", + get: func(ctx context.Context, app *appctx.App, id int64) (filingTarget, error) { + q, err := app.Account().Checkins().GetQuestionnaire(ctx, id) + if err != nil { + return filingTarget{}, err + } + return bucketTarget(q.ID, q.Title, q.AppURL, q.Bucket), nil + }, + }, + get: func(ctx context.Context, app *appctx.App, id int64) (any, filingSource, error) { + q, err := app.Account().Checkins().GetQuestion(ctx, id) + if err != nil { + return nil, filingSource{}, err + } + return q, filingSource{id: q.ID, typ: q.Type, status: q.Status, title: q.Title, bucket: q.Bucket, parent: q.Parent}, nil + }, + crumbs: func(id, projectID int64) []output.Breadcrumb { + return []output.Breadcrumb{{ + Action: "show", + Cmd: fmt.Sprintf("basecamp checkins question show %d --in %d", id, projectID), + Description: "View the question", + }} + }, + } +} diff --git a/internal/commands/checkins_filing_test.go b/internal/commands/checkins_filing_test.go new file mode 100644 index 000000000..375eb0e44 --- /dev/null +++ b/internal/commands/checkins_filing_test.go @@ -0,0 +1,56 @@ +package commands + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" +) + +// checkinsFilingTransport has question 456 in Automatic Check-ins 555 in +// Source (123), and Dest (999) with Automatic Check-ins 900, which the filed +// question 4560 is in. +func checkinsFilingTransport() *familyTransport { + return &familyTransport{gets: map[string]string{ + "/projects": filingProjects, + "/projects/123": `{"id":123,"name":"Source","dock":[{"name":"questionnaire","id":555,"title":"Automatic Check-ins","enabled":true}]}`, + "/projects/999": `{"id":999,"name":"Dest","dock":[{"name":"questionnaire","id":900,"title":"Automatic Check-ins","enabled":true,"app_url":"https://3.basecamp.com/99999/buckets/999/questionnaires/900"}]}`, + "/questionnaires/555": `{"id":555,"title":"Automatic Check-ins","bucket":{"id":123,"name":"Source"}}`, + "/questionnaires/900": `{"id":900,"title":"Automatic Check-ins","bucket":{"id":999,"name":"Dest"}}`, + "/questions/456": `{"id":456,"type":"Question","title":"What did you work on?","bucket":{"id":123,"name":"Source"},"parent":{"id":555,"title":"Automatic Check-ins","type":"Questionnaire"}}`, + "/questions/4560": `{"id":4560,"type":"Question","title":"What did you work on?","bucket":{"id":999,"name":"Dest"},"parent":{"id":900,"title":"Automatic Check-ins","type":"Questionnaire"}}`, + }} +} + +func TestCheckinsQuestionMoveToProject(t *testing.T) { + transport := checkinsFilingTransport() + run := runFamilyCommand(t, transport, NewCheckinsCmd(), "question", "move", "456", "--to-project", "Dest") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/99999/recordings/456/moves"}, transport.posts) + assert.Equal(t, float64(900), transport.bodies[0]["filing_destination_id"]) + data := run.data(t) + assert.Equal(t, float64(4560), data["id"]) + assert.Equal(t, float64(456), data["source_id"]) + assert.Equal(t, "Moved question #456 to Dest › Automatic Check-ins as question #4560", run.envelope.Summary) + assert.Equal(t, "basecamp checkins question show 4560 --in 999", run.envelope.Breadcrumbs[0].Cmd) +} + +func TestCheckinsQuestionMoveOntoItsOwnQuestionnaireIsRefused(t *testing.T) { + transport := checkinsFilingTransport() + run := runFamilyCommand(t, transport, NewCheckinsCmd(), "question", "move", "456", "--to-questionnaire", "555") + e := run.failure(t) + assert.Equal(t, output.CodeUsage, e.Code) + assert.Contains(t, e.Message, "is already in Automatic Check-ins 555") + assert.Empty(t, transport.posts) +} + +func TestCheckinsQuestionCopyWithoutDestinationGoesIntoItsOwnQuestionnaire(t *testing.T) { + transport := checkinsFilingTransport() + run := runFamilyCommand(t, transport, NewCheckinsCmd(), "question", "copy", "456") + require.NoError(t, run.err) + assert.Equal(t, []string{"/99999/recordings/456/copies"}, transport.posts) + assert.Equal(t, float64(555), transport.bodies[0]["filing_destination_id"]) +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 7936ac2d7..3c4bc76ff 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -34,21 +34,21 @@ func CommandCategories() []CommandCategory { { Name: "Core Commands", Commands: []CommandInfo{ - {Name: "projects", Category: "core", Description: "Manage projects", Actions: []string{"list", "show", "create", "update", "delete", "wallpaper"}}, - {Name: "todos", Category: "core", Description: "Manage to-dos", Actions: []string{"list", "show", "create", "update", "complete", "uncomplete", "position", "trash", "archive", "restore"}}, + {Name: "projects", Category: "core", Description: "Manage projects", Actions: []string{"list", "show", "create", "update", "delete", "wallpaper", "logo"}}, + {Name: "todos", Category: "core", Description: "Manage to-dos", Actions: []string{"list", "show", "create", "update", "complete", "uncomplete", "position", "copy", "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: "todolists", Category: "core", Description: "Manage to-do lists", Actions: []string{"list", "show", "create", "update", "position", "copy", "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"}}, {Name: "gauges", Category: "core", Description: "Manage gauges", Actions: []string{"list", "needles", "needle", "create", "update", "delete", "enable", "disable"}}, - {Name: "todolistgroups", Category: "core", Description: "Manage to-do list groups", Actions: []string{"list", "show", "create", "update", "position"}}, - {Name: "messages", Category: "core", Description: "Manage messages", Actions: []string{"list", "show", "create", "update", "publish", "pin", "unpin", "trash", "archive", "restore"}}, + {Name: "todolistgroups", Category: "core", Description: "Manage to-do list groups", Actions: []string{"list", "show", "create", "update", "position", "copy"}}, + {Name: "messages", Category: "core", Description: "Manage messages", Actions: []string{"list", "show", "create", "update", "publish", "pin", "unpin", "move", "copy", "trash", "archive", "restore"}}, {Name: "chat", Category: "core", Description: "Chat in real-time", Actions: []string{"list", "messages", "post", "upload", "line", "update", "delete"}}, {Name: "pings", Category: "core", Description: "Talk to people directly", Actions: []string{"list", "show", "start", "messages", "post", "upload"}}, - {Name: "cards", Category: "core", Description: "Manage Kanban cards", Actions: []string{"list", "show", "create", "update", "move", "done", "columns", "wormholes", "steps", "trash", "archive", "restore"}}, - {Name: "files", Category: "core", Description: "Manage files, documents, and folders", Actions: []string{"list", "show", "versions", "replace", "download", "update", "move", "trash", "archive", "restore"}}, + {Name: "cards", Category: "core", Description: "Manage Kanban cards", Actions: []string{"list", "show", "create", "update", "move", "copy", "done", "columns", "wormholes", "steps", "trash", "archive", "restore"}}, + {Name: "files", Category: "core", Description: "Manage files, documents, and folders", Actions: []string{"list", "show", "versions", "replace", "download", "update", "move", "copy", "trash", "archive", "restore"}}, {Name: "checkins", Category: "core", Description: "View automatic check-ins", Actions: []string{"questions", "question", "answers", "answer", "reminders"}}, - {Name: "schedule", Category: "core", Description: "Manage schedule entries", Actions: []string{"show", "entries", "create", "update"}}, + {Name: "schedule", Category: "core", Description: "Manage schedule entries", Actions: []string{"show", "entries", "create", "update", "move", "copy"}}, }, }, { @@ -65,9 +65,9 @@ func CommandCategories() []CommandCategory { { Name: "Files & Docs", Commands: []CommandInfo{ - {Name: "uploads", Category: "files", Description: "List and manage uploads", Actions: []string{"list", "show", "download", "update", "trash", "archive", "restore"}}, - {Name: "vaults", Category: "files", Description: "Manage folders (vaults)", Actions: []string{"list", "show", "download", "update", "move", "trash", "archive", "restore"}}, - {Name: "docs", Category: "files", Description: "Manage documents", Actions: []string{"list", "show", "download", "update", "move", "trash", "archive", "restore"}}, + {Name: "uploads", Category: "files", Description: "List and manage uploads", Actions: []string{"list", "show", "download", "update", "move", "copy", "trash", "archive", "restore"}}, + {Name: "vaults", Category: "files", Description: "Manage folders (vaults)", Actions: []string{"list", "show", "download", "update", "move", "copy", "trash", "archive", "restore"}}, + {Name: "docs", Category: "files", Description: "Manage documents", Actions: []string{"list", "show", "download", "update", "move", "copy", "trash", "archive", "restore"}}, }, }, { @@ -103,7 +103,7 @@ func CommandCategories() []CommandCategory { Commands: []CommandInfo{ {Name: "messageboards", Category: "communication", Description: "View message boards", Actions: []string{"show"}}, {Name: "messagetypes", Category: "communication", Description: "Manage message categories", Actions: []string{"list", "show", "create", "update", "delete"}}, - {Name: "forwards", Category: "communication", Description: "Manage email forwards (inbox)", Actions: []string{"list", "show", "inbox", "replies", "reply"}}, + {Name: "forwards", Category: "communication", Description: "Manage email forwards (inbox)", Actions: []string{"list", "show", "inbox", "replies", "reply", "move", "copy"}}, {Name: "subscriptions", Category: "communication", Description: "Manage notification subscriptions", Actions: []string{"show", "subscribe", "unsubscribe", "add", "remove"}}, {Name: "attachments", Category: "communication", Description: "List and download attachments", Actions: []string{"list", "download"}}, {Name: "embeds", Category: "communication", Description: "Embed videos, charts and other links in rich text", Actions: []string{"create", "show"}}, @@ -119,6 +119,7 @@ func CommandCategories() []CommandCategory { {Name: "search", Category: "search", Description: "Search across projects"}, {Name: "recordings", Category: "search", Description: "Browse content by type across projects", Actions: []string{"list", "trash", "archive", "restore", "visibility"}}, {Name: "show", Category: "search", Description: "Show any item by ID"}, + {Name: "filings", Category: "search", Description: "Check a move or copy that is still running", Actions: []string{"show"}}, {Name: "events", Category: "search", Description: "View change history and poll the account event feed", Actions: []string{"poll", "ticket"}}, {Name: "url", Category: "search", Description: "Parse Basecamp URLs"}, }, @@ -145,7 +146,7 @@ func CommandCategories() []CommandCategory { Commands: []CommandInfo{ {Name: "commands", Category: "additional", Description: "List all commands"}, {Name: "completion", Category: "additional", Description: "Generate shell completions", Actions: []string{"bash", "zsh", "fish", "powershell", "refresh", "status"}}, - {Name: "tools", Category: "additional", Description: "Manage project dock tools", Actions: []string{"show", "create", "update", "trash", "enable", "disable", "reposition"}}, + {Name: "tools", Category: "additional", Description: "Manage project dock tools", Actions: []string{"show", "create", "update", "trash", "enable", "disable", "reposition", "move"}}, {Name: "skill", Category: "additional", Description: "Manage the embedded agent skill file", Actions: []string{"install"}}, {Name: "tui", Category: "additional", Description: "Launch the Basecamp workspace", Experimental: true, DevOnly: true}, {Name: "bonfire", Category: "additional", Description: "Multi-chat orchestration", Actions: []string{"split", "layout"}, Experimental: true, DevOnly: true}, diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index 0339ea7a3..cc7728a27 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -77,6 +77,7 @@ func buildRootWithAllCommands() *cobra.Command { root.AddCommand(commands.NewURLCmd()) root.AddCommand(commands.NewSearchCmd()) root.AddCommand(commands.NewRecordingsCmd()) + root.AddCommand(commands.NewFilingsCmd()) root.AddCommand(commands.NewChatCmd()) root.AddCommand(commands.NewPingsCmd()) root.AddCommand(commands.NewScheduleCmd()) diff --git a/internal/commands/files.go b/internal/commands/files.go index cc3b97c7c..236423134 100644 --- a/internal/commands/files.go +++ b/internal/commands/files.go @@ -2,6 +2,7 @@ package commands import ( "cmp" + "context" "errors" "fmt" "io" @@ -48,11 +49,12 @@ Each project has a root folder containing documents, uploads, and subfolders.`, newFoldersCmd(&project, &vaultID), newUploadsCmd(&project, &vaultID), newDocsCmd(&project, &vaultID), - newFilesShowCmd(&project), + newFilesShowCmd(), newFilesVersionsCmd(&project), newFilesReplaceCmd(&project), newFilesUpdateCmd(&project), newFilesMoveCmd(), + newFilesCopyCmd(), newFilesDownloadCmd(&project), newRecordableTrashCmd("file"), newRecordableArchiveCmd("file"), @@ -88,7 +90,7 @@ func NewUploadsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "uploads", Short: "Manage uploaded files", - Long: "List, show, and upload files in a project's Docs & Files area.", + Long: "List, show, upload, move and copy files in a project's Docs & Files area.", } cmd.PersistentFlags().StringVarP(&project, "project", "p", "", "Project ID or name") @@ -99,7 +101,9 @@ func NewUploadsCmd() *cobra.Command { cmd.AddCommand( newUploadsListCmd(&project, &vaultID), newUploadsCreateCmd(&project, &vaultID), - newFilesShowCmd(&project), + newFilesShowCmd(), + newFilesMoveCmd(), + newFilesCopyCmd(), ) return cmd @@ -1417,7 +1421,7 @@ Use - as the content argument to read the document body from stdin: return cmd } -func newFilesShowCmd(project *string) *cobra.Command { +func newFilesShowCmd() *cobra.Command { var itemType string var dlDir *string var cf *commentFlags @@ -1425,10 +1429,11 @@ func newFilesShowCmd(project *string) *cobra.Command { cmd := &cobra.Command{ Use: "show <id|url>", Short: "Show item details", - Long: `Show details for a vault, document, or upload. + Long: `Show details for a folder (vault), document, upload, cloud file or Google +document. You can pass either an item ID or a Basecamp URL: - basecamp files show 789 --in my-project + basecamp files show 789 basecamp files show https://3.basecamp.com/123/buckets/456/documents/789`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -1438,8 +1443,8 @@ You can pass either an item ID or a Basecamp URL: return err } - // Extract ID and project from URL if provided - itemIDStr, urlProjectID, err := extractWithProject(args[0]) + // The item is read by its account-wide ID, so no project is needed. + itemIDStr, _, err := extractWithProject(args[0]) if err != nil { return err } @@ -1449,73 +1454,23 @@ You can pass either an item ID or a Basecamp URL: return output.ErrUsage("Invalid item ID") } - // Resolve project - use URL > flag > config, with interactive fallback - projectID := *project - if projectID == "" && urlProjectID != "" { - projectID = urlProjectID - } - if projectID == "" { - projectID = app.Flags.Project - } - if projectID == "" { - projectID = app.Config.ProjectID - } - if projectID == "" { - if err := ensureProject(cmd, app); err != nil { - return err - } - projectID = app.Config.ProjectID - } - - resolvedProjectID, _, err := app.Names.ResolveProject(cmd.Context(), projectID) - if err != nil { - return err - } - // Try to detect type if not specified var result any + var bucket *basecamp.Bucket var detectedType string var title string if itemType == "" { - // Auto-detect type by trying each in order - // Track first error to return if all fail (may be auth error, not 404) - var firstErr error - - // Try vault first - vault, err := app.Account().Vaults().Get(cmd.Context(), itemID) - if err == nil { - result = vault - detectedType = "vault" - title = vault.Title - } else { - firstErr = err - // Try upload - upload, err := app.Account().Uploads().Get(cmd.Context(), itemID) - if err == nil { - result = upload - detectedType = "upload" - title = upload.Filename - if title == "" { - title = upload.Title - } - } else { - // Try document - doc, err := app.Account().Documents().Get(cmd.Context(), itemID) - if err == nil { - result = doc - detectedType = "document" - title = doc.Title - } - } - } - - // If all probes failed, check if first error was 404 or something else - if result == nil && firstErr != nil { - if sdkErr := basecamp.AsError(firstErr); sdkErr == nil || sdkErr.Code != basecamp.CodeNotFound { - // Return actual error (auth, permission, network, etc.) - return convertSDKError(firstErr) + // Each kind's read of a moved item's old id is redirected to + // the item, so the kind is taken from what comes back. + record, src, err := getFileItem(cmd.Context(), app, itemID) + if err != nil { + if sdkErr := basecamp.AsError(err); sdkErr == nil || sdkErr.Code != basecamp.CodeNotFound { + return convertSDKError(err) } + } else { + result, bucket, title = record, src.bucket, src.title + detectedType = cmp.Or(fileShowTypes[src.typ], "item") } } else { switch itemType { @@ -1524,7 +1479,7 @@ You can pass either an item ID or a Basecamp URL: if err != nil { return convertSDKError(err) } - result = vault + result, bucket = vault, vault.Bucket detectedType = "vault" title = vault.Title case "upload", "file": @@ -1532,7 +1487,7 @@ You can pass either an item ID or a Basecamp URL: if err != nil { return convertSDKError(err) } - result = upload + result, bucket = upload, upload.Bucket detectedType = "upload" title = upload.Filename if title == "" { @@ -1543,13 +1498,29 @@ You can pass either an item ID or a Basecamp URL: if err != nil { return convertSDKError(err) } - result = doc + result, bucket = doc, doc.Bucket detectedType = "document" title = doc.Title + case "cloud_file", "cloud": + cloud, err := app.Account().CloudFiles().Get(cmd.Context(), itemID) + if err != nil { + return convertSDKError(err) + } + result, bucket = cloud, cloud.Bucket + detectedType = "cloud_file" + title = cloud.Title + case "google_document", "google_doc", "gdoc": + google, err := app.Account().GoogleDocuments().Get(cmd.Context(), itemID) + if err != nil { + return convertSDKError(err) + } + result, bucket = google, google.Bucket + detectedType = "google_document" + title = google.Title default: return output.ErrUsageHint( fmt.Sprintf("Invalid type: %s", itemType), - "Use: vault, upload, or document", + "Use: vault, upload, document, cloud_file, or google_document", ) } } @@ -1557,21 +1528,36 @@ You can pass either an item ID or a Basecamp URL: if result == nil { return output.ErrNotFound("item", itemIDStr) } + // A moved item's old id redirects to the item at its new id, and + // only the new id takes writes. + switch item := result.(type) { + case *basecamp.Vault: + itemIDStr = strconv.FormatInt(item.ID, 10) + case *basecamp.Upload: + itemIDStr = strconv.FormatInt(item.ID, 10) + case *basecamp.Document: + itemIDStr = strconv.FormatInt(item.ID, 10) + case *basecamp.CloudFile: + itemIDStr = strconv.FormatInt(item.ID, 10) + case *basecamp.GoogleDocument: + itemIDStr = strconv.FormatInt(item.ID, 10) + } summary := fmt.Sprintf("%s: %s", detectedType, title) - breadcrumbs := []output.Breadcrumb{ - { + var breadcrumbs []output.Breadcrumb + if detectedType != "cloud_file" && detectedType != "google_document" { + breadcrumbs = append(breadcrumbs, output.Breadcrumb{ Action: "update", - Cmd: fmt.Sprintf("basecamp files update %s --in %s", itemIDStr, resolvedProjectID), + Cmd: fmt.Sprintf("basecamp files update %s --in %s", itemIDStr, recordProject(bucket)), Description: "Update item", - }, + }) } if detectedType == "vault" { breadcrumbs = append(breadcrumbs, output.Breadcrumb{ Action: "contents", - Cmd: fmt.Sprintf("basecamp files --vault %s --in %s", itemIDStr, resolvedProjectID), + Cmd: fmt.Sprintf("basecamp files --vault %s --in %s", itemIDStr, recordProject(bucket)), Description: "List contents", }) } @@ -1615,7 +1601,7 @@ You can pass either an item ID or a Basecamp URL: }, } - cmd.Flags().StringVarP(&itemType, "type", "t", "", "Item type (vault, upload, document)") + cmd.Flags().StringVarP(&itemType, "type", "t", "", "Item type (vault, upload, document, cloud_file, google_document)") dlDir = addDownloadAttachmentsFlag(cmd) cf = addCommentFlags(cmd, false) @@ -2277,32 +2263,55 @@ func updateDocument(cmd *cobra.Command, app *appctx.App, itemID int64, existingD func newFilesMoveCmd() *cobra.Command { var to string var position int + var toProject, toFolder string cmd := &cobra.Command{ - Use: "move <id|url>", - Short: "Move a document, upload, or folder into another folder", - Long: `Move a document, upload, or folder into another folder in the same project. - -The item keeps its ID, comments, and history, and a folder takes everything -inside it along. Moving into the folder an item is already in changes only its -position. To move something to another project, use the Move menu in Basecamp. - -You can pass IDs or Basecamp URLs for both the item and the folder: + Use: "move <id|url>...", + Short: "Move documents, files or folders to another folder or project", + Long: `Move a document, upload, or folder into another folder in the same project, +or move items into another project's Docs & Files. + +--to moves within the project: the item keeps its ID, comments, and history, +and a folder takes everything inside it along. Moving into the folder an item +is already in changes only its position. You can pass IDs or Basecamp URLs for +both the item and the folder: basecamp files move 789 --to 456 - basecamp files move https://3.basecamp.com/123/buckets/1/documents/789 --to https://3.basecamp.com/123/buckets/1/vaults/456`, + basecamp files move 789 --to 456 --position 1 + basecamp files move https://3.basecamp.com/123/buckets/1/documents/789 --to https://3.basecamp.com/123/buckets/1/vaults/456 + +--to-project and --to-folder move documents, uploads, cloud files, Google +documents and folders into another project, as the web's Move… does. Each one +lands under a new ID; several IDs move one by one, as the web's bulk Move… does: + basecamp files move 789 --to-project "Marketing" + basecamp files move 789 --to-project "Marketing" --to-folder "Contracts" + basecamp files move 789 790 --to-folder https://3.basecamp.com/123/buckets/999/vaults/555 +--to-folder takes a folder ID or URL anywhere, or with --to-project the title of +a folder at the top of that project's Docs & Files. The command waits for +Basecamp to finish and shows each item where it landed.`, Example: ` basecamp files move 789 --to 456 - basecamp files move 789 --to 456 --position 1`, - Annotations: map[string]string{"agent_notes": "Moves in place within one project: the ID, URL, and comments stay the same. --position is 1-indexed (default: first). A folder in another project is refused."}, - Args: cobra.ExactArgs(1), + basecamp files move 789 --to-project "Marketing" --to-folder "Contracts"`, + Annotations: map[string]string{"agent_notes": "--to moves within one project in place: the ID, URL, and comments stay the same; --position is 1-indexed (default: first). " + + "--to-project/--to-folder move into another project's Docs & Files under a NEW id: output data.id is the new id and data.source_id the old (several IDs: a list). Write to the new id; the old id stops taking changes, and reading it follows the move. " + + "--to-project alone lands at the top of that project's Docs & Files; --to-folder <id|url> picks any folder, nested ones included, and --to-folder <title> with --to-project a top-level folder. " + + "--to/--position cannot be combined with --to-project/--to-folder. A --to folder in another project, or a --to-folder in the item's own project, is refused with the command that fits. " + + "Several IDs move one by one, and naming one item twice (even by its old and new id) is refused before any moves. If any is not moved the command exits non-zero and reports each: run it again only for the ones it says Basecamp refused, never for one it reports moved or not settled. " + + "An in_progress error names its filing: do not move it again; basecamp filings show <filing id> gives its new id once it lands."}, + Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // --vault/--folder filter the files group's listings; here they would be - // silently ignored, and reading them as the destination is a guess. - if cmd.Flags().Changed("vault") || cmd.Flags().Changed("folder") { - return output.ErrUsageHint("files move takes its destination from --to", "basecamp files move <id|url> --to <folder-id|url>") + if familyFilingSet(cmd, filesFamily()) { + if to != "" || cmd.Flags().Changed("position") { + return output.ErrUsageHint("--to and --position move within the project, and cannot be combined with --to-project or --to-folder", + "Use --to <folder> to move within the project, or --to-project/--to-folder to move to another project") + } + return runFamilyFiling(cmd, filesFamily(), basecamp.FilingMove, args, toProject, toFolder, false) } if to == "" { return missingArg(cmd, "--to") } + if len(args) > 1 { + return output.ErrUsageHint("--to moves one item at a time", + "Move each with its own files move --to, or move several to another project with --to-project/--to-folder") + } positionSet := cmd.Flags().Changed("position") if positionSet && (position < 1 || position > math.MaxInt32) { return output.ErrUsage("--position must be a positive integer (1-indexed)") @@ -2321,8 +2330,8 @@ You can pass IDs or Basecamp URLs for both the item and the folder: itemID, itemProjectID, itemKind := item.id, item.project, item.kind folderID, folderProjectID := folder.id, folder.project if itemProjectID != "" && folderProjectID != "" && !sameNumericID(itemProjectID, folderProjectID) { - return output.ErrUsageHint("The folder is in a different project", - "files move only moves within a project; use the Move menu in Basecamp to move to another project") + return output.ErrUsageHint("The folder is in a different project, and --to moves only within a project", + fmt.Sprintf("Move it to that project with: basecamp files move %d --to-folder %d", itemID, folderID)) } if err := ensureAccount(cmd, app); err != nil { @@ -2341,9 +2350,8 @@ You can pass IDs or Basecamp URLs for both the item and the folder: opts = &basecamp.MoveToVaultOptions{Position: int32(position)} // #nosec G115 -- bounds checked above } if err := app.Account().Recordings().MoveToVault(cmd.Context(), itemID, folderID, opts); err != nil { - return filesMoveError(err) + return filesMoveError(err, itemID, folderID) } - itemName := fmt.Sprintf("#%d", itemID) if title := fileItemTitle(cmd, app, itemID, itemKind); title != "" { itemName = title @@ -2383,10 +2391,11 @@ You can pass IDs or Basecamp URLs for both the item and the folder: }, } - cmd.Flags().StringVar(&to, "to", "", "Destination folder ID or URL") + cmd.Flags().StringVar(&to, "to", "", "Destination folder ID or URL in the same project") cmd.Flags().IntVar(&position, "position", 0, "Position in the folder (1-indexed, default: first; past the end means last)") + addFilesFilingFlags(cmd, "Move", &toProject, &toFolder) - return cmd + return guardFilingFlags(cmd) } // filesMoveRef is an ID or URL given to files move, reduced to what the move @@ -2464,7 +2473,7 @@ func inFlag(project string) string { // filesMoveError turns the filing endpoint's refusals into messages that say // what to change. Only some of its 422s carry a reason, so the hint lists them. -func filesMoveError(err error) error { +func filesMoveError(err error, itemID, folderID int64) error { var sdkErr *basecamp.Error if !errors.As(err, &sdkErr) { return convertSDKError(err) @@ -2494,7 +2503,7 @@ func filesMoveError(err error) error { return &output.Error{ Code: output.CodeNotFound, Message: "Item or folder not found", - Hint: "Check both IDs; the folder must be in the same project as the item (use the Move menu in Basecamp to move to another project)", + Hint: fmt.Sprintf("Check both IDs. --to takes a folder in the item's own project; for a folder in another project use: basecamp files move %d --to-folder %d", itemID, folderID), HTTPStatus: sdkErr.HTTPStatus, Cause: sdkErr, } @@ -2776,3 +2785,331 @@ func humanSize(bytes int64) string { func getVaultID(cmd *cobra.Command, app *appctx.App, projectID string) (string, error) { return getDockToolID(cmd.Context(), app, projectID, "vault", "", "vault", "vault") } + +var filesCopyHelp = filingHelp{ + use: "copy <id|url>...", + short: "Copy documents, files or folders to Docs & Files in this or another project", + long: `Copy documents, uploads, cloud files, Google documents or folders into a +project's Docs & Files, as the web's Copy… does. The originals stay where they +are; a folder is copied with everything in it. + +With no destination, each copy goes into the folder its original is in: + basecamp files copy 789 + basecamp files copy 789 --to-project "Marketing" + basecamp files copy 789 --to-project "Marketing" --to-folder "Contracts" + basecamp files copy 789 --to-folder https://3.basecamp.com/123/buckets/456/vaults/999 + basecamp files copy 789 790 791 --to-project "Marketing" --include-comments + +--to-folder takes a folder ID or URL anywhere, or with --to-project the title of +a folder at the top of that project's Docs & Files. Several IDs are copied one +by one, as the web's bulk Copy… does. Basecamp makes each copy in the +background; the command waits and shows the new items.`, + notes: "Without --to-project/--to-folder each copy goes into its original's own folder. " + + "--to-project <project> (ID or name) copies into the top of that project's Docs & Files; --to-folder <id|url> picks any folder, nested ones included, and --to-folder <title> with --to-project a top-level folder. " + + "Output is the new item (several: a list); data.id is the copy and data.source_id the original. " + + "Several IDs are copied one by one, and naming one item twice (even by its old and new id) is refused before any is copied. If any is not copied the command exits non-zero and reports each: copy again only the ones it says Basecamp refused, never one it reports copied, not read back or not settled, since that makes another copy. " + + "Never re-run a copy that reported in_progress or a may-have-started error: each run makes another copy; check an in_progress one with basecamp filings show <filing id>. " + + "--vault/--folder name a folder to list and are refused here; use --to-folder.", +} + +// newFilesCopyCmd copies Docs & Files items, one filing each. +func newFilesCopyCmd() *cobra.Command { + cmd := newFilingCmd(filesFamily(), basecamp.FilingCopy, filesCopyHelp, true) + cmd.Flags().Lookup("to-folder").Usage = "Copy into this folder (ID or URL, or with --to-project a top-level folder's title)" + return cmd +} + +// addFilesFilingFlags adds --to-project and --to-folder, which name the Docs & +// Files and folder items go into. +func addFilesFilingFlags(cmd *cobra.Command, verb string, toProject, toFolder *string) { + addFamilyFilingFlags(cmd, filesFamily(), verb, toProject, toFolder) + cmd.Flags().Lookup("to-folder").Usage = verb + " into this folder in another project (ID or URL, or with --to-project a top-level folder's title)" +} + +// fileKinds are the Docs & Files items the web moves and copies, with the URL +// kind that names each and its own read. +var fileKinds = []struct { + typ, urlKind string + get func(ctx context.Context, app *appctx.App, id int64) (any, filingSource, error) +}{ + {"Document", "documents", func(ctx context.Context, app *appctx.App, id int64) (any, filingSource, error) { + d, err := app.Account().Documents().Get(ctx, id) + if err != nil { + return nil, filingSource{}, err + } + return d, filingSource{id: d.ID, typ: d.Type, status: d.Status, title: d.Title, bucket: d.Bucket, parent: d.Parent}, nil + }}, + {"Upload", "uploads", func(ctx context.Context, app *appctx.App, id int64) (any, filingSource, error) { + u, err := app.Account().Uploads().Get(ctx, id) + if err != nil { + return nil, filingSource{}, err + } + return u, filingSource{id: u.ID, typ: u.Type, status: u.Status, title: cmp.Or(u.Filename, u.Title), bucket: u.Bucket, parent: u.Parent}, nil + }}, + {"Vault", "vaults", func(ctx context.Context, app *appctx.App, id int64) (any, filingSource, error) { + v, err := app.Account().Vaults().Get(ctx, id) + if err != nil { + return nil, filingSource{}, err + } + return v, filingSource{id: v.ID, typ: v.Type, status: v.Status, title: v.Title, bucket: v.Bucket, parent: v.Parent}, nil + }}, + {"CloudFile", "cloud_files", func(ctx context.Context, app *appctx.App, id int64) (any, filingSource, error) { + c, err := app.Account().CloudFiles().Get(ctx, id) + if err != nil { + return nil, filingSource{}, err + } + return c, filingSource{id: c.ID, typ: c.Type, status: c.Status, title: c.Title, bucket: c.Bucket, parent: c.Parent}, nil + }}, + {"GoogleDocument", "google_documents", func(ctx context.Context, app *appctx.App, id int64) (any, filingSource, error) { + g, err := app.Account().GoogleDocuments().Get(ctx, id) + if err != nil { + return nil, filingSource{}, err + } + return g, filingSource{id: g.ID, typ: g.Type, status: g.Status, title: g.Title, bucket: g.Bucket, parent: g.Parent}, nil + }}, +} + +// fileShowTypes names each Docs & Files type as files show reports it. +var fileShowTypes = map[string]string{ + "Document": "document", + "Upload": "upload", + "Vault": "vault", + "CloudFile": "cloud_file", + "GoogleDocument": "google_document", +} + +// getFileItem reads a Docs & Files item of any kind by ID, trying each kind's +// read until one returns a record of its own kind. Only a not-found moves on: +// any other failure is the answer. +func getFileItem(ctx context.Context, app *appctx.App, id int64) (any, filingSource, error) { + var other filingSource + var otherRecord any + var lastErr error + for _, kind := range fileKinds { + record, src, err := kind.get(ctx, app, id) + if err != nil { + var sdkErr *basecamp.Error + if errors.As(err, &sdkErr) && sdkErr.Code == basecamp.CodeNotFound { + lastErr = err + continue + } + return nil, filingSource{}, err + } + if src.typ == kind.typ { + return record, src, nil + } + other, otherRecord = src, record + } + if otherRecord != nil { + return otherRecord, other, nil + } + return nil, filingSource{}, lastErr +} + +// docsAndFiles is a project's Docs & Files, which items are filed into. +func docsAndFiles() filingContainer { + return filingContainer{ + dockName: "vault", + noun: "Docs & Files", + plural: "Docs & Files tools", + flag: "--to-folder", + urlKind: "vaults", + urlShape: "…/vaults/<id>", + get: func(ctx context.Context, app *appctx.App, id int64) (filingTarget, error) { + return filesFolderTarget(ctx, app, id) + }, + } +} + +// filesFamily is how documents, uploads, cloud files, Google documents and +// folders move and copy between projects' Docs & Files. +func filesFamily() filingFamily { + return filingFamily{ + noun: "file", + group: "basecamp files", + types: []string{"Document", "Upload", "Vault", "CloudFile", "GoogleDocument"}, + urlKinds: []string{"documents", "uploads", "vaults", "cloud_files", "google_documents"}, + urlShape: "…/documents/<id>, …/uploads/<id>, …/vaults/<id>, …/cloud_files/<id> or …/google_documents/<id>", + into: docsAndFiles(), + get: getFileItem, + resolve: resolveFilesTarget, + own: func(ctx context.Context, app *appctx.App, src filingSource) (filingTarget, error) { + if src.parent == nil { + return filingTarget{}, output.ErrUsageHint( + fmt.Sprintf("Could not tell which folder file #%d is in", src.id), + "Name the destination with --to-project <project> or --to-folder <id|url>", + ) + } + return filesFolderTarget(ctx, app, src.parent.ID) + }, + alreadyThere: func(src filingSource, target filingTarget) error { + if src.bucket == nil || src.bucket.ID != target.projectID { + return nil + } + folder := cmp.Or(target.parentID, target.id) + return output.ErrUsageHint( + fmt.Sprintf("File #%d is already in %s, where --to-project or --to-folder would give it a new ID", src.id, target.projectName), + fmt.Sprintf("Move it to that folder and keep its ID with: basecamp files move %d --to %d", src.id, folder), + ) + }, + crumbs: func(id, projectID int64) []output.Breadcrumb { + return []output.Breadcrumb{{ + Action: "show", + Cmd: fmt.Sprintf("basecamp files show %d --in %d", id, projectID), + Description: "View the item", + }} + }, + } +} + +// resolveFilesTarget finds the Docs & Files destination: a project's Docs & +// Files, any folder by ID or URL (sent as parent_id inside its project's Docs +// & Files), or a folder at the top of the project's Docs & Files by title. +func resolveFilesTarget(cmd *cobra.Command, app *appctx.App, toProject, toFolder string) (filingTarget, error) { + if toFolder == "" { + return resolveFilingTarget(cmd, app, docsAndFiles(), toProject, "") + } + + var urlProjectID, url string + if urlarg.IsURL(toFolder) { + ref, err := parseFilingRef(app, toFolder, "folder", "vaults", "…/vaults/<id>") + if err != nil { + return filingTarget{}, err + } + url = toFolder + toFolder, urlProjectID = strconv.FormatInt(ref.id, 10), ref.project + } + + // A folder title can be all digits ("2026"), so with --to-project a number + // that names no folder is looked up as a title. A URL names only an id. + if isNumericID(toFolder) { + id, _ := strconv.ParseInt(toFolder, 10, 64) + target, err := filesFolderTarget(cmd.Context(), app, id) + if err != nil && output.AsError(err).Code == output.CodeNotFound { + switch { + case url != "": + return filingTarget{}, filingURLNotFound("--to-folder", "folder", url, "", + "Pass the folder's current URL or ID, or its title with --to-project") + case toProject != "": + return filesFolderByTitle(cmd, app, toProject, toFolder) + } + } + if err != nil { + return filingTarget{}, err + } + if urlProjectID != "" && !sameNumericID(urlProjectID, strconv.FormatInt(target.projectID, 10)) { + return filingTarget{}, output.ErrUsage(fmt.Sprintf("The --to-folder URL names project %s, but folder %d is in project %d", urlProjectID, id, target.projectID)) + } + if toProject != "" { + projectID, _, err := app.Names.ResolveProject(cmd.Context(), toProject) + if err != nil { + return filingTarget{}, err + } + if !sameNumericID(projectID, strconv.FormatInt(target.projectID, 10)) { + return filingTarget{}, output.ErrUsageHint( + fmt.Sprintf("Folder %d is in project %d, but --to-project points at project %s", id, target.projectID, projectID), + "Drop --to-project, since the folder names its own project", + ) + } + } + return target, nil + } + + return filesFolderByTitle(cmd, app, toProject, toFolder) +} + +// filesFolderByTitle finds a folder at the top of a project's Docs & Files by +// title, or the Docs & Files itself. A folder can share the Docs & Files' +// title, so the Docs & Files is one of the candidates, not a match that wins. +func filesFolderByTitle(cmd *cobra.Command, app *appctx.App, toProject, toFolder string) (filingTarget, error) { + root, err := resolveFilingTarget(cmd, app, docsAndFiles(), toProject, "") + if err != nil { + return filingTarget{}, err + } + folders, err := app.Account().Vaults().List(cmd.Context(), root.id, nil) + if err != nil { + return filingTarget{}, convertSDKError(err) + } + var named []basecamp.Vault + if strings.EqualFold(toFolder, root.title) { + named = append(named, basecamp.Vault{ID: root.id, Title: root.title}) + } + for _, folder := range folders.Vaults { + if strings.EqualFold(folder.Title, toFolder) { + named = append(named, folder) + } + } + switch len(named) { + case 1: + if named[0].ID != root.id { + root.parentID, root.parentTitle, root.appURL = named[0].ID, named[0].Title, named[0].AppURL + } + return root, nil + case 0: + titles := make([]string, len(folders.Vaults)) + for i, folder := range folders.Vaults { + titles[i] = fmt.Sprintf("%d (%s)", folder.ID, folder.Title) + } + hint := "Pass a nested folder's ID or URL" + if len(titles) > 0 { + hint = fmt.Sprintf("Folders at the top: %s. Pass a nested folder's ID or URL", strings.Join(titles, ", ")) + } + return filingTarget{}, output.ErrUsageHint( + fmt.Sprintf("No folder %q at the top of %s's Docs & Files", toFolder, root.projectName), hint) + default: + lines := make([]string, len(named)) + for i, folder := range named { + lines[i] = fmt.Sprintf(" %d %s", folder.ID, folder.Title) + if folder.ID == root.id { + lines[i] += " (the top of Docs & Files)" + } + } + return filingTarget{}, &output.Error{ + Code: output.CodeAmbiguous, + Message: fmt.Sprintf("%s's Docs & Files has %d places called %q", root.projectName, len(named), toFolder), + Hint: "Specify one with --to-folder <id>:\n" + strings.Join(lines, "\n"), + } + } +} + +// filesFolderTarget is a folder as a filing destination: the Docs & Files on +// its project's dock it is in, with the folder as parent_id unless it is that +// Docs & Files itself. +func filesFolderTarget(ctx context.Context, app *appctx.App, folderID int64) (filingTarget, error) { + folder, err := app.Account().Vaults().Get(ctx, folderID) + if err != nil { + return filingTarget{}, convertSDKError(err) + } + if folder.Bucket == nil { + return filingTarget{}, output.ErrNotFound("folder", strconv.FormatInt(folderID, 10)) + } + projectID := strconv.FormatInt(folder.Bucket.ID, 10) + enabled, all, err := getDockTools(ctx, app, projectID, "vault") + if err != nil { + return filingTarget{}, err + } + docked := func(id int64) bool { + return slices.ContainsFunc(enabled, func(tool DockTool) bool { return tool.ID == id }) + } + + if docked(folder.ID) { + return bucketTarget(folder.ID, folder.Title, folder.AppURL, folder.Bucket), nil + } + parent := folder.Parent + for range 32 { + if parent == nil || parent.Type != "Vault" { + break + } + if docked(parent.ID) { + target := bucketTarget(parent.ID, parent.Title, parent.AppURL, folder.Bucket) + target.parentID, target.parentTitle, target.appURL = folder.ID, folder.Title, folder.AppURL + return target, nil + } + above, err := app.Account().Vaults().Get(ctx, parent.ID) + if err != nil { + return filingTarget{}, convertSDKError(err) + } + parent = above.Parent + } + return filingTarget{}, noFilingContainerError(docsAndFiles(), all, projectID, folder.Bucket.Name) +} diff --git a/internal/commands/files_filing_test.go b/internal/commands/files_filing_test.go new file mode 100644 index 000000000..d9d15191c --- /dev/null +++ b/internal/commands/files_filing_test.go @@ -0,0 +1,324 @@ +package commands + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" +) + +// filesFilingTransport has, in Source (123), Docs & Files 500 with folder 501 +// ("Specs"), document 456 at its top and upload 457 and cloud file 458 in +// Specs. Dest (999) has Docs & Files 900 with folder 950 ("Contracts") and +// 960 ("2026") inside that. A filed item <id> reads back as <id>0. +func filesFilingTransport() *familyTransport { + return &familyTransport{gets: map[string]string{ + "/projects": filingProjects, + "/projects/123": `{"id":123,"name":"Source","dock":[{"name":"vault","id":500,"title":"Docs & Files","enabled":true}]}`, + "/projects/999": `{"id":999,"name":"Dest","dock":[{"name":"vault","id":900,"title":"Docs & Files","enabled":true,"app_url":"https://3.basecamp.com/99999/buckets/999/vaults/900"}]}`, + "/vaults/500": `{"id":500,"type":"Vault","title":"Docs & Files","bucket":{"id":123,"name":"Source"}}`, + "/vaults/501": `{"id":501,"type":"Vault","title":"Specs","bucket":{"id":123,"name":"Source"},"parent":{"id":500,"title":"Docs & Files","type":"Vault"}}`, + "/vaults/900": `{"id":900,"type":"Vault","title":"Docs & Files","bucket":{"id":999,"name":"Dest"}}`, + "/vaults/950": `{"id":950,"type":"Vault","title":"Contracts","bucket":{"id":999,"name":"Dest"},"parent":{"id":900,"title":"Docs & Files","type":"Vault"}}`, + "/vaults/960": `{"id":960,"type":"Vault","title":"2026","bucket":{"id":999,"name":"Dest"},"parent":{"id":950,"title":"Contracts","type":"Vault"}}`, + "/vaults/900/vaults": `[{"id":950,"type":"Vault","title":"Contracts","app_url":"https://3.basecamp.com/99999/buckets/999/vaults/950"}]`, + "/documents/456": `{"id":456,"type":"Document","title":"Plan","bucket":{"id":123,"name":"Source"},"parent":{"id":500,"title":"Docs & Files","type":"Vault"}}`, + "/documents/4560": `{"id":4560,"type":"Document","title":"Plan","bucket":{"id":999,"name":"Dest"},"parent":{"id":900,"title":"Docs & Files","type":"Vault"}}`, + "/uploads/457": `{"id":457,"type":"Upload","filename":"spec.pdf","bucket":{"id":123,"name":"Source"},"parent":{"id":501,"title":"Specs","type":"Vault"}}`, + "/uploads/4570": `{"id":4570,"type":"Upload","filename":"spec.pdf","bucket":{"id":999,"name":"Dest"},"parent":{"id":900,"title":"Docs & Files","type":"Vault"}}`, + "/cloud_files/458": `{"id":458,"type":"CloudFile","title":"Board deck","bucket":{"id":123,"name":"Source"},"parent":{"id":501,"title":"Specs","type":"Vault"}}`, + "/cloud_files/4580": `{"id":4580,"type":"CloudFile","title":"Board deck","bucket":{"id":999,"name":"Dest"},"parent":{"id":950,"title":"Contracts","type":"Vault"}}`, + }} +} + +func TestFilesMoveToProjectLandsAtTheTopOfItsDocsAndFiles(t *testing.T) { + transport := filesFilingTransport() + run := runFamilyCommand(t, transport, NewFilesCmd(), "move", "456", "--to-project", "Dest") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/99999/recordings/456/moves"}, transport.posts) + assert.Equal(t, float64(900), transport.bodies[0]["filing_destination_id"]) + assert.NotContains(t, transport.bodies[0], "parent_id") + data := run.data(t) + assert.Equal(t, float64(4560), data["id"]) + assert.Equal(t, float64(456), data["source_id"]) + assert.Equal(t, "Moved file #456 to Dest › Docs & Files as file #4560", run.envelope.Summary) +} + +func TestFilesMoveToATopLevelFolderByTitle(t *testing.T) { + transport := filesFilingTransport() + run := runFamilyCommand(t, transport, NewFilesCmd(), "move", "456", "--to-project", "Dest", "--to-folder", "contracts") + require.NoError(t, run.err) + assert.Equal(t, float64(900), transport.bodies[0]["filing_destination_id"]) + assert.Equal(t, float64(950), transport.bodies[0]["parent_id"]) +} + +func TestFilesMoveToANestedFolderByURLFindsItsDocsAndFiles(t *testing.T) { + transport := filesFilingTransport() + run := runFamilyCommand(t, transport, NewFilesCmd(), "move", "458", "--to-folder", "https://3.basecamp.com/99999/buckets/999/vaults/960") + require.NoError(t, run.err) + assert.Equal(t, float64(900), transport.bodies[0]["filing_destination_id"]) + assert.Equal(t, float64(960), transport.bodies[0]["parent_id"]) + assert.Equal(t, "CloudFile", run.data(t)["type"], "a cloud file reads back as itself") +} + +func TestFilesMoveFolderTitleNotAtTheTopListsWhatIs(t *testing.T) { + transport := filesFilingTransport() + run := runFamilyCommand(t, transport, NewFilesCmd(), "move", "456", "--to-project", "Dest", "--to-folder", "2026") + e := run.failure(t) + assert.Equal(t, `No folder "2026" at the top of Dest's Docs & Files`, e.Message) + assert.Contains(t, e.Hint, "950 (Contracts)") + assert.Contains(t, e.Hint, "nested folder's ID or URL") + assert.Empty(t, transport.posts) +} + +func TestFilesMoveSeveralFilesEachOne(t *testing.T) { + transport := filesFilingTransport() + run := runFamilyCommand(t, transport, NewFilesCmd(), "move", "456", "457", "--to-project", "Dest") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/99999/recordings/456/moves", "/99999/recordings/457/moves"}, transport.posts) + var data []map[string]any + require.NoError(t, json.Unmarshal(run.envelope.Data, &data)) + require.Len(t, data, 2) + assert.Equal(t, "spec.pdf", data[1]["filename"]) + assert.Equal(t, float64(457), data[1]["source_id"]) +} + +func TestFilesMoveSeveralReportsEachWhenOneFails(t *testing.T) { + transport := filesFilingTransport() + transport.refuse = map[int64]int{456: 403} + run := runFamilyCommand(t, transport, NewFilesCmd(), "move", "456", "457", "--to-project", "Dest") + e := run.failure(t) + assert.Equal(t, output.CodeForbidden, e.Code) + assert.Contains(t, e.Message, "#457 as #4570 in Dest › Docs & Files") + assert.Contains(t, e.Message, "#456 refused") + assert.Contains(t, e.Hint, "run the command again for #456 alone") + assert.NotContains(t, e.Hint, "#457", "a file that moved is never offered for another run") + assert.Len(t, transport.posts, 2) +} + +func TestFilesMoveToAFolderInItsOwnProjectPointsAtTo(t *testing.T) { + transport := filesFilingTransport() + run := runFamilyCommand(t, transport, NewFilesCmd(), "move", "456", "--to-folder", "501") + e := run.failure(t) + assert.Equal(t, output.CodeUsage, e.Code) + assert.Equal(t, "Move it to that folder and keep its ID with: basecamp files move 456 --to 501", e.Hint) + assert.Empty(t, transport.posts) +} + +func TestFilesMoveRefusesToBesideToProject(t *testing.T) { + transport := filesFilingTransport() + run := runFamilyCommand(t, transport, NewFilesCmd(), "move", "456", "--to", "501", "--to-project", "Dest") + e := run.failure(t) + assert.Contains(t, e.Message, "cannot be combined with --to-project or --to-folder") + assert.Empty(t, transport.posts) +} + +func TestFilesMoveToAFolderInAnotherProjectPointsAtToFolder(t *testing.T) { + transport := filesFilingTransport() + run := runFamilyCommand(t, transport, NewFilesCmd(), "move", + "https://3.basecamp.com/99999/buckets/123/documents/456", "--to", "https://3.basecamp.com/99999/buckets/999/vaults/950") + e := run.failure(t) + assert.Equal(t, "Move it to that project with: basecamp files move 456 --to-folder 950", e.Hint) + assert.Empty(t, transport.posts) +} + +func TestFilesCopyWithoutDestinationGoesIntoItsOwnFolder(t *testing.T) { + transport := filesFilingTransport() + run := runFamilyCommand(t, transport, NewFilesCmd(), "copy", "457") + require.NoError(t, run.err) + assert.Equal(t, []string{"/99999/recordings/457/copies"}, transport.posts) + assert.Equal(t, float64(500), transport.bodies[0]["filing_destination_id"]) + assert.Equal(t, float64(501), transport.bodies[0]["parent_id"]) +} + +func TestFilesMoveAndCopyAreOnEveryFilesGroup(t *testing.T) { + for name, group := range map[string]func() *cobra.Command{ + "uploads": NewUploadsCmd, + "docs": NewDocsCmd, + "vaults": NewVaultsCmd, + } { + t.Run(name, func(t *testing.T) { + transport := filesFilingTransport() + run := runFamilyCommand(t, transport, group(), "copy", "456", "--to-project", "Dest", "--include-comments") + require.NoError(t, run.err) + assert.Equal(t, true, transport.bodies[0]["include_comments"]) + + transport = filesFilingTransport() + run = runFamilyCommand(t, transport, group(), "move", "456", "--to-project", "Dest") + require.NoError(t, run.err) + assert.Equal(t, []string{"/99999/recordings/456/moves"}, transport.posts) + }) + } +} + +func TestFilesRefuseAnItemNamedTwiceBeforeFilingAny(t *testing.T) { + for name, tc := range map[string]struct { + args []string + message string + }{ + "the same id twice": {[]string{"copy", "456", "456", "--to-project", "Dest"}, "456 and 456 are both file #456"}, + "copy by its old id and its new one": {[]string{"copy", "457", "111", "456", "--to-project", "Dest"}, "111 and 456 are both file #456"}, + "move by its old id and its new one": {[]string{"move", "111", "456", "--to-project", "Dest"}, "111 and 456 are both file #456"}, + } { + t.Run(name, func(t *testing.T) { + transport := filesFilingTransport() + transport.gets["/documents/111"] = transport.gets["/documents/456"] + run := runFamilyCommand(t, transport, NewFilesCmd(), tc.args...) + e := run.failure(t) + + assert.Equal(t, output.CodeUsage, e.Code) + assert.Equal(t, tc.message, e.Message) + assert.Empty(t, transport.posts, "nothing is filed") + }) + } +} + +func TestFilesShowFindsCloudFilesAndGoogleDocuments(t *testing.T) { + for name, tc := range map[string]struct { + id, typ string + }{ + "a cloud file": {"4580", "CloudFile"}, + "a Google document": {"4590", "GoogleDocument"}, + "a typed cloud file": {"4580", "CloudFile"}, + "a typed Google file": {"4590", "GoogleDocument"}, + } { + t.Run(name, func(t *testing.T) { + transport := filesFilingTransport() + transport.gets["/google_documents/4590"] = `{"id":4590,"type":"GoogleDocument","title":"Roadmap","bucket":{"id":999,"name":"Dest"},"parent":{"id":900,"title":"Docs & Files","type":"Vault"}}` + args := []string{"show", tc.id, "--in", "Dest"} + if strings.HasPrefix(name, "a typed") { + args = append(args, "--type", map[string]string{"CloudFile": "cloud_file", "GoogleDocument": "google_document"}[tc.typ]) + } + run := runFamilyCommand(t, transport, NewFilesCmd(), args...) + require.NoError(t, run.err) + assert.Equal(t, tc.typ, run.data(t)["type"]) + }) + } +} + +func TestFilesCopyOfACloudFileFollowsUpWithCommandsThatFindIt(t *testing.T) { + transport := filesFilingTransport() + run := runFamilyCommand(t, transport, NewFilesCmd(), "copy", "458", "--to-project", "Dest") + require.NoError(t, run.err) + require.NotEmpty(t, run.envelope.Breadcrumbs) + show := strings.Fields(run.envelope.Breadcrumbs[0].Cmd)[1:] + shown := runFamilyCommand(t, transport, NewFilesCmd(), show[1:]...) + require.NoError(t, shown.err, "the breadcrumb %v finds the copy", show) + assert.Equal(t, float64(4580), shown.data(t)["id"]) + + transport.refuse = map[int64]int{458: 502} + e := runFamilyCommand(t, transport, NewFilesCmd(), "copy", "458", "--to-project", "Dest").failure(t) + assert.Contains(t, e.Hint, "may have started the copy") + assert.Contains(t, e.Hint, "basecamp timeline --in 999 --all", "files list does not list cloud files, so the check is the timeline") + assert.NotContains(t, e.Hint, "files list") +} + +func TestFilesCopyRecoveryListsArchivedItemsWhereTheyShow(t *testing.T) { + transport := filesFilingTransport() + transport.gets["/documents/456"] = strings.Replace(transport.gets["/documents/456"], `"type":"Document"`, `"type":"Document","status":"archived"`, 1) + transport.refuse = map[int64]int{456: 502} + e := runFamilyCommand(t, transport, NewFilesCmd(), "copy", "456", "--to-project", "Dest").failure(t) + assert.Contains(t, e.Hint, "basecamp recordings list Document --in 999 --status archived --all") +} + +func TestFilesCopyOfAGoogleDocumentPointsAtItsFolderInBasecamp(t *testing.T) { + transport := filesFilingTransport() + transport.gets["/google_documents/459"] = `{"id":459,"type":"GoogleDocument","title":"Roadmap","bucket":{"id":123,"name":"Source"},"parent":{"id":501,"title":"Specs","type":"Vault"}}` + transport.refuse = map[int64]int{459: 502} + e := runFamilyCommand(t, transport, NewFilesCmd(), "copy", "459", "--to-project", "Dest", "--to-folder", "Contracts").failure(t) + assert.Contains(t, e.Hint, "may have started the copy") + assert.Contains(t, e.Hint, "open the destination in Basecamp: https://3.basecamp.com/99999/buckets/999/vaults/950") + assert.NotContains(t, e.Hint, "timeline", "the timeline never shows a Google document") +} + +// Basecamp's timeline shows a copied cloud file as reposted, but leaves out +// a trashed one, and a copy keeps its source's status. +func TestFilesCopyOfACloudFileIsLookedForWhereItsStatusShows(t *testing.T) { + for status, want := range map[string]string{ + "active": "basecamp timeline --in 999 --all", + "archived": "basecamp timeline --in 999 --all", + "trashed": "open the destination in Basecamp: https://3.basecamp.com/99999/buckets/999/vaults/900", + } { + t.Run(status, func(t *testing.T) { + transport := filesFilingTransport() + transport.gets["/cloud_files/458"] = strings.Replace(transport.gets["/cloud_files/458"], `"type":"CloudFile"`, `"type":"CloudFile","status":"`+status+`"`, 1) + transport.refuse = map[int64]int{458: 502} + e := runFamilyCommand(t, transport, NewFilesCmd(), "copy", "458", "--to-project", "Dest").failure(t) + assert.Contains(t, e.Hint, "may have started the copy") + assert.Contains(t, e.Hint, "Check first: "+want+`, then look for "Board deck" in Docs & Files (#900)`) + }) + } +} + +func TestFilesFolderTitleSharedWithItsDocsAndFilesIsAmbiguous(t *testing.T) { + transport := filesFilingTransport() + transport.gets["/vaults/900/vaults"] = `[{"id":950,"type":"Vault","title":"Contracts"},{"id":951,"type":"Vault","title":"Docs & Files"}]` + run := runFamilyCommand(t, transport, NewFilesCmd(), "copy", "456", "--to-project", "Dest", "--to-folder", "docs & files") + e := run.failure(t) + + assert.Equal(t, output.CodeAmbiguous, e.Code) + assert.Contains(t, e.Hint, "900 Docs & Files (the top of Docs & Files)") + assert.Contains(t, e.Hint, "951 Docs & Files") + assert.Empty(t, transport.posts) +} + +func TestFilesFolderTitleOfItsDocsAndFilesAloneIsTheTop(t *testing.T) { + transport := filesFilingTransport() + run := runFamilyCommand(t, transport, NewFilesCmd(), "copy", "456", "--to-project", "Dest", "--to-folder", "Docs & Files") + require.NoError(t, run.err) + assert.Equal(t, float64(900), transport.bodies[0]["filing_destination_id"]) + assert.NotContains(t, transport.bodies[0], "parent_id") +} + +func TestFilesCopySeveralNamesWhereEachLanded(t *testing.T) { + transport := filesFilingTransport() + transport.gets["/documents/4560"] = `{"id":4560,"type":"Document","title":"Plan","bucket":{"id":123,"name":"Source"},"parent":{"id":500,"title":"Docs & Files","type":"Vault"}}` + transport.gets["/uploads/4570"] = `{"id":4570,"type":"Upload","filename":"spec.pdf","bucket":{"id":123,"name":"Source"},"parent":{"id":501,"title":"Specs","type":"Vault"}}` + run := runFamilyCommand(t, transport, NewFilesCmd(), "copy", "456", "457") + require.NoError(t, run.err) + + assert.Equal(t, float64(500), transport.bodies[0]["filing_destination_id"]) + assert.Equal(t, float64(501), transport.bodies[1]["parent_id"], "each copy goes into its own original's folder") + assert.Equal(t, "Copied 2 files: #456 as #4560 in Source › Docs & Files, #457 as #4570 in Source › Specs", run.envelope.Summary) + crumbs := make([]string, 0, len(run.envelope.Breadcrumbs)) + for _, b := range run.envelope.Breadcrumbs { + crumbs = append(crumbs, b.Cmd) + } + assert.Contains(t, crumbs, "basecamp files show 4560 --in 123") + assert.Contains(t, crumbs, "basecamp files show 4570 --in 123") +} + +func TestFilesCopyOfADraftDocumentWithNoFilingIsLookedForAmongDrafts(t *testing.T) { + transport := filesFilingTransport() + transport.gets["/documents/456"] = strings.Replace(transport.gets["/documents/456"], `"type":"Document"`, `"type":"Document","status":"drafted"`, 1) + transport.refuse = map[int64]int{456: 502} + e := runFamilyCommand(t, transport, NewFilesCmd(), "copy", "456", "--to-project", "Dest").failure(t) + assert.Contains(t, e.Hint, "may have started the copy") + assert.Contains(t, e.Hint, "basecamp drafts list --all") + assert.NotContains(t, e.Hint, "--status drafted") +} + +// A flat API URL names no project, and its id can be a folder's title too. +// A URL that names no folder is refused, never looked up as that title. +func TestFilesFolderURLThatNamesNoFolderIsNeverATitle(t *testing.T) { + for _, url := range []string{ + "https://3.basecampapi.com/99999/vaults/2026.json", + "https://3.basecamp.com/99999/buckets/999/vaults/2026", + } { + t.Run(url, func(t *testing.T) { + transport := filesFilingTransport() + transport.gets["/vaults/900/vaults"] = `[{"id":950,"type":"Vault","title":"Contracts"},{"id":970,"type":"Vault","title":"2026"}]` + e := runFamilyCommand(t, transport, NewFilesCmd(), "copy", "456", "--to-project", "Dest", "--to-folder", url).failure(t) + assert.Equal(t, output.CodeNotFound, e.Code) + assert.Contains(t, e.Message, url) + assert.Empty(t, transport.posts) + }) + } +} diff --git a/internal/commands/files_test.go b/internal/commands/files_test.go index 0688019ac..2db13fe52 100644 --- a/internal/commands/files_test.go +++ b/internal/commands/files_test.go @@ -1748,8 +1748,6 @@ func TestFilesMoveWithoutDestinationShowsHelp(t *testing.T) { func TestFilesMoveRefusesBadArgumentsBeforeTheWire(t *testing.T) { cases := map[string][]string{ - "--folder for --to": {"123", "--folder", "456"}, - "--vault beside --to": {"123", "--to", "456", "--vault", "789"}, "zero position": {"123", "--to", "456", "--position", "0"}, "negative position": {"123", "--to", "456", "--position", "-1"}, "non-numeric destination": {"123", "--to", "Designs"}, @@ -1790,7 +1788,7 @@ func TestFilesMoveExplainsRefusals(t *testing.T) { {"vault into its own subfolder", 422, `{"error":"Parent must not be self or descendant"}`, output.CodeValidation, "", "Basecamp refused the move: Parent must not be self or descendant"}, {"inactive destination or item", 422, ``, output.CodeValidation, "active folder", "Basecamp refused the move"}, {"moves restricted", 403, ``, output.CodeForbidden, "permission", ""}, - {"destination elsewhere", 404, `{"error":"Not found"}`, output.CodeNotFound, "same project", ""}, + {"destination elsewhere", 404, `{"error":"Not found"}`, output.CodeNotFound, "basecamp files move 123 --to-folder 456", ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/commands/filing.go b/internal/commands/filing.go new file mode 100644 index 000000000..533272971 --- /dev/null +++ b/internal/commands/filing.go @@ -0,0 +1,700 @@ +package commands + +import ( + "cmp" + "context" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/output" +) + +// recordingFiling is one Move… or Copy… of a recording, as the web's dialogs +// make it through Basecamp's recording moves and copies. Basecamp files it in +// the background, so runFiling waits. Each kind of recording fills in how its +// messages name it, where to look for it, and how to read back what was filed. +type recordingFiling struct { + kind basecamp.FilingType + sourceID int64 + // noun names the recording in messages: "card" gives "card #456". + noun string + // destinationID is the container it goes into, from the destination + // project's dock. toProjectID is set instead for a dock tool, which goes + // to a project. + destinationID int64 + // parentID is a folder inside the destination Docs & Files to put it in. + parentID int64 + toProjectID int64 + includeComments bool + + // destination describes where it goes for errors: "card table 900 (Ideas)". + destination string + // showCmd shows a recording of this kind given its id: "basecamp cards show". + showCmd string + // lookFor finds a copy that may have started without Basecamp naming its + // filing, so it is checked for before copying again. + lookFor copyCheck + // read reads back the recording the filing made, by its new id. + read func(ctx context.Context, id int64) (filedRecording, error) + + // scope is the --profile and --account every command the filing prints + // must carry to reach this account from a fresh shell. runFiling and + // runFilings set it. + scope string +} + +// filedRecording is a recording that a filing made, read back for output: +// result is the recording with its source_id, and the rest names it and +// where it landed for the summary and follow-ups. +type filedRecording struct { + result any + id int64 + bucket *basecamp.Bucket + parent *basecamp.Parent + breadcrumbs []output.Breadcrumb +} + +// filingOutcome is how far a filing got, which decides whether running it +// again is safe. +type filingOutcome int + +const ( + // filingDone filed the recording and read back what it made. + filingDone filingOutcome = iota + // filingRefused was refused by Basecamp before anything was filed, so + // running it again is safe. + filingRefused + // filingUnread filed the recording under a known new id but could not + // read it back. Running it again moves it twice or makes another copy. + filingUnread + // filingUnsettled may have started, failed inside Basecamp, or was still + // running when the wait ended. It must be checked before running again. + filingUnsettled +) + +// runFiling files one recording and outputs what it made. scope is read +// before the command resolved its account, as newFollowUpScope requires. +func runFiling(cmd *cobra.Command, app *appctx.App, scope followUpScope, f recordingFiling) error { + f.scope = scope.args(app.Config.AccountID) + filed, _, err := fileRecording(cmd.Context(), app, f) + if err != nil { + return err + } + _, _, past := f.verbs() + return filedOK(app, f.noun, past, f.sourceID, filed) +} + +// fileRecording starts the filing once, never retrying it (each copy request +// makes another copy), waits for Basecamp to finish it, and reads back what it +// made. +func fileRecording(ctx context.Context, app *appctx.App, f recordingFiling) (filedRecording, filingOutcome, error) { + filing, err := f.start(ctx, app) + if err != nil { + err = copyStartError(f, convertSDKError(err)) + if refusedBeforeFiling(output.AsError(err)) { + return filedRecording{}, filingRefused, err + } + return filedRecording{}, filingUnsettled, err + } + + finished := func() bool { + return filing.Status == basecamp.FilingCompleted || filing.Status == basecamp.FilingFailed + } + _, waitErr := pollUntil(ctx, func(ctx context.Context) (bool, error) { + if finished() { + return true, nil + } + latest, err := app.Account().Recordings().GetFiling(ctx, filing.ID) + if err != nil { + return false, err + } + filing = latest + return finished(), nil + }) + + verb, _, past := f.verbs() + switch { + case filing.Status == basecamp.FilingFailed: + return filedRecording{}, filingUnsettled, &output.Error{ + Code: output.CodeAPI, + Message: fmt.Sprintf("Basecamp could not %s %s #%d to %s", verb, f.noun, f.sourceID, f.destination), + Hint: fmt.Sprintf("Check the %s with: %s", f.noun, f.show(f.sourceID)), + } + case filing.Status != basecamp.FilingCompleted || filing.DestinationRecording == nil: + return filedRecording{}, filingUnsettled, stillFilingError(f, filing.ID, waitErr) + } + + newID := filing.DestinationRecording.ID + filed, err := f.read(ctx, newID) + if err != nil { + return filedRecording{id: newID}, filingUnread, &output.Error{ + Code: output.CodeAPI, + Message: fmt.Sprintf("%s %s #%d as %s #%d, but could not read the new %s", past, f.noun, f.sourceID, f.noun, newID, f.noun), + Hint: "Show it with: " + f.show(newID), + Cause: textCause(err), + } + } + for i := range filed.breadcrumbs { + filed.breadcrumbs[i].Cmd += f.scope + } + return filed, filingDone, nil +} + +// show is the command that shows a recording of this kind by its id. +func (f recordingFiling) show(id int64) string { + return fmt.Sprintf("%s %d%s", f.showCmd, id, f.scope) +} + +// check says how to find a copy that may have started. +func (f recordingFiling) check() string { + return f.lookFor.text(f.scope) +} + +func (f recordingFiling) start(ctx context.Context, app *appctx.App) (*basecamp.Filing, error) { + recordings := app.Account().Recordings() + if f.kind == basecamp.FilingCopy { + return recordings.Copy(ctx, f.sourceID, &basecamp.CopyRecordingRequest{ + FilingDestinationID: f.destinationID, + DestinationProjectID: f.toProjectID, + ParentID: f.parentID, + IncludeComments: f.includeComments, + }) + } + return recordings.Move(ctx, f.sourceID, &basecamp.MoveRecordingRequest{ + FilingDestinationID: f.destinationID, + DestinationProjectID: f.toProjectID, + ParentID: f.parentID, + }) +} + +// verbs returns the filing's verb as an infinitive, a progressive and a past. +func (f recordingFiling) verbs() (string, string, string) { + if f.kind == basecamp.FilingCopy { + return "copy", "copying", "Copied" + } + return "move", "moving", "Moved" +} + +// copyStartError keeps a failed copy request from inviting a retry. Only a 4xx +// answer shows Basecamp refused the copy; a 5xx, a dropped connection or an +// unreadable answer can follow a copy it already started, so retrying would +// make a second one. A rate limit stays retryable: it is refused before +// anything is copied. +func copyStartError(f recordingFiling, err error) error { + e := output.AsError(err) + if f.kind != basecamp.FilingCopy || refusedBeforeFiling(e) { + return err + } + unsure := *e + unsure.Retryable = false + unsure.Hint = "Basecamp may have started the copy before this failed, and copying again makes another copy. Check first: " + f.check() + return &unsure +} + +// refusedBeforeFiling reports whether a failed request to start a filing was +// refused before anything was filed: a 4xx answer, or a rate limit, which is +// refused before the request is handled. +func refusedBeforeFiling(e *output.Error) bool { + return (e.HTTPStatus >= 400 && e.HTTPStatus < 500) || e.Code == output.CodeRateLimit +} + +// stillFilingError reports a move or copy that Basecamp accepted but had not +// finished when the wait ended. Its filing says where it got to, whatever the +// recording's type and status, so the hint names the filing rather than a +// listing the result may not show in. +func stillFilingError(f recordingFiling, filingID int64, waitErr error) error { + _, progressive, _ := f.verbs() + hint := fmt.Sprintf("Do not move it again. Check it with: basecamp filings show %d%s, which names the %s's new id once it lands", filingID, f.scope, f.noun) + if f.kind == basecamp.FilingCopy { + hint = fmt.Sprintf("Do not copy it again, which makes another copy. Check it with: basecamp filings show %d%s, which names the copy once it lands", filingID, f.scope) + } + return &output.Error{ + Code: output.CodeInProgress, + Message: fmt.Sprintf("Basecamp is still %s %s #%d to %s", progressive, f.noun, f.sourceID, f.destination), + Hint: hint, + Cause: textCause(waitErr), + } +} + +// textCause keeps a cause's message but not its type. output.AsError renders +// a rate-limit gate error found anywhere in the chain in place of the error +// wrapping it, which would turn an accepted filing's "do not retry" into a +// retryable rate limit. +func textCause(err error) error { + if err == nil { + return nil + } + return errors.New(err.Error()) +} + +// filedOK outputs a recording that a move, copy or teleport made, with a +// summary naming the one it came from and where it landed. +func filedOK(app *appctx.App, noun, past string, sourceID int64, filed filedRecording) error { + summary := fmt.Sprintf("%s %s #%d to %s as %s #%d", past, noun, sourceID, filed.place(), noun, filed.id) + return app.OK(filed.result, output.WithSummary(summary), output.WithBreadcrumbs(filed.breadcrumbs...)) +} + +// place names where a filed recording landed: "Dest › Triage". +func (filed filedRecording) place() string { + place := []string{} + if filed.bucket != nil { + place = append(place, filed.bucket.Name) + } + if filed.parent != nil { + place = append(place, filed.parent.Title) + } + return strings.Join(place, " › ") +} + +// runFilings files several recordings one by one, as the web's bulk Move… +// and Copy… do, and outputs what each made. A failure does not stop the rest, +// and none is retried. If any is not cleanly filed the command fails, naming +// how far each got, and asks to run again only the ones Basecamp refused +// before filing: running again one that was filed, or may have been, moves it +// twice or makes another copy. +func runFilings(cmd *cobra.Command, app *appctx.App, scope followUpScope, filings []recordingFiling) error { + if len(filings) == 1 { + return runFiling(cmd, app, scope, filings[0]) + } + verb, _, past := filings[0].verbs() + nouns := output.PluralNoun(filings[0].noun) + + var ( + results []any + done []string + breadcrumbs []output.Breadcrumb + report []string + refused []int64 + causes []string + unread []string + unsettled = map[string][]int64{} + checks []string + // warned holds the checks that already say not to file again: a + // still-running filing's. + warned = map[string]bool{} + worst *output.Error + worstOf filingOutcome + ) + for _, f := range filings { + f.scope = scope.args(app.Config.AccountID) + filed, outcome, err := fileRecording(cmd.Context(), app, f) + var e *output.Error + if err != nil { + // The report keeps only each failure's text, so a 401's remedy + // for the credential sent is settled here, while the SDK error + // that App.Err would read is still attached. + e = output.AsError(app.WithAuthRemedy(err)) + if worst == nil || outcome > worstOf { + worst, worstOf = e, outcome + } + } + switch outcome { + case filingDone: + results = append(results, filed.result) + done = append(done, fmt.Sprintf("#%d as #%d in %s", f.sourceID, filed.id, filed.place())) + for _, crumb := range filed.breadcrumbs { + if !slices.ContainsFunc(breadcrumbs, func(b output.Breadcrumb) bool { return b.Cmd == crumb.Cmd }) { + breadcrumbs = append(breadcrumbs, crumb) + } + } + case filingRefused: + refused = append(refused, f.sourceID) + if e.Hint != "" && !slices.Contains(causes, e.Hint) { + causes = append(causes, e.Hint) + } + report = append(report, fmt.Sprintf("#%d refused: %s", f.sourceID, e.Message)) + case filingUnread: + unread = append(unread, fmt.Sprintf("#%d is #%d now (%s)", f.sourceID, filed.id, f.show(filed.id))) + report = append(report, fmt.Sprintf("#%d %s as #%d, not read back", f.sourceID, strings.ToLower(past), filed.id)) + case filingUnsettled: + check := e.Hint + if check == "" { + check = "Check it with: " + f.show(f.sourceID) + } + if _, seen := unsettled[check]; !seen { + checks = append(checks, check) + } + warned[check] = e.Code == output.CodeInProgress + unsettled[check] = append(unsettled[check], f.sourceID) + report = append(report, fmt.Sprintf("#%d not settled: %s", f.sourceID, e.Message)) + } + } + + if worst == nil { + summary := fmt.Sprintf("%s %d %s: %s", past, len(done), nouns, strings.Join(done, ", ")) + return app.OK(results, output.WithSummary(summary), output.WithBreadcrumbs(breadcrumbs...)) + } + + message := fmt.Sprintf("%s %d of %d %s", past, len(done), len(filings), nouns) + if len(done) > 0 { + message += " (" + strings.Join(done, ", ") + ")" + } + var hints []string + if len(unread) > 0 { + hints = append(hints, fmt.Sprintf("Do not %s these again: %s.", verb, strings.Join(unread, ", "))) + } + for _, check := range checks { + if warned[check] { + hints = append(hints, fmt.Sprintf("%s: %s", sourceList(unsettled[check]), check)) + continue + } + hints = append(hints, fmt.Sprintf("Do not %s %s again yet. %s", verb, sourceList(unsettled[check]), check)) + } + if len(refused) > 0 { + hints = append(hints, fmt.Sprintf("Basecamp refused %s before filing anything; once the cause is fixed, run the command again for %s alone.", + sourceList(refused), sourceList(refused))) + hints = append(hints, causes...) + } + return &output.Error{ + Code: worst.Code, + Message: message + "; " + strings.Join(report, "; "), + Hint: strings.Join(hints, "\n"), + HTTPStatus: worst.HTTPStatus, + Cause: textCause(worst), + } +} + +// filingNames holds the recordings a bulk move or copy names, each by the id +// it read as and the argument that named it. A moved recording's old id reads +// as its new one, so two arguments can name one recording, and filing it twice +// moves it twice or makes two copies. Comparing what each argument read as, +// before anything is filed, catches that where comparing the arguments cannot. +type filingNames map[int64]string + +// add records that arg read as recording id, refusing a second name for it. +func (named filingNames) add(noun, arg string, id int64) error { + if first, ok := named[id]; ok { + return output.ErrUsageHint( + fmt.Sprintf("%s and %s are both %s #%d", first, arg, noun, id), + fmt.Sprintf("A moved %s's old id reads as its new one. Name %s #%d once", noun, noun, id), + ) + } + named[id] = arg + return nil +} + +// sourceList names recordings by id: "#456, #457". +func sourceList(ids []int64) string { + names := make([]string, len(ids)) + for i, id := range ids { + names[i] = fmt.Sprintf("#%d", id) + } + return strings.Join(names, ", ") +} + +// filingWait bounds the wait for a recording that Basecamp moves or copies in +// the background: checks start first apart, back off to max, and stop after +// total. Tests shorten it. +var filingWait = struct{ first, max, total time.Duration }{ + first: 250 * time.Millisecond, + max: 2 * time.Second, + total: time.Minute, +} + +// copyPlace is a copy by what decides which listing can show it: the +// recordable type, the status it keeps from its source, and whether its +// container is in a calendar rather than a project. +type copyPlace struct { + typ string + status string + calendar bool +} + +// copyListing lists, every page, where a copy in place lands in t. +type copyListing func(place copyPlace, t filingTarget) string + +// copyListings is how to find a copy whose start got no answer, so it is +// checked for before copying again. A place is here only when its listing is +// known to show a copy in that status; any other, a type or status added +// later included, is checked by opening the destination in Basecamp. +// +// - recordings list serves these types in the active, archived and trashed +// statuses, in projects (bc3 doc/api/sections/recordings.md). +// - drafts list holds drafted messages, documents and uploads, and no +// other drafts (drafts.md). It holds only the 250 most recently updated, +// and a copy keeps its original's updated_at, so it is capped: a copy +// missing there is checked for on the destination's page too. +// - schedule entries --status lists a schedule's active, archived or +// trashed entries (schedule_entries.md), a calendar's included. +// - timeline shows a copied cloud file or question, active or archived, as +// reposted; it relays nothing drafted or trashed (Timeline::Relayer), and +// no copied forward, column or Google document (checked against bc3). +// - checkins questions, forwards list and cards columns list only +// recordings that inherit their status, which an archived, trashed or +// drafted copy does not. +var copyListings = copyTable([]copyRow{ + {[]string{"Document", "Kanban::Card", "Message", "Schedule::Entry", "Todo", "Todolist", "Upload", "Vault"}, []string{"active", "archived", "trashed"}, false, listRecordings, false}, + {[]string{"Message", "Document", "Upload"}, []string{"drafted"}, false, listDrafts, true}, + {[]string{"Schedule::Entry"}, []string{"active", "archived", "trashed"}, true, listCalendarEntries, false}, + {[]string{"CloudFile"}, []string{"active", "archived"}, false, listTimeline, false}, + {[]string{"Question"}, []string{"archived"}, false, listTimeline, false}, + {[]string{"Question"}, []string{"active"}, false, listQuestions, false}, + {[]string{"Inbox::Forward"}, []string{"active"}, false, listForwards, false}, + {[]string{"Kanban::Column"}, []string{"active"}, false, listColumns, false}, +}) + +// copyRow is one listing of copyListings with the places it shows. +type copyRow struct { + types []string + statuses []string + calendar bool + listing copyListing + // capped is a listing that may stop before an old enough copy. + capped bool +} + +// copyTable spreads rows into one listing per place, refusing a place two +// rows claim. +func copyTable(rows []copyRow) map[copyPlace]copyRow { + table := map[copyPlace]copyRow{} + for _, row := range rows { + for _, typ := range row.types { + for _, status := range row.statuses { + place := copyPlace{typ: typ, status: status, calendar: row.calendar} + if _, claimed := table[place]; claimed { + panic(fmt.Sprintf("copyListings names %+v twice", place)) + } + table[place] = row + } + } + } + return table +} + +func listRecordings(place copyPlace, t filingTarget) string { + return fmt.Sprintf("basecamp recordings list %s --in %d --status %s --all", place.typ, t.projectID, place.status) +} + +func listDrafts(copyPlace, filingTarget) string { return "basecamp drafts list --all" } + +func listCalendarEntries(place copyPlace, t filingTarget) string { + return fmt.Sprintf("basecamp schedule entries --in %d --schedule %d --status %s --all", t.projectID, t.id, place.status) +} + +func listTimeline(_ copyPlace, t filingTarget) string { + return fmt.Sprintf("basecamp timeline --in %d --all", t.projectID) +} + +func listQuestions(_ copyPlace, t filingTarget) string { + return fmt.Sprintf("basecamp checkins questions --in %d --questionnaire %d --all", t.projectID, t.id) +} + +func listForwards(_ copyPlace, t filingTarget) string { + return fmt.Sprintf("basecamp forwards list --in %d --inbox %d --all", t.projectID, t.id) +} + +func listColumns(_ copyPlace, t filingTarget) string { + return fmt.Sprintf("basecamp cards columns --in %d --card-table %d", t.projectID, t.id) +} + +// copyCheck is how to find a copy whose start got no answer: a listing that +// shows it, or else the destination's page in Basecamp, then what tells it +// apart there. +type copyCheck struct { + listing string + capped bool + page string + match string +} + +// copyLookFor says where to find a copy of src that lands in t: its listing +// in copyListings, or else the destination's page in Basecamp. A copy keeps +// its original's title, created_at and updated_at, so no sort puts it first; +// it is told apart by its title in the destination. +func copyLookFor(src filingSource, t filingTarget) copyCheck { + place := copyPlace{typ: src.typ, status: cmp.Or(src.status, "active"), calendar: t.calendar} + check := copyCheck{page: cmp.Or(t.appURL, t.describe("destination")), match: copyMatch(src, t)} + if row, ok := copyListings[place]; ok { + check.listing, check.capped = row.listing(place, t), row.capped + } + return check +} + +// text says how to check, with scope on the listing so it reaches this +// account from a fresh shell. +func (c copyCheck) text(scope string) string { + where := "open the destination in Basecamp: " + c.page + if c.listing != "" { + where = c.listing + scope + } + text := where + ", then " + c.match + if c.capped { + text += ". That listing stops at the most recently updated, so if the copy is not there, open the destination in Basecamp before copying again: " + c.page + } + return text +} + +// copyMatch says what tells a copy of src apart in t: its title, in the +// container it lands in. +func copyMatch(src filingSource, t filingTarget) string { + name := fmt.Sprintf("the copy of #%d", src.id) + if src.title != "" { + name = fmt.Sprintf("%q", src.title) + } + container, id := t.title, t.id + if t.parentID != 0 { + container, id = t.parentTitle, t.parentID + } + return fmt.Sprintf("look for %s in %s (#%d); a copy has its original's title and dates", name, cmp.Or(container, "the destination"), id) +} + +// pollUntil runs check, backing off between runs, until it reports done or +// filingWait.total passes. It returns false with no error when the wait runs +// out, and ctx's error when ctx is canceled first. +func pollUntil(ctx context.Context, check func(context.Context) (bool, error)) (bool, error) { + waitCtx, cancel := context.WithTimeout(ctx, filingWait.total) + defer cancel() + + for delay := filingWait.first; ; delay = min(2*delay, filingWait.max) { + done, err := check(waitCtx) + if err == nil && done { + return true, nil + } + if err != nil && waitCtx.Err() == nil { + return false, err + } + select { + case <-waitCtx.Done(): + return false, ctx.Err() + case <-time.After(delay): + } + } +} + +// parseFilingRef reads the recording or destination a move or copy names: a +// positive ID, or a URL to one recording of kind on a trusted Basecamp host in +// the session's account. extractID keeps only a URL's trailing number, which on +// a look-alike host, in another account or on another kind of recording names +// something else in this account. It runs after ensureAccount. +func parseFilingRef(app *appctx.App, arg, noun, kind, urlShape string) (filesMoveRef, error) { + return parseFilingRefOf(app, arg, noun, urlShape, []string{kind}) +} + +// parseFilingRefOf is parseFilingRef for a recording a URL may name under +// several kinds, such as a Docs & Files item. +func parseFilingRefOf(app *appctx.App, arg, noun, urlShape string, kinds []string) (filesMoveRef, error) { + ref, err := parseFilesMoveRef(app, arg, kinds) + if err != nil { + return ref, output.ErrUsageHint(fmt.Sprintf("Expected a %s ID or URL: %v", noun, err), + fmt.Sprintf("Pass a %s ID or a Basecamp %s URL (%s)", noun, noun, urlShape)) + } + if ref.account != "" && !accountIDsEqual(ref.account, app.Config.AccountID) { + return ref, output.ErrUsage(fmt.Sprintf("URL is for account %s, but this session uses account %s", ref.account, app.Config.AccountID)) + } + return ref, nil +} + +// rejectProjectConflict fails when an explicitly supplied project (the +// --in/--project flag or the recording URL's bucket) resolves to a different +// project than the recording's own bucket, before a move or copy acts on it. +// Config/interactive defaults are not checked — only explicit caller context +// can conflict. +func rejectProjectConflict(cmd *cobra.Command, app *appctx.App, noun, flagProject, urlProjectID string, bucketID int64) error { + want := strconv.FormatInt(bucketID, 10) + check := func(value, label string) error { + if value == "" { + return nil + } + resolved, _, err := app.Names.ResolveProject(cmd.Context(), value) + if err != nil { + return err + } + if resolved != want { + return output.ErrUsageHint( + fmt.Sprintf("The %s is in project %s, but %s points at project %s", noun, want, label, resolved), + fmt.Sprintf("Omit the conflicting project — the %s's own project is always used", noun), + ) + } + return nil + } + if err := check(flagProject, "--in/--project"); err != nil { + return err + } + // app.Flags.Project carries a root-level --project (e.g. + // `basecamp --project 999 cards move …`), which lands here rather than in + // the command's own flag — check it too so a root-level conflict can't slip + // past into a move or copy. + if err := check(app.Flags.Project, "--project"); err != nil { + return err + } + return check(urlProjectID, "the "+noun+" URL") +} + +// AnnotationFilingReads lists, space-separated, the container flags that a +// command moving or copying recordings inherits and reads. guardFilingFlags +// sets it. +const AnnotationFilingReads = "filing_reads" + +// guardFilingFlags makes a command that moves or copies recordings refuse, +// before it reads or sends anything, a container flag it inherits but never +// reads. Such a flag would be ignored: todos copy --todolist 901 copied the +// to-do into its own list and reported success. reads names the inherited +// container flags the command does read, in some mode. +// +// A container flag is the root's --todolist or a flag that a command group +// adds for its commands (--card-table, --vault, --list, …). --project and +// --in are left to each command, which holds a filed recording to them; the +// root's account, profile and output flags are not containers. +func guardFilingFlags(cmd *cobra.Command, reads ...string) *cobra.Command { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[AnnotationFilingReads] = strings.Join(reads, " ") + run := cmd.RunE + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if err := refuseUnreadContainerFlags(cmd, reads); err != nil { + return err + } + return run(cmd, args) + } + return cmd +} + +func refuseUnreadContainerFlags(cmd *cobra.Command, reads []string) error { + var unread *pflag.Flag + cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) { + if unread == nil && f.Changed && isContainerFlag(cmd, f) && !slices.Contains(reads, f.Name) { + unread = f + } + }) + if unread == nil { + return nil + } + hint := fmt.Sprintf("%s takes no destination; drop --%s", cmd.CommandPath(), unread.Name) + if dests := destinationFlags(cmd); len(dests) > 0 { + hint = fmt.Sprintf("Name where it goes with %s (see %s --help)", strings.Join(dests, " or "), cmd.CommandPath()) + } + return output.ErrUsageHint(fmt.Sprintf("%s does not read --%s, so it would ignore it", cmd.CommandPath(), unread.Name), hint) +} + +func isContainerFlag(cmd *cobra.Command, f *pflag.Flag) bool { + switch f.Name { + case "project", "in": + return false + case "todolist": + return true + } + return cmd.Root().PersistentFlags().Lookup(f.Name) != f +} + +// destinationFlags lists a command's own flags that name where it files a +// recording: --to (a folder or column) and the --to-<container> flags. +func destinationFlags(cmd *cobra.Command) []string { + var dests []string + cmd.LocalNonPersistentFlags().VisitAll(func(f *pflag.Flag) { + if strings.HasPrefix(f.Name, "to-") || (f.Name == "to" && f.Value.Type() == "string") { + dests = append(dests, "--"+f.Name) + } + }) + return dests +} diff --git a/internal/commands/filing_family.go b/internal/commands/filing_family.go new file mode 100644 index 000000000..a1bc39773 --- /dev/null +++ b/internal/commands/filing_family.go @@ -0,0 +1,499 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "slices" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/output" + "github.com/basecamp/basecamp-cli/internal/urlarg" +) + +// filingContainer is a kind of project tool that the web's Move… and Copy… +// dialogs file recordings into, such as a message board, and how a command +// names one: the project it is in (--to-project), which of that project's +// tools of the kind (flag, by ID or title), or its ID or URL alone. +type filingContainer struct { + // dockName is the tool's name in a project's dock: "message_board". + dockName string + noun string + // plural names several of them in messages: "message boards", or + // "To-dos tools" where appending "s" reads wrong. Every container sets it. + plural string + flag string + urlKind string + urlShape string + // get reads one by ID, for a destination named without --to-project. + get func(ctx context.Context, app *appctx.App, id int64) (filingTarget, error) +} + +// filingTarget is the container a recording is moved or copied into. +type filingTarget struct { + id int64 + title string + // appURL is where a filed recording lands, in Basecamp: the folder when + // parentID is set. + appURL string + projectID int64 + projectName string + // calendar is set when the container is in a calendar, such as an + // employee's, rather than a project. + calendar bool + // parentID is a folder inside a Docs & Files destination, or 0 for its + // top level. + parentID int64 + parentTitle string +} + +// describe names the target for messages: "message board 900 (Ideas) in Dest". +func (t filingTarget) describe(noun string) string { + place := fmt.Sprintf("%s %d", noun, t.id) + if t.title != "" { + place += " (" + t.title + ")" + } + if t.parentID != 0 { + place = fmt.Sprintf("folder %d (%s) in %s", t.parentID, t.parentTitle, place) + } + if t.projectName != "" { + place += " in " + t.projectName + } + return place +} + +// resolveFilingTarget finds the container named by --to-project and the +// container's own flag: an ID or URL on its own, a project's only enabled +// tool of the kind, or one of them picked by ID or title. Only enabled tools +// on the project's dock count, as in the web's dialogs. A URL is held to +// parseFilingRef's host and account checks, and its project must agree with +// --to-project. +func resolveFilingTarget(cmd *cobra.Command, app *appctx.App, c filingContainer, toProject, toContainer string) (filingTarget, error) { + var urlProjectID, url string + if urlarg.IsURL(toContainer) { + ref, err := parseFilingRef(app, toContainer, c.noun, c.urlKind, c.urlShape) + if err != nil { + return filingTarget{}, err + } + url = toContainer + toContainer, urlProjectID = strconv.FormatInt(ref.id, 10), ref.project + } + + if toProject == "" { + id, err := strconv.ParseInt(toContainer, 10, 64) + if err != nil || id <= 0 { + return filingTarget{}, output.ErrUsage(fmt.Sprintf("%s must be a positive %s ID", c.flag, c.noun)) + } + target, err := c.get(cmd.Context(), app, id) + if err != nil { + return filingTarget{}, convertSDKError(err) + } + if urlProjectID != "" && !sameNumericID(urlProjectID, strconv.FormatInt(target.projectID, 10)) { + return filingTarget{}, output.ErrUsage(fmt.Sprintf("The %s URL names project %s, but %s %d is in project %d", c.flag, urlProjectID, c.noun, id, target.projectID)) + } + return target, nil + } + + projectID, projectName, err := app.Names.ResolveProject(cmd.Context(), toProject) + if err != nil { + return filingTarget{}, err + } + if urlProjectID != "" && !sameNumericID(urlProjectID, projectID) { + return filingTarget{}, output.ErrUsageHint( + fmt.Sprintf("The %s URL is in project %s, but --to-project points at project %s", c.flag, urlProjectID, projectID), + fmt.Sprintf("Drop --to-project, since the %s URL names its own project", c.noun), + ) + } + enabled, all, err := getDockTools(cmd.Context(), app, projectID, c.dockName) + if err != nil { + return filingTarget{}, err + } + projectName = projectLabel(cmd, app, projectID, projectName) + pick := func(tool DockTool) filingTarget { + id, _ := strconv.ParseInt(projectID, 10, 64) + return filingTarget{id: tool.ID, title: tool.Title, appURL: tool.AppURL, projectID: id, projectName: projectName} + } + + if toContainer == "" { + switch len(enabled) { + case 0: + return filingTarget{}, noFilingContainerError(c, all, projectID, projectName) + case 1: + return pick(enabled[0]), nil + default: + return filingTarget{}, ambiguousFilingContainerError(c, projectName, enabled) + } + } + var named []DockTool + for _, tool := range enabled { + if strconv.FormatInt(tool.ID, 10) == toContainer { + return pick(tool), nil + } + if url == "" && strings.EqualFold(tool.Title, toContainer) { + named = append(named, tool) + } + } + if url != "" { + return filingTarget{}, filingURLNotFound(c.flag, c.noun, url, projectName, + fmt.Sprintf("Its %s: %s", c.plural, formatDockTools(enabled))) + } + switch len(named) { + case 0: + case 1: + return pick(named[0]), nil + default: + return filingTarget{}, ambiguousFilingContainerError(c, projectName, named) + } + if len(enabled) == 0 { + return filingTarget{}, noFilingContainerError(c, all, projectID, projectName) + } + return filingTarget{}, output.ErrUsageHint( + fmt.Sprintf("%s %q is not a %s in %s", c.flag, toContainer, c.noun, projectName), + fmt.Sprintf("Its %s: %s", c.plural, formatDockTools(enabled)), + ) +} + +// bucketTarget is a container read by ID, in the project its bucket names. +func bucketTarget(id int64, title, appURL string, bucket *basecamp.Bucket) filingTarget { + target := filingTarget{id: id, title: title, appURL: appURL} + if bucket != nil { + target.projectID, target.projectName = bucket.ID, bucket.Name + target.calendar = bucket.Type == "Calendar" + } + return target +} + +// filingURLNotFound refuses a destination URL that names no container, in +// project when it is set. A URL carries an id, never a title, so it is not +// looked up as one: a flat URL names no project, and its id ("2026") can be a +// folder's or board's title. +func filingURLNotFound(flag, noun, url, project, hint string) error { + message := fmt.Sprintf("%s %s names no %s", flag, url, noun) + if project != "" { + message += " in " + project + } + return &output.Error{Code: output.CodeNotFound, Message: message, Hint: hint} +} + +// noFilingContainerError says the project has no enabled tool of the kind, +// and how to turn one on when it is only disabled. +func noFilingContainerError(c filingContainer, all []DockTool, projectID, projectName string) error { + for _, tool := range all { + if !tool.Enabled { + return &output.Error{ + Code: output.CodeNotFound, + Message: fmt.Sprintf("%s's %s (%s) is turned off, so nothing can be moved or copied into it", projectName, c.noun, tool.Title), + Hint: fmt.Sprintf("Turn it on first with: basecamp tools enable %d --in %s", tool.ID, projectID), + } + } + } + return &output.Error{ + Code: output.CodeNotFound, + Message: fmt.Sprintf("Project %s has no %s", projectName, c.noun), + Hint: fmt.Sprintf("Pick a project that has a %s", c.noun), + } +} + +func ambiguousFilingContainerError(c filingContainer, projectName string, tools []DockTool) error { + lines := make([]string, 0, len(tools)) + for _, tool := range tools { + lines = append(lines, fmt.Sprintf(" %d %s", tool.ID, tool.Title)) + } + return &output.Error{ + Code: output.CodeAmbiguous, + Message: fmt.Sprintf("%s has %d %s that match", projectName, len(tools), c.plural), + Hint: fmt.Sprintf("Specify one with %s <id>:\n%s", c.flag, strings.Join(lines, "\n")), + } +} + +func formatDockTools(tools []DockTool) string { + if len(tools) == 0 { + return "none" + } + names := make([]string, len(tools)) + for i, tool := range tools { + names[i] = fmt.Sprintf("%d (%s)", tool.ID, tool.Title) + } + return strings.Join(names, ", ") +} + +// filingSource is a recording to move or copy, as its own read returns it. +type filingSource struct { + id int64 + typ string + status string + title string + bucket *basecamp.Bucket + parent *basecamp.Parent +} + +// filingFamily is one kind of recording the web moves and copies on its own, +// such as messages, with what its move and copy commands need to know. +type filingFamily struct { + noun string + // group is the command group that shows and moves one: "basecamp messages". + group string + types []string + urlKinds []string + urlShape string + into filingContainer + // get reads one by ID: the record to output, and what filing needs. + get func(ctx context.Context, app *appctx.App, id int64) (any, filingSource, error) + // check refuses a recording Basecamp will not file; nil checks the type. + check func(src filingSource) error + // resolve finds the destination; nil uses resolveFilingTarget. + resolve func(cmd *cobra.Command, app *appctx.App, toProject, toContainer string) (filingTarget, error) + // own is the container the recording is in now, for a copy with no + // destination; nil reads the parent as an into container. + own func(ctx context.Context, app *appctx.App, src filingSource) (filingTarget, error) + // alreadyThere refuses moving a recording onto its own container; nil + // compares the parent with the target. + alreadyThere func(src filingSource, target filingTarget) error + // sameHint is the same-project command for a move onto the recording's + // own container, given its ID; empty when there is none. + sameHint func(src filingSource, target filingTarget) string + // crumbs are the follow-ups for a filed recording. + crumbs func(id, projectID int64) []output.Breadcrumb +} + +// filingHelp is a move or copy command's help. +type filingHelp struct { + use, short, long, notes string +} + +func (fam filingFamily) showCmd() string { return fam.group + " show" } + +// newFilingCmd makes a family's move or copy command. +func newFilingCmd(fam filingFamily, kind basecamp.FilingType, help filingHelp, bulk bool) *cobra.Command { + var toProject, toContainer string + var includeComments bool + + verb := "Move" + if kind == basecamp.FilingCopy { + verb = "Copy" + } + args := cobra.ExactArgs(1) + if bulk { + args = cobra.MinimumNArgs(1) + } + cmd := &cobra.Command{ + Use: help.use, + Short: help.short, + Long: help.long, + Args: args, + Annotations: map[string]string{"agent_notes": help.notes}, + RunE: func(cmd *cobra.Command, args []string) error { + return runFamilyFiling(cmd, fam, kind, args, toProject, toContainer, includeComments) + }, + } + addFamilyFilingFlags(cmd, fam, verb, &toProject, &toContainer) + if kind == basecamp.FilingCopy { + cmd.Flags().BoolVar(&includeComments, "include-comments", false, "Copy the "+fam.noun+"'s comments too") + } + return guardFilingFlags(cmd) +} + +func addFamilyFilingFlags(cmd *cobra.Command, fam filingFamily, verb string, toProject, toContainer *string) { + cmd.Flags().StringVar(toProject, "to-project", "", fmt.Sprintf("%s it into this project's %s (ID or name)", verb, fam.into.noun)) + cmd.Flags().StringVar(toContainer, strings.TrimPrefix(fam.into.flag, "--"), "", fmt.Sprintf("%s it into this %s (ID, URL, or a name with --to-project)", verb, fam.into.noun)) +} + +// familyFilingSet reports whether a destination flag was given. +func familyFilingSet(cmd *cobra.Command, fam filingFamily) bool { + return cmd.Flags().Changed("to-project") || cmd.Flags().Changed(strings.TrimPrefix(fam.into.flag, "--")) +} + +// runFamilyFiling moves or copies each recording in args into the destination +// the flags name, one filing each. Every recording is read before any is +// filed, so a bad ID, a recording named twice or one already in the +// destination stops the command before anything moves. +func runFamilyFiling(cmd *cobra.Command, fam filingFamily, kind basecamp.FilingType, args []string, toProject, toContainer string, includeComments bool) error { + filingSet := familyFilingSet(cmd, fam) + if kind == basecamp.FilingMove && !filingSet { + return missingArg(cmd, "--to-project") + } + if filingSet { + if toProject == "" && toContainer == "" { + return output.ErrUsage(fmt.Sprintf("--to-project or %s needs a value", fam.into.flag)) + } + if toProject == "" && !isNumericID(toContainer) && !urlarg.IsURL(toContainer) { + return output.ErrUsageHint( + fmt.Sprintf("%s %q is a name, which needs a project to look it up in", fam.into.flag, toContainer), + fmt.Sprintf("Add --to-project <project>, or pass the %s's ID or URL", fam.into.noun), + ) + } + } + + app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + refs := make([]filesMoveRef, 0, len(args)) + for _, arg := range args { + ref, err := parseFilingRefOf(app, arg, fam.noun, fam.urlShape, fam.urlKinds) + if err != nil { + return err + } + refs = append(refs, ref) + } + + var target filingTarget + if filingSet { + var err error + if fam.resolve != nil { + target, err = fam.resolve(cmd, app, toProject, toContainer) + } else { + target, err = resolveFilingTarget(cmd, app, fam.into, toProject, toContainer) + } + if err != nil { + return err + } + } + + filings := make([]recordingFiling, 0, len(refs)) + named := filingNames{} + for i, ref := range refs { + _, src, err := fam.get(cmd.Context(), app, ref.id) + if err != nil { + return convertSDKError(err) + } + if err := fam.checkSource(src, ref.id); err != nil { + return err + } + if err := named.add(fam.noun, args[i], src.id); err != nil { + return err + } + if err := rejectProjectConflict(cmd, app, fam.noun, flagValue(cmd, "project"), ref.project, src.bucket.ID); err != nil { + return err + } + dest := target + if !filingSet { + if dest, err = fam.ownContainer(cmd.Context(), app, src); err != nil { + return err + } + } + if kind == basecamp.FilingMove { + if err := fam.refuseOwnContainer(src, dest); err != nil { + return err + } + } + filings = append(filings, fam.filing(app, kind, src, dest, includeComments)) + } + return runFilings(cmd, app, scope, filings) +} + +// flagValue reads a flag the command may inherit, or "" when it has none. +func flagValue(cmd *cobra.Command, name string) string { + if f := cmd.Flags().Lookup(name); f != nil { + return f.Value.String() + } + return "" +} + +func (fam filingFamily) checkSource(src filingSource, requestedID int64) error { + if src.bucket == nil { + return output.ErrNotFound(fam.noun, strconv.FormatInt(requestedID, 10)) + } + // A moved recording's old id redirects to where it went, which may be + // read through another kind's endpoint, so the read alone does not prove + // the id names one of these. + if src.typ != "" && !slices.Contains(fam.types, src.typ) { + return output.ErrUsage(fmt.Sprintf("%d is a %s, not a %s", requestedID, src.typ, fam.noun)) + } + if fam.check != nil { + return fam.check(src) + } + return nil +} + +func (fam filingFamily) ownContainer(ctx context.Context, app *appctx.App, src filingSource) (filingTarget, error) { + if fam.own != nil { + return fam.own(ctx, app, src) + } + if src.parent == nil { + return filingTarget{}, output.ErrUsageHint( + fmt.Sprintf("Could not tell which %s %s #%d is in", fam.into.noun, fam.noun, src.id), + fmt.Sprintf("Name the destination with --to-project <project> or %s <id|url>", fam.into.flag), + ) + } + target, err := fam.into.get(ctx, app, src.parent.ID) + if err != nil { + return filingTarget{}, convertSDKError(err) + } + return target, nil +} + +func (fam filingFamily) refuseOwnContainer(src filingSource, target filingTarget) error { + if fam.alreadyThere != nil { + return fam.alreadyThere(src, target) + } + if src.parent == nil || src.parent.ID != target.id { + return nil + } + e := output.ErrUsage(fmt.Sprintf("%s #%d is already in %s", capitalizeFirst(fam.noun), src.id, target.describe(fam.into.noun))) + if fam.sameHint != nil { + e.Hint = fam.sameHint(src, target) + } else { + e.Hint = fmt.Sprintf("Pick another %s with --to-project <project> or %s <id|url>", fam.into.noun, fam.into.flag) + } + return e +} + +// filing is a Move… or Copy… of src into target, read back by the family's +// own read. +func (fam filingFamily) filing(app *appctx.App, kind basecamp.FilingType, src filingSource, target filingTarget, includeComments bool) recordingFiling { + return recordingFiling{ + kind: kind, + sourceID: src.id, + noun: fam.noun, + destinationID: target.id, + parentID: target.parentID, + includeComments: includeComments, + destination: target.describe(fam.into.noun), + showCmd: fam.showCmd(), + lookFor: copyLookFor(src, target), + read: func(ctx context.Context, id int64) (filedRecording, error) { + record, filed, err := fam.get(ctx, app, id) + if err != nil { + return filedRecording{}, err + } + var projectID int64 + if filed.bucket != nil { + projectID = filed.bucket.ID + } + return filedRecording{ + result: withSourceID(record, src.id), + id: filed.id, + bucket: filed.bucket, + parent: filed.parent, + breadcrumbs: fam.crumbs(filed.id, projectID), + }, nil + }, + } +} + +// withSourceID adds source_id, the recording a filing came from, to a filed +// recording's JSON. +func withSourceID(record any, sourceID int64) any { + raw, err := json.Marshal(record) + if err != nil { + return record + } + fields := map[string]any{} + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&fields); err != nil { + return record + } + fields["source_id"] = sourceID + return fields +} diff --git a/internal/commands/filing_family_test.go b/internal/commands/filing_family_test.go new file mode 100644 index 000000000..d693ba2df --- /dev/null +++ b/internal/commands/filing_family_test.go @@ -0,0 +1,607 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "sync" + "testing" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + "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" +) + +// familyTransport serves JSON by path suffix and plays Basecamp's recording +// moves and copies: a POST to /recordings/<id>/moves or /copies answers a +// pending filing numbered <id>, whose GET completes with recording <id>0 +// (or a still-processing filing for the ids in stuck). A source in refuse +// answers its POST with that status. +type familyTransport struct { + gets map[string]string + refuse map[int64]int + stuck map[int64]bool + + mu sync.Mutex + posts []string + bodies []map[string]any +} + +func (m *familyTransport) RoundTrip(req *http.Request) (*http.Response, error) { + m.mu.Lock() + defer m.mu.Unlock() + path := strings.TrimSuffix(req.URL.Path, ".json") + respond := func(status int, body string) (*http.Response, error) { + header := make(http.Header) + header.Set("Content-Type", "application/json") + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: header}, nil + } + + if req.Method == http.MethodPost { + m.posts = append(m.posts, path) + body := map[string]any{} + if req.Body != nil { + raw, _ := io.ReadAll(req.Body) + _ = json.Unmarshal(raw, &body) + } + m.bodies = append(m.bodies, body) + parts := strings.Split(path, "/") + if len(parts) < 2 { + return respond(404, `{"error":"not found"}`) + } + source, _ := strconv.ParseInt(parts[len(parts)-2], 10, 64) + if status := m.refuse[source]; status != 0 { + return respond(status, `{"error":"refused"}`) + } + kind := "Move" + if strings.HasSuffix(path, "/copies") { + kind = "Copy" + } + return respond(201, fmt.Sprintf(`{"id":%d,"status":"pending","type":%q,"source_recording_id":%d}`, source, kind, source)) + } + + if i := strings.Index(path, "/filings/"); i >= 0 { + id, _ := strconv.ParseInt(path[i+len("/filings/"):], 10, 64) + if m.stuck[id] { + return respond(200, fmt.Sprintf(`{"id":%d,"status":"processing","type":"Move","source_recording_id":%d}`, id, id)) + } + return respond(200, fmt.Sprintf(`{"id":%d,"status":"completed","type":"Move","source_recording_id":%d,"destination_recording":{"id":%d0}}`, id, id, id)) + } + + // Longest suffix wins, so /projects/999 is not answered as /projects. A + // key with a query answers only a request with that query. + best := "" + for suffix := range m.gets { + target := path + if strings.Contains(suffix, "?") { + target = path + "?" + req.URL.RawQuery + } + if strings.HasSuffix(target, suffix) && len(suffix) > len(best) { + best = suffix + } + } + if best != "" { + return respond(200, m.gets[best]) + } + return respond(404, `{"error":"not found"}`) +} + +// familyRun is one command run against a familyTransport. +type familyRun struct { + err error + envelope struct { + Data json.RawMessage `json:"data"` + Summary string `json:"summary"` + Breadcrumbs []struct { + Cmd string `json:"cmd"` + } `json:"breadcrumbs"` + } +} + +func (r familyRun) data(t *testing.T) map[string]any { + t.Helper() + var data map[string]any + require.NoError(t, json.Unmarshal(r.envelope.Data, &data)) + return data +} + +func (r familyRun) failure(t *testing.T) *output.Error { + t.Helper() + require.Error(t, r.err) + return output.AsError(r.err) +} + +func runFamilyCommand(t *testing.T, transport *familyTransport, cmd *cobra.Command, args ...string) familyRun { + t.Helper() + shortFilingWait(t) + app, buf := newTestAppWithTransport(t, transport) + app.Flags.Hints = true + var run familyRun + run.err = executeCommand(cmd, app, args...) + if run.err == nil { + require.NoError(t, json.Unmarshal(buf.Bytes(), &run.envelope)) + } + return run +} + +// filingProjects are the projects every family test files between: 123 +// "Source", 999 "Dest", and 888 "Bare", whose dock comes from each test. +const filingProjects = `[{"id":123,"name":"Source"},{"id":999,"name":"Dest"},{"id":888,"name":"Bare"}]` + +func boardsContainer() filingContainer { + return filingContainer{ + dockName: "message_board", + noun: "message board", + plural: "message boards", + flag: "--to-board", + urlKind: "message_boards", + urlShape: "…/message_boards/<id>", + get: func(ctx context.Context, app *appctx.App, id int64) (filingTarget, error) { + return filingTarget{id: id, title: "Board", projectID: 999, projectName: "Dest"}, nil + }, + } +} + +func resolveTestTarget(t *testing.T, transport *familyTransport, toProject, toContainer string) (filingTarget, error) { + t.Helper() + var target filingTarget + cmd := &cobra.Command{RunE: func(cmd *cobra.Command, _ []string) error { + var err error + target, err = resolveFilingTarget(cmd, appctx.FromContext(cmd.Context()), boardsContainer(), toProject, toContainer) + return err + }} + app, _ := newTestAppWithTransport(t, transport) + err := executeCommand(cmd, app) + return target, err +} + +// Appending "s" to a container's noun once named a project's To-dos tools +// "To-doss", so every filingContainer literal spells its plural out. The test +// reads the package's source, so it covers containers no test resolves. +func TestEveryFilingContainerNamesItsPlural(t *testing.T) { + files, err := filepath.Glob("*.go") + require.NoError(t, err) + fset := token.NewFileSet() + var containers int + for _, name := range files { + src, err := os.ReadFile(name) + require.NoError(t, err) + file, err := parser.ParseFile(fset, name, src, 0) + require.NoError(t, err) + ast.Inspect(file, func(n ast.Node) bool { + lit, ok := n.(*ast.CompositeLit) + if !ok { + return true + } + if typ, ok := lit.Type.(*ast.Ident); !ok || typ.Name != "filingContainer" { + return true + } + containers++ + named := slices.ContainsFunc(lit.Elts, func(e ast.Expr) bool { + kv, ok := e.(*ast.KeyValueExpr) + if !ok { + return false + } + key, ok := kv.Key.(*ast.Ident) + value, isString := kv.Value.(*ast.BasicLit) + return ok && key.Name == "plural" && isString && value.Value != `""` + }) + assert.True(t, named, "%s: a filingContainer sets plural", fset.Position(lit.Pos())) + return true + }) + } + assert.GreaterOrEqual(t, containers, 7, "the scan finds every family's container") +} + +func dockTransport(dock string) *familyTransport { + return &familyTransport{gets: map[string]string{ + "/projects": filingProjects, + "/projects/999": `{"id":999,"name":"Dest","dock":[` + dock + `]}`, + }} +} + +func TestFilingTargetIsTheProjectsOnlyEnabledContainer(t *testing.T) { + transport := dockTransport(`{"name":"message_board","id":900,"title":"Board","enabled":true},{"name":"message_board","id":901,"title":"Old","enabled":false}`) + target, err := resolveTestTarget(t, transport, "Dest", "") + require.NoError(t, err) + assert.Equal(t, filingTarget{id: 900, title: "Board", projectID: 999, projectName: "Dest"}, target) +} + +func TestFilingTargetAmongSeveralIsAmbiguousAndListsThem(t *testing.T) { + transport := dockTransport(`{"name":"message_board","id":900,"title":"Board","enabled":true},{"name":"message_board","id":950,"title":"News","enabled":true}`) + _, err := resolveTestTarget(t, transport, "Dest", "") + e := output.AsError(err) + assert.Equal(t, output.CodeAmbiguous, e.Code) + assert.Contains(t, e.Hint, "--to-board <id>") + assert.Contains(t, e.Hint, "900 Board") + assert.Contains(t, e.Hint, "950 News") +} + +func TestFilingTargetNamedByATitleTwoShareIsAmbiguous(t *testing.T) { + transport := dockTransport(`{"name":"message_board","id":900,"title":"Board","enabled":true},{"name":"message_board","id":950,"title":"board","enabled":true},{"name":"message_board","id":960,"title":"News","enabled":true}`) + _, err := resolveTestTarget(t, transport, "Dest", "Board") + e := output.AsError(err) + assert.Equal(t, output.CodeAmbiguous, e.Code) + assert.Contains(t, e.Hint, "950 board") + assert.NotContains(t, e.Hint, "960", "only the containers with that title are candidates") +} + +func TestFilingTargetByTitleOrID(t *testing.T) { + transport := dockTransport(`{"name":"message_board","id":900,"title":"Board","enabled":true},{"name":"message_board","id":950,"title":"News","enabled":true}`) + byTitle, err := resolveTestTarget(t, transport, "Dest", "news") + require.NoError(t, err) + assert.Equal(t, int64(950), byTitle.id) + byID, err := resolveTestTarget(t, transport, "Dest", "900") + require.NoError(t, err) + assert.Equal(t, int64(900), byID.id) +} + +func TestFilingTargetNotInTheProjectListsWhatIs(t *testing.T) { + transport := dockTransport(`{"name":"message_board","id":900,"title":"Board","enabled":true}`) + _, err := resolveTestTarget(t, transport, "Dest", "Announcements") + e := output.AsError(err) + assert.Equal(t, `--to-board "Announcements" is not a message board in Dest`, e.Message) + assert.Contains(t, e.Hint, "900 (Board)") +} + +func TestFilingTargetThatIsTurnedOffSaysHowToTurnItOn(t *testing.T) { + transport := dockTransport(`{"name":"message_board","id":901,"title":"Old","enabled":false}`) + _, err := resolveTestTarget(t, transport, "Dest", "") + e := output.AsError(err) + assert.Equal(t, output.CodeNotFound, e.Code) + assert.Equal(t, "Dest's message board (Old) is turned off, so nothing can be moved or copied into it", e.Message) + assert.Equal(t, "Turn it on first with: basecamp tools enable 901 --in 999", e.Hint) +} + +func TestFilingTargetInAProjectWithoutOneNamesTheProject(t *testing.T) { + transport := dockTransport(`{"name":"chat","id":700,"title":"Chat","enabled":true}`) + _, err := resolveTestTarget(t, transport, "Dest", "") + e := output.AsError(err) + assert.Equal(t, output.CodeNotFound, e.Code) + assert.Equal(t, "Project Dest has no message board", e.Message) +} + +func TestFilingTargetURLMustAgreeWithToProject(t *testing.T) { + transport := dockTransport(`{"name":"message_board","id":900,"title":"Board","enabled":true}`) + _, err := resolveTestTarget(t, transport, "Dest", "https://3.basecamp.com/99999/buckets/123/message_boards/900") + e := output.AsError(err) + assert.Equal(t, output.CodeUsage, e.Code) + assert.Contains(t, e.Message, "--to-project points at project 999") +} + +func TestFilingTargetURLFromAnotherAccountIsRefused(t *testing.T) { + transport := dockTransport(``) + _, err := resolveTestTarget(t, transport, "", "https://3.basecamp.com/11111/buckets/999/message_boards/900") + e := output.AsError(err) + assert.Contains(t, e.Message, "URL is for account 11111") +} + +func TestFilingTargetByIDAloneReadsTheContainer(t *testing.T) { + transport := dockTransport(``) + target, err := resolveTestTarget(t, transport, "", "https://3.basecamp.com/99999/buckets/999/message_boards/900") + require.NoError(t, err) + assert.Equal(t, int64(900), target.id) + assert.Empty(t, transport.posts) +} + +func testFiling(app *appctx.App, kind basecamp.FilingType, sourceID int64) recordingFiling { + return recordingFiling{ + kind: kind, + sourceID: sourceID, + noun: "file", + destinationID: 900, + parentID: 950, + destination: "Docs & Files 900 in Dest", + showCmd: "basecamp files show", + lookFor: copyCheck{listing: "basecamp files list --in 999", match: "look for the copy"}, + read: func(_ context.Context, id int64) (filedRecording, error) { + return filedRecording{ + result: map[string]any{"id": id, "source_id": sourceID}, + id: id, + bucket: &basecamp.Bucket{ID: 999, Name: "Dest"}, + parent: &basecamp.Parent{ID: 950, Title: "Folder"}, + }, nil + }, + } +} + +func runTestFilings(t *testing.T, transport *familyTransport, kind basecamp.FilingType, sources ...int64) familyRun { + t.Helper() + cmd := &cobra.Command{RunE: func(cmd *cobra.Command, _ []string) error { + app := appctx.FromContext(cmd.Context()) + filings := make([]recordingFiling, len(sources)) + for i, source := range sources { + filings[i] = testFiling(app, kind, source) + } + return runFilings(cmd, app, followUpScope{}, filings) + }} + return runFamilyCommand(t, transport, cmd) +} + +// A still-running item's line says once not to file it again, and names the +// filing that shows where it got to. +func TestBulkFilingStillRunningSaysNotToFileAgainOnce(t *testing.T) { + for _, kind := range []basecamp.FilingType{basecamp.FilingMove, basecamp.FilingCopy} { + t.Run(string(kind), func(t *testing.T) { + shortFilingWait(t) + transport := &familyTransport{stuck: map[int64]bool{11: true, 22: true}} + e := runTestFilings(t, transport, kind, 11, 22).failure(t) + lines := strings.Split(e.Hint, "\n") + require.Len(t, lines, 2, e.Hint) + for i, id := range []int{11, 22} { + assert.True(t, strings.HasPrefix(lines[i], fmt.Sprintf("#%d: ", id)), lines[i]) + assert.Equal(t, 1, strings.Count(lines[i], "Do not"), lines[i]) + assert.Contains(t, lines[i], fmt.Sprintf("basecamp filings show %d", id)) + } + }) + } +} + +func TestBulkFilingFilesEachAndListsTheNewRecordings(t *testing.T) { + transport := &familyTransport{} + run := runTestFilings(t, transport, basecamp.FilingCopy, 11, 22) + require.NoError(t, run.err) + + assert.Equal(t, []string{"/99999/recordings/11/copies", "/99999/recordings/22/copies"}, transport.posts) + assert.Equal(t, float64(950), transport.bodies[0]["parent_id"], "a folder inside the destination goes as parent_id") + var data []map[string]any + require.NoError(t, json.Unmarshal(run.envelope.Data, &data)) + require.Len(t, data, 2) + assert.Equal(t, float64(110), data[0]["id"]) + assert.Equal(t, float64(22), data[1]["source_id"]) + assert.Equal(t, "Copied 2 files: #11 as #110 in Dest › Folder, #22 as #220 in Dest › Folder", run.envelope.Summary) +} + +func TestSingleFilingOutputsTheRecordingItself(t *testing.T) { + transport := &familyTransport{} + run := runTestFilings(t, transport, basecamp.FilingMove, 11) + require.NoError(t, run.err) + assert.Equal(t, float64(110), run.data(t)["id"]) + assert.Equal(t, float64(950), transport.bodies[0]["parent_id"]) +} + +func TestWithSourceIDKeepsLargeIDsExact(t *testing.T) { + raw, err := json.Marshal(withSourceID(map[string]any{"id": int64(9007199254740993)}, 7)) + require.NoError(t, err) + assert.JSONEq(t, `{"id":9007199254740993,"source_id":7}`, string(raw)) +} + +// A copy whose start went unanswered has no filing to read, and keeps its +// source's status, so it is looked for among recordings in that status, every +// page; a type the recordings listing does not serve is looked for in its own +// listing, every page, or in the timeline when that listing leaves its status +// out, and where no listing shows that status, in the destination in Basecamp. +func TestCopyRecoveryListsWhereTheCopyShows(t *testing.T) { + for name, tc := range map[string]struct { + transport func() *familyTransport + cmd func() *cobra.Command + source string + status string + want string + }{ + "a message": {messagesTransport, NewMessagesCmd, "/messages/456", "", "basecamp recordings list Message --in 999 --status active --all"}, + "an archived message": {messagesTransport, NewMessagesCmd, "/messages/456", "archived", "basecamp recordings list Message --in 999 --status archived --all"}, + "an event": {scheduleFilingTransport, NewScheduleCmd, "/schedule_entries/456", "", "basecamp recordings list Schedule::Entry --in 999 --status active --all"}, + "an archived event": {scheduleFilingTransport, NewScheduleCmd, "/schedule_entries/456", "archived", "basecamp recordings list Schedule::Entry --in 999 --status archived --all"}, + "a check-in question": {checkinsFilingTransport, NewCheckinsCmd, "/questions/456", "", "basecamp checkins questions --in 999 --questionnaire 900 --all"}, + "an archived check-in question": {checkinsFilingTransport, NewCheckinsCmd, "/questions/456", "archived", "basecamp timeline --in 999 --all"}, + "a trashed check-in question": {checkinsFilingTransport, NewCheckinsCmd, "/questions/456", "trashed", "open the destination in Basecamp: https://3.basecamp.com/99999/buckets/999/questionnaires/900"}, + "an email forward": {forwardsFilingTransport, NewForwardsCmd, "/inbox_forwards/456", "", "basecamp forwards list --in 999 --inbox 900 --all"}, + "an archived email forward": {forwardsFilingTransport, NewForwardsCmd, "/inbox_forwards/456", "archived", "open the destination in Basecamp: https://3.basecamp.com/99999/buckets/999/inboxes/900"}, + "a trashed email forward": {forwardsFilingTransport, NewForwardsCmd, "/inbox_forwards/456", "trashed", "open the destination in Basecamp: https://3.basecamp.com/99999/buckets/999/inboxes/900"}, + "a card table column": {columnsTransport, NewCardsCmd, "/card_tables/columns/456", "", "basecamp cards columns --in 999 --card-table 900"}, + "an archived card table column": {columnsTransport, NewCardsCmd, "/card_tables/columns/456", "archived", "open the destination in Basecamp: https://3.basecamp.com/99999/buckets/999/card_tables/900"}, + } { + t.Run(name, func(t *testing.T) { + transport := tc.transport() + if tc.status != "" { + transport.gets[tc.source] = strings.Replace(transport.gets[tc.source], `"id":456,`, `"id":456,"status":"`+tc.status+`",`, 1) + } + transport.refuse = map[int64]int{456: 502} + args := []string{"copy", "456", "--to-project", "Dest"} + switch { + case strings.Contains(name, "check-in"): + args = append([]string{"question"}, args...) + case strings.Contains(name, "column"): + args = append([]string{"column"}, args...) + } + e := runFamilyCommand(t, transport, tc.cmd(), args...).failure(t) + assert.Contains(t, e.Hint, "may have started the copy") + assert.Contains(t, e.Hint, tc.want) + }) + } +} + +// Every recording a move or copy files, in every status a copy can keep, is +// looked for in a listing known to show it there, or at the destination's page +// in Basecamp. Each listing below carries the places it is known to show; a +// recovery naming anything else, or a listing for a place it hides, fails. +// Each listing carries the run's --profile and --account, so it searches the +// account the copy went to; the destination's page needs neither. +func TestEveryCopyRecoveryCanShowTheCopy(t *testing.T) { + const scope = " --profile work --account 99999" + dest := filingTarget{id: 7, title: "Dest tool", appURL: "https://3.basecamp.com/99999/buckets/42/vaults/7", projectID: 42} + listedStatuses := []string{"active", "archived", "trashed"} + // bc3 doc/api/sections/recordings.md: the types GET /projects/recordings.json takes. + recordingsTypes := []string{"Comment", "Document", "Door", "Kanban::Card", "Kanban::Step", "Message", "Question::Answer", "Schedule::Entry", "Todo", "Todolist", "Upload", "Vault"} + type knownListing struct { + listing *regexp.Regexp + shows func(place copyPlace, match []string) bool + } + knownGood := []knownListing{ + {regexp.MustCompile(`^basecamp recordings list (\S+) --in 42 --status (\S+) --all$`), + func(p copyPlace, m []string) bool { + return !p.calendar && m[1] == p.typ && m[2] == p.status && slices.Contains(recordingsTypes, p.typ) && slices.Contains(listedStatuses, p.status) + }}, + // drafts.md: messages, documents and uploads, and no other drafts. + {regexp.MustCompile(`^basecamp drafts list --all$`), + func(p copyPlace, _ []string) bool { + return p.status == "drafted" && slices.Contains([]string{"Message", "Document", "Upload"}, p.typ) + }}, + // schedule_entries.md: a schedule's entries by status, active, archived or trashed. + {regexp.MustCompile(`^basecamp schedule entries --in 42 --schedule 7 --status (\S+) --all$`), + func(p copyPlace, m []string) bool { + return p.typ == "Schedule::Entry" && m[1] == p.status && slices.Contains(listedStatuses, p.status) + }}, + // Seen against bc3: a copied cloud file (active or archived) and an + // archived question show as reposted; nothing trashed or drafted does. + {regexp.MustCompile(`^basecamp timeline --in 42 --all$`), + func(p copyPlace, _ []string) bool { + return !p.calendar && (p.typ == "CloudFile" && (p.status == "active" || p.status == "archived") || + p.typ == "Question" && p.status == "archived") + }}, + // These list only recordings that inherit their status: active ones. + {regexp.MustCompile(`^basecamp checkins questions --in 42 --questionnaire 7 --all$`), + func(p copyPlace, _ []string) bool { return !p.calendar && p.typ == "Question" && p.status == "active" }}, + {regexp.MustCompile(`^basecamp forwards list --in 42 --inbox 7 --all$`), + func(p copyPlace, _ []string) bool { + return !p.calendar && p.typ == "Inbox::Forward" && p.status == "active" + }}, + {regexp.MustCompile(`^basecamp cards columns --in 42 --card-table 7$`), + func(p copyPlace, _ []string) bool { + return !p.calendar && p.typ == "Kanban::Column" && p.status == "active" + }}, + } + + types := []string{"Todo", "Todolist", "Kanban::Card"} + for _, fam := range []filingFamily{messageFamily(), filesFamily(), columnFamily(), scheduleEntryFamily(), questionFamily(), forwardFamily()} { + types = append(types, fam.types...) + } + statuses := make([]string, 0, 4+len(copyListings)) + statuses = append(statuses, "active", "archived", "trashed", "drafted") + for place := range copyListings { + types, statuses = append(types, place.typ), append(statuses, place.status) + } + listed := map[copyPlace]bool{} + for _, typ := range types { + for _, status := range statuses { + for _, calendar := range []bool{false, true} { + place := copyPlace{typ: typ, status: status, calendar: calendar} + target := dest + target.calendar = calendar + hint, capped := strings.CutSuffix(copyLookFor(filingSource{id: 456, typ: typ, status: status, title: "Plan"}, target).text(scope), + ". That listing stops at the most recently updated, so if the copy is not there, open the destination in Basecamp before copying again: "+dest.appURL) + hint, matched := strings.CutSuffix(hint, `, then look for "Plan" in Dest tool (#7); a copy has its original's title and dates`) + require.True(t, matched, "%+v: %q names the copy by its title in the destination", place, hint) + if hint == "open the destination in Basecamp: "+dest.appURL { + continue + } + hint, matched = strings.CutSuffix(hint, scope) + require.True(t, matched, "%+v: %q carries the profile and account to reach this account", place, hint) + listed[place] = true + // The drafts listing holds only the 250 most recently updated, + // and a copy keeps its original's updated_at. + assert.Equal(t, strings.HasPrefix(hint, "basecamp drafts list"), capped, + "%+v: %q sends a capped listing's miss to the destination", place, hint) + shown := slices.ContainsFunc(knownGood, func(good knownListing) bool { + match := good.listing.FindStringSubmatch(hint) + return match != nil && good.shows(place, match) + }) + assert.True(t, shown, "%+v: %q is not known to show this copy", place, hint) + } + } + } + assert.Len(t, listed, len(copyListings), "every listing in the table was checked") +} + +// A filing's breadcrumbs name a show command for the record, which reads it by +// its account-wide ID, so each runs with no project configured and names the +// record's own project in its breadcrumbs. +func TestFilingAdvertisedShowCommandsNeedNoProject(t *testing.T) { + families := []struct { + group string + transport func() *familyTransport + root func() *cobra.Command + move []string + }{ + {"messages", messagesTransport, NewMessagesCmd, []string{"move"}}, + {"files", filesFilingTransport, NewFilesCmd, []string{"move"}}, + {"schedule", scheduleFilingTransport, NewScheduleCmd, []string{"move"}}, + {"cards column", columnsTransport, NewCardsCmd, []string{"column", "move"}}, + {"checkins question", checkinsFilingTransport, NewCheckinsCmd, []string{"question", "move"}}, + {"forwards", forwardsFilingTransport, NewForwardsCmd, []string{"move"}}, + } + showCmd := regexp.MustCompile(`basecamp [a-z ]+? show \d+( --in \d+)?`) + for _, f := range families { + t.Run(f.group, func(t *testing.T) { + args := append(slices.Clone(f.move), "456", "--to-project", "Dest") + transport := f.transport() + run := runFamilyCommand(t, transport, f.root(), args...) + require.NoError(t, run.err) + + line := run.envelope.Breadcrumbs[0].Cmd + require.Regexp(t, showCmd, line) + app, buf := newTestAppWithTransport(t, transport) + app.Config.ProjectID = "" + require.NoError(t, executeCommand(f.root(), app, strings.Fields(line)[2:]...), line) + + var shown familyRun + require.NoError(t, json.Unmarshal(buf.Bytes(), &shown.envelope), line) + for _, crumb := range shown.envelope.Breadcrumbs { + assert.NotContains(t, crumb.Cmd, "<project>", line) + } + }) + } +} + +// A move or copy still running when the wait ends is checked by its filing, +// which says where it got to whatever the recording's type and status, rather +// than by a listing that may not show it. +func TestStillFilingNamesTheFilingForEveryFamily(t *testing.T) { + type family struct { + run func(t *testing.T, verb string) error + filing string + } + familyRun := func(transport func() *familyTransport, root func() *cobra.Command, prefix ...string) func(*testing.T, string) error { + return func(t *testing.T, verb string) error { + tr := transport() + tr.stuck = map[int64]bool{456: true} + args := append(slices.Clone(prefix), verb, "456", "--to-project", "Dest") + return runFamilyCommand(t, tr, root(), args...).err + } + } + families := map[string]family{ + "cards": {func(t *testing.T, verb string) error { + return runFilingCommand(t, &mockFilingTransport{statuses: []string{"processing"}}, verb, "456", "--to-project", "Dest").err + }, "70"}, + "to-dos": {func(t *testing.T, verb string) error { + transport := &mockTodoFilingTransport{stuck: map[string]bool{"456": true}} + return runTodoFilingCommand(t, transport, NewTodosCmd(), verb, "456", "--to-list", "901").err + }, "456"}, + "messages": {familyRun(messagesTransport, NewMessagesCmd), "456"}, + "files": {familyRun(filesFilingTransport, NewFilesCmd), "456"}, + "columns": {familyRun(columnsTransport, NewCardsCmd, "column"), "456"}, + "schedule entries": {familyRun(scheduleFilingTransport, NewScheduleCmd), "456"}, + "check-in questions": {familyRun(checkinsFilingTransport, NewCheckinsCmd, "question"), "456"}, + "email forwards": {familyRun(forwardsFilingTransport, NewForwardsCmd), "456"}, + } + for name, f := range families { + for _, verb := range []string{"move", "copy"} { + t.Run(name+" "+verb, func(t *testing.T) { + err := f.run(t, verb) + require.Error(t, err) + e := output.AsError(err) + assert.Equal(t, output.CodeInProgress, e.Code) + assert.Contains(t, e.Hint, "Do not "+verb+" it again") + assert.Contains(t, e.Hint, "Check it with: basecamp filings show "+f.filing+",") + assert.NotContains(t, e.Hint, " list") + assert.NotContains(t, e.Hint, "timeline") + }) + } + } +} diff --git a/internal/commands/filing_guard_test.go b/internal/commands/filing_guard_test.go new file mode 100644 index 000000000..73df77173 --- /dev/null +++ b/internal/commands/filing_guard_test.go @@ -0,0 +1,206 @@ +package commands_test + +import ( + "bytes" + "context" + "io" + "net/http" + "slices" + "sort" + "strings" + "sync" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/auth" + "github.com/basecamp/basecamp-cli/internal/commands" + "github.com/basecamp/basecamp-cli/internal/config" + "github.com/basecamp/basecamp-cli/internal/names" + "github.com/basecamp/basecamp-cli/internal/output" +) + +// filingReads is, for every command that moves or copies recordings, the +// container flags it inherits and reads. Each other container flag it +// inherits must be refused before anything is sent. +var filingReads = map[string][]string{ + "basecamp cards column copy": {}, + "basecamp cards column move": {"card-table"}, + "basecamp cards copy": {}, + "basecamp cards move": {"card-table"}, + "basecamp checkins question copy": {}, + "basecamp checkins question move": {}, + "basecamp docs copy": {}, + "basecamp docs move": {}, + "basecamp files copy": {}, + "basecamp files move": {}, + "basecamp forwards copy": {}, + "basecamp forwards move": {}, + "basecamp messages copy": {}, + "basecamp messages move": {}, + "basecamp schedule copy": {}, + "basecamp schedule move": {}, + "basecamp todolistgroups copy": {}, + "basecamp todolistgroups position": {}, + "basecamp todolists copy": {}, + "basecamp todolists position": {}, + "basecamp todos copy": {}, + "basecamp todos position": {}, + "basecamp tools move": {}, + "basecamp tools reposition": {}, + "basecamp uploads copy": {}, + "basecamp uploads move": {}, + "basecamp vaults copy": {}, + "basecamp vaults move": {}, +} + +// notFilings are move and copy commands that file nothing into a container. +var notFilings = map[string]string{ + "basecamp cards step move": "reorders a step within its card", + "basecamp subtasks move": "reorders a subtask within its parent", + "basecamp templates copy": "makes a template from a project", +} + +func TestEveryMoveAndCopyCommandDeclaresTheContainerFlagsItReads(t *testing.T) { + declared := map[string][]string{} + var walk func(*cobra.Command) + walk = func(cmd *cobra.Command) { + for _, sub := range cmd.Commands() { + walk(sub) + } + path := cmd.CommandPath() + reads, guarded := cmd.Annotations[commands.AnnotationFilingReads] + if guarded { + declared[path] = strings.Fields(reads) + } + names := append([]string{cmd.Name()}, cmd.Aliases...) + if (slices.Contains(names, "move") || slices.Contains(names, "copy")) && !guarded { + assert.Contains(t, notFilings, path, "%s moves or copies but does not guard the container flags it ignores", path) + } + } + walk(buildRootWithAllCommands()) + + assert.Equal(t, filingReads, declared) +} + +func TestFilingCommandsRefuseContainerFlagsTheyDoNotRead(t *testing.T) { + rows := 0 + for path, reads := range filingReads { + for _, flag := range inheritedContainerFlags(t, path) { + args := append(strings.Fields(path)[1:], "1", "--"+flag, "2") + if slices.Contains(reads, flag) { + t.Run(path+" reads --"+flag, func(t *testing.T) { + _, err := runAtRoot(t, args) + if err != nil { + assert.NotContains(t, err.Error(), "does not read") + } + }) + continue + } + rows++ + t.Run(path+" refuses --"+flag, func(t *testing.T) { + requests, err := runAtRoot(t, args) + require.Error(t, err) + e := output.AsError(err) + assert.Equal(t, output.CodeUsage, e.Code) + assert.Contains(t, e.Message, "--"+flag) + assert.Empty(t, requests, "nothing is sent") + }) + } + } + assert.Positive(t, rows) +} + +func TestTodosCopyRefusesTheTodolistFlagItWouldIgnore(t *testing.T) { + for name, args := range map[string][]string{ + "after the command": {"todos", "copy", "456", "--todolist", "901"}, + "before the command": {"--todolist", "901", "todos", "copy", "456"}, + } { + t.Run(name, func(t *testing.T) { + requests, err := runAtRoot(t, args) + require.Error(t, err) + e := output.AsError(err) + assert.Equal(t, output.CodeUsage, e.Code) + assert.Equal(t, "basecamp todos copy does not read --todolist, so it would ignore it", e.Message) + assert.Contains(t, e.Hint, "--to-list") + assert.Empty(t, requests, "nothing is read or copied") + }) + } +} + +// inheritedContainerFlags lists the container flags the command at path +// inherits: those its command groups add, and the root's --todolist. --project +// and --in name the recording's own project, which the commands check. +func inheritedContainerFlags(t *testing.T, path string) []string { + t.Helper() + cmd, _, err := buildRootWithAllCommands().Find(strings.Fields(path)[1:]) + require.NoError(t, err) + flags := []string{"todolist"} + for group := cmd.Parent(); group.HasParent(); group = group.Parent() { + group.PersistentFlags().VisitAll(func(f *pflag.Flag) { + if f.Name != "project" && f.Name != "in" && !slices.Contains(flags, f.Name) { + flags = append(flags, f.Name) + } + }) + } + sort.Strings(flags) + return flags +} + +// runAtRoot runs args through the whole command tree against a +// transport that records every request and answers each with a 404. +func runAtRoot(t *testing.T, args []string) ([]string, error) { + t.Helper() + t.Setenv("BASECAMP_NO_KEYRING", "1") + transport := &recordingTransport{} + cfg := &config.Config{AccountID: "99999"} + sdk := basecamp.NewClient(&basecamp.Config{BaseURL: "https://3.basecampapi.com"}, staticToken{}, + basecamp.WithTransport(transport), basecamp.WithMaxRetries(1)) + authMgr := auth.NewManager(cfg, nil) + app := &appctx.App{ + Config: cfg, + Auth: authMgr, + SDK: sdk, + Names: names.NewResolver(sdk, authMgr, cfg.AccountID), + Output: output.New(output.Options{Format: output.FormatJSON, Writer: &bytes.Buffer{}}), + } + + root := buildRootWithAllCommands() + root.PersistentPreRunE = nil + root.PersistentPostRunE = nil + root.SetContext(appctx.WithApp(context.Background(), app)) + root.SetArgs(args) + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + err := root.Execute() + return transport.sent(), err +} + +type staticToken struct{} + +func (staticToken) AccessToken(context.Context) (string, error) { return "test-token", nil } + +type recordingTransport struct { + mu sync.Mutex + requests []string +} + +func (r *recordingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.requests = append(r.requests, req.Method+" "+req.URL.Path) + header := http.Header{"Content-Type": []string{"application/json"}} + return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader(`{}`)), Header: header}, nil +} + +func (r *recordingTransport) sent() []string { + r.mu.Lock() + defer r.mu.Unlock() + return slices.Clone(r.requests) +} diff --git a/internal/commands/filing_show_test.go b/internal/commands/filing_show_test.go new file mode 100644 index 000000000..57c7a34c3 --- /dev/null +++ b/internal/commands/filing_show_test.go @@ -0,0 +1,82 @@ +package commands + +import ( + "regexp" + "slices" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Reading a moved record by its old id answers with the record at its new id, +// and the old id no longer takes writes, so every follow-up a show command +// advertises names the id it read back. +func TestShowingAMovedRecordByItsOldIDFollowsUpOnTheNewID(t *testing.T) { + for _, tc := range []struct { + name string + root func() *cobra.Command + args []string + path string + body string + want string + }{ + {"cards show", NewCardsCmd, []string{"show"}, "/card_tables/cards/456", + `{"id":4560,"type":"Kanban::Card","title":"Card","bucket":{"id":999,"name":"Dest"}}`, "basecamp cards done 4560"}, + {"cards column show", NewCardsCmd, []string{"column", "show"}, "/card_tables/columns/456", + `{"id":4560,"type":"Kanban::Column","title":"Doing","bucket":{"id":999,"name":"Dest"}}`, "basecamp cards column update 4560 --in 999"}, + {"todos show", NewTodosCmd, []string{"show"}, "/todos/456", + `{"id":4560,"type":"Todo","content":"Ship","bucket":{"id":999,"name":"Dest"}}`, "basecamp todos complete 4560"}, + {"todolists show", NewTodolistsCmd, []string{"show"}, "/todolists/456", + `{"id":4560,"type":"Todolist","name":"Launch","bucket":{"id":999,"name":"Dest"}}`, "basecamp todos --list 4560"}, + {"messages show", NewMessagesCmd, []string{"show"}, "/messages/456", + `{"id":4560,"type":"Message","subject":"Kickoff","bucket":{"id":999,"name":"Dest"}}`, "basecamp comments create 4560"}, + {"files show", NewFilesCmd, []string{"show"}, "/documents/456", + `{"id":4560,"type":"Document","title":"Plan","bucket":{"id":999,"name":"Dest"}}`, "basecamp files update 4560 --in 999"}, + {"files show a folder", NewFilesCmd, []string{"show"}, "/vaults/456", + `{"id":4560,"type":"Vault","title":"Specs","bucket":{"id":999,"name":"Dest"}}`, "basecamp files --vault 4560 --in 999"}, + {"schedule show", NewScheduleCmd, []string{"show"}, "/schedule_entries/456", + `{"id":4560,"type":"Schedule::Entry","summary":"Launch","bucket":{"id":999,"name":"Dest"}}`, "basecamp schedule update 4560"}, + {"schedule show a recurring event", NewScheduleCmd, []string{"show"}, "/schedule_entries/456", + `{"id":7000,"type":"Schedule::Entry::Occurrence","summary":"Standup","bucket":{"id":999,"name":"Dest"},"parent":{"id":4560,"type":"Schedule::Entry"}}`, "basecamp schedule update 4560"}, + {"checkins question show", NewCheckinsCmd, []string{"question", "show"}, "/questions/456", + `{"id":4560,"type":"Question","title":"What did you work on?","bucket":{"id":999,"name":"Dest"}}`, "basecamp checkins answers 4560 --in 999"}, + {"forwards show", NewForwardsCmd, []string{"show"}, "/inbox_forwards/456", + `{"id":4560,"type":"Inbox::Forward","subject":"Invoice","bucket":{"id":999,"name":"Dest"}}`, "basecamp forwards replies 4560 --in 999"}, + } { + t.Run(tc.name, func(t *testing.T) { + transport := &familyTransport{gets: map[string]string{tc.path: tc.body}} + run := runFamilyCommand(t, transport, tc.root(), append(tc.args, "456")...) + require.NoError(t, run.err) + + var crumbs []string + for _, crumb := range run.envelope.Breadcrumbs { + crumbs = append(crumbs, crumb.Cmd) + assert.NotRegexp(t, regexp.MustCompile(`\b456\b`), crumb.Cmd, "the old id takes no writes") + } + assert.True(t, slices.ContainsFunc(crumbs, func(cmd string) bool { return strings.HasPrefix(cmd, tc.want) }), + "want %q among %q", tc.want, crumbs) + }) + } +} + +// Basecamp redirects every kind's read of a moved item's old id to the item, +// so the folder read that files show tries first answers with a moved +// document. The show must take it for the document it is. +func TestFilesShowOfAMovedDocumentByItsOldIDIsNotAFolder(t *testing.T) { + moved := `{"id":4560,"type":"Document","title":"Plan","content":"<div>Body</div>","bucket":{"id":999,"name":"Dest"}}` + gets := map[string]string{} + for _, kind := range []string{"vaults", "uploads", "documents", "cloud_files", "google_documents"} { + gets["/"+kind+"/456"] = moved + } + run := runFamilyCommand(t, &familyTransport{gets: gets}, NewFilesCmd(), "show", "456") + require.NoError(t, run.err) + + assert.Equal(t, "document: Plan", run.envelope.Summary) + assert.Equal(t, "<div>Body</div>", run.data(t)["content"]) + for _, crumb := range run.envelope.Breadcrumbs { + assert.NotContains(t, crumb.Cmd, "--vault", "a document has no folder contents to list") + } +} diff --git a/internal/commands/filing_test.go b/internal/commands/filing_test.go new file mode 100644 index 000000000..602a20b13 --- /dev/null +++ b/internal/commands/filing_test.go @@ -0,0 +1,83 @@ +package commands + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + "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" +) + +// runTestFiling runs f against transport, which answers its POST with filing +// 70 and plays back the filing's statuses (see mockFilingTransport). +func runTestFiling(t *testing.T, transport *mockFilingTransport, f recordingFiling) filingRun { + t.Helper() + shortFilingWait(t) + app, buf := newTestAppWithTransport(t, transport) + cmd := &cobra.Command{RunE: func(cmd *cobra.Command, _ []string) error { + app := appctx.FromContext(cmd.Context()) + return runFiling(cmd, app, newFollowUpScope(app), f) + }} + var run filingRun + run.err = executeCommand(cmd, app) + if run.err == nil { + require.NoError(t, json.Unmarshal(buf.Bytes(), &run.envelope)) + } + return run +} + +func TestFilingADockToolNamesItsProjectAndReadsBackTheNewRecording(t *testing.T) { + transport := &mockFilingTransport{statuses: []string{"processing", "completed"}} + var readID int64 + run := runTestFiling(t, transport, recordingFiling{ + kind: basecamp.FilingCopy, + sourceID: 456, + noun: "message board", + toProjectID: 999, + destination: "project 999", + read: func(_ context.Context, id int64) (filedRecording, error) { + readID = id + return filedRecording{ + result: map[string]any{"id": id}, + id: id, + bucket: &basecamp.Bucket{ID: 999, Name: "Dest"}, + }, nil + }, + }) + require.NoError(t, run.err) + + assert.Equal(t, []string{"/99999/recordings/456/copies"}, transport.posts) + assert.Equal(t, float64(999), transport.body["destination_project_id"]) + assert.NotContains(t, transport.body, "filing_destination_id") + assert.Equal(t, int64(4560), readID, "the new recording is read by the id the filing returned") + assert.Equal(t, "Copied message board #456 to Dest as message board #4560", run.envelope.Summary) +} + +func TestFilingWhoseNewRecordingCannotBeReadNamesItsNewID(t *testing.T) { + transport := &mockFilingTransport{statuses: []string{"completed"}} + run := runTestFiling(t, transport, recordingFiling{ + kind: basecamp.FilingMove, + sourceID: 456, + noun: "to-do", + destinationID: 900, + destination: "to-do list 900 (Ideas)", + showCmd: "basecamp todos show", + read: func(context.Context, int64) (filedRecording, error) { + return filedRecording{}, errors.New("connection reset by peer") + }, + }) + + var e *output.Error + require.ErrorAs(t, run.err, &e) + assert.Equal(t, output.CodeAPI, e.Code) + assert.Equal(t, "Moved to-do #456 as to-do #4560, but could not read the new to-do", e.Message) + assert.Equal(t, "Show it with: basecamp todos show 4560", e.Hint) + assert.Len(t, transport.posts, 1, "the move is not sent again") +} diff --git a/internal/commands/filings.go b/internal/commands/filings.go new file mode 100644 index 000000000..c006af80c --- /dev/null +++ b/internal/commands/filings.go @@ -0,0 +1,151 @@ +package commands + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/output" +) + +// NewFilingsCmd creates the filings command, which reads the background move +// or copy that a move or copy command started. +// +// A filing is account-wide and personal, like a draft: only the person who +// started it can read it, so there is no project to scope it to. Every +// family's move and copy commands start one, so it has a group of its own +// rather than a leaf under any one of them. +func NewFilingsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "filings", + Short: "Check a move or copy that is still running", + Long: `Check a move or copy that Basecamp runs in the background. + +Every move and copy command waits for its filing to finish. When the wait ends +first, the command names the filing; read it here. + + basecamp filings show 4`, + Annotations: map[string]string{ + "agent_notes": "Account-wide and personal: no --in <project> needed, and only the person who started the move or copy can read it.\n" + + "Read-only. Use it when a move or copy command reports the filing still running.", + }, + } + + cmd.AddCommand(newFilingsShowCmd()) + + return cmd +} + +func newFilingsShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show <filing_id>", + Short: "Show where a move or copy got to", + Long: `Show a move's or copy's status, what it was of, and once it completes, the +recording it made. + +A filing is pending or processing while Basecamp works, then completed or +failed. A completed move names the recording's new id; a completed copy names +the copy. + + basecamp filings show 4`, + Annotations: map[string]string{ + "agent_notes": "Takes the filing id a move or copy command names when its wait ends, not a recording id.\n" + + "pending/processing: do not move or copy the recording again; check this again later.\n" + + "completed: destination_recording is the moved recording at its new id, or the copy.\n" + + "failed: a move is safe to try again. Basecamp does not say whether a failed copy left anything behind, so look in the destination before copying again.\n" + + "Not found: only the person who started it can read it, and a completed one whose recording was since deleted is gone.", + }, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + id, err := strconv.ParseInt(args[0], 10, 64) + if err != nil || id <= 0 { + return output.ErrUsage("Filing ID must be a positive number") + } + + filing, err := app.Account().Recordings().GetFiling(cmd.Context(), id) + if err != nil { + if sdkErr := basecamp.AsError(err); sdkErr != nil && sdkErr.Code == basecamp.CodeNotFound { + return output.ErrNotFoundHint("filing", args[0], + "Only the person who started a move or copy can read its filing, and a completed one whose recording was since deleted is gone") + } + return convertSDKError(err) + } + return app.OK(filing, filingReport(filing, scope.args(app.Config.AccountID))...) + }, + } +} + +// filingReport summarizes a filing by its status, with what to do next. scope +// is the --profile and --account each next command needs from a fresh shell. +func filingReport(filing *basecamp.Filing, scope string) []output.ResponseOption { + verb, past := "move", "Moved" + if filing.Type == basecamp.FilingCopy { + verb, past = "copy", "Copied" + } + again := output.Breadcrumb{Action: "check", Cmd: fmt.Sprintf("basecamp filings show %d%s", filing.ID, scope), Description: "Check it again"} + + switch dest := filing.DestinationRecording; { + case filing.Status == basecamp.FilingCompleted && dest != nil: + summary := fmt.Sprintf("%s #%d as %s #%d", past, filing.SourceRecordingID, dest.Type, dest.ID) + return []output.ResponseOption{ + output.WithSummary(summary), + output.WithBreadcrumbs(output.Breadcrumb{Action: "show", Cmd: filedShowCmd(dest) + scope, Description: "View it"}), + } + case filing.Status == basecamp.FilingFailed && filing.Type == basecamp.FilingCopy: + return []output.ResponseOption{ + output.WithSummary(fmt.Sprintf("Basecamp could not copy #%d", filing.SourceRecordingID)), + output.WithNotice("Basecamp does not say whether a failed copy left anything behind. Look in the destination before copying again."), + } + case filing.Status == basecamp.FilingFailed: + return []output.ResponseOption{ + output.WithSummary(fmt.Sprintf("Basecamp could not move #%d", filing.SourceRecordingID)), + output.WithNotice("A move is safe to try again: Basecamp refuses one that is running or has landed."), + } + default: + return []output.ResponseOption{ + output.WithSummary(fmt.Sprintf("The %s of #%d is %s", verb, filing.SourceRecordingID, filing.Status)), + output.WithNotice(fmt.Sprintf("Do not %s #%d again while this runs.", verb, filing.SourceRecordingID)), + output.WithBreadcrumbs(again), + } + } +} + +// filedShowCmds are the commands that show a recording of each type a move or +// copy makes, by its account-wide id. +var filedShowCmds = map[string]string{ + "Todo": "basecamp todos show", + "Todolist": "basecamp todolists show", + "Message": "basecamp messages show", + "Document": "basecamp files show", + "Upload": "basecamp files show", + "Vault": "basecamp files show", + "CloudFile": "basecamp files show", + "GoogleDocument": "basecamp files show", + "Kanban::Card": "basecamp cards show", + "Kanban::Column": "basecamp cards column show", + "Schedule::Entry": "basecamp schedule show", + "Question": "basecamp checkins question show", + "Inbox::Forward": "basecamp forwards show", +} + +// filedShowCmd shows the recording a filing made: by its type's own command, +// or, for a dock tool, by its URL. +func filedShowCmd(dest *basecamp.FilingDestinationRecording) string { + if show, ok := filedShowCmds[dest.Type]; ok { + return fmt.Sprintf("%s %d", show, dest.ID) + } + if dest.AppURL != "" { + return "basecamp show " + dest.AppURL + } + return fmt.Sprintf("basecamp show %d", dest.ID) +} diff --git a/internal/commands/filings_test.go b/internal/commands/filings_test.go new file mode 100644 index 000000000..ac9f99a32 --- /dev/null +++ b/internal/commands/filings_test.go @@ -0,0 +1,144 @@ +package commands + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" +) + +// filingTransport answers GET /filings/4 with status and body. +type filingTransport struct { + status int + body string +} + +func (f filingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + header := make(http.Header) + header.Set("Content-Type", "application/json") + status, body := f.status, f.body + if strings.TrimSuffix(req.URL.Path, ".json") != "/99999/filings/4" { + status, body = http.StatusNotFound, `{"error":"not found"}` + } + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: header}, nil +} + +type filingsShown struct { + Data struct { + Status string `json:"status"` + Type string `json:"type"` + SourceRecordingID int64 `json:"source_recording_id"` + DestinationRecording *struct { + ID int64 `json:"id"` + Type string `json:"type"` + AppURL string `json:"app_url"` + } `json:"destination_recording"` + } `json:"data"` + Summary string `json:"summary"` + Notice string `json:"notice"` + Breadcrumbs []struct { + Cmd string `json:"cmd"` + } `json:"breadcrumbs"` +} + +func showFiling(t *testing.T, status int, body string) (filingsShown, error) { + t.Helper() + app, buf := newTestAppWithTransport(t, filingTransport{status: status, body: body}) + app.Flags.Hints = true + var shown filingsShown + err := executeCommand(NewFilingsCmd(), app, "show", "4") + if err == nil { + require.NoError(t, json.Unmarshal(buf.Bytes(), &shown)) + } + return shown, err +} + +func TestFilingsShowPendingSaysNotToRunItAgain(t *testing.T) { + shown, err := showFiling(t, 200, `{"id":4,"status":"processing","type":"Copy","source_recording_id":456}`) + require.NoError(t, err) + assert.Equal(t, "processing", shown.Data.Status) + assert.Equal(t, int64(456), shown.Data.SourceRecordingID) + assert.Equal(t, "The copy of #456 is processing", shown.Summary) + assert.Equal(t, "Do not copy #456 again while this runs.", shown.Notice) + require.Len(t, shown.Breadcrumbs, 1) + assert.Equal(t, "basecamp filings show 4", shown.Breadcrumbs[0].Cmd) +} + +func TestFilingsShowCompletedNamesTheRecordItMade(t *testing.T) { + for _, tc := range []struct { + name, dest, want string + }{ + {"a moved message", `{"id":4560,"type":"Message","app_url":"https://3.basecamp.com/99999/buckets/999/messages/4560"}`, "basecamp messages show 4560"}, + {"a moved column", `{"id":4560,"type":"Kanban::Column","app_url":"https://3.basecamp.com/99999/buckets/999/card_tables/columns/4560"}`, "basecamp cards column show 4560"}, + {"a moved dock tool", `{"id":4560,"type":"Message::Board","app_url":"https://3.basecamp.com/99999/buckets/999/message_boards/4560"}`, "basecamp show https://3.basecamp.com/99999/buckets/999/message_boards/4560"}, + } { + t.Run(tc.name, func(t *testing.T) { + shown, err := showFiling(t, 200, `{"id":4,"status":"completed","type":"Move","source_recording_id":456,"destination_recording":`+tc.dest+`}`) + require.NoError(t, err) + require.NotNil(t, shown.Data.DestinationRecording) + assert.Equal(t, int64(4560), shown.Data.DestinationRecording.ID) + assert.NotEmpty(t, shown.Data.DestinationRecording.AppURL) + assert.Contains(t, shown.Summary, "Moved #456 as") + assert.Contains(t, shown.Summary, "#4560") + require.Len(t, shown.Breadcrumbs, 1) + assert.Equal(t, tc.want, shown.Breadcrumbs[0].Cmd) + }) + } +} + +// Basecamp's docs say a move is safe to retry but say nothing of what a failed +// copy leaves behind, so only a failed move reads as safe to run again. +// A filing is read in the account and profile that started it, so the next +// command it prints names both when this run was given them. +func TestFilingsShowNamesTheProfileAndAnAccountGivenThisRun(t *testing.T) { + const scope = " --profile work --account 99999" + for name, tc := range map[string]struct{ body, want string }{ + "still running": {`{"id":4,"status":"processing","type":"Copy","source_recording_id":456}`, "basecamp filings show 4" + scope}, + "completed": {`{"id":4,"status":"completed","type":"Move","source_recording_id":456,"destination_recording":{"id":4560,"type":"Message"}}`, "basecamp messages show 4560" + scope}, + } { + t.Run(name, func(t *testing.T) { + app, buf := newTestAppWithTransport(t, filingTransport{status: 200, body: tc.body}) + app.Flags.Hints = true + app.Config.ActiveProfile = "work" + app.Config.Sources = map[string]string{"account_id": "flag"} + require.NoError(t, executeCommand(NewFilingsCmd(), app, "show", "4")) + + var shown filingsShown + require.NoError(t, json.Unmarshal(buf.Bytes(), &shown)) + require.Len(t, shown.Breadcrumbs, 1) + assert.Equal(t, tc.want, shown.Breadcrumbs[0].Cmd) + }) + } +} + +func TestFilingsShowFailedIsSafeToRetryOnlyForAMove(t *testing.T) { + moved, err := showFiling(t, 200, `{"id":4,"status":"failed","type":"Move","source_recording_id":456}`) + require.NoError(t, err) + assert.Equal(t, "Basecamp could not move #456", moved.Summary) + assert.Contains(t, moved.Notice, "safe to try again") + + copied, err := showFiling(t, 200, `{"id":4,"status":"failed","type":"Copy","source_recording_id":456}`) + require.NoError(t, err) + assert.Equal(t, "Basecamp could not copy #456", copied.Summary) + assert.NotContains(t, copied.Notice, "safe") + assert.Contains(t, copied.Notice, "Look in the destination before copying again") +} + +func TestFilingsShowNotFoundSaysWhoCanReadIt(t *testing.T) { + _, err := showFiling(t, 404, `{"error":"not found"}`) + e := output.AsError(err) + assert.Equal(t, output.CodeNotFound, e.Code) + assert.Contains(t, e.Hint, "Only the person who started a move or copy can read its filing") +} + +func TestFilingsShowRefusesANonNumericID(t *testing.T) { + app, _ := newTestAppWithTransport(t, filingTransport{}) + err := executeCommand(NewFilingsCmd(), app, "show", "abc") + assert.Equal(t, output.CodeUsage, output.AsError(err).Code) +} diff --git a/internal/commands/forwards.go b/internal/commands/forwards.go index a7bab31f6..9145bc229 100644 --- a/internal/commands/forwards.go +++ b/internal/commands/forwards.go @@ -1,6 +1,7 @@ package commands import ( + "context" "fmt" "strconv" @@ -33,10 +34,12 @@ that can receive forwarded emails.`, cmd.AddCommand( newForwardsListCmd(&project, &inboxID), - newForwardsShowCmd(&project), + newForwardsShowCmd(), newForwardsInboxCmd(&project, &inboxID), newForwardsRepliesCmd(&project), newForwardsReplyCmd(&project), + newFilingCmd(forwardFamily(), basecamp.FilingMove, forwardsMoveHelp, false), + newFilingCmd(forwardFamily(), basecamp.FilingCopy, forwardsCopyHelp, false), ) return cmd @@ -274,7 +277,7 @@ func runForwardsListEverywhere(cmd *cobra.Command, app *appctx.App, inboxID stri return app.OK(forwards, respOpts...) } -func newForwardsShowCmd(project *string) *cobra.Command { +func newForwardsShowCmd() *cobra.Command { var cf *commentFlags cmd := &cobra.Command{ @@ -283,7 +286,7 @@ func newForwardsShowCmd(project *string) *cobra.Command { Long: `Display detailed information about an email forward. You can pass either a forward ID or a Basecamp URL: - basecamp forwards show 789 --in my-project + basecamp forwards show 789 basecamp forwards show https://3.basecamp.com/123/buckets/456/inbox_forwards/789`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -293,8 +296,8 @@ You can pass either a forward ID or a Basecamp URL: return err } - // Extract ID and project from URL if provided - forwardIDStr, urlProjectID, err := extractWithProject(args[0]) + // The forward is read by its account-wide ID, so no project is needed. + forwardIDStr, _, err := extractWithProject(args[0]) if err != nil { return err } @@ -304,33 +307,13 @@ You can pass either a forward ID or a Basecamp URL: return output.ErrUsage("Invalid forward ID") } - // Resolve project - use URL > flag > config, with interactive fallback - projectID := *project - if projectID == "" && urlProjectID != "" { - projectID = urlProjectID - } - if projectID == "" { - projectID = app.Flags.Project - } - if projectID == "" { - projectID = app.Config.ProjectID - } - if projectID == "" { - if err := ensureProject(cmd, app); err != nil { - return err - } - projectID = app.Config.ProjectID - } - - resolvedProjectID, _, err := app.Names.ResolveProject(cmd.Context(), projectID) - if err != nil { - return err - } - forward, err := app.Account().Forwards().Get(cmd.Context(), forwardID) if err != nil { return convertSDKError(err) } + // A moved forward's old id redirects to the forward at its new + // id, and only the new id reaches its replies and comments. + forwardIDStr = strconv.FormatInt(forward.ID, 10) subject := forward.Subject if subject == "" { @@ -346,12 +329,12 @@ You can pass either a forward ID or a Basecamp URL: output.WithBreadcrumbs( output.Breadcrumb{ Action: "replies", - Cmd: fmt.Sprintf("basecamp forwards replies %s --in %s", forwardIDStr, resolvedProjectID), + Cmd: fmt.Sprintf("basecamp forwards replies %s --in %s", forwardIDStr, recordProject(forward.Bucket)), Description: "View replies", }, output.Breadcrumb{ Action: "list", - Cmd: fmt.Sprintf("basecamp forwards --in %s", resolvedProjectID), + Cmd: fmt.Sprintf("basecamp forwards --in %s", recordProject(forward.Bucket)), Description: "List all forwards", }, ), @@ -635,3 +618,78 @@ You can pass either IDs or Basecamp URLs: }, } } + +var forwardsMoveHelp = filingHelp{ + use: "move <id|url>", + short: "Move an email forward to another project's Email Forwards", + long: `Move an email forward, with its replies, to the Email Forwards of another +project, as the web's Move… does. Email Forwards must be turned on there. + + basecamp forwards move 789 --to-project "Marketing" + basecamp forwards move https://3.basecamp.com/123/buckets/456/inbox_forwards/789 --to-inbox 1069479999 + +The command waits for Basecamp to finish the move and shows the forward where +it landed, under a new ID.`, + notes: "--to-project <project> (ID or name) moves the forward to that project's Email Forwards, which must be turned on (the error names the tools enable command when it is off); --to-inbox <id|url> names one directly. " + + "Output is the moved forward: data.id is its NEW id and data.source_id the old one. Write to the new id; the old id stops taking changes, and reading it follows the move. " + + "--inbox names the forward's own Email Forwards and is refused; moving into the one it is in is refused. Basecamp refuses moving a reply on its own. " + + "An in_progress error names its filing: do not move it again; basecamp filings show <filing id> gives its new id once it lands.", +} + +var forwardsCopyHelp = filingHelp{ + use: "copy <id|url>", + short: "Copy an email forward to Email Forwards in this or another project", + long: `Copy an email forward into a project's Email Forwards, as the web's Copy… +does. The original stays where it is. Email Forwards must be turned on there. + +With no destination, the copy goes into the forward's own Email Forwards: + basecamp forwards copy 789 + basecamp forwards copy 789 --to-project "Marketing" --include-comments + +Basecamp makes the copy in the background; the command waits for it and shows +the new forward.`, + notes: "Without --to-project/--to-inbox the copy goes into the forward's own Email Forwards. The destination's Email Forwards must be turned on. " + + "Output is the new forward; its id is data.id and the original's is data.source_id. " + + "Never re-run a copy that reported in_progress or a may-have-started error: each run makes another copy; check an in_progress one with basecamp filings show <filing id>.", +} + +// forwardFamily is how email forwards move and copy between projects' Email +// Forwards. +func forwardFamily() filingFamily { + return filingFamily{ + noun: "forward", + group: "basecamp forwards", + types: []string{"Inbox::Forward"}, + urlKinds: []string{"inbox_forwards"}, + urlShape: "…/inbox_forwards/<id>", + into: filingContainer{ + dockName: "inbox", + noun: "Email Forwards", + plural: "Email Forwards tools", + flag: "--to-inbox", + urlKind: "inboxes", + urlShape: "…/inboxes/<id>", + get: func(ctx context.Context, app *appctx.App, id int64) (filingTarget, error) { + inbox, err := app.Account().Forwards().GetInbox(ctx, id) + if err != nil { + return filingTarget{}, err + } + return bucketTarget(inbox.ID, inbox.Title, inbox.AppURL, inbox.Bucket), nil + }, + }, + get: func(ctx context.Context, app *appctx.App, id int64) (any, filingSource, error) { + f, err := app.Account().Forwards().Get(ctx, id) + if err != nil { + return nil, filingSource{}, err + } + return f, filingSource{id: f.ID, typ: f.Type, status: f.Status, title: f.Subject, bucket: f.Bucket, parent: f.Parent}, nil + }, + crumbs: func(id, projectID int64) []output.Breadcrumb { + return []output.Breadcrumb{{ + Action: "show", + Cmd: fmt.Sprintf("basecamp forwards show %d --in %d", id, projectID), + Description: "View the forward", + }} + }, + } +} diff --git a/internal/commands/forwards_filing_test.go b/internal/commands/forwards_filing_test.go new file mode 100644 index 000000000..d776043e4 --- /dev/null +++ b/internal/commands/forwards_filing_test.go @@ -0,0 +1,57 @@ +package commands + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" +) + +// forwardsFilingTransport has forward 456 in Email Forwards 555 in Source +// (123), Dest (999) with Email Forwards 900, which the filed forward 4560 is +// in, and Bare (888) whose Email Forwards 880 is turned off. +func forwardsFilingTransport() *familyTransport { + return &familyTransport{gets: map[string]string{ + "/projects": filingProjects, + "/projects/123": `{"id":123,"name":"Source","dock":[{"name":"inbox","id":555,"title":"Forwards","enabled":true}]}`, + "/projects/999": `{"id":999,"name":"Dest","dock":[{"name":"inbox","id":900,"title":"Forwards","enabled":true,"app_url":"https://3.basecamp.com/99999/buckets/999/inboxes/900"}]}`, + "/projects/888": `{"id":888,"name":"Bare","dock":[{"name":"inbox","id":880,"title":"Forwards","enabled":false}]}`, + "/inboxes/555": `{"id":555,"title":"Forwards","bucket":{"id":123,"name":"Source"}}`, + "/inbox_forwards/456": `{"id":456,"type":"Inbox::Forward","subject":"Invoice","bucket":{"id":123,"name":"Source"},"parent":{"id":555,"title":"Forwards","type":"Inbox"}}`, + "/inbox_forwards/4560": `{"id":4560,"type":"Inbox::Forward","subject":"Invoice","bucket":{"id":999,"name":"Dest"},"parent":{"id":900,"title":"Forwards","type":"Inbox"}}`, + }} +} + +func TestForwardsMoveToProject(t *testing.T) { + transport := forwardsFilingTransport() + run := runFamilyCommand(t, transport, NewForwardsCmd(), "move", "456", "--to-project", "Dest") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/99999/recordings/456/moves"}, transport.posts) + assert.Equal(t, float64(900), transport.bodies[0]["filing_destination_id"]) + data := run.data(t) + assert.Equal(t, float64(4560), data["id"]) + assert.Equal(t, float64(456), data["source_id"]) + assert.Equal(t, "Moved forward #456 to Dest › Forwards as forward #4560", run.envelope.Summary) +} + +func TestForwardsMoveToTurnedOffEmailForwardsSaysHowToTurnThemOn(t *testing.T) { + transport := forwardsFilingTransport() + run := runFamilyCommand(t, transport, NewForwardsCmd(), "move", "456", "--to-project", "Bare") + e := run.failure(t) + assert.Equal(t, output.CodeNotFound, e.Code) + assert.Equal(t, "Bare's Email Forwards (Forwards) is turned off, so nothing can be moved or copied into it", e.Message) + assert.Equal(t, "Turn it on first with: basecamp tools enable 880 --in 888", e.Hint) + assert.Empty(t, transport.posts) +} + +func TestForwardsCopyWithoutDestinationGoesIntoItsOwnInbox(t *testing.T) { + transport := forwardsFilingTransport() + run := runFamilyCommand(t, transport, NewForwardsCmd(), "copy", "456", "--include-comments") + require.NoError(t, run.err) + assert.Equal(t, []string{"/99999/recordings/456/copies"}, transport.posts) + assert.Equal(t, float64(555), transport.bodies[0]["filing_destination_id"]) + assert.Equal(t, true, transport.bodies[0]["include_comments"]) +} diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go index 8cbff8676..fda8d0e74 100644 --- a/internal/commands/helpers.go +++ b/internal/commands/helpers.go @@ -12,6 +12,8 @@ import ( "github.com/spf13/cobra" + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + "github.com/basecamp/basecamp-cli/internal/appctx" "github.com/basecamp/basecamp-cli/internal/config" "github.com/basecamp/basecamp-cli/internal/dateparse" @@ -183,6 +185,7 @@ type DockTool struct { Title string `json:"title"` ID int64 `json:"id"` Enabled bool `json:"enabled"` + AppURL string `json:"app_url"` } // getDockTools fetches a project's dock and partitions tools matching dockName @@ -923,3 +926,12 @@ func projectFlagChanged(cmd *cobra.Command) bool { } return false } + +// recordProject names a record's project for breadcrumbs. A record read by +// its account-wide ID names its own project, so its read needs none. +func recordProject(bucket *basecamp.Bucket) string { + if bucket == nil { + return "<project>" + } + return strconv.FormatInt(bucket.ID, 10) +} diff --git a/internal/commands/messages.go b/internal/commands/messages.go index 9fe6044c2..d6346a5e9 100644 --- a/internal/commands/messages.go +++ b/internal/commands/messages.go @@ -47,6 +47,8 @@ use --message-board <id> to specify which one.`, newMessagesPublishCmd(), newMessagesPinCmd(), newMessagesUnpinCmd(), + newFilingCmd(messageFamily(), basecamp.FilingMove, messagesMoveHelp, false), + newFilingCmd(messageFamily(), basecamp.FilingCopy, messagesCopyHelp, false), newRecordableTrashCmd("message"), newRecordableArchiveCmd("message"), newRecordableRestoreCmd("message"), @@ -379,6 +381,9 @@ You can pass either a message ID or a Basecamp URL: if err != nil { return convertSDKError(err) } + // A moved message's old id redirects to the message at its new id, + // and only the new id reaches its comments and attachments. + messageIDStr = strconv.FormatInt(message.ID, 10) enrichment := fetchCommentsForRecording(cmd.Context(), app, messageIDStr, cf) @@ -1065,3 +1070,82 @@ func resolveMessageCategory(ctx context.Context, app *appctx.App, projectID, cat return 0, ambiguous } } + +var messagesMoveHelp = filingHelp{ + use: "move <id|url>", + short: "Move a message to another project's message board", + long: `Move a message to a message board in another project, as the web's Move… +does. Its comments go with it. + + basecamp messages move 789 --to-project "Marketing" + basecamp messages move 789 --to-project "Marketing" --to-board "Announcements" + basecamp messages move https://3.basecamp.com/123/buckets/456/messages/789 --to-board 1069479999 + +--to-project alone needs a project with one message board; add --to-board +when it has several. The command waits for Basecamp to finish the move and +shows the message where it landed, under a new ID.`, + notes: "--to-project <project> (ID or name) moves the message to that project's message board; add --to-board <id|title> when it has several, or pass --to-board <id|url> alone. " + + "Output is the moved message: data.id is its NEW id and data.source_id the old one. Write to the new id; the old id stops taking changes, and reading it follows the move. " + + "--message-board names the message's own board and is refused; moving onto the board it is already on is refused. " + + "An in_progress error names its filing: do not move it again; basecamp filings show <filing id> gives its new id once it lands. " + + "Comments move with their message; Basecamp refuses moving a comment on its own.", +} + +var messagesCopyHelp = filingHelp{ + use: "copy <id|url>", + short: "Copy a message to a message board in this or another project", + long: `Copy a message onto a message board, as the web's Copy… does. The original +stays where it is. + +With no destination, the copy goes onto the message's own board: + basecamp messages copy 789 + basecamp messages copy 789 --to-project "Marketing" + basecamp messages copy 789 --to-project "Marketing" --to-board "Announcements" --include-comments + +Basecamp makes the copy in the background; the command waits for it and shows +the new message.`, + notes: "Without --to-project/--to-board the copy goes onto the message's own board. " + + "--to-project alone uses that project's only message board and fails as ambiguous when it has several; add --to-board. " + + "Output is the new message; its id is data.id and the original's is data.source_id. --include-comments copies the comments too. " + + "Never re-run a copy that reported in_progress or a may-have-started error: each run makes another copy; check an in_progress one with basecamp filings show <filing id>.", +} + +// messageFamily is how messages move and copy between message boards. +func messageFamily() filingFamily { + return filingFamily{ + noun: "message", + group: "basecamp messages", + types: []string{"Message"}, + urlKinds: []string{"messages"}, + urlShape: "…/messages/<id>", + into: filingContainer{ + dockName: "message_board", + noun: "message board", + plural: "message boards", + flag: "--to-board", + urlKind: "message_boards", + urlShape: "…/message_boards/<id>", + get: func(ctx context.Context, app *appctx.App, id int64) (filingTarget, error) { + board, err := app.Account().MessageBoards().Get(ctx, id) + if err != nil { + return filingTarget{}, err + } + return bucketTarget(board.ID, board.Title, board.AppURL, board.Bucket), nil + }, + }, + get: func(ctx context.Context, app *appctx.App, id int64) (any, filingSource, error) { + m, err := app.Account().Messages().Get(ctx, id) + if err != nil { + return nil, filingSource{}, err + } + return m, filingSource{id: m.ID, typ: m.Type, status: m.Status, title: m.Subject, bucket: m.Bucket, parent: m.Parent}, nil + }, + crumbs: func(id, projectID int64) []output.Breadcrumb { + return []output.Breadcrumb{{ + Action: "show", + Cmd: fmt.Sprintf("basecamp messages show %d --in %d", id, projectID), + Description: "View the message", + }} + }, + } +} diff --git a/internal/commands/messages_filing_test.go b/internal/commands/messages_filing_test.go new file mode 100644 index 000000000..de99b2bc4 --- /dev/null +++ b/internal/commands/messages_filing_test.go @@ -0,0 +1,163 @@ +package commands + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" +) + +// messagesTransport has message 456 on board 555 in Source (123), and +// Dest (999) with board 900, which the moved or copied message 4560 is on. +func messagesTransport() *familyTransport { + return &familyTransport{gets: map[string]string{ + "/projects": filingProjects, + "/projects/123": `{"id":123,"name":"Source","dock":[{"name":"message_board","id":555,"title":"Message Board","enabled":true}]}`, + "/projects/999": `{"id":999,"name":"Dest","dock":[{"name":"message_board","id":900,"title":"Announcements","enabled":true}]}`, + "/projects/888": `{"id":888,"name":"Bare","dock":[{"name":"chat","id":880,"title":"Chat","enabled":true}]}`, + "/message_boards/555": `{"id":555,"title":"Message Board","bucket":{"id":123,"name":"Source"}}`, + "/message_boards/900": `{"id":900,"title":"Announcements","bucket":{"id":999,"name":"Dest"}}`, + "/messages/456": `{"id":456,"type":"Message","subject":"Kickoff","bucket":{"id":123,"name":"Source"},"parent":{"id":555,"title":"Message Board","type":"Message::Board"}}`, + "/messages/4560": `{"id":4560,"type":"Message","subject":"Kickoff","bucket":{"id":999,"name":"Dest"},"parent":{"id":900,"title":"Announcements","type":"Message::Board"}}`, + "/messages/457": `{"id":457,"type":"Message","subject":"Draft","bucket":{"id":999,"name":"Dest"},"parent":{"id":900,"title":"Announcements","type":"Message::Board"}}`, + "/messages/458": `{"id":4580,"type":"Todo","bucket":{"id":123,"name":"Source"}}`, + "/messages/5550": `{"id":5550,"type":"Message","subject":"Kickoff","bucket":{"id":123,"name":"Source"},"parent":{"id":555,"title":"Message Board","type":"Message::Board"}}`, + }} +} + +func TestMessagesMoveToProjectByName(t *testing.T) { + transport := messagesTransport() + run := runFamilyCommand(t, transport, NewMessagesCmd(), "move", "456", "--to-project", "Dest") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/99999/recordings/456/moves"}, transport.posts) + assert.Equal(t, float64(900), transport.bodies[0]["filing_destination_id"]) + data := run.data(t) + assert.Equal(t, float64(4560), data["id"]) + assert.Equal(t, float64(456), data["source_id"]) + assert.Equal(t, "Moved message #456 to Dest › Announcements as message #4560", run.envelope.Summary) + require.NotEmpty(t, run.envelope.Breadcrumbs) + assert.Equal(t, "basecamp messages show 4560 --in 999", run.envelope.Breadcrumbs[0].Cmd) +} + +func TestMessagesMoveToBoardURL(t *testing.T) { + transport := messagesTransport() + run := runFamilyCommand(t, transport, NewMessagesCmd(), "move", + "https://3.basecamp.com/99999/buckets/123/messages/456", + "--to-board", "https://3.basecamp.com/99999/buckets/999/message_boards/900") + require.NoError(t, run.err) + assert.Equal(t, float64(900), transport.bodies[0]["filing_destination_id"]) +} + +func TestMessagesMoveNeedsADestination(t *testing.T) { + t.Setenv("BASECAMP_NONINTERACTIVE", "1") + transport := messagesTransport() + run := runFamilyCommand(t, transport, NewMessagesCmd(), "move", "456") + e := run.failure(t) + assert.Equal(t, output.CodeUsage, e.Code) + assert.Contains(t, e.Message, "--to-project") + assert.Empty(t, transport.posts) +} + +func TestMessagesMoveBoardNameNeedsAProject(t *testing.T) { + transport := messagesTransport() + run := runFamilyCommand(t, transport, NewMessagesCmd(), "move", "456", "--to-board", "Announcements") + e := run.failure(t) + assert.Contains(t, e.Message, "is a name, which needs a project") + assert.Empty(t, transport.posts) +} + +func TestMessagesMoveOntoItsOwnBoardIsRefused(t *testing.T) { + transport := messagesTransport() + run := runFamilyCommand(t, transport, NewMessagesCmd(), "move", "456", "--to-project", "Source") + e := run.failure(t) + assert.Equal(t, output.CodeUsage, e.Code) + assert.Equal(t, "Message #456 is already in message board 555 (Message Board) in Source", e.Message) + assert.Empty(t, transport.posts) +} + +func TestMessagesMoveToProjectWithoutBoardNamesIt(t *testing.T) { + transport := messagesTransport() + run := runFamilyCommand(t, transport, NewMessagesCmd(), "move", "456", "--to-project", "Bare") + e := run.failure(t) + assert.Equal(t, "Project Bare has no message board", e.Message) + assert.Empty(t, transport.posts) +} + +func TestMessagesMoveRefusesAnIDThatIsNotAMessage(t *testing.T) { + transport := messagesTransport() + run := runFamilyCommand(t, transport, NewMessagesCmd(), "move", "458", "--to-project", "Dest") + e := run.failure(t) + assert.Equal(t, "458 is a Todo, not a message", e.Message) + assert.Empty(t, transport.posts) +} + +func TestMessagesMoveRefusesAConflictingProject(t *testing.T) { + transport := messagesTransport() + run := runFamilyCommand(t, transport, NewMessagesCmd(), "move", "456", "--in", "Dest", "--to-project", "Dest") + e := run.failure(t) + assert.Contains(t, e.Message, "The message is in project 123, but --in/--project points at project 999") + assert.Empty(t, transport.posts) +} + +func TestMessagesCopyWithoutDestinationGoesOntoItsOwnBoard(t *testing.T) { + transport := messagesTransport() + transport.gets["/messages/4560"] = transport.gets["/messages/5550"] + run := runFamilyCommand(t, transport, NewMessagesCmd(), "copy", "456", "--include-comments") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/99999/recordings/456/copies"}, transport.posts) + assert.Equal(t, float64(555), transport.bodies[0]["filing_destination_id"]) + assert.Equal(t, true, transport.bodies[0]["include_comments"]) + assert.Equal(t, float64(456), run.data(t)["source_id"]) +} + +func TestMessagesCopyToAnotherProject(t *testing.T) { + transport := messagesTransport() + run := runFamilyCommand(t, transport, NewMessagesCmd(), "copy", "456", "--to-project", "999", "--to-board", "Announcements") + require.NoError(t, run.err) + assert.Equal(t, float64(900), transport.bodies[0]["filing_destination_id"]) + assert.NotContains(t, transport.bodies[0], "include_comments") + assert.Equal(t, "Copied message #456 to Dest › Announcements as message #4560", run.envelope.Summary) +} + +func TestMessagesCopyThatMayHaveStartedSaysWhereToLook(t *testing.T) { + transport := messagesTransport() + transport.refuse = map[int64]int{456: 502} + run := runFamilyCommand(t, transport, NewMessagesCmd(), "copy", "456", "--to-project", "Dest") + e := run.failure(t) + assert.False(t, e.Retryable) + assert.Contains(t, e.Hint, "basecamp recordings list Message --in 999 --status active --all") + assert.Len(t, transport.posts, 1) +} + +// A copy keeps its source's status, and the recordings listing serves no +// drafted status (Basecamp answers it with 400), so a drafted message's copy +// is looked for among the person's drafts. +func TestMessagesCopyOfADraftWithNoFilingIsLookedForAmongDrafts(t *testing.T) { + transport := messagesTransport() + transport.gets["/messages/456"] = strings.Replace(transport.gets["/messages/456"], `"type":"Message"`, `"type":"Message","status":"drafted"`, 1) + transport.refuse = map[int64]int{456: 502} + e := runFamilyCommand(t, transport, NewMessagesCmd(), "copy", "456", "--to-project", "Dest").failure(t) + assert.Contains(t, e.Hint, "may have started the copy") + assert.Contains(t, e.Hint, "basecamp drafts list --all") + assert.NotContains(t, e.Hint, "--status drafted") + assert.Contains(t, e.Hint, "if the copy is not there, open the destination in Basecamp before copying again", + "drafts list stops at the 250 most recently updated, and a copy keeps its original's updated_at") +} + +// A flat API URL names no project, and its id can be a board's title too. A +// URL that names no board of the project is refused, never read as that title. +func TestMessagesBoardURLThatNamesNoBoardIsNeverATitle(t *testing.T) { + transport := messagesTransport() + transport.gets["/projects/999"] = `{"id":999,"name":"Dest","dock":[{"name":"message_board","id":900,"title":"Announcements","enabled":true},{"name":"message_board","id":901,"title":"2026","enabled":true}]}` + url := "https://3.basecampapi.com/99999/message_boards/2026.json" + e := runFamilyCommand(t, transport, NewMessagesCmd(), "copy", "456", "--to-project", "Dest", "--to-board", url).failure(t) + assert.Equal(t, output.CodeNotFound, e.Code) + assert.Contains(t, e.Message, url) + assert.Contains(t, e.Hint, "901 (2026)") + assert.Empty(t, transport.posts) +} diff --git a/internal/commands/occurrence_url_test.go b/internal/commands/occurrence_url_test.go index cb3d27471..3a20465b3 100644 --- a/internal/commands/occurrence_url_test.go +++ b/internal/commands/occurrence_url_test.go @@ -80,6 +80,8 @@ func occurrenceAPIResponse(method, path string) (int, string) { return http.StatusOK, occurrenceEntryJSON case "GET /schedule_entries/789/occurrences/20261022": return http.StatusOK, occurrenceJSON + case "GET /projects/123": + return http.StatusOK, `{"id": 123, "name": "Source", "dock": [{"name": "schedule", "id": 456, "title": "Schedule", "enabled": true}]}` case "PUT /recordings/789/status/trashed", "PUT /recordings/789/status/archived": return http.StatusNoContent, "" case "POST /recordings/789/comments", "POST /recordings/1001/comments": @@ -316,11 +318,12 @@ func TestScheduleShowOccurrenceURLReadsItsComments(t *testing.T) { assert.Equal(t, []string{ "GET /99999/schedule_entries/789/occurrences/20261022", "GET /99999/recordings/1001/comments.json", + "GET /99999/projects/123.json", }, requests) env := decodeOccurrenceEnvelope(t, out) assert.Equal(t, []string{ "basecamp comments create 1001 <text>", - "basecamp schedule entries --project 123", + "basecamp schedule entries --project 123 --schedule 456", "basecamp comments list --all 1001", }, env.cmds()) }) diff --git a/internal/commands/projects.go b/internal/commands/projects.go index 015066dd9..54b0e6e02 100644 --- a/internal/commands/projects.go +++ b/internal/commands/projects.go @@ -41,6 +41,7 @@ func NewProjectsCmd() *cobra.Command { newProjectsUpdateCmd(), newProjectsDeleteCmd(), newProjectsWallpaperCmd(), + newProjectsLogoCmd(), ) return cmd @@ -431,8 +432,8 @@ func newProjectsDeleteCmd() *cobra.Command { } } -// wallpaperFormats match the server's shared image validation, not the narrower web picker. -var wallpaperFormats = []struct{ mime, name string }{ +// projectImageFormats match the server's shared image validation, not the narrower web picker. +var projectImageFormats = []struct{ mime, name string }{ {"image/png", "PNG"}, {"image/jpeg", "JPEG"}, {"image/gif", "GIF"}, @@ -485,7 +486,7 @@ func newProjectsWallpaperSetCmd() *cobra.Command { Accepts %s files up to %d MB. --mode lays the image out (%s); without it the project keeps -its current mode.`, wallpaperFormatNames(), maxWallpaperMB, strings.Join(wallpaperModes, " or ")), +its current mode.`, projectImageFormatNames(), maxWallpaperMB, strings.Join(wallpaperModes, " or ")), Example: " $ basecamp projects wallpaper set skyline.jpg --mode fill --project 12345", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -497,14 +498,14 @@ its current mode.`, wallpaperFormatNames(), maxWallpaperMB, strings.Join(wallpap return err } } - path, contentType, err := wallpaperFile(args[0]) + path, contentType, err := projectImageFile(args[0], "wallpaper", maxWallpaperMB) if err != nil { return err } app := appctx.FromContext(cmd.Context()) scope := newFollowUpScope(app) - projectID, err := wallpaperProjectID(cmd, app) + projectID, err := projectImageProjectID(cmd, app) if err != nil { return err } @@ -525,7 +526,7 @@ its current mode.`, wallpaperFormatNames(), maxWallpaperMB, strings.Join(wallpap if mode != "" { summary += " (" + mode + ")" } - return respondWithWallpaperProject(cmd, app, projectID, scope, map[string]any{"id": projectID}, summary, + return respondWithProjectImage(cmd, app, projectID, scope, map[string]any{"id": projectID}, summary, output.Breadcrumb{ Action: "mode", Cmd: fmt.Sprintf("basecamp projects wallpaper mode <fill|tile> --project %d", projectID), @@ -569,7 +570,7 @@ A project with no wallpaper keeps the mode for the next one uploaded.`, app := appctx.FromContext(cmd.Context()) scope := newFollowUpScope(app) - projectID, err := wallpaperProjectID(cmd, app) + projectID, err := projectImageProjectID(cmd, app) if err != nil { return err } @@ -585,7 +586,7 @@ A project with no wallpaper keeps the mode for the next one uploaded.`, return convertSDKError(err) } - return respondWithWallpaperProject(cmd, app, projectID, scope, project, "Wallpaper mode set to "+mode, + return respondWithProjectImage(cmd, app, projectID, scope, project, "Wallpaper mode set to "+mode, output.Breadcrumb{ Action: "set", Cmd: fmt.Sprintf("basecamp projects wallpaper set <file> --project %d", projectID), @@ -607,7 +608,7 @@ func newProjectsWallpaperRemoveCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) scope := newFollowUpScope(app) - projectID, err := wallpaperProjectID(cmd, app) + projectID, err := projectImageProjectID(cmd, app) if err != nil { return err } @@ -616,7 +617,7 @@ func newProjectsWallpaperRemoveCmd() *cobra.Command { return convertSDKError(err) } - return respondWithWallpaperProject(cmd, app, projectID, scope, map[string]any{"id": projectID}, "Wallpaper removed", + return respondWithProjectImage(cmd, app, projectID, scope, map[string]any{"id": projectID}, "Wallpaper removed", output.Breadcrumb{ Action: "set", Cmd: fmt.Sprintf("basecamp projects wallpaper set <file> --project %d", projectID), @@ -627,9 +628,115 @@ func newProjectsWallpaperRemoveCmd() *cobra.Command { } } -// wallpaperFile checks the size and MIME hint before uploading. The server's +const maxLogoMB = 5 + +func logoAgentNotes() map[string]string { + return map[string]string{"agent_notes": "People and agents who can edit the project can change its logo; others get 403, and a project the caller cannot see gets 404\n.data.logo is {url}, absent when there is none; url requires authentication and redirects to the image"} +} + +func newProjectsLogoCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "logo", + Short: "Manage a project's logo", + Annotations: logoAgentNotes(), + Long: `Set or remove a project's logo, the image shown beside its name. + +The project comes from --project (or --in), then the configured default. +People and agents who can edit the project can change its logo.`, + Example: ` $ basecamp projects logo set brand.png --project 12345 + $ basecamp projects logo remove --project 12345`, + } + + cmd.AddCommand( + newProjectsLogoSetCmd(), + newProjectsLogoRemoveCmd(), + ) + + return cmd +} + +func newProjectsLogoSetCmd() *cobra.Command { + return &cobra.Command{ + Use: "set <file>", + Annotations: logoAgentNotes(), + Short: "Upload or replace a project's logo", + Long: fmt.Sprintf(`Upload or replace the project's logo. + +Accepts %s files up to %d MB.`, projectImageFormatNames(), maxLogoMB), + Example: " $ basecamp projects logo set brand.png --project 12345", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return missingArg(cmd, "<file>") + } + path, contentType, err := projectImageFile(args[0], "logo", maxLogoMB) + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) + projectID, err := projectImageProjectID(cmd, app) + if err != nil { + return err + } + + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("%s: %w", args[0], err) + } + defer f.Close() + + filename := filepath.Base(path) + if err := app.Account().Projects().UpdateLogo(cmd.Context(), projectID, f, filename, contentType); err != nil { + return convertSDKError(err) + } + + return respondWithProjectImage(cmd, app, projectID, scope, map[string]any{"id": projectID}, "Logo set: "+filename, + output.Breadcrumb{ + Action: "remove", + Cmd: fmt.Sprintf("basecamp projects logo remove --project %d", projectID), + Description: "Remove the logo", + }, + ) + }, + } +} + +func newProjectsLogoRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove", + Annotations: logoAgentNotes(), + Short: "Remove a project's logo", + Long: "Remove the project's logo. Removing it from a project that has none succeeds.", + Example: " $ basecamp projects logo remove --project 12345", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) + projectID, err := projectImageProjectID(cmd, app) + if err != nil { + return err + } + + if err := app.Account().Projects().RemoveLogo(cmd.Context(), projectID); err != nil { + return convertSDKError(err) + } + + return respondWithProjectImage(cmd, app, projectID, scope, map[string]any{"id": projectID}, "Logo removed", + output.Breadcrumb{ + Action: "set", + Cmd: fmt.Sprintf("basecamp projects logo set <file> --project %d", projectID), + Description: "Upload a new logo", + }, + ) + }, + } +} + +// projectImageFile checks the size and MIME hint before uploading. The server's // shared image validations remain authoritative. -func wallpaperFile(arg string) (path, contentType string, err error) { +func projectImageFile(arg, kind string, maxMB int) (path, contentType string, err error) { path = richtext.NormalizeDragPath(arg) if err := richtext.ValidateFile(path); err != nil { return "", "", fmt.Errorf("%s: %w", arg, err) @@ -639,22 +746,22 @@ func wallpaperFile(arg string) (path, contentType string, err error) { return "", "", fmt.Errorf("%s: %w", arg, err) } name := filepath.Base(path) - if info.Size() > maxWallpaperMB<<20 { - return "", "", output.ErrUsage(fmt.Sprintf("%s exceeds maximum wallpaper size of %d MB", name, maxWallpaperMB)) + if info.Size() > int64(maxMB)<<20 { + return "", "", output.ErrUsage(fmt.Sprintf("%s exceeds maximum %s size of %d MB", name, kind, maxMB)) } contentType = richtext.DetectMIME(path) - for _, f := range wallpaperFormats { + for _, f := range projectImageFormats { if f.mime == contentType { return path, contentType, nil } } - return "", "", output.ErrUsage(fmt.Sprintf("%s: unsupported image type %s (use %s)", name, contentType, wallpaperFormatNames())) + return "", "", output.ErrUsage(fmt.Sprintf("%s: unsupported image type %s (use %s)", name, contentType, projectImageFormatNames())) } -// wallpaperFormatNames lists the accepted formats for help and validation errors. -func wallpaperFormatNames() string { - names := make([]string, len(wallpaperFormats)) - for i, f := range wallpaperFormats { +// projectImageFormatNames lists the accepted formats for help and validation errors. +func projectImageFormatNames() string { + names := make([]string, len(projectImageFormats)) + for i, f := range projectImageFormats { names[i] = f.name } return strings.Join(names[:len(names)-1], ", ") + ", or " + names[len(names)-1] @@ -667,7 +774,7 @@ func validateWallpaperMode(mode string) error { return output.ErrUsageHint(fmt.Sprintf("unknown wallpaper mode %q", mode), "Use "+strings.Join(wallpaperModes, " or ")) } -func wallpaperProjectID(cmd *cobra.Command, app *appctx.App) (int64, error) { +func projectImageProjectID(cmd *cobra.Command, app *appctx.App) (int64, error) { if err := ensureAccount(cmd, app); err != nil { return 0, err } @@ -682,11 +789,11 @@ func wallpaperProjectID(cmd *cobra.Command, app *appctx.App) (int64, error) { return projectID, nil } -// respondWithWallpaperProject answers with the project as it now stands, so -// the output carries its wallpaper. The change has already happened, so a +// respondWithProjectImage answers with the project as it now stands, so the +// output carries its wallpaper and logo. The change has already happened, so a // failed read falls back to what the caller has, with a diagnostic, rather // than failing the command. -func respondWithWallpaperProject(cmd *cobra.Command, app *appctx.App, projectID int64, scope followUpScope, fallback any, summary string, crumbs ...output.Breadcrumb) error { +func respondWithProjectImage(cmd *cobra.Command, app *appctx.App, projectID int64, scope followUpScope, fallback any, summary string, crumbs ...output.Breadcrumb) error { crumbs = append([]output.Breadcrumb{{ Action: "show", Cmd: fmt.Sprintf("basecamp projects show %d", projectID), diff --git a/internal/commands/projects_logo_test.go b/internal/commands/projects_logo_test.go new file mode 100644 index 000000000..e8f6e060e --- /dev/null +++ b/internal/commands/projects_logo_test.go @@ -0,0 +1,262 @@ +package commands + +import ( + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/config" + "github.com/basecamp/basecamp-cli/internal/output" +) + +func TestProjectsLogoRemoveReadsProjectWithoutLogo(t *testing.T) { + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodDelete && r.URL.Path == "/99999/projects/123/logo.json" { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method == http.MethodGet && r.URL.Path == "/99999/projects/123" { + fmt.Fprint(w, `{"id":123,"name":"Launch","wallpaper":{"mode":"tile","url":"keep"}}`) + return + } + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(server.Close) + app, buf := newRequestLevelApp(t, server.URL) + app.Config.ProjectID = "123" + require.NoError(t, executeCommand(NewProjectsCmd(), app, "logo", "remove")) + assert.Equal(t, []string{"DELETE /99999/projects/123/logo.json", "GET /99999/projects/123"}, calls) + env := decodeWallpaperEnvelope(t, buf) + assert.Equal(t, "Logo removed", env.Summary) + assert.NotContains(t, env.Data, "logo") + assert.Equal(t, map[string]any{"url": "keep", "mode": "tile"}, env.Data["wallpaper"]) +} + +const projectLogoURL = "https://3.basecampapi.com/99999/projects/123/logo?v=1" + +func TestProjectsLogoSetUploadsAndReadsProject(t *testing.T) { + var calls []string + var upload *wallpaperUpload + parser := &wallpaperServer{t: t} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPut && r.URL.Path == "/99999/projects/123/logo.json": + upload = parser.readUpload(r) + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/99999/projects/123": + fmt.Fprintf(w, `{"id":123,"name":"Launch","description":"Keep me","logo":{"url":%q},"wallpaper":{"url":"wallpaper-url","mode":"tile"}}`, projectLogoURL) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + app, buf := newRequestLevelApp(t, server.URL) + app.Flags.Project = "123" + path := writeWallpaperFile(t, "brand.png", 0) + require.NoError(t, executeCommand(NewProjectsCmd(), app, "logo", "set", path)) + assert.Equal(t, []string{"PUT /99999/projects/123/logo.json", "GET /99999/projects/123"}, calls) + require.NotNil(t, upload) + assert.Equal(t, "logo", upload.field) + assert.Equal(t, "brand.png", upload.filename) + assert.Equal(t, "image/png", upload.contentType) + assert.Equal(t, []byte("\x89PNG\r\n\x1a\n"), upload.data) + env := decodeWallpaperEnvelope(t, buf) + assert.True(t, env.OK) + assert.Equal(t, "Logo set: brand.png", env.Summary) + assert.Equal(t, map[string]any{"url": projectLogoURL}, env.Data["logo"]) + assert.Equal(t, "Keep me", env.Data["description"]) + assert.Equal(t, map[string]any{"url": "wallpaper-url", "mode": "tile"}, env.Data["wallpaper"]) +} + +func TestProjectsLogoFollowUpsPreserveScope(t *testing.T) { + for _, scope := range []struct{ name, profile, source, suffix string }{ + {"configured", "", string(config.SourceGlobal), ""}, + {"override", "staging", string(config.SourceFlag), " --profile staging --account 99999"}, + } { + for _, action := range []string{"set", "remove"} { + t.Run(scope.name+"/"+action, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"id":123,"name":"Launch"}`) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + app, buf := newRequestLevelApp(t, server.URL) + app.Flags.Project = "123" + app.Config.ActiveProfile = scope.profile + app.Config.Sources = map[string]string{"account_id": scope.source} + args := []string{"logo", action} + want := map[string]string{"show": "basecamp projects show 123" + scope.suffix} + if action == "set" { + args = append(args, writeWallpaperFile(t, "brand.png", 0)) + want["remove"] = "basecamp projects logo remove --project 123" + scope.suffix + } else { + want["set"] = "basecamp projects logo set <file> --project 123" + scope.suffix + } + require.NoError(t, executeCommand(NewProjectsCmd(), app, args...)) + env := decodeWallpaperEnvelope(t, buf) + got := map[string]string{} + for _, crumb := range env.Breadcrumbs { + got[crumb.Action] = crumb.Cmd + } + assert.Equal(t, want, got) + }) + } + } +} + +func TestProjectsLogoSetAcceptsServerFormatsAndSizeBoundary(t *testing.T) { + for _, tc := range []struct { + file, mime string + size int64 + }{ + {"brand.png", "image/png", 5 << 20}, + {"brand.jpg", "image/jpeg", 0}, + {"brand.gif", "image/gif", 0}, + {"brand.webp", "image/webp", 0}, + {"brand.avif", "image/avif", 0}, + {"brand.heif", "image/heif", 0}, + {"brand.HEIC", "image/heic", 0}, + } { + t.Run(tc.file, func(t *testing.T) { + var upload *wallpaperUpload + parser := &wallpaperServer{t: t} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut { + upload = parser.readUpload(r) + w.WriteHeader(http.StatusNoContent) + return + } + fmt.Fprint(w, `{"id":123,"name":"Launch"}`) + })) + defer server.Close() + app, _ := newRequestLevelApp(t, server.URL) + app.Config.ProjectID = "123" + require.NoError(t, executeCommand(NewProjectsCmd(), app, "logo", "set", writeWallpaperFile(t, tc.file, tc.size))) + require.NotNil(t, upload) + assert.Equal(t, tc.mime, upload.contentType) + }) + } +} + +func TestProjectsLogoRejectsInvalidInputBeforeRequests(t *testing.T) { + t.Setenv("BASECAMP_NONINTERACTIVE", "1") + for _, tc := range []struct { + name string + args []string + message string + }{ + {"missing argument", nil, "<file> required"}, + {"too many arguments", []string{"one.png", "two.png"}, "accepts at most 1 arg(s)"}, + {"oversize", []string{"big.png"}, "big.png exceeds maximum logo size of 5 MB"}, + {"svg", []string{"image.svg"}, "unsupported image type image/svg+xml"}, + {"text", []string{"notes.txt"}, "unsupported image type text/plain"}, + {"missing file", []string{"missing.png"}, "missing.png"}, + {"directory", []string{"directory"}, "directory"}, + } { + t.Run(tc.name, func(t *testing.T) { + s, app, _ := newWallpaperServer(t) + app.Flags.Project = "123" + args := append([]string{"logo", "set"}, tc.args...) + if len(tc.args) == 1 { + switch tc.name { + case "missing file": + args[2] = filepath.Join(t.TempDir(), tc.args[0]) + case "directory": + args[2] = t.TempDir() + default: + size := int64(0) + if tc.name == "oversize" { + size = 5<<20 + 1 + } + args[2] = writeWallpaperFile(t, tc.args[0], size) + } + } + err := executeCommand(NewProjectsCmd(), app, args...) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.message) + assert.Empty(t, s.calls()) + }) + } +} + +func TestProjectsLogoMutationRefusalDoesNotReadBack(t *testing.T) { + for _, action := range []string{"set", "remove"} { + for _, status := range []int{http.StatusForbidden, http.StatusUnprocessableEntity} { + t.Run(fmt.Sprintf("%s/%d", action, status), func(t *testing.T) { + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + fmt.Fprint(w, `{"error":"Logo refused"}`) + })) + defer server.Close() + app, _ := newRequestLevelApp(t, server.URL) + app.Flags.Project = "123" + args := []string{"logo", action} + method := "DELETE" + if action == "set" { + args = append(args, writeWallpaperFile(t, "brand.png", 0)) + method = "PUT" + } + err := executeCommand(NewProjectsCmd(), app, args...) + var outErr *output.Error + require.ErrorAs(t, err, &outErr) + assert.Equal(t, status, outErr.HTTPStatus) + assert.Contains(t, err.Error(), "Logo refused") + assert.Equal(t, []string{method + " /99999/projects/123/logo.json"}, calls) + }) + } + } +} + +func TestProjectsLogoReadbackFailureReportsSuccessfulMutation(t *testing.T) { + for _, action := range []string{"set", "remove"} { + t.Run(action, func(t *testing.T) { + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":"cannot read project"}`) + })) + defer server.Close() + app, buf := newRequestLevelApp(t, server.URL) + app.Flags.Project = "123" + app.Config.ActiveProfile = "staging" + app.Config.Sources = map[string]string{"account_id": string(config.SourceFlag)} + args := []string{"logo", action} + method := "DELETE" + if action == "set" { + args = append(args, writeWallpaperFile(t, "brand.png", 0)) + method = "PUT" + } + require.NoError(t, executeCommand(NewProjectsCmd(), app, args...)) + env := decodeWallpaperEnvelope(t, buf) + assert.True(t, env.OK) + assert.Equal(t, float64(123), env.Data["id"]) + assert.Contains(t, env.Notice, "but fetching the latest project state failed") + assert.Equal(t, "basecamp projects show 123 --profile staging --account 99999", env.Breadcrumbs[0].Cmd) + assert.Equal(t, []string{method + " /99999/projects/123/logo.json", "GET /99999/projects/123"}, calls) + }) + } +} diff --git a/internal/commands/schedule.go b/internal/commands/schedule.go index b95e04025..e43b19b0c 100644 --- a/internal/commands/schedule.go +++ b/internal/commands/schedule.go @@ -1,7 +1,10 @@ package commands import ( + "context" "fmt" + "path" + "slices" "strconv" "strings" "time" @@ -31,7 +34,7 @@ Use 'basecamp schedule entries' to list schedule entries. Use 'basecamp schedule create' to create new entries. For a cross-project view of your upcoming schedule, use 'basecamp reports schedule'.`, - Annotations: map[string]string{"agent_notes": "Each project has one schedule\nRecurring events: use --date on show to get a specific occurrence\nschedule settings --include-due makes todo/card due dates appear on the schedule\nNatural dates work for --starts-at: tomorrow, next monday"}, + Annotations: map[string]string{"agent_notes": "A project usually has one schedule but can have several: with one, commands find it; with several, they refuse and list the schedule IDs, so pass --schedule <id> (move and copy take --to-schedule <id>)\nRecurring events: use --date on show to get a specific occurrence\nschedule settings --include-due makes todo/card due dates appear on the schedule\nNatural dates work for --starts-at: tomorrow, next monday"}, } cmd.PersistentFlags().StringVarP(&project, "project", "p", "", "Project ID or name") @@ -41,10 +44,12 @@ For a cross-project view of your upcoming schedule, use 'basecamp reports schedu cmd.AddCommand( newScheduleShowCmd(&project, &scheduleID), newScheduleEntriesCmd(&project, &scheduleID), - newScheduleEntryShowCmd(&project), + newScheduleEntryShowCmd(), newScheduleCreateCmd(&project, &scheduleID), newScheduleUpdateCmd(&project), newScheduleSettingsCmd(&project, &scheduleID), + newFilingCmd(scheduleEntryFamily(), basecamp.FilingMove, scheduleMoveHelp, false), + newFilingCmd(scheduleEntryFamily(), basecamp.FilingCopy, scheduleCopyHelp, false), ) return cmd @@ -247,7 +252,7 @@ func runScheduleEntries(cmd *cobra.Command, app *appctx.App, project, scheduleID ) } -func newScheduleEntryShowCmd(project *string) *cobra.Command { +func newScheduleEntryShowCmd() *cobra.Command { var occurrenceDate string cmd := &cobra.Command{ @@ -256,7 +261,7 @@ func newScheduleEntryShowCmd(project *string) *cobra.Command { Long: `Display details of a schedule entry. You can pass either an entry ID or a Basecamp URL: - basecamp schedule show 789 --in my-project + basecamp schedule show 789 basecamp schedule show https://3.basecamp.com/123/buckets/456/schedule_entries/789`, Args: cobra.ExactArgs(1), } @@ -268,7 +273,7 @@ You can pass either an entry ID or a Basecamp URL: if err := ensureAccount(cmd, app); err != nil { return err } - return runScheduleEntryShow(cmd, app, args[0], *project, occurrenceDate, cf) + return runScheduleEntryShow(cmd, app, args[0], occurrenceDate, cf) } cmd.Flags().StringVar(&occurrenceDate, "date", "", "Access specific occurrence of recurring entry (YYYYMMDD)") @@ -277,11 +282,9 @@ You can pass either an entry ID or a Basecamp URL: return cmd } -func runScheduleEntryShow(cmd *cobra.Command, app *appctx.App, entryID, project, occurrenceDate string, cf *commentFlags) error { - // Extract ID, project, and occurrence date from URL if provided. - // Uses Parse directly (instead of extractWithProject) to access the - // occurrence date that the URL encodes. - var urlProjectID string +func runScheduleEntryShow(cmd *cobra.Command, app *appctx.App, entryID, occurrenceDate string, cf *commentFlags) error { + // Extract ID and occurrence date from URL if provided. The entry is read + // by its account-wide ID, so no project is needed. if parsed := urlarg.Parse(entryID); parsed != nil { if parsed.RecordingID == "" { return output.ErrUsageHint( @@ -290,35 +293,11 @@ func runScheduleEntryShow(cmd *cobra.Command, app *appctx.App, entryID, project, ) } entryID = parsed.RecordingID - urlProjectID = parsed.ProjectID if occurrenceDate == "" { occurrenceDate = parsed.OccurrenceDate } } - // Resolve project - use URL > flag > config, with interactive fallback - projectID := project - if projectID == "" && urlProjectID != "" { - projectID = urlProjectID - } - if projectID == "" { - projectID = app.Flags.Project - } - if projectID == "" { - projectID = app.Config.ProjectID - } - if projectID == "" { - if err := ensureProject(cmd, app); err != nil { - return err - } - projectID = app.Config.ProjectID - } - - resolvedProjectID, _, err := app.Names.ResolveProject(cmd.Context(), projectID) - if err != nil { - return err - } - entryIDInt, _ := strconv.ParseInt(entryID, 10, 64) // Use SDK method for specific occurrences of recurring entries @@ -354,7 +333,7 @@ func runScheduleEntryShow(cmd *cobra.Command, app *appctx.App, entryID, project, }, output.Breadcrumb{ Action: "entries", - Cmd: fmt.Sprintf("basecamp schedule entries --project %s", resolvedProjectID), + Cmd: scheduleEntriesCmd(cmd.Context(), app, entry), Description: "View all entries", }, ), @@ -368,6 +347,13 @@ func runScheduleEntryShow(cmd *cobra.Command, app *appctx.App, entryID, project, if err != nil { return convertSDKError(err) } + // A moved event's old id redirects to the event at its new id, and only + // the new id takes writes. A recurring event reads as an occurrence whose + // parent is the event. + entryID = strconv.FormatInt(entry.ID, 10) + if entry.Type == occurrenceType && entry.Parent != nil { + entryID = strconv.FormatInt(entry.Parent.ID, 10) + } title := entry.Summary if title == "" { @@ -387,12 +373,12 @@ func runScheduleEntryShow(cmd *cobra.Command, app *appctx.App, entryID, project, output.WithBreadcrumbs( output.Breadcrumb{ Action: "update", - Cmd: fmt.Sprintf("basecamp schedule update %s --summary \"...\" --project %s", entryID, resolvedProjectID), + Cmd: fmt.Sprintf("basecamp schedule update %s --summary \"...\" --project %s", entryID, recordProject(entry.Bucket)), Description: "Update entry", }, output.Breadcrumb{ Action: "entries", - Cmd: fmt.Sprintf("basecamp schedule entries --project %s", resolvedProjectID), + Cmd: scheduleEntriesCmd(cmd.Context(), app, entry), Description: "View all entries", }, ), @@ -402,6 +388,28 @@ func runScheduleEntryShow(cmd *cobra.Command, app *appctx.App, entryID, project, return app.OK(data, opts...) } +// scheduleEntriesCmd lists the entries of the schedule an event is on, +// naming the schedule, since a project can have several and a calendar has +// no dock to find one on. A recurring event's parent is its series, not its +// schedule, so for one it looks the schedule up. +func scheduleEntriesCmd(ctx context.Context, app *appctx.App, entry *basecamp.ScheduleEntry) string { + list := "basecamp schedule entries --project " + recordProject(entry.Bucket) + if entry.Parent == nil { + return list + } + schedule := entry.Parent + if schedule.Type != "Schedule" { + if entry.Bucket == nil { + return list + } + var err error + if schedule, err = seriesSchedule(ctx, app, entry.Bucket, entry.Parent.ID, entry.Status); err != nil || schedule == nil { + return list + } + } + return fmt.Sprintf("%s --schedule %d", list, schedule.ID) +} + func newScheduleCreateCmd(project, scheduleID *string) *cobra.Command { var summary string var startsAt string @@ -931,3 +939,162 @@ func validateScheduleTimestamp(flag, value string) error { } return output.ErrUsage(fmt.Sprintf("Invalid --%s: %q is neither an RFC 3339 timestamp (2026-06-01T09:00:00Z) nor a date (2026-06-01)", flag, value)) } + +var scheduleMoveHelp = filingHelp{ + use: "move <id|url>", + short: "Move a schedule entry (event) to another project's schedule", + long: `Move a schedule entry, an event, to a schedule in another project, as the +web's Move… does. A recurring event moves as a whole, every occurrence with it. + + basecamp schedule move 789 --to-project "Marketing" + basecamp schedule move https://3.basecamp.com/123/buckets/456/schedule_entries/789 --to-project "Marketing" + basecamp schedule move 789 --to-schedule 1069479999 + +--to-project alone needs a project with one schedule; add --to-schedule when it +has several, or pass a schedule's ID or URL alone. The command waits for +Basecamp to finish the move and shows the event where it landed, under a new ID.`, + notes: "--to-project <project> (ID or name) moves the event to that project's schedule; --to-schedule <id|url> names a schedule directly, which is how to name an employee calendar's schedule (a calendar is not a project). " + + "A recurring event moves as a whole; an occurrence URL moves the whole series. " + + "Output is the moved event: data.id is its NEW id and data.source_id the old one. Write to the new id; the old id stops taking changes, and reading it follows the move. " + + "--schedule names the event's own schedule and is refused; moving onto the schedule it is already on is refused. " + + "An in_progress error names its filing: do not move it again; basecamp filings show <filing id> gives its new id once it lands.", +} + +var scheduleCopyHelp = filingHelp{ + use: "copy <id|url>", + short: "Copy a schedule entry (event) to a schedule in this or another project", + long: `Copy a schedule entry, an event, onto a schedule, as the web's Copy… does. The +original stays where it is; a recurring event is copied as a whole series. + +With no destination, the copy goes onto the event's own schedule: + basecamp schedule copy 789 + basecamp schedule copy 789 --to-project "Marketing" + basecamp schedule copy 789 --to-schedule 1069479999 --include-comments + +Basecamp makes the copy in the background; the command waits for it and shows +the new event.`, + notes: "Without --to-project/--to-schedule the copy goes onto the event's own schedule; --to-schedule <id|url> also names an employee calendar's schedule. " + + "Output is the new event; its id is data.id and the original's is data.source_id. --include-comments copies the comments too. " + + "Never re-run a copy that reported in_progress or a may-have-started error: each run makes another copy; check an in_progress one with basecamp filings show <filing id>.", +} + +// scheduleEntryFamily is how schedule entries move and copy between +// schedules. +func scheduleEntryFamily() filingFamily { + return filingFamily{ + noun: "schedule entry", + group: "basecamp schedule", + types: []string{"Schedule::Entry"}, + urlKinds: []string{"schedule_entries"}, + urlShape: "…/schedule_entries/<id>", + into: filingContainer{ + dockName: "schedule", + noun: "schedule", + plural: "schedules", + flag: "--to-schedule", + urlKind: "schedules", + urlShape: "…/schedules/<id>", + get: func(ctx context.Context, app *appctx.App, id int64) (filingTarget, error) { + schedule, err := app.Account().Schedules().Get(ctx, id) + if err != nil { + return filingTarget{}, err + } + return bucketTarget(schedule.ID, schedule.Title, schedule.AppURL, schedule.Bucket), nil + }, + }, + get: getScheduleEntryForFiling, + crumbs: func(id, projectID int64) []output.Breadcrumb { + return []output.Breadcrumb{{ + Action: "show", + Cmd: fmt.Sprintf("basecamp schedule show %d --in %d", id, projectID), + Description: "View the event", + }} + }, + } +} + +// occurrenceType is what Basecamp answers a recurring event's read with: it +// redirects to an occurrence, whose parent is the event. +const occurrenceType = "Schedule::Entry::Occurrence" + +// getScheduleEntryForFiling reads an event to move or copy. A recurring +// event's read answers with one occurrence, so the event is its parent and +// files as a whole series; the output names the event, with the occurrence +// read as occurrence_id, and seriesSchedule finds the schedule it is on. +func getScheduleEntryForFiling(ctx context.Context, app *appctx.App, id int64) (any, filingSource, error) { + e, err := app.Account().Schedules().GetEntry(ctx, id) + if err != nil { + return nil, filingSource{}, err + } + src := filingSource{id: e.ID, typ: e.Type, status: e.Status, title: e.Summary, bucket: e.Bucket, parent: e.Parent} + if e.Type != occurrenceType || e.Parent == nil { + return e, src, nil + } + + src.id, src.typ, src.parent = e.Parent.ID, "Schedule::Entry", nil + if e.Bucket != nil { + if src.parent, err = seriesSchedule(ctx, app, e.Bucket, src.id, e.Status); err != nil { + return nil, filingSource{}, err + } + } + record, ok := withSourceID(e, 0).(map[string]any) + if !ok { + return e, src, nil + } + delete(record, "source_id") + record["occurrence_id"] = record["id"] + record["id"] = src.id + record["type"] = src.typ + record["parent"] = src.parent + return record, src, nil +} + +// seriesSchedule finds the schedule a recurring event is on, which its +// occurrence's read does not name. A calendar, such as an employee's, has one +// schedule, which the calendar's read links to; a project's schedules are on +// its dock, and of several, the one whose entries list the event, in its +// status (an entries listing shows one status), is it. It returns nil when +// none does. +func seriesSchedule(ctx context.Context, app *appctx.App, bucket *basecamp.Bucket, eventID int64, status string) (*basecamp.Parent, error) { + scheduleOf := func(id int64) (*basecamp.Parent, error) { + schedule, err := app.Account().Schedules().Get(ctx, id) + if err != nil { + return nil, err + } + return &basecamp.Parent{ID: schedule.ID, Title: schedule.Title, Type: "Schedule"}, nil + } + + if bucket.Type == "Calendar" { + calendar, err := app.Account().Calendars().Get(ctx, bucket.ID) + if err != nil { + return nil, err + } + id, err := strconv.ParseInt(strings.TrimSuffix(path.Base(calendar.ScheduleURL), ".json"), 10, 64) + if err != nil { + return nil, fmt.Errorf("calendar %d links to no schedule (%q)", bucket.ID, calendar.ScheduleURL) + } + return scheduleOf(id) + } + + _, schedules, err := getDockTools(ctx, app, strconv.FormatInt(bucket.ID, 10), "schedule") + if err != nil { + return nil, err + } + if len(schedules) == 1 { + return &basecamp.Parent{ID: schedules[0].ID, Title: schedules[0].Title, Type: "Schedule"}, nil + } + var opts *basecamp.ScheduleEntryListOptions + if status == "archived" || status == "trashed" { + opts = &basecamp.ScheduleEntryListOptions{Status: status} + } + for _, schedule := range schedules { + entries, err := app.Account().Schedules().ListEntries(ctx, schedule.ID, opts) + if err != nil { + return nil, err + } + if slices.ContainsFunc(entries.Entries, func(e basecamp.ScheduleEntry) bool { return e.ID == eventID }) { + return &basecamp.Parent{ID: schedule.ID, Title: schedule.Title, Type: "Schedule"}, nil + } + } + return nil, nil +} diff --git a/internal/commands/schedule_filing_test.go b/internal/commands/schedule_filing_test.go new file mode 100644 index 000000000..0bd69adc7 --- /dev/null +++ b/internal/commands/schedule_filing_test.go @@ -0,0 +1,238 @@ +package commands + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" +) + +// scheduleFilingTransport has event 456 on schedule 555 in Source (123), and +// Dest (999) with schedule 900, which the filed event 4560 is on. +func scheduleFilingTransport() *familyTransport { + return &familyTransport{gets: map[string]string{ + "/projects": filingProjects, + "/projects/123": `{"id":123,"name":"Source","dock":[{"name":"schedule","id":555,"title":"Schedule","enabled":true}]}`, + "/projects/999": `{"id":999,"name":"Dest","dock":[{"name":"schedule","id":900,"title":"Calendar","enabled":true}]}`, + "/schedules/555": `{"id":555,"title":"Schedule","bucket":{"id":123,"name":"Source"}}`, + "/schedules/900": `{"id":900,"title":"Calendar","bucket":{"id":999,"name":"Dest"}}`, + "/schedule_entries/456": `{"id":456,"type":"Schedule::Entry","summary":"Launch","bucket":{"id":123,"name":"Source"},"parent":{"id":555,"title":"Schedule","type":"Schedule"}}`, + "/schedule_entries/4560": `{"id":4560,"type":"Schedule::Entry","summary":"Launch","bucket":{"id":999,"name":"Dest"},"parent":{"id":900,"title":"Calendar","type":"Schedule"}}`, + }} +} + +func TestScheduleMoveToProject(t *testing.T) { + transport := scheduleFilingTransport() + run := runFamilyCommand(t, transport, NewScheduleCmd(), "move", "456", "--to-project", "Dest") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/99999/recordings/456/moves"}, transport.posts) + assert.Equal(t, float64(900), transport.bodies[0]["filing_destination_id"]) + data := run.data(t) + assert.Equal(t, float64(4560), data["id"]) + assert.Equal(t, float64(456), data["source_id"]) + assert.Equal(t, "Moved schedule entry #456 to Dest › Calendar as schedule entry #4560", run.envelope.Summary) + assert.Equal(t, "basecamp schedule show 4560 --in 999", run.envelope.Breadcrumbs[0].Cmd) +} + +func TestScheduleMoveTakesAnOccurrenceURLAsItsSeries(t *testing.T) { + transport := scheduleFilingTransport() + run := runFamilyCommand(t, transport, NewScheduleCmd(), "move", + "https://3.basecamp.com/99999/buckets/123/schedule_entries/456/occurrences/20261101", "--to-schedule", "900") + require.NoError(t, run.err) + assert.Equal(t, []string{"/99999/recordings/456/moves"}, transport.posts) +} + +func TestScheduleMoveOntoItsOwnScheduleIsRefused(t *testing.T) { + transport := scheduleFilingTransport() + run := runFamilyCommand(t, transport, NewScheduleCmd(), "move", "456", "--to-project", "Source") + e := run.failure(t) + assert.Equal(t, output.CodeUsage, e.Code) + assert.Contains(t, e.Message, "is already in schedule 555") + assert.Empty(t, transport.posts) +} + +func TestScheduleCopyWithoutDestinationGoesOntoItsOwnSchedule(t *testing.T) { + transport := scheduleFilingTransport() + run := runFamilyCommand(t, transport, NewScheduleCmd(), "copy", "456") + require.NoError(t, run.err) + assert.Equal(t, []string{"/99999/recordings/456/copies"}, transport.posts) + assert.Equal(t, float64(555), transport.bodies[0]["filing_destination_id"]) +} + +func TestScheduleMoveOfARecurringEventFilesTheSeries(t *testing.T) { + transport := scheduleFilingTransport() + // Basecamp answers a recurring event's read with an occurrence of it. + transport.gets["/schedule_entries/457"] = `{"id":7001,"type":"Schedule::Entry::Occurrence","summary":"Standup","bucket":{"id":123,"name":"Source"},"parent":{"id":457,"title":"Standup","type":"Schedule::Entry"}}` + transport.gets["/schedule_entries/4570"] = `{"id":7002,"type":"Schedule::Entry::Occurrence","summary":"Standup","bucket":{"id":999,"name":"Dest"},"parent":{"id":4570,"title":"Standup","type":"Schedule::Entry"}}` + run := runFamilyCommand(t, transport, NewScheduleCmd(), "move", "457", "--to-project", "Dest") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/99999/recordings/457/moves"}, transport.posts) + data := run.data(t) + assert.Equal(t, float64(4570), data["id"], "the output names the moved event, not the occurrence read") + assert.Equal(t, float64(7002), data["occurrence_id"]) + assert.Equal(t, "Schedule::Entry", data["type"]) + assert.Equal(t, float64(457), data["source_id"]) + assert.Equal(t, "Moved schedule entry #457 to Dest › Calendar as schedule entry #4570", run.envelope.Summary) +} + +func TestScheduleMoveOfARecurringEventOntoItsOwnScheduleIsRefused(t *testing.T) { + transport := scheduleFilingTransport() + transport.gets["/schedule_entries/457"] = `{"id":7001,"type":"Schedule::Entry::Occurrence","summary":"Standup","bucket":{"id":123,"name":"Source"},"parent":{"id":457,"title":"Standup","type":"Schedule::Entry"}}` + run := runFamilyCommand(t, transport, NewScheduleCmd(), "move", "457", "--to-project", "Source") + e := run.failure(t) + assert.Contains(t, e.Message, "is already in schedule 555") + assert.Empty(t, transport.posts) +} + +// A recurring event in an employee calendar (bucket 777, a Calendar, not a +// project) is read as an occurrence; its schedule, 770, is the one the +// calendar links to. +func calendarTransport() *familyTransport { + transport := scheduleFilingTransport() + transport.gets["/calendars/777"] = `{"id":777,"type":"Calendar","name":"Jason's calendar","schedule_url":"https://3.basecampapi.com/99999/buckets/777/schedules/770.json"}` + transport.gets["/schedules/770"] = `{"id":770,"title":"Jason's calendar","bucket":{"id":777,"name":"Jason's calendar","type":"Calendar"}}` + transport.gets["/schedule_entries/458"] = `{"id":7003,"type":"Schedule::Entry::Occurrence","summary":"1:1","bucket":{"id":777,"name":"Jason's calendar","type":"Calendar"},"parent":{"id":458,"title":"1:1","type":"Schedule::Entry"}}` + transport.gets["/schedule_entries/4580"] = `{"id":7004,"type":"Schedule::Entry::Occurrence","summary":"1:1","bucket":{"id":999,"name":"Dest","type":"Project"},"parent":{"id":4580,"title":"1:1","type":"Schedule::Entry"}}` + transport.gets["/schedule_entries/4570"] = `{"id":7002,"type":"Schedule::Entry::Occurrence","summary":"Standup","bucket":{"id":777,"name":"Jason's calendar","type":"Calendar"},"parent":{"id":4570,"title":"Standup","type":"Schedule::Entry"}}` + transport.gets["/schedule_entries/457"] = `{"id":7001,"type":"Schedule::Entry::Occurrence","summary":"Standup","bucket":{"id":123,"name":"Source","type":"Project"},"parent":{"id":457,"title":"Standup","type":"Schedule::Entry"}}` + return transport +} + +func TestScheduleFilesARecurringCalendarEventFromItsCalendarsSchedule(t *testing.T) { + transport := calendarTransport() + run := runFamilyCommand(t, transport, NewScheduleCmd(), "copy", "458") + require.NoError(t, run.err) + assert.Equal(t, float64(770), transport.bodies[0]["filing_destination_id"], "a copy with no destination goes onto the calendar it is on") + + transport = calendarTransport() + run = runFamilyCommand(t, transport, NewScheduleCmd(), "move", "458", "--to-project", "Dest") + require.NoError(t, run.err) + assert.Equal(t, "Moved schedule entry #458 to Dest › Calendar as schedule entry #4580", run.envelope.Summary) + + transport = calendarTransport() + e := runFamilyCommand(t, transport, NewScheduleCmd(), "move", "458", "--to-schedule", "770").failure(t) + assert.Contains(t, e.Message, "is already in schedule 770") + assert.Empty(t, transport.posts) +} + +func TestScheduleShowListsACalendarEventsEntriesByItsSchedule(t *testing.T) { + for name, args := range map[string][]string{ + "a recurring event": {"show", "458"}, + "one occurrence of a recurring": {"show", "458", "--date", "20261101"}, + "a single event": {"show", "459"}, + } { + t.Run(name, func(t *testing.T) { + transport := calendarTransport() + transport.gets["/schedule_entries/459"] = `{"id":459,"type":"Schedule::Entry","summary":"Review","bucket":{"id":777,"name":"Jason's calendar","type":"Calendar"},"parent":{"id":770,"title":"Jason's calendar","type":"Schedule"}}` + transport.gets["/schedule_entries/458/occurrences/20261101"] = `{"id":7005,"type":"Schedule::Entry::Occurrence","summary":"1:1","bucket":{"id":777,"name":"Jason's calendar","type":"Calendar"},"parent":{"id":458,"title":"1:1","type":"Schedule::Entry"}}` + run := runFamilyCommand(t, transport, NewScheduleCmd(), args...) + require.NoError(t, run.err) + assert.Equal(t, "basecamp schedule entries --project 777 --schedule 770", run.envelope.Breadcrumbs[1].Cmd) + }) + } +} + +func TestScheduleShowListsAProjectEventsEntriesByItsSchedule(t *testing.T) { + transport := scheduleFilingTransport() + transport.gets["/schedule_entries/460"] = `{"id":460,"type":"Schedule::Entry","summary":"Retro","bucket":{"id":123,"name":"Source","type":"Project"},"parent":{"id":556,"title":"Second schedule","type":"Schedule"}}` + run := runFamilyCommand(t, transport, NewScheduleCmd(), "show", "460") + require.NoError(t, run.err) + assert.Equal(t, "basecamp schedule entries --project 123 --schedule 556", run.envelope.Breadcrumbs[1].Cmd, + "a project can have several schedules, so the entries command names the one the event is on") +} + +func TestScheduleShowListsARecurringProjectEventsEntriesByItsSchedule(t *testing.T) { + for name, args := range map[string][]string{ + "a recurring event": {"show", "457"}, + "one occurrence of a recurring": {"show", "457", "--date", "20261101"}, + } { + t.Run(name, func(t *testing.T) { + transport := calendarTransport() + transport.gets["/projects/123"] = `{"id":123,"name":"Source","dock":[{"name":"schedule","id":555,"title":"Schedule","enabled":true},{"name":"schedule","id":556,"title":"Launches","enabled":true}]}` + transport.gets["/schedules/555/entries"] = `[{"id":456,"type":"Schedule::Entry","summary":"Launch"}]` + transport.gets["/schedules/556/entries"] = `[{"id":457,"type":"Schedule::Entry","summary":"Standup"}]` + transport.gets["/schedules/556"] = `{"id":556,"title":"Launches","bucket":{"id":123,"name":"Source"}}` + transport.gets["/schedule_entries/457/occurrences/20261101"] = `{"id":7006,"type":"Schedule::Entry::Occurrence","summary":"Standup","bucket":{"id":123,"name":"Source","type":"Project"},"parent":{"id":457,"title":"Standup","type":"Schedule::Entry"}}` + run := runFamilyCommand(t, transport, NewScheduleCmd(), args...) + require.NoError(t, run.err) + assert.Equal(t, "basecamp schedule entries --project 123 --schedule 556", run.envelope.Breadcrumbs[1].Cmd, + "a recurring event's parent is its series, so the schedule listing it is looked up") + }) + } +} + +func TestScheduleFilesARecurringEventIntoACalendarAndReadsItBack(t *testing.T) { + transport := calendarTransport() + run := runFamilyCommand(t, transport, NewScheduleCmd(), "move", "457", "--to-schedule", "770") + require.NoError(t, run.err, "the event lands in the calendar and is read back there") + assert.Equal(t, float64(770), transport.bodies[0]["filing_destination_id"]) + assert.Equal(t, "Moved schedule entry #457 to Jason's calendar › Jason's calendar as schedule entry #4570", run.envelope.Summary) +} + +func TestScheduleFindsARecurringEventsScheduleAmongSeveral(t *testing.T) { + twoSchedules := func() *familyTransport { + transport := calendarTransport() + transport.gets["/projects/123"] = `{"id":123,"name":"Source","dock":[{"name":"schedule","id":555,"title":"Schedule","enabled":true},{"name":"schedule","id":556,"title":"Launches","enabled":true}]}` + transport.gets["/schedules/555/entries"] = `[{"id":456,"type":"Schedule::Entry","summary":"Launch"}]` + transport.gets["/schedules/556/entries"] = `[{"id":457,"type":"Schedule::Entry","summary":"Standup"}]` + transport.gets["/schedules/556"] = `{"id":556,"title":"Launches","bucket":{"id":123,"name":"Source"}}` + return transport + } + transport := twoSchedules() + run := runFamilyCommand(t, transport, NewScheduleCmd(), "copy", "457") + require.NoError(t, run.err) + assert.Equal(t, float64(556), transport.bodies[0]["filing_destination_id"], "the copy goes onto the schedule listing the event") + + transport = twoSchedules() + e := runFamilyCommand(t, transport, NewScheduleCmd(), "move", "457", "--to-schedule", "556").failure(t) + assert.Contains(t, e.Message, "is already in schedule 556") + assert.Empty(t, transport.posts) +} + +// An entries listing shows active entries unless asked for another status, +// so an archived series is found among its project's schedules only in the +// archived listing. +func TestScheduleFindsAnArchivedRecurringEventsScheduleAmongSeveral(t *testing.T) { + transport := scheduleFilingTransport() + transport.gets["/projects/123"] = `{"id":123,"name":"Source","dock":[{"name":"schedule","id":555,"title":"Schedule","enabled":true},{"name":"schedule","id":556,"title":"Launches","enabled":true}]}` + transport.gets["/schedules/556"] = `{"id":556,"title":"Launches","bucket":{"id":123,"name":"Source"}}` + transport.gets["/schedule_entries/457"] = `{"id":7001,"status":"archived","type":"Schedule::Entry::Occurrence","summary":"Standup","bucket":{"id":123,"name":"Source","type":"Project"},"parent":{"id":457,"title":"Standup","type":"Schedule::Entry"}}` + transport.gets["/schedules/555/entries"] = `[]` + transport.gets["/schedules/556/entries"] = `[]` + transport.gets["/schedules/555/entries?status=archived"] = `[]` + transport.gets["/schedules/556/entries?status=archived"] = `[{"id":457,"status":"archived","type":"Schedule::Entry","summary":"Standup"}]` + transport.gets["/schedule_entries/4570"] = `{"id":7002,"status":"archived","type":"Schedule::Entry::Occurrence","summary":"Standup","bucket":{"id":123,"name":"Source","type":"Project"},"parent":{"id":4570,"title":"Standup","type":"Schedule::Entry"}}` + + run := runFamilyCommand(t, transport, NewScheduleCmd(), "copy", "457") + require.NoError(t, run.err) + assert.Equal(t, []string{"/99999/recordings/457/copies"}, transport.posts) + assert.Equal(t, float64(556), transport.bodies[0]["filing_destination_id"], "the copy goes onto the schedule whose archived entries list the event") +} + +func TestScheduleCopyIntoACalendarIsLookedForInItsEntries(t *testing.T) { + transport := calendarTransport() + transport.refuse = map[int64]int{456: 502} + e := runFamilyCommand(t, transport, NewScheduleCmd(), "copy", "456", "--to-schedule", "770").failure(t) + assert.Contains(t, e.Hint, "may have started the copy") + assert.Contains(t, e.Hint, "basecamp schedule entries --in 777 --schedule 770 --status active --all", + "the recordings listing does not serve a calendar") +} + +// Basecamp lists drafted schedule entries nowhere in its API, neither among +// drafts nor in a recordings or entries listing. +func TestScheduleCopyOfADraftWithNoFilingNamesNoListing(t *testing.T) { + for _, dest := range []string{"900", "770"} { + transport := calendarTransport() + transport.gets["/schedule_entries/456"] = strings.Replace(transport.gets["/schedule_entries/456"], `"type":"Schedule::Entry"`, `"type":"Schedule::Entry","status":"drafted"`, 1) + transport.refuse = map[int64]int{456: 502} + e := runFamilyCommand(t, transport, NewScheduleCmd(), "copy", "456", "--to-schedule", dest).failure(t) + assert.Contains(t, e.Hint, "may have started the copy") + assert.Contains(t, e.Hint, "open the destination in Basecamp: ", dest) + assert.NotContains(t, e.Hint, "--status drafted", dest) + } +} diff --git a/internal/commands/subtasks_test.go b/internal/commands/subtasks_test.go index aa604aa56..d8e5c4771 100644 --- a/internal/commands/subtasks_test.go +++ b/internal/commands/subtasks_test.go @@ -517,3 +517,53 @@ func TestShowTrustsACompleteEmbed(t *testing.T) { assert.Empty(t, transport.queriesFor(subtasksListPath(777))) } + +// A moved card or to-do's old id reads back the record at its new id, and only +// the new id reaches its steps. +func TestShowingAMovedRecordByItsOldIDReadsEveryStepAtTheNewID(t *testing.T) { + t.Run("cards show", func(t *testing.T) { + app, _, out := setupPersonalFeedApp(t, + cardGetRoute(777, parentJSON(778, "Kanban::Card", 100, 135)), + subtaskRoute(http.MethodGet, subtasksListPath(778), http.StatusOK, stepsJSON(135))) + + require.NoError(t, executeRecordingCommand(NewCardsCmd(), app, "show", "777")) + + data, summary := subtaskEnvelope(t, out) + var card struct { + Steps json.RawMessage `json:"steps"` + } + require.NoError(t, json.Unmarshal(data, &card)) + assert.Equal(t, 135, len(decodeSteps(t, card.Steps))) + assert.Contains(t, summary, "#778") + assert.NotContains(t, out.String(), "cards done 777") + }) + + t.Run("cards steps", func(t *testing.T) { + app, _, out := setupPersonalFeedApp(t, + projectsRoute(), + cardGetRoute(777, parentJSON(778, "Kanban::Card", 100, 135)), + subtaskRoute(http.MethodGet, subtasksListPath(778), http.StatusOK, stepsJSON(135))) + + require.NoError(t, executeRecordingCommand(NewCardsCmd(), app, "steps", "777", "--in", "123")) + + data, summary := subtaskEnvelope(t, out) + assert.Equal(t, 135, len(decodeSteps(t, data))) + assert.Equal(t, "135 steps on card #778", summary) + }) + + t.Run("todos show", func(t *testing.T) { + app, _, out := setupPersonalFeedApp(t, + todoGetRoute(888, parentJSON(889, "Todo", 100, 135)), + subtaskRoute(http.MethodGet, subtasksListPath(889), http.StatusOK, stepsJSON(135))) + + require.NoError(t, executeRecordingCommand(NewTodosCmd(), app, "show", "888")) + + data, _ := subtaskEnvelope(t, out) + var todo struct { + Steps json.RawMessage `json:"steps"` + } + require.NoError(t, json.Unmarshal(data, &todo)) + assert.Equal(t, 135, len(decodeSteps(t, todo.Steps))) + assert.NotContains(t, out.String(), "todos complete 888") + }) +} diff --git a/internal/commands/todolistgroups.go b/internal/commands/todolistgroups.go index df6ed8ddf..51e069cd5 100644 --- a/internal/commands/todolistgroups.go +++ b/internal/commands/todolistgroups.go @@ -35,7 +35,8 @@ Todolist groups allow you to organize todolists into collapsible sections.`, newTodolistgroupsShowCmd(&project), newTodolistgroupsCreateCmd(&project, &todolist), newTodolistgroupsUpdateCmd(), - newTodolistgroupsPositionCmd(), + newTodolistgroupsPositionCmd(&project), + newTodolistsCopyCmd(&project), ) return cmd @@ -339,15 +340,36 @@ func newTodolistgroupsUpdateCmd() *cobra.Command { return cmd } -func newTodolistgroupsPositionCmd() *cobra.Command { +func newTodolistgroupsPositionCmd(project *string) *cobra.Command { var position int + var dest todolistDestination cmd := &cobra.Command{ Use: "position <id>", Aliases: []string{"move"}, - Short: "Change group position", - Args: cobra.ExactArgs(1), + Short: "Move a group to a list in another project, or reorder it", + Long: `Move a group (a list inside a to-do list), with its to-dos, into a to-do list +in any project as the web's Move… does; it stays a group there. Or reorder it +within its list (1 = top). + + basecamp todolistgroups move 456 --to-project "Marketing" --to-list "Launch" + basecamp todolistgroups move 456 --to-list https://3.basecamp.com/1/buckets/2/todolists/321 + basecamp todolistgroups position 456 --position 1 + +Given a top-level to-do list instead, it moves the list into a project's +To-dos (--to-project, --to-todoset), as todolists move does.`, + Args: cobra.ExactArgs(1), + Annotations: map[string]string{ + "agent_notes": "--to-project/--to-list move the group to another project (the web's Move…); --position reorders it within its list and cannot be combined with them. " + + todolistFilingNotes + " The old id stops accepting changes, and reading it follows the move. --list, the group's own list, is refused here: name the destination with --to-list.", + }, RunE: func(cmd *cobra.Command, args []string) error { + if destinationFlagChanged(cmd) { + if err := rejectRepositionWithFiling(cmd, "--position"); err != nil { + return err + } + return fileTodolist(cmd, basecamp.FilingMove, args[0], *project, dest, false) + } if position == 0 { return output.ErrUsage("--position is required (1-based)") } @@ -382,6 +404,7 @@ func newTodolistgroupsPositionCmd() *cobra.Command { cmd.Flags().IntVar(&position, "position", 0, "New position, 1-based (required)") cmd.Flags().IntVar(&position, "pos", 0, "New position (alias)") + dest.addFlags(cmd, "Move") - return cmd + return guardFilingFlags(cmd) } diff --git a/internal/commands/todolistgroups_test.go b/internal/commands/todolistgroups_test.go index de92a34db..3ec117bc0 100644 --- a/internal/commands/todolistgroups_test.go +++ b/internal/commands/todolistgroups_test.go @@ -15,7 +15,8 @@ func TestTodolistgroupsPositionAcceptsPosAlias(t *testing.T) { app, _ := setupTestApp(t) app.Config.ProjectID = "123" - cmd := newTodolistgroupsPositionCmd() + var project string + cmd := newTodolistgroupsPositionCmd(&project) // --pos should reach the RunE and proceed past the position guard err := executeCommand(cmd, app, "456", "--pos", "2") @@ -34,7 +35,8 @@ func TestTodolistgroupsPositionRequiresPosition(t *testing.T) { app, _ := setupTestApp(t) app.Config.ProjectID = "123" - cmd := newTodolistgroupsPositionCmd() + var project string + cmd := newTodolistgroupsPositionCmd(&project) err := executeCommand(cmd, app, "456") require.NotNil(t, err) diff --git a/internal/commands/todolists.go b/internal/commands/todolists.go index 4c1b7fbe3..d28f98290 100644 --- a/internal/commands/todolists.go +++ b/internal/commands/todolists.go @@ -1,6 +1,7 @@ package commands import ( + "context" "errors" "fmt" "strconv" @@ -36,10 +37,11 @@ to disambiguate when needed.`, cmd.AddCommand( newTodolistsListCmd(&project, &todosetID), - newTodolistsShowCmd(&project), + newTodolistsShowCmd(), newTodolistsCreateCmd(&project, &todosetID), newTodolistsUpdateCmd(&project), newTodolistsPositionCmd(&project), + newTodolistsCopyCmd(&project), newRecordableTrashCmd("todolist"), newRecordableArchiveCmd("todolist"), newRecordableRestoreCmd("todolist"), @@ -182,7 +184,7 @@ func runTodolistsList(cmd *cobra.Command, project, todosetFlag string, limit, pa return app.OK(todolists, respOpts...) } -func newTodolistsShowCmd(project *string) *cobra.Command { +func newTodolistsShowCmd() *cobra.Command { var cf *commentFlags cmd := &cobra.Command{ @@ -204,29 +206,12 @@ You can pass either a todolist ID or a Basecamp URL: return err } - // Extract ID and project from URL if provided - todolistIDStr, urlProjectID, err := extractWithProject(args[0]) + // The list is read by its account-wide ID, so no project is needed. + todolistIDStr, _, err := extractWithProject(args[0]) if err != nil { return err } - // Resolve project - use URL > flag > config, with interactive fallback - projectID := *project - if projectID == "" && urlProjectID != "" { - projectID = urlProjectID - } - if projectID == "" { - projectID = app.Flags.Project - } - if projectID == "" { - projectID = app.Config.ProjectID - } - if projectID == "" { - if err := ensureProject(cmd, app); err != nil { - return err - } - } - // Parse todolist ID as int64 todolistID, err := strconv.ParseInt(todolistIDStr, 10, 64) if err != nil { @@ -238,6 +223,9 @@ You can pass either a todolist ID or a Basecamp URL: if err != nil { return convertSDKError(err) } + // A moved list's old id redirects to the list at its new id, and + // only the new id takes its to-dos and comments. + todolistIDStr = strconv.FormatInt(todolist.ID, 10) enrichment := fetchCommentsForRecording(cmd.Context(), app, todolistIDStr, cf) data, commentOpts := enrichment.apply(todolist, "") @@ -491,12 +479,25 @@ You can pass either a todolist ID or a Basecamp URL: func newTodolistsPositionCmd(project *string) *cobra.Command { var position int + var dest todolistDestination cmd := &cobra.Command{ Use: "position <id|url>...", Aliases: []string{"move", "reorder"}, - Short: "Change todolist position", - Long: `Reorder a todolist within its todoset. Position is 1-based (1 = top). + Short: "Move a to-do list or group to another project, or reorder lists", + Long: `Move a to-do list, with its to-dos, to another project as the web's Move… +does, or reorder lists within their To-dos. Both are "todolists move". + +A to-do list goes into a project's To-dos. It gets a new ID there; the command +waits for Basecamp and prints the moved list: + basecamp todolists move 789 --to-project "Marketing" + basecamp todolists move 789 --to-project "Marketing" --to-todoset 1069478882 + +A group (a list inside a list) goes into a to-do list and stays a group there: + basecamp todolists move 456 --to-project "Marketing" --to-list "Launch" + basecamp todolists move 456 --to-list https://3.basecamp.com/1/buckets/2/todolists/321 + +Reorder within its To-dos. Position is 1-based (1 = top). basecamp todolists position 789 --to 1 basecamp todolists position https://3.basecamp.com/1/buckets/2/todolists/789 --to 1 @@ -510,10 +511,23 @@ and be incomplete (completed lists are positioned separately by Basecamp): Positioning is relative and cascades: sibling lists shift to make room, and the server translates the position for loose to-dos and hidden completed lists. Confirm with ` + "`basecamp todolists list`" + `.`, + Annotations: map[string]string{ + "agent_notes": "--to-project/--to-todoset/--to-list move one list or group to another project (the web's Move…); --to/--position reorder lists within their To-dos and cannot be combined with them. " + + todolistFilingNotes + " The old id stops accepting changes, and reading it follows the move.", + }, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return missingArg(cmd, "<id|url>...") } + if destinationFlagChanged(cmd) { + if err := rejectRepositionWithFiling(cmd, "--to, --position"); err != nil { + return err + } + if len(args) > 1 { + return output.ErrUsage("Move one to-do list or group at a time to another project") + } + return fileTodolist(cmd, basecamp.FilingMove, args[0], *project, dest, false) + } app := appctx.FromContext(cmd.Context()) if app == nil { @@ -725,6 +739,500 @@ Confirm with ` + "`basecamp todolists list`" + `.`, cmd.Flags().IntVar(&position, "to", 0, "Target position, 1-based (1 = top)") cmd.Flags().IntVar(&position, "position", 0, "Target position (alias for --to)") + dest.addFlags(cmd, "Move") - return cmd + return guardFilingFlags(cmd) +} + +// rejectRepositionWithFiling refuses the reorder flags next to the flags that +// move a list or group to another project. +func rejectRepositionWithFiling(cmd *cobra.Command, reorderFlags string) error { + for _, name := range []string{"to", "position", "pos"} { + if f := cmd.Flags().Lookup(name); f != nil && f.Changed { + return output.ErrUsage(fmt.Sprintf("--to-project, --to-todoset and --to-list cannot be combined with %s", reorderFlags)) + } + } + return nil +} + +// addToListFlags adds --to-project and --to-list, which name the to-do list a +// to-do or a group is moved or copied into. +func addToListFlags(cmd *cobra.Command, toProject, toList *string, verb string) { + cmd.Flags().StringVar(toProject, "to-project", "", verb+" into this project (ID or name); add --to-list unless it has one to-do list") + cmd.Flags().StringVar(toList, "to-list", "", verb+" into this to-do list (ID, URL, or a name with --to-project)") +} + +// validateToListFlags checks --to-project and --to-list before any request. +func validateToListFlags(toProject, toList string) error { + if toProject == "" && toList == "" { + return output.ErrUsage("--to-project or --to-list needs a value") + } + if toProject == "" && !isNumericID(toList) && !urlarg.IsURL(toList) { + return output.ErrUsageHint( + fmt.Sprintf("To-do list %q is a name, which needs a project to look it up in", toList), + "Add --to-project <project>, or pass the list's ID or URL", + ) + } + return nil +} + +// resolveFilingTodolist finds the to-do list that --to-project and --to-list +// name for a to-do or a group: a list ID or URL on its own, a project's only +// list, or one of its lists picked by ID or name. Only lists in the to-dos +// tools on the project's dock count, as in the web's dialogs. A URL is held to +// parseFilingRef's host and account checks, and a list's project must agree +// with --to-project. +func resolveFilingTodolist(cmd *cobra.Command, app *appctx.App, toProject, toList string) (*basecamp.Todolist, error) { + var urlProjectID string + if urlarg.IsURL(toList) { + ref, err := parseFilingRef(app, toList, "to-do list", "todolists", "…/todolists/<id>") + if err != nil { + return nil, err + } + toList, urlProjectID = strconv.FormatInt(ref.id, 10), ref.project + } + + var projectID, projectName string + if toProject != "" { + var err error + if projectID, projectName, err = app.Names.ResolveProject(cmd.Context(), toProject); err != nil { + return nil, err + } + if urlProjectID != "" && !sameNumericID(urlProjectID, projectID) { + return nil, output.ErrUsageHint( + fmt.Sprintf("The --to-list URL is in project %s, but --to-project points at project %s", urlProjectID, projectID), + "Drop --to-project, since the list URL names its own project", + ) + } + } + + if isNumericID(toList) { + id, err := strconv.ParseInt(toList, 10, 64) + if err != nil || id <= 0 { + return nil, output.ErrUsage("--to-list must be a positive to-do list ID") + } + list, err := app.Account().Todolists().Get(cmd.Context(), id) + if err != nil { + return nil, notATodolistError(cmd, app, id, err) + } + listProject := strconv.FormatInt(todolistProjectID(list), 10) + if urlProjectID != "" && !sameNumericID(urlProjectID, listProject) { + return nil, output.ErrUsage(fmt.Sprintf("The --to-list URL names project %s, but to-do list %d is in project %s", urlProjectID, list.ID, listProject)) + } + if projectID != "" && !sameNumericID(projectID, listProject) { + return nil, output.ErrUsageHint( + fmt.Sprintf("To-do list %d is in project %s, not in --to-project %s", list.ID, listProject, projectID), + "Drop --to-project, since a list ID names its own project", + ) + } + return list, requireFilingDestinationList(list) + } + + lists, todosets, allTodosets, err := projectTodolists(cmd, app, projectID) + if err != nil { + return nil, err + } + if len(todosets) == 0 { + return nil, noFilingContainerError(todosetContainer, allTodosets, projectID, projectLabel(cmd, app, projectID, projectName)) + } + if toList == "" { + switch len(lists) { + case 0: + return nil, &output.Error{ + Code: output.CodeNotFound, + Message: fmt.Sprintf("Project %s has no to-do list", projectLabel(cmd, app, projectID, projectName)), + Hint: fmt.Sprintf("Add one first with: basecamp todolists create <name> --in %s", projectID), + } + case 1: + return &lists[0], nil + default: + return nil, ambiguousTodolistsError(lists) + } + } + var named []basecamp.Todolist + for _, list := range lists { + if strings.EqualFold(list.Name, toList) { + named = append(named, list) + } + } + switch len(named) { + case 1: + return &named[0], nil + case 0: + return nil, output.ErrUsageHint( + fmt.Sprintf("To-do list '%s' not found in project %s", toList, projectLabel(cmd, app, projectID, projectName)), + "Available lists: "+formatTodolists(lists), + ) + default: + return nil, ambiguousTodolistsError(named) + } +} + +// notATodolistError explains a --to-list that names a project's To-dos, which +// Basecamp does not file into, rather than reporting it as not found. +func notATodolistError(cmd *cobra.Command, app *appctx.App, id int64, err error) error { + if basecamp.AsError(err).Code != basecamp.CodeNotFound { + return convertSDKError(err) + } + todoset, todosetErr := app.Account().Todosets().Get(cmd.Context(), id) + if todosetErr != nil || todoset.Bucket == nil { + return convertSDKError(err) + } + return output.ErrUsageHint( + fmt.Sprintf("%d is To-dos in project %d, not a to-do list", todoset.ID, todoset.Bucket.ID), + fmt.Sprintf("--to-list names a to-do list. See the lists there with: basecamp todolists list --in %d --todoset %d", todoset.Bucket.ID, todoset.ID), + ) +} + +// requireFilingDestinationList refuses a destination Basecamp refuses: a group +// (to-dos and groups go into a list, never a group) or a list that is not +// active. +func requireFilingDestinationList(list *basecamp.Todolist) error { + if list.Type != "" && list.Type != "Todolist" { + return output.ErrUsage(fmt.Sprintf("%d is a %s, not a to-do list", list.ID, list.Type)) + } + if isTodolistGroup(list) { + return output.ErrUsageHint( + fmt.Sprintf("%d (%s) is a group in to-do list %d (%s); Basecamp files into a list, not a group", list.ID, list.Name, list.Parent.ID, list.Parent.Title), + fmt.Sprintf("Use its list: --to-list %d", list.Parent.ID), + ) + } + if list.Status != "" && list.Status != "active" { + return output.ErrUsage(fmt.Sprintf("To-do list %d (%s) is %s; Basecamp files only into an active list", list.ID, list.Name, list.Status)) + } + return nil +} + +// projectTodolists lists the active to-do lists in the to-dos tools on a +// project's dock. +// projectTodolists returns the lists in a project's enabled To-dos tools, +// along with those tools and every To-dos tool on its dock. +func projectTodolists(cmd *cobra.Command, app *appctx.App, projectID string) (lists []basecamp.Todolist, todosets, all []DockTool, err error) { + todosets, all, err = getDockTools(cmd.Context(), app, projectID, "todoset") + if err != nil { + return nil, nil, nil, err + } + for _, todoset := range todosets { + result, err := app.Account().Todolists().List(cmd.Context(), todoset.ID, nil) + if err != nil { + return nil, nil, nil, convertSDKError(err) + } + lists = append(lists, result.Todolists...) + } + return lists, todosets, all, nil +} + +func ambiguousTodolistsError(lists []basecamp.Todolist) error { + lines := make([]string, 0, len(lists)) + for _, list := range lists { + lines = append(lines, fmt.Sprintf(" %d %s", list.ID, list.Name)) + } + return &output.Error{ + Code: output.CodeAmbiguous, + Message: "Multiple to-do lists found", + Hint: fmt.Sprintf("Specify one with --to-list <id>:\n%s", strings.Join(lines, "\n")), + } +} + +func formatTodolists(lists []basecamp.Todolist) string { + if len(lists) == 0 { + return "none" + } + names := make([]string, len(lists)) + for i, list := range lists { + names[i] = fmt.Sprintf("%d (%s)", list.ID, list.Name) + } + return strings.Join(names, ", ") +} + +// projectLabel names a project for a message: its name when known or +// readable, otherwise its ID. +func projectLabel(cmd *cobra.Command, app *appctx.App, projectID, projectName string) string { + if projectName != "" { + return projectName + } + if id, err := strconv.ParseInt(projectID, 10, 64); err == nil { + if name, err := app.Names.ProjectName(cmd.Context(), id); err == nil && name != "" { + return name + } + } + return projectID +} + +// isTodolistGroup reports whether a to-do list is a group, a list inside a list. +func isTodolistGroup(list *basecamp.Todolist) bool { + return list.Parent != nil && list.Parent.Type == "Todolist" +} + +func todolistProjectID(list *basecamp.Todolist) int64 { + if list.Bucket == nil { + return 0 + } + return list.Bucket.ID +} + +// todolistDestination holds the flags that name where a to-do list or a group +// is moved or copied: a list goes into a project's to-dos tool (a todoset), a +// group into a to-do list. +type todolistDestination struct { + toProject, toTodoset, toList string +} + +func (d *todolistDestination) addFlags(cmd *cobra.Command, verb string) { + cmd.Flags().StringVar(&d.toProject, "to-project", "", verb+" into this project (ID or name): a list into its To-dos, a group into its only to-do list") + cmd.Flags().StringVar(&d.toTodoset, "to-todoset", "", "For a to-do list: the To-dos tool to "+strings.ToLower(verb)+" it into (ID, URL, or a name with --to-project)") + cmd.Flags().StringVar(&d.toList, "to-list", "", "For a group: the to-do list to "+strings.ToLower(verb)+" it into, where it stays a group (ID, URL, or a name with --to-project)") +} + +func destinationFlagChanged(cmd *cobra.Command) bool { + return cmd.Flags().Changed("to-project") || cmd.Flags().Changed("to-todoset") || cmd.Flags().Changed("to-list") +} + +const todolistFilingNotes = "A top-level to-do list goes into a project's To-dos (--to-project, plus --to-todoset when the project has several); " + + "a group (a list inside a list) goes into a to-do list (--to-list, or --to-project when the project has one list) and stays a group there. " + + "--to-todoset for a group, or --to-list for a list, is refused. " + + "The new list or group comes with its to-dos and has a NEW id (data.id; the original's is data.source_id). " + + "Basecamp refuses a group as the destination and an archived list." + +func newTodolistsCopyCmd(project *string) *cobra.Command { + var dest todolistDestination + var includeComments bool + + cmd := &cobra.Command{ + Use: "copy <id|url>", + Short: "Copy a to-do list or group to this or another project", + Long: `Copy a to-do list, with its to-dos, as the web's Copy… does. The original +stays where it is. + +A to-do list goes into a project's To-dos; with no destination, into its own: + basecamp todolists copy 789 + basecamp todolists copy 789 --to-project "Marketing" + basecamp todolists copy 789 --to-project "Marketing" --to-todoset 1069478882 + +A group (a list inside a list) goes into a to-do list and stays a group there; +with no destination, into its own list: + basecamp todolistgroups copy 456 --to-project "Marketing" --to-list "Launch" + basecamp todolistgroups copy 456 --to-list 1069479999 --include-comments + +Basecamp makes the copy in the background; the command waits for it and shows +the new list or group.`, + Args: cobra.ExactArgs(1), + Annotations: map[string]string{ + "agent_notes": "Without a destination the copy goes next to the original. " + todolistFilingNotes + + " Never re-run a copy that failed with in_progress or a may-have-started hint: each run makes another copy; check an in_progress one with basecamp filings show <filing id>.", + }, + RunE: func(cmd *cobra.Command, args []string) error { + return fileTodolist(cmd, basecamp.FilingCopy, args[0], *project, dest, includeComments) + }, + } + + dest.addFlags(cmd, "Copy") + cmd.Flags().BoolVar(&includeComments, "include-comments", false, "Copy the comments too") + + return guardFilingFlags(cmd) +} + +// fileTodolist moves or copies a to-do list or a group, as the web's Move… and +// Copy… do. Which it is decides where it may go: a list into a todoset, a +// group into a list. With no destination, a copy goes next to the original. +func fileTodolist(cmd *cobra.Command, kind basecamp.FilingType, arg, flagProject string, dest todolistDestination, includeComments bool) error { + destinationSet := destinationFlagChanged(cmd) + if destinationSet { + if err := validateTodolistDestination(dest); err != nil { + return err + } + } + + app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + ref, err := parseFilingRef(app, arg, "to-do list", "todolists", "…/todolists/<id>") + if err != nil { + return err + } + source, err := app.Account().Todolists().Get(cmd.Context(), ref.id) + if err != nil { + return convertSDKError(err) + } + if source.Bucket == nil || source.Parent == nil { + return output.ErrNotFound("to-do list", strconv.FormatInt(ref.id, 10)) + } + if source.Type != "" && source.Type != "Todolist" { + return output.ErrUsage(fmt.Sprintf("%d is a %s, not a to-do list", ref.id, source.Type)) + } + if err := rejectProjectConflict(cmd, app, "to-do list", flagProject, ref.project, source.Bucket.ID); err != nil { + return err + } + + var f recordingFiling + if isTodolistGroup(source) { + f, err = groupFiling(cmd, app, kind, source, dest, destinationSet) + } else { + f, err = listFiling(cmd, app, kind, source, dest, destinationSet) + } + if err != nil { + return err + } + f.includeComments = includeComments + return runFiling(cmd, app, scope, f) +} + +// validateTodolistDestination checks the destination flags before any request. +func validateTodolistDestination(dest todolistDestination) error { + if dest.toTodoset != "" && dest.toList != "" { + return output.ErrUsageHint( + "--to-todoset and --to-list cannot be combined", + "A to-do list goes into a To-dos tool (--to-todoset); a group goes into a to-do list (--to-list)", + ) + } + if dest.toProject == "" && dest.toTodoset == "" && dest.toList == "" { + return output.ErrUsage("--to-project, --to-todoset or --to-list needs a value") + } + for flag, value := range map[string]string{"--to-todoset": dest.toTodoset, "--to-list": dest.toList} { + if value != "" && dest.toProject == "" && !isNumericID(value) && !urlarg.IsURL(value) { + return output.ErrUsageHint( + fmt.Sprintf("%s %q is a name, which needs a project to look it up in", flag, value), + "Add --to-project <project>, or pass an ID or URL", + ) + } + } + return nil +} + +// listFiling files a top-level to-do list into a todoset. +func listFiling(cmd *cobra.Command, app *appctx.App, kind basecamp.FilingType, list *basecamp.Todolist, dest todolistDestination, destinationSet bool) (recordingFiling, error) { + if dest.toList != "" { + return recordingFiling{}, output.ErrUsageHint( + fmt.Sprintf("%d (%s) is a to-do list, which goes into a project's To-dos, not into a list", list.ID, list.Name), + "Use --to-project <project>, and --to-todoset <id> when it has several To-dos", + ) + } + todoset := &basecamp.Todoset{ID: list.Parent.ID, Title: list.Parent.Title, Bucket: list.Bucket} + if destinationSet { + var err error + if todoset, err = resolveFilingTodoset(cmd, app, dest.toProject, dest.toTodoset); err != nil { + return recordingFiling{}, err + } + } + if kind == basecamp.FilingMove && todoset.ID == list.Parent.ID { + return recordingFiling{}, output.ErrUsageHint( + fmt.Sprintf("To-do list %d is already in To-dos %d", list.ID, todoset.ID), + fmt.Sprintf("Reorder it there with: basecamp todolists move %d --to <position>", list.ID), + ) + } + projectID := todosetProjectID(todoset) + return recordingFiling{ + kind: kind, + sourceID: list.ID, + noun: "to-do list", + destinationID: todoset.ID, + destination: fmt.Sprintf("To-dos %d in project %d", todoset.ID, projectID), + showCmd: "basecamp todolists show", + lookFor: copyLookFor(filingSource{id: list.ID, typ: "Todolist", status: list.Status, title: list.Name}, filingTarget{id: todoset.ID, title: todoset.Title, appURL: todoset.AppURL, projectID: projectID}), + read: readFiledTodolist(app, list.ID), + }, nil +} + +// groupFiling files a group into a to-do list, where it stays a group. +func groupFiling(cmd *cobra.Command, app *appctx.App, kind basecamp.FilingType, group *basecamp.Todolist, dest todolistDestination, destinationSet bool) (recordingFiling, error) { + if dest.toTodoset != "" { + return recordingFiling{}, output.ErrUsageHint( + fmt.Sprintf("%d (%s) is a group, which goes into a to-do list, not into To-dos", group.ID, group.Name), + "Use --to-list <list> (with --to-project <project> for a list name)", + ) + } + into := &basecamp.Todolist{ID: group.Parent.ID, Name: group.Parent.Title, Bucket: group.Bucket} + if destinationSet { + var err error + if into, err = resolveFilingTodolist(cmd, app, dest.toProject, dest.toList); err != nil { + return recordingFiling{}, err + } + } + if kind == basecamp.FilingMove && into.ID == group.Parent.ID { + return recordingFiling{}, output.ErrUsageHint( + fmt.Sprintf("Group %d is already in to-do list %d (%s)", group.ID, into.ID, into.Name), + fmt.Sprintf("Reorder it there with: basecamp todolistgroups move %d --position <position>", group.ID), + ) + } + return recordingFiling{ + kind: kind, + sourceID: group.ID, + noun: "to-do group", + destinationID: into.ID, + destination: fmt.Sprintf("to-do list %d (%s)", into.ID, into.Name), + showCmd: "basecamp todolists show", + lookFor: copyLookFor(filingSource{id: group.ID, typ: "Todolist", status: group.Status, title: group.Name}, filingTarget{id: into.ID, title: into.Name, appURL: into.AppURL, projectID: todolistProjectID(into)}), + read: readFiledTodolist(app, group.ID), + }, nil +} + +// filedTodolistResult is a to-do list or group that a move or copy made, with +// the id of the one it came from. +type filedTodolistResult struct { + *basecamp.Todolist + SourceID int64 `json:"source_id"` +} + +func readFiledTodolist(app *appctx.App, sourceID int64) func(context.Context, int64) (filedRecording, error) { + return func(ctx context.Context, id int64) (filedRecording, error) { + list, err := app.Account().Todolists().Get(ctx, id) + if err != nil { + return filedRecording{}, err + } + var in string + if list.Bucket != nil { + in = fmt.Sprintf(" --in %d", list.Bucket.ID) + } + return filedRecording{ + result: filedTodolistResult{Todolist: list, SourceID: sourceID}, + id: list.ID, + bucket: list.Bucket, + parent: list.Parent, + breadcrumbs: []output.Breadcrumb{ + {Action: "show", Cmd: fmt.Sprintf("basecamp todolists show %d%s", list.ID, in), Description: "View it"}, + {Action: "todos", Cmd: fmt.Sprintf("basecamp todos list --list %d%s", list.ID, in), Description: "List its to-dos"}, + }, + }, nil + } +} + +// todosetContainer is a project's To-dos tool, where a to-do list is filed. +var todosetContainer = filingContainer{ + dockName: "todoset", + noun: "To-dos", + plural: "To-dos tools", + flag: "--to-todoset", + urlKind: "todosets", + urlShape: "…/todosets/<id>", + get: func(ctx context.Context, app *appctx.App, id int64) (filingTarget, error) { + todoset, err := app.Account().Todosets().Get(ctx, id) + if err != nil { + return filingTarget{}, err + } + target := filingTarget{id: todoset.ID, title: todoset.Title} + if todoset.Bucket != nil { + target.projectID, target.projectName = todoset.Bucket.ID, todoset.Bucket.Name + } + return target, nil + }, +} + +// resolveFilingTodoset finds the To-dos tool that --to-project and +// --to-todoset name for a to-do list. +func resolveFilingTodoset(cmd *cobra.Command, app *appctx.App, toProject, toTodoset string) (*basecamp.Todoset, error) { + target, err := resolveFilingTarget(cmd, app, todosetContainer, toProject, toTodoset) + if err != nil { + return nil, err + } + return &basecamp.Todoset{ID: target.id, Title: target.title, AppURL: target.appURL, Bucket: &basecamp.Bucket{ID: target.projectID, Name: target.projectName}}, nil +} + +func todosetProjectID(todoset *basecamp.Todoset) int64 { + if todoset.Bucket == nil { + return 0 + } + return todoset.Bucket.ID } diff --git a/internal/commands/todolists_filing_test.go b/internal/commands/todolists_filing_test.go new file mode 100644 index 000000000..337042d57 --- /dev/null +++ b/internal/commands/todolists_filing_test.go @@ -0,0 +1,179 @@ +package commands + +import ( + "encoding/json" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" +) + +func TestTodolistsMoveToAProjectWithOneTodosetFilesIntoIt(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodolistsCmd(), "move", "310", "--to-project", "Dest") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/recordings/310/moves"}, transport.posts) + assert.Equal(t, float64(900), transport.bodies[0]["filing_destination_id"]) + var list map[string]any + require.NoError(t, json.Unmarshal(run.data, &list)) + assert.Equal(t, float64(3101), list["id"]) + assert.Equal(t, float64(310), list["source_id"]) + assert.Equal(t, "Moved to-do list #310 to Dest › To-dos as to-do list #3101", run.summary) + assert.Contains(t, run.output, "basecamp todolists show 3101 --in 999") +} + +func TestTodolistsMoveToAProjectWithSeveralTodosetsIsAmbiguous(t *testing.T) { + transport := &mockTodoFilingTransport{twoTodosets: true} + run := runTodoFilingCommand(t, transport, NewTodolistsCmd(), "move", "310", "--to-project", "Dest") + e := requireUsageRefusal(t, run, transport) + + assert.Equal(t, output.CodeAmbiguous, e.Code) + assert.Equal(t, "Dest has 2 To-dos tools that match", e.Message) + assert.Contains(t, e.Hint, "--to-todoset") + assert.Contains(t, e.Hint, "900 To-dos") + assert.Contains(t, e.Hint, "950 To-dos") + assert.NotContains(t, e.Hint, "960", "a To-dos turned off is not a destination") +} + +func TestTodolistsMoveToAPickedTodoset(t *testing.T) { + transport := &mockTodoFilingTransport{twoTodosets: true} + run := runTodoFilingCommand(t, transport, NewTodolistsCmd(), "move", "310", "--to-project", "Dest", "--to-todoset", "950") + require.NoError(t, run.err) + + assert.Equal(t, float64(950), transport.bodies[0]["filing_destination_id"]) +} + +func TestTodolistsMoveToAProjectWithoutTodosSaysHowToTurnItOn(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodolistsCmd(), "move", "310", "--to-project", "Bare") + e := requireUsageRefusal(t, run, transport) + + assert.Equal(t, output.CodeNotFound, e.Code) + assert.Contains(t, e.Message, "Bare") + assert.Contains(t, e.Hint, "basecamp tools enable 880 --in 888") +} + +func TestTodolistsMoveAGroupFilesItIntoAList(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodolistsCmd(), "move", "311", "--to-project", "Dest", "--to-list", "Launch") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/recordings/311/moves"}, transport.posts) + assert.Equal(t, float64(901), transport.bodies[0]["filing_destination_id"]) + assert.Equal(t, "Moved to-do group #311 to Dest › Launch as to-do group #3111", run.summary) +} + +func TestTodolistsMoveRefusesTheWrongKindOfDestination(t *testing.T) { + for name, tc := range map[string]struct { + args []string + hint string + }{ + "a list into a list": {[]string{"move", "310", "--to-list", "901"}, "--to-project <project>"}, + "a group into To-dos": {[]string{"move", "311", "--to-todoset", "900"}, "--to-list <list>"}, + "both kinds named": {[]string{"move", "310", "--to-todoset", "900", "--to-list", "901"}, "a group goes into a to-do list"}, + "a reorder flag mixed": {[]string{"move", "310", "--to-project", "Dest", "--to", "1"}, ""}, + } { + t.Run(name, func(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodolistsCmd(), tc.args...) + e := requireUsageRefusal(t, run, transport) + + assert.Equal(t, output.CodeUsage, e.Code) + assert.Contains(t, e.Hint, tc.hint) + }) + } +} + +func TestTodolistsMoveRefusesWhereItAlreadyIs(t *testing.T) { + for name, tc := range map[string]struct { + group *cobra.Command + args []string + hint string + }{ + "a list in its To-dos": {NewTodolistsCmd(), []string{"move", "310", "--to-todoset", "300"}, "basecamp todolists move 310 --to <position>"}, + "a group in its list": {NewTodolistgroupsCmd(), []string{"move", "311", "--to-list", "310"}, "basecamp todolistgroups move 311 --position <position>"}, + } { + t.Run(name, func(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, tc.group, tc.args...) + e := requireUsageRefusal(t, run, transport) + + assert.Contains(t, e.Message, "is already in") + assert.Equal(t, "Reorder it there with: "+tc.hint, e.Hint) + }) + } +} + +func TestTodolistsRepositionStillReordersWithoutFilingFlags(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodolistsCmd(), "move", "310", "--to", "2") + require.NoError(t, run.err) + + assert.Empty(t, transport.posts) + assert.Contains(t, transport.requests, "PUT /todosets/todolists/310/position") + assert.Equal(t, "Moved todolist #310 to position 2", run.summary) +} + +func TestEveryTodolistGroupAliasMovesAndCopiesTheSameWay(t *testing.T) { + for _, group := range []string{"todolists", "todolist", "todolistgroups", "todolistgroup", "tlgroups", "tlgroup"} { + for verb, path := range map[string]string{"move": "/recordings/311/moves", "copy": "/recordings/311/copies"} { + t.Run(group+" "+verb, func(t *testing.T) { + root := &cobra.Command{Use: "basecamp"} + root.AddCommand(NewTodolistsCmd(), NewTodolistgroupsCmd()) + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, root, group, verb, "311", "--to-list", "901") + require.NoError(t, run.err) + + assert.Equal(t, []string{path}, transport.posts) + assert.Equal(t, float64(901), transport.bodies[0]["filing_destination_id"]) + }) + } + } +} + +func TestTodolistsCopyWithoutDestinationGoesNextToTheOriginal(t *testing.T) { + for name, tc := range map[string]struct { + id string + into float64 + }{ + "a list": {"310", 300}, + "a group": {"311", 310}, + } { + t.Run(name, func(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodolistsCmd(), "copy", tc.id, "--include-comments") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/recordings/" + tc.id + "/copies"}, transport.posts) + assert.Equal(t, tc.into, transport.bodies[0]["filing_destination_id"]) + assert.Equal(t, true, transport.bodies[0]["include_comments"]) + }) + } +} + +func TestTodolistsCopyToAnotherProjectIsNotRetried(t *testing.T) { + transport := &mockTodoFilingTransport{postStatus: 502} + run := runTodoFilingCommand(t, transport, NewTodolistsCmd(), "copy", "310", "--to-project", "Dest") + e := output.AsError(run.err) + require.NotNil(t, run.err) + + assert.Len(t, transport.posts, 1) + assert.False(t, e.Retryable) + assert.Contains(t, e.Hint, "basecamp recordings list Todolist --in 999 --status active --all") +} + +// A filing's recovery points at `todolists show <id>`, which reads the list by +// its account-wide ID, so it must not need a project to be configured. +func TestTodolistsShowNeedsNoProject(t *testing.T) { + transport := &mockTodoFilingTransport{} + app, buf := newTestAppWithTransport(t, transport) + app.Config.ProjectID = "" + app.Flags.Project = "" + + require.NoError(t, executeCommand(NewTodolistsCmd(), app, "show", "901")) + assert.Contains(t, buf.String(), `901`) +} diff --git a/internal/commands/todos.go b/internal/commands/todos.go index f49a8fb78..3bbfb4a35 100644 --- a/internal/commands/todos.go +++ b/internal/commands/todos.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "slices" "sort" "strconv" "strings" @@ -60,6 +61,7 @@ func NewTodosCmd() *cobra.Command { newTodosUncompleteCmd(), newTodosSweepCmd(), newTodosPositionCmd(), + newTodosCopyCmd(), newRecordableTrashCmd("todo"), newRecordableArchiveCmd("todo"), newRecordableRestoreCmd("todo"), @@ -1162,6 +1164,12 @@ You can pass either a todo ID or a Basecamp URL: if err != nil { return convertSDKError(err) } + // A moved to-do's old id redirects to the to-do at its new id, and the + // old id 404s for everything after this read. + if todo.ID != 0 && todo.ID != todoID { + todoID = todo.ID + todoIDStr = strconv.FormatInt(todoID, 10) + } if todo.Steps, err = everySubtask(cmd.Context(), app, todoID, todo.Steps, todo.SubtasksCount); err != nil { return err } @@ -2287,28 +2295,57 @@ func reopenTodos(cmd *cobra.Command, todoIDs []string) error { func newTodosPositionCmd() *cobra.Command { var ( - position int - list string + position int + list string + toProject string + toList string ) cmd := &cobra.Command{ - Use: "position <id|url>", + Use: "position <id|url>...", Aliases: []string{"move", "reorder"}, - Short: "Change todo position or move between lists", - Long: `Reorder a todo within its todolist, or move it to a different list in the -same project. Position is 1-based (1 = top). - -You can pass either a todo ID or a Basecamp URL: - basecamp todos position 789 --to 1 - basecamp todos position https://3.basecamp.com/123/buckets/456/todos/789 --to 1 - -Move to a different todolist in the same project: - basecamp todos position 789 --to 1 --list "Sprint 1" --in myproject - basecamp todos position 789 --to 1 --list 321 - basecamp todos position <todo-url> --to 1 --list <todolist-url>`, + Short: "Move to-dos to a list in this or another project, or reorder one", + Long: `Move to-dos into a to-do list in any project, as the web's Move… does, or +reorder a to-do within its project. Both are "todos move". + +Move to another project with --to-project and --to-list. Each to-do gets a +new ID there; the command waits for Basecamp and prints the moved to-dos: + basecamp todos move 789 --to-project "Marketing" --to-list "Launch" + basecamp todos move 789 790 791 --to-project "Marketing" --to-list "Launch" + basecamp todos move 789 --to-list https://3.basecamp.com/123/buckets/456/todolists/321 + +Reorder within its list, or move to another list in the same project and keep +its ID, with --to (1 = top) and --list: + basecamp todos move 789 --to 1 + basecamp todos move 789 --to 1 --list "Sprint 1" --in myproject + basecamp todos move <todo-url> --to 1 --list <todolist-url>`, + Annotations: map[string]string{ + "agent_notes": "--to-project/--to-list file the to-dos into a to-do list in any project (the web's Move…): --to-list takes a list ID, URL, or a name looked up in --to-project; --to-project alone needs a project with one to-do list. " + + "Several IDs move one by one; each is reported, and the command fails if any failed, naming which moved. " + + "A filed to-do gets a NEW id (data.id; the old one is data.source_id); the old id stops accepting changes, and reading it follows the move. " + + "Basecamp refuses a group as the destination (use its list), an archived list, and the list the to-do is already in. " + + "--to/--list reorder one to-do or move it to another list in its own project, keeping its id; a --list in another project fails with the --to-project command to run. " + + "--to-project/--to-list cannot be combined with --to, --position or --list.", + }, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { - return missingArg(cmd, "<id|url>") + return missingArg(cmd, "<id|url>...") + } + + if cmd.Flags().Changed("to-project") || cmd.Flags().Changed("to-list") { + if cmd.Flags().Changed("to") || cmd.Flags().Changed("position") || list != "" { + return output.ErrUsageHint( + "--to-project and --to-list cannot be combined with --to, --position or --list", + "--list and --to reorder within the to-do's own project; for the destination list with --to-project, use --to-list", + ) + } + return fileTodos(cmd, basecamp.FilingMove, args, toProject, toList, false) + } + if len(args) > 1 { + return output.ErrUsageHint( + "--to reorders one to-do at a time", + "To move several to-dos into a list, use --to-list <list> (with --to-project <project> for a list name)", + ) } app := appctx.FromContext(cmd.Context()) @@ -2374,15 +2411,11 @@ Move to a different todolist in the same project: resolvedProject = rp } - // Cross-project moves are not supported by the reposition endpoint. - // Only enforce when the todo's project comes from its URL (high - // confidence). Config/flag project is a default context — it may - // not match where a bare-ID todo actually lives. + // The reposition only moves a to-do between lists of its own + // project. Both URLs name their projects, so a mismatch is + // refused here; requireListInTodosProject reads both otherwise. if todoProjectID != "" && listProjectID != "" && resolvedProject != listProjectID { - return output.ErrUsageHint( - "Cannot move a todo to a list in a different project.", - "Pass a todolist from the same project; cross-project moves are not supported.", - ) + return foreignListError(todoID, todoProjectID, listIDStr, listProjectID) } // Resolve todolist name to ID when not already numeric @@ -2401,6 +2434,9 @@ Move to a different todolist in the same project: if parseErr != nil { return output.ErrUsage("Invalid todolist ID") } + if err := requireListInTodosProject(cmd, app, todoID, listID); err != nil { + return err + } parentID = &listID } @@ -2434,7 +2470,261 @@ Move to a different todolist in the same project: cmd.Flags().IntVar(&position, "to", 0, "Target position, 1-based (1 = top)") cmd.Flags().IntVar(&position, "position", 0, "Target position (alias for --to)") - cmd.Flags().StringVarP(&list, "list", "l", "", "Destination todolist ID, name, or URL (move to a different list)") + cmd.Flags().StringVarP(&list, "list", "l", "", "With --to: a list in the to-do's own project to move it into, keeping its id (ID, name, or URL); for another project use --to-list") + addToListFlags(cmd, &toProject, &toList, "Move the to-dos") - return cmd + return guardFilingFlags(cmd) +} + +func newTodosCopyCmd() *cobra.Command { + var toProject, toList string + var includeComments bool + + cmd := &cobra.Command{ + Use: "copy <id|url>...", + Short: "Copy to-dos to a list in this or another project", + Long: `Copy to-dos into a to-do list, as the web's Copy… does. The originals stay +where they are. + +With no destination, each copy goes into the list its to-do is in: + basecamp todos copy 789 + basecamp todos copy 789 --to-project "Marketing" --to-list "Launch" + basecamp todos copy 789 790 791 --to-list 1069479999 --include-comments + basecamp todos copy 789 --to-list https://3.basecamp.com/123/buckets/456/todolists/321 + +Basecamp makes each copy in the background; the command waits for it and +shows the new to-dos.`, + Annotations: map[string]string{ + "agent_notes": "Without --to-project/--to-list each copy goes into the list its to-do is in. " + + "--to-list takes a list ID, URL, or a name looked up in --to-project; --to-project alone needs a project with one to-do list. " + + "Several IDs copy one by one; each is reported, and the command fails if any failed, naming which were copied. " + + "Output is the new to-do; its id is data.id and the original's is data.source_id. " + + "Basecamp refuses a group as the destination (use its list) and an archived list. " + + "Never re-run a copy that failed with in_progress or a may-have-started hint: each run makes another copy; check an in_progress one with basecamp filings show <filing id>.", + }, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return missingArg(cmd, "<id|url>...") + } + return fileTodos(cmd, basecamp.FilingCopy, args, toProject, toList, includeComments) + }, + } + + addToListFlags(cmd, &toProject, &toList, "Copy the to-dos") + cmd.Flags().BoolVar(&includeComments, "include-comments", false, "Copy the to-dos' comments too") + + return guardFilingFlags(cmd) +} + +// fileTodos moves or copies to-dos into a to-do list, one filing each, as the +// web's Move… and Copy… do. Every to-do is read before any is filed, so a bad +// ID, a to-do named twice or a to-do already in the destination stops the +// command before anything moves. A moved to-do's old id reads as its new one, +// so a to-do is named twice when two arguments read as the same to-do. With no +// destination, a copy goes into its to-do's own list. +func fileTodos(cmd *cobra.Command, kind basecamp.FilingType, args []string, toProject, toList string, includeComments bool) error { + destinationSet := cmd.Flags().Changed("to-project") || cmd.Flags().Changed("to-list") + if destinationSet { + if err := validateToListFlags(toProject, toList); err != nil { + return err + } + } + + app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + var ( + refs []filesMoveRef + tokens []string + ) + for _, arg := range args { + for token := range strings.SplitSeq(arg, ",") { + if token = strings.TrimSpace(token); token == "" { + continue + } + ref, err := parseFilingRef(app, token, "to-do", "todos", "…/todos/<id>") + if err != nil { + return err + } + refs = append(refs, ref) + tokens = append(tokens, token) + } + } + if len(refs) == 0 { + return missingArg(cmd, "<id|url>...") + } + + var destination *basecamp.Todolist + if destinationSet { + var err error + if destination, err = resolveFilingTodolist(cmd, app, toProject, toList); err != nil { + return err + } + } + + filings := make([]recordingFiling, 0, len(refs)) + named := filingNames{} + for i, ref := range refs { + todo, err := app.Account().Todos().Get(cmd.Context(), ref.id) + if err != nil { + return convertSDKError(err) + } + if err := requireFilableTodo(todo, ref.id); err != nil { + return err + } + if err := named.add("to-do", tokens[i], todo.ID); err != nil { + return err + } + if err := rejectProjectConflict(cmd, app, "to-do", "", ref.project, todo.Bucket.ID); err != nil { + return err + } + home, lists, err := todoLists(cmd, app, todo) + if err != nil { + return err + } + into := destination + if into == nil { + into = home + } + if into == nil { + return output.ErrUsageHint( + fmt.Sprintf("To-do #%d is loose in its project's To-dos, not in a list, so a copy needs a destination list", todo.ID), + fmt.Sprintf("Name the list (an ID or name) to copy it into: basecamp todos copy %d --to-project %d --to-list <list>", todo.ID, todo.Bucket.ID), + ) + } + if kind == basecamp.FilingMove && slices.Contains(lists, into.ID) { + return output.ErrUsageHint( + fmt.Sprintf("To-do #%d is already in to-do list %d (%s)", todo.ID, into.ID, into.Name), + fmt.Sprintf("Reorder it there with: basecamp todos move %d --to <position>", todo.ID), + ) + } + filings = append(filings, todoFiling(app, kind, todo, into, includeComments)) + } + return runFilings(cmd, app, scope, filings) +} + +// requireFilableTodo refuses what the to-do read returned when it is not a +// to-do, as a moved recording's old id can redirect to another kind. +func requireFilableTodo(todo *basecamp.Todo, requestedID int64) error { + if todo.Bucket == nil || todo.Parent == nil { + return output.ErrNotFound("to-do", strconv.FormatInt(requestedID, 10)) + } + if todo.Type != "" && todo.Type != "Todo" { + return output.ErrUsage(fmt.Sprintf("%d is a %s, not a to-do", requestedID, todo.Type)) + } + return nil +} + +// todoLists reads the list a to-do is in. home is the top-level list, which a +// copy with no destination goes into, since Basecamp files to-dos into lists, +// never groups. lists holds the to-do's group, if any, and its list: Basecamp +// refuses to move a to-do into either. A loose to-do sits on its To-dos, in +// no list, so it has neither and may be filed into any list. +func todoLists(cmd *cobra.Command, app *appctx.App, todo *basecamp.Todo) (home *basecamp.Todolist, lists []int64, err error) { + if todo.Parent.Type == "Todoset" { + return nil, nil, nil + } + parent, err := app.Account().Todolists().Get(cmd.Context(), todo.Parent.ID) + if err != nil { + return nil, nil, convertSDKError(err) + } + if isTodolistGroup(parent) { + home, err = app.Account().Todolists().Get(cmd.Context(), parent.Parent.ID) + if err != nil { + return nil, nil, convertSDKError(err) + } + return home, []int64{parent.ID, home.ID}, nil + } + return parent, []int64{parent.ID}, nil +} + +// todoFiling is a Move… or Copy… of a to-do into a to-do list. +func todoFiling(app *appctx.App, kind basecamp.FilingType, todo *basecamp.Todo, list *basecamp.Todolist, includeComments bool) recordingFiling { + return recordingFiling{ + kind: kind, + sourceID: todo.ID, + noun: "to-do", + destinationID: list.ID, + includeComments: includeComments, + destination: fmt.Sprintf("to-do list %d (%s)", list.ID, list.Name), + showCmd: "basecamp todos show", + lookFor: copyLookFor(filingSource{id: todo.ID, typ: "Todo", status: todo.Status, title: todo.Title}, filingTarget{id: list.ID, title: list.Name, appURL: list.AppURL, projectID: todolistProjectID(list)}), + read: func(ctx context.Context, id int64) (filedRecording, error) { + filed, err := app.Account().Todos().Get(ctx, id) + if err != nil { + return filedRecording{}, err + } + return filedTodo(filed, todo.ID), nil + }, + } +} + +// filedTodoResult is a to-do that a move or copy made, with the id of the +// to-do it came from. +type filedTodoResult struct { + *basecamp.Todo + SourceID int64 `json:"source_id"` +} + +func filedTodo(todo *basecamp.Todo, sourceID int64) filedRecording { + breadcrumbs := []output.Breadcrumb{{ + Action: "show", + Cmd: fmt.Sprintf("basecamp todos show %d", todo.ID), + Description: "View the to-do", + }} + if todo.Bucket != nil && todo.Parent != nil { + breadcrumbs = append(breadcrumbs, output.Breadcrumb{ + Action: "list", + Cmd: fmt.Sprintf("basecamp todos list --in %d --list %d", todo.Bucket.ID, todo.Parent.ID), + Description: "List the to-dos in its list", + }) + } + return filedRecording{ + result: filedTodoResult{Todo: todo, SourceID: sourceID}, + id: todo.ID, + bucket: todo.Bucket, + parent: todo.Parent, + breadcrumbs: breadcrumbs, + } +} + +// requireListInTodosProject refuses a --list in another project before the +// reposition, which only moves a to-do within its own project. The reposition +// also takes the project's To-dos, which moves the to-do out of its list. +func requireListInTodosProject(cmd *cobra.Command, app *appctx.App, todoID, listID int64) error { + todo, err := app.Account().Todos().Get(cmd.Context(), todoID) + if err != nil { + return convertSDKError(err) + } + list, err := app.Account().Todolists().Get(cmd.Context(), listID) + if err != nil && basecamp.AsError(err).Code == basecamp.CodeNotFound { + todoset, todosetErr := app.Account().Todosets().Get(cmd.Context(), listID) + if todosetErr != nil { + return convertSDKError(err) + } + if todo.Bucket == nil || todoset.Bucket == nil || todo.Bucket.ID == todoset.Bucket.ID { + return nil + } + return output.ErrUsageHint( + fmt.Sprintf("To-dos %d is in project %d, not in the to-do's project %d", todoset.ID, todoset.Bucket.ID, todo.Bucket.ID), + fmt.Sprintf("--list only moves a to-do within its project, and Basecamp moves one to another project only into a to-do list: basecamp todos move %d --to-project %d --to-list <list>", todo.ID, todoset.Bucket.ID), + ) + } + if err != nil { + return convertSDKError(err) + } + if todo.Bucket == nil || list.Bucket == nil || todo.Bucket.ID == list.Bucket.ID { + return nil + } + return foreignListError(todo.ID, strconv.FormatInt(todo.Bucket.ID, 10), strconv.FormatInt(list.ID, 10), strconv.FormatInt(list.Bucket.ID, 10)) +} + +func foreignListError(todoID int64, todoProjectID, listID, listProjectID string) error { + return output.ErrUsageHint( + fmt.Sprintf("To-do list %s is in project %s, not in the to-do's project %s", listID, listProjectID, todoProjectID), + fmt.Sprintf("--list only moves a to-do within its project. To move it to that list, run: basecamp todos move %d --to-project %s --to-list %s", todoID, listProjectID, listID), + ) } diff --git a/internal/commands/todos_filing_test.go b/internal/commands/todos_filing_test.go new file mode 100644 index 000000000..34a1b0d28 --- /dev/null +++ b/internal/commands/todos_filing_test.go @@ -0,0 +1,744 @@ +package commands + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "sync" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" +) + +// mockTodoFilingTransport serves to-dos, lists and groups in one project and +// to-do lists in others to move or copy them into. +// +// - Project 123 ("Source") has To-dos 300 with list 310 ("Backlog"), which +// holds group 311 ("Ideas"). To-dos 456, 457 and 459 are in list 310, +// and 458 in group 311. To-do 460 is loose, on To-dos 300 in no list. +// To-do 111 was moved and is 457 now, so reading 111 answers 457. +// - Project 999 ("Dest") has To-dos 900 with lists 901 ("Launch") and 902 +// ("Later"), group 920 in list 901, and an archived list 930. With +// twoTodosets it also has To-dos 950 with list 951 ("Roadmap"); with +// duplicateName, list 903 ("launch") in To-dos 900. A disabled To-dos 960 +// is always on its dock. +// - Project 777 ("Solo") has To-dos 700 with one list, 701 ("Only"). +// - Project 888 ("Bare") has only a disabled To-dos, 880. +// - A move or copy POST of recording N answers filing N, pending; its first +// GET completes it as recording N*10+1, read back in the destination it +// was filed into. postStatus answers every POST with that status, +// refuse answers the POST for those recordings with 403 and drop with +// 502, a filing in stuck stays processing, and a read of an unreadable +// new recording answers 404. +type mockTodoFilingTransport struct { + twoTodosets bool + duplicateName bool + postStatus int + refuse map[string]bool + drop map[string]bool + stuck map[string]bool + unreadable map[string]bool + + mu sync.Mutex + requests []string + posts []string + bodies []map[string]any + puts []map[string]any + filedTo map[int64]int64 +} + +func (m *mockTodoFilingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + m.mu.Lock() + defer m.mu.Unlock() + path := strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, "/99999"), ".json") + m.requests = append(m.requests, req.Method+" "+path) + respond := func(status int, body string) (*http.Response, error) { + header := make(http.Header) + header.Set("Content-Type", "application/json") + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: header}, nil + } + parts := strings.Split(strings.Trim(path, "/"), "/") + + if req.Method == http.MethodPost { + m.posts = append(m.posts, path) + body := map[string]any{} + if req.Body != nil { + raw, _ := io.ReadAll(req.Body) + _ = json.Unmarshal(raw, &body) + } + m.bodies = append(m.bodies, body) + if m.postStatus != 0 { + return respond(m.postStatus, `{"error":"upstream"}`) + } + if len(parts) == 3 && parts[0] == "recordings" { + if m.refuse[parts[1]] { + return respond(403, `{"error":"forbidden"}`) + } + if m.drop[parts[1]] { + return respond(502, `{"error":"upstream"}`) + } + source, _ := strconv.ParseInt(parts[1], 10, 64) + if m.filedTo == nil { + m.filedTo = map[int64]int64{} + } + dest, _ := body["filing_destination_id"].(float64) + m.filedTo[source*10+1] = int64(dest) + return respond(201, fmt.Sprintf(`{"id":%d,"status":"pending","type":"Move","source_recording_id":%d}`, source, source)) + } + } + if req.Method == http.MethodPut { + body := map[string]any{} + if req.Body != nil { + raw, _ := io.ReadAll(req.Body) + _ = json.Unmarshal(raw, &body) + } + m.puts = append(m.puts, body) + return respond(204, ``) + } + + if len(parts) == 2 && parts[0] == "filings" { + source, _ := strconv.ParseInt(parts[1], 10, 64) + if m.stuck[parts[1]] { + return respond(200, fmt.Sprintf(`{"id":%d,"status":"processing","type":"Move","source_recording_id":%d}`, source, source)) + } + return respond(200, fmt.Sprintf(`{"id":%d,"status":"completed","type":"Move","source_recording_id":%d,"destination_recording":{"id":%d}}`, source, source, source*10+1)) + } + + dock := func(id int, tools ...string) string { + return fmt.Sprintf(`{"id":%d,"dock":[%s]}`, id, strings.Join(tools, ",")) + } + todoset := func(id int, enabled bool) string { + return fmt.Sprintf(`{"name":"todoset","id":%d,"title":"To-dos","enabled":%t}`, id, enabled) + } + switch path { + case "/projects": + return respond(200, `[{"id":123,"name":"Source"},{"id":999,"name":"Dest"},{"id":777,"name":"Solo"},{"id":888,"name":"Bare"}]`) + case "/projects/123": + return respond(200, dock(123, todoset(300, true))) + case "/projects/999": + tools := []string{todoset(900, true), todoset(960, false)} + if m.twoTodosets { + tools = append(tools, todoset(950, true)) + } + return respond(200, dock(999, tools...)) + case "/projects/777": + return respond(200, dock(777, todoset(700, true))) + case "/projects/888": + return respond(200, dock(888, todoset(880, false))) + case "/todosets/300/todolists": + return respond(200, "["+filingListJSON(310, "Backlog", 123, "Todoset", 300)+"]") + case "/todosets/900/todolists": + lists := []string{filingListJSON(901, "Launch", 999, "Todoset", 900), filingListJSON(902, "Later", 999, "Todoset", 900)} + if m.duplicateName { + lists = append(lists, filingListJSON(903, "launch", 999, "Todoset", 900)) + } + return respond(200, "["+strings.Join(lists, ",")+"]") + case "/todosets/950/todolists": + return respond(200, "["+filingListJSON(951, "Roadmap", 999, "Todoset", 950)+"]") + case "/todosets/700/todolists": + return respond(200, "["+filingListJSON(701, "Only", 777, "Todoset", 700)+"]") + case "/todosets/300", "/todosets/900", "/todosets/950", "/todosets/700": + bucket := map[string]int{"300": 123, "900": 999, "950": 999, "700": 777}[parts[1]] + return respond(200, fmt.Sprintf(`{"id":%s,"title":"To-dos","bucket":{"id":%d,"name":%q}}`, parts[1], bucket, filingProjectNames[bucket])) + case "/todolists/310": + return respond(200, filingListJSON(310, "Backlog", 123, "Todoset", 300)) + case "/todolists/311": + return respond(200, filingListJSON(311, "Ideas", 123, "Todolist", 310)) + case "/todolists/901": + return respond(200, filingListJSON(901, "Launch", 999, "Todoset", 900)) + case "/todolists/920": + return respond(200, filingListJSON(920, "Phase 2", 999, "Todolist", 901)) + case "/todolists/930": + return respond(200, strings.Replace(filingListJSON(930, "Old", 999, "Todoset", 900), `"active"`, `"archived"`, 1)) + case "/todos/456", "/todos/457", "/todos/459": + return respond(200, filingTodoJSON(parts[1], 123, 310, "Backlog")) + case "/todos/111": + return respond(200, filingTodoJSON("457", 123, 310, "Backlog")) + case "/todos/458": + return respond(200, filingTodoJSON("458", 123, 311, "Ideas")) + case "/todos/460": + return respond(200, strings.Replace(filingTodoJSON("460", 123, 300, "To-dos"), `"type":"Todolist"`, `"type":"Todoset"`, 1)) + } + + if len(parts) == 2 && (parts[0] == "todos" || parts[0] == "todolists") && !m.unreadable[parts[1]] { + id, _ := strconv.ParseInt(parts[1], 10, 64) + if into, ok := m.filedTo[id]; ok { + bucket, title := 999, "Launch" + switch into { + case 300: + bucket, title = 123, "To-dos" + case 310: + bucket, title = 123, "Backlog" + case 701: + bucket, title = 777, "Only" + case 900, 950: + title = "To-dos" + } + if parts[0] == "todos" { + return respond(200, filingTodoJSON(parts[1], bucket, into, title)) + } + return respond(200, fmt.Sprintf(`{"id":%d,"name":"Filed","type":"Todolist","status":"active","bucket":{"id":%d,"name":%q},"parent":{"id":%d,"title":%q}}`, id, bucket, filingProjectNames[bucket], into, title)) + } + } + return respond(404, `{"error":"not found"}`) +} + +var filingProjectNames = map[int]string{123: "Source", 999: "Dest", 777: "Solo"} + +func filingListJSON(id int, name string, bucket int, parentType string, parent int) string { + return fmt.Sprintf(`{"id":%d,"name":%q,"title":%q,"type":"Todolist","status":"active","bucket":{"id":%d,"name":%q},"parent":{"id":%d,"title":"Parent","type":%q}}`, + id, name, name, bucket, filingProjectNames[bucket], parent, parentType) +} + +func filingTodoJSON(id string, bucket int, parent int64, parentTitle string) string { + return fmt.Sprintf(`{"id":%s,"title":"Task %s","type":"Todo","bucket":{"id":%d,"name":%q},"parent":{"id":%d,"title":%q,"type":"Todolist"}}`, + id, id, bucket, filingProjectNames[bucket], parent, parentTitle) +} + +type todoFilingRun struct { + err error + output string + summary string + data json.RawMessage + requests []string +} + +func runTodoFilingCommand(t *testing.T, transport *mockTodoFilingTransport, group *cobra.Command, args ...string) todoFilingRun { + t.Helper() + shortFilingWait(t) + app, buf := newTestAppWithTransport(t, transport) + app.Flags.Hints = true + run := todoFilingRun{err: executeCommand(group, app, args...), output: buf.String()} + if run.err == nil { + var envelope struct { + Data json.RawMessage `json:"data"` + Summary string `json:"summary"` + } + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope)) + run.summary, run.data = envelope.Summary, envelope.Data + } + run.requests = transport.requests + return run +} + +func requireUsageRefusal(t *testing.T, run todoFilingRun, transport *mockTodoFilingTransport) *output.Error { + t.Helper() + e := output.AsError(run.err) + require.NotNil(t, run.err) + assert.Empty(t, transport.posts, "nothing is filed") + for _, r := range transport.requests { + assert.False(t, strings.HasPrefix(r, "PUT"), "nothing is repositioned: %s", r) + } + return e +} + +func TestTodosMoveToAListInAnotherProjectByName(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", "456", "--to-project", "Dest", "--to-list", "launch") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/recordings/456/moves"}, transport.posts) + assert.Equal(t, float64(901), transport.bodies[0]["filing_destination_id"]) + var todo map[string]any + require.NoError(t, json.Unmarshal(run.data, &todo)) + assert.Equal(t, float64(4561), todo["id"]) + assert.Equal(t, float64(456), todo["source_id"]) + assert.Equal(t, "Moved to-do #456 to Dest › Launch as to-do #4561", run.summary) + assert.Contains(t, run.output, "basecamp todos show 4561") +} + +func TestTodosMoveSeveralFilesEachAndReportsEach(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", "456", "457", "--to-list", "901") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/recordings/456/moves", "/recordings/457/moves"}, transport.posts) + var todos []map[string]any + require.NoError(t, json.Unmarshal(run.data, &todos)) + require.Len(t, todos, 2) + assert.Equal(t, float64(457), todos[1]["source_id"]) + assert.Equal(t, "Moved 2 to-dos: #456 as #4561 in Dest › Launch, #457 as #4571 in Dest › Launch", run.summary) +} + +func TestTodosMoveSeveralFailsWhenOneFailsAndNamesWhatMoved(t *testing.T) { + transport := &mockTodoFilingTransport{refuse: map[string]bool{"457": true}} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", "457", "456", "--to-list", "901") + e := output.AsError(run.err) + require.NotNil(t, run.err) + + assert.Equal(t, []string{"/recordings/457/moves", "/recordings/456/moves"}, transport.posts, "a failure does not stop the rest") + assert.Equal(t, output.CodeForbidden, e.Code) + assert.Contains(t, e.Message, "Moved 1 of 2 to-dos (#456 as #4561 in Dest › Launch)") + assert.Contains(t, e.Message, "#457 refused") + assert.Contains(t, e.Hint, "run the command again for #457 alone") + assert.False(t, e.Retryable) +} + +func TestTodosCopySeveralAsksToRunAgainOnlyForTheRefused(t *testing.T) { + transport := &mockTodoFilingTransport{ + refuse: map[string]bool{"457": true}, + unreadable: map[string]bool{"4581": true}, + drop: map[string]bool{"459": true}, + } + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "copy", "456", "457", "458", "459", "--to-list", "901") + e := output.AsError(run.err) + require.NotNil(t, run.err) + + assert.Equal(t, []string{"/recordings/456/copies", "/recordings/457/copies", "/recordings/458/copies", "/recordings/459/copies"}, + transport.posts, "each is copied once and none is retried") + assert.NotZero(t, output.ExitCodeFor(e.Code)) + assert.NotEqual(t, output.CodeForbidden, e.Code, "a copy that may have started outranks a refusal") + assert.False(t, e.Retryable) + + assert.Contains(t, e.Message, "Copied 1 of 4 to-dos (#456 as #4561 in Dest › Launch)") + assert.Contains(t, e.Message, "#457 refused") + assert.Contains(t, e.Message, "#458 copied as #4581, not read back") + assert.Contains(t, e.Message, "#459 not settled") + + assert.Contains(t, e.Hint, "Do not copy these again: #458 is #4581 now (basecamp todos show 4581)") + assert.Contains(t, e.Hint, "Do not copy #459 again yet. Basecamp may have started the copy") + runAgain := regexp.MustCompile(`(?i)\brun\b.*\bagain\b`) + offers := 0 + for sentence := range strings.SplitSeq(strings.ReplaceAll(e.Hint, "\n", ". "), ". ") { + if runAgain.MatchString(sentence) && !strings.HasPrefix(sentence, "Do not") { + offers++ + assert.Contains(t, sentence, "#457", "only the refused copy is offered to run again: %q", sentence) + for _, other := range []string{"#456", "#458", "#459"} { + assert.NotContains(t, sentence, other, "never asks to repeat a copy that was made or may have been: %q", sentence) + } + } + } + assert.Equal(t, 1, offers, "the refused copy is offered to run again: %q", e.Hint) +} + +func TestTodosCopySeveralNeverAsksToRepeatACopyThatWasMadeOrMayHaveBeen(t *testing.T) { + for name, transport := range map[string]*mockTodoFilingTransport{ + "copied but not read back": {unreadable: map[string]bool{"4571": true}}, + "may have started": {drop: map[string]bool{"457": true}}, + } { + t.Run(name, func(t *testing.T) { + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "copy", "456", "457", "--to-list", "901") + e := output.AsError(run.err) + require.NotNil(t, run.err) + + assert.Len(t, transport.posts, 2) + assert.NotZero(t, output.ExitCodeFor(e.Code)) + assert.Contains(t, e.Message, "Copied 1 of 2 to-dos (#456 as #4561 in Dest › Launch)") + assert.Contains(t, e.Hint, "Do not copy") + assert.NotContains(t, strings.ToLower(e.Hint), "run the command again") + assert.NotContains(t, strings.ToLower(e.Hint), "run it again") + }) + } +} + +func TestTodosMoveRefusesAListInAnotherProjectInsteadOfReportingSuccess(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", "456", "--to", "1", "--list", "901") + e := requireUsageRefusal(t, run, transport) + + assert.Equal(t, output.CodeUsage, e.Code) + assert.Equal(t, "To-do list 901 is in project 999, not in the to-do's project 123", e.Message) + assert.Contains(t, e.Hint, "basecamp todos move 456 --to-project 999 --to-list 901") +} + +func TestTodosMoveRefusesAListURLInAnotherProjectWithTheFilingCommand(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "position", + "https://3.basecamp.com/99999/buckets/123/todos/456", "--to", "1", + "--list", "https://3.basecamp.com/99999/buckets/999/todolists/901") + e := requireUsageRefusal(t, run, transport) + + assert.Contains(t, e.Hint, "basecamp todos move 456 --to-project 999 --to-list 901") + assert.Empty(t, transport.requests, "both URLs name their projects") +} + +func TestTodosMoveToAnotherListInItsProjectStillRepositions(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", "456", "--to", "2", "--list", "310") + require.NoError(t, run.err) + + assert.Contains(t, transport.requests, "PUT /todos/456/position") + assert.Empty(t, transport.posts, "a move within the project keeps the to-do's id") + assert.Equal(t, "Moved todo #456 to list #310 at position 2", run.summary) +} + +func TestTodosMoveToItsProjectsTodosRepositionsItLoose(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", "456", "--to", "1", "--list", "300") + require.NoError(t, run.err) + + assert.Contains(t, transport.requests, "PUT /todos/456/position") + require.Len(t, transport.puts, 1) + assert.Equal(t, float64(300), transport.puts[0]["parent_id"], "the to-do moves onto its project's To-dos, in no list") + assert.Empty(t, transport.posts) +} + +func TestTodosMoveRefusesTheTodosOfAnotherProject(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", "456", "--to", "1", "--list", "900") + e := requireUsageRefusal(t, run, transport) + + assert.Equal(t, "To-dos 900 is in project 999, not in the to-do's project 123", e.Message) + assert.Contains(t, e.Hint, "basecamp todos move 456 --to-project 999 --to-list <list>") +} + +func TestTodosMoveRefusesToDosAsAFilingDestination(t *testing.T) { + for _, id := range []string{"300", "900"} { + t.Run(id, func(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", "456", "--to-list", id) + e := requireUsageRefusal(t, run, transport) + + assert.Contains(t, e.Message, id+" is To-dos in project", "Basecamp files a to-do into a list, never onto To-dos") + assert.Contains(t, e.Hint, "basecamp todolists list --in") + }) + } +} + +func TestTodosRepositionRefusesSeveralTodos(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", "456", "457", "--to", "1") + e := requireUsageRefusal(t, run, transport) + + assert.Contains(t, e.Hint, "--to-list") +} + +func TestTodosMoveRefusesFilingFlagsWithRepositionFlags(t *testing.T) { + for name, args := range map[string][]string{ + "--to": {"move", "456", "--to-project", "Dest", "--to-list", "Launch", "--to", "1"}, + "--list": {"move", "456", "--to-project", "Dest", "--list", "Launch"}, + } { + t.Run(name, func(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), args...) + e := requireUsageRefusal(t, run, transport) + + assert.Equal(t, output.CodeUsage, e.Code) + assert.Contains(t, e.Hint, "use --to-list") + assert.Empty(t, transport.requests) + }) + } +} + +func TestTodosMoveToAProjectWithSeveralListsIsAmbiguous(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", "456", "--to-project", "Dest") + e := requireUsageRefusal(t, run, transport) + + assert.Equal(t, output.CodeAmbiguous, e.Code) + assert.Contains(t, e.Hint, "--to-list") + assert.Contains(t, e.Hint, "901 Launch") + assert.Contains(t, e.Hint, "902 Later") +} + +func TestTodosMoveToAProjectWithOneListUsesIt(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", "456", "--to-project", "Solo") + require.NoError(t, run.err) + + assert.Equal(t, float64(701), transport.bodies[0]["filing_destination_id"]) + assert.Equal(t, "Moved to-do #456 to Solo › Only as to-do #4561", run.summary) +} + +func TestTodosMoveToAListNameTwoListsShareIsAmbiguous(t *testing.T) { + transport := &mockTodoFilingTransport{duplicateName: true} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", "456", "--to-project", "Dest", "--to-list", "Launch") + e := requireUsageRefusal(t, run, transport) + + assert.Equal(t, output.CodeAmbiguous, e.Code) + assert.Contains(t, e.Hint, "901 Launch") + assert.Contains(t, e.Hint, "903 launch") + assert.NotContains(t, e.Hint, "902", "only the lists with that name are candidates") +} + +func TestTodosFilingToAProjectWithTodosTurnedOffSaysHowToTurnItOn(t *testing.T) { + for name, args := range map[string][]string{ + "move": {"move", "456", "--to-project", "Bare"}, + "copy": {"copy", "456", "--to-project", "Bare"}, + "copy to a list name": {"copy", "456", "--to-project", "Bare", "--to-list", "Launch"}, + } { + t.Run(name, func(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), args...) + e := requireUsageRefusal(t, run, transport) + + assert.Equal(t, output.CodeNotFound, e.Code) + assert.Contains(t, e.Message, "is turned off") + assert.Equal(t, "Turn it on first with: basecamp tools enable 880 --in 888", e.Hint) + }) + } +} + +func TestTodosMoveRefusesADestinationBasecampRefuses(t *testing.T) { + for name, tc := range map[string]struct{ list, message, hint string }{ + "a group": {"920", "920 (Phase 2) is a group in to-do list 901", "--to-list 901"}, + "an archived one": {"930", "To-do list 930 (Old) is archived", ""}, + } { + t.Run(name, func(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", "456", "--to-list", tc.list) + e := requireUsageRefusal(t, run, transport) + + assert.Contains(t, e.Message, tc.message) + assert.Contains(t, e.Hint, tc.hint) + }) + } +} + +func TestTodosMoveRefusesTheListTheTodoIsIn(t *testing.T) { + for name, todo := range map[string]string{"in the list": "456", "in a group in the list": "458"} { + t.Run(name, func(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", todo, "--to-list", "310") + e := requireUsageRefusal(t, run, transport) + + assert.Contains(t, e.Message, "is already in to-do list 310") + assert.Contains(t, e.Hint, "basecamp todos move "+todo+" --to <position>") + }) + } +} + +func TestTodosMoveRefusesAListNameWithoutAProject(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", "456", "--to-list", "Launch") + e := requireUsageRefusal(t, run, transport) + + assert.Contains(t, e.Hint, "--to-project") + assert.Empty(t, transport.requests) +} + +func TestTodosCopyWithoutDestinationGoesIntoTheTodosOwnList(t *testing.T) { + for name, tc := range map[string]struct { + todo string + into float64 + }{ + "in a list": {"456", 310}, + "in a group": {"458", 310}, + } { + t.Run(name, func(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "copy", tc.todo, "--include-comments") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/recordings/" + tc.todo + "/copies"}, transport.posts) + assert.Equal(t, tc.into, transport.bodies[0]["filing_destination_id"], "Basecamp files into a list, not a group") + assert.Equal(t, true, transport.bodies[0]["include_comments"]) + assert.Equal(t, "Copied to-do #"+tc.todo+" to Source › Backlog as to-do #"+tc.todo+"1", run.summary) + }) + } +} + +func TestTodosCopySeveralToAnotherProjectNeverRetriesAFailedCopy(t *testing.T) { + transport := &mockTodoFilingTransport{postStatus: 502} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "copy", "456", "457", "--to-project", "Dest", "--to-list", "Later") + e := output.AsError(run.err) + require.NotNil(t, run.err) + + assert.Equal(t, []string{"/recordings/456/copies", "/recordings/457/copies"}, transport.posts, "one request per to-do") + assert.Equal(t, float64(902), transport.bodies[0]["filing_destination_id"]) + assert.False(t, e.Retryable) + assert.Contains(t, e.Message, "Copied 0 of 2 to-dos") + assert.Contains(t, e.Hint, "may have started the copy") + assert.Contains(t, e.Hint, "basecamp recordings list Todo --in 999 --status active --all") + assert.Contains(t, e.Hint, `look for "Task 456" in Later (#902)`, "a copy keeps its original's created_at, so its title tells it apart") + assert.Contains(t, e.Hint, `look for "Task 457" in Later (#902)`) +} + +func TestTodosCopyIntoTheListItIsInIsAllowed(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "copy", "456", "--to-list", "https://3.basecamp.com/99999/buckets/123/todolists/310") + require.NoError(t, run.err) + + assert.Equal(t, float64(310), transport.bodies[0]["filing_destination_id"]) +} + +func TestTodosCopyRefusesAListURLThatDisagreesWithToProject(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "copy", "456", "--to-project", "Solo", + "--to-list", "https://3.basecamp.com/99999/buckets/999/todolists/901") + e := requireUsageRefusal(t, run, transport) + + assert.Contains(t, e.Message, "--to-list URL is in project 999, but --to-project points at project 777") +} + +func TestTodosMoveAndCopyALooseTodoIntoAList(t *testing.T) { + for name, tc := range map[string]struct { + args []string + post string + into float64 + summary string + }{ + "move to another project": {[]string{"move", "460", "--to-project", "Dest", "--to-list", "Launch"}, "/recordings/460/moves", 901, "Moved to-do #460 to Dest › Launch as to-do #4601"}, + "move into a list in its project": {[]string{"move", "460", "--to-list", "310"}, "/recordings/460/moves", 310, "Moved to-do #460 to Source › Backlog as to-do #4601"}, + "copy to another project": {[]string{"copy", "460", "--to-list", "901"}, "/recordings/460/copies", 901, "Copied to-do #460 to Dest › Launch as to-do #4601"}, + } { + t.Run(name, func(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), tc.args...) + require.NoError(t, run.err) + + assert.Equal(t, []string{tc.post}, transport.posts) + assert.Equal(t, tc.into, transport.bodies[0]["filing_destination_id"]) + assert.Equal(t, tc.summary, run.summary) + }) + } +} + +func TestTodosCopyALooseTodoWithoutADestinationIsRefused(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "copy", "460") + e := requireUsageRefusal(t, run, transport) + + assert.Equal(t, output.CodeUsage, e.Code) + assert.Contains(t, e.Message, "To-do #460 is loose") + assert.Contains(t, e.Hint, "basecamp todos copy 460 --to-project 123 --to-list <list>") +} + +func TestTodosRefuseATodoNamedTwiceBeforeFilingAny(t *testing.T) { + for name, tc := range map[string]struct { + args []string + message string + }{ + "copy by its old id and its new one": {[]string{"copy", "456", "111", "457", "--to-list", "901"}, "111 and 457 are both to-do #457"}, + "move by its old id and its new one": {[]string{"move", "111", "457", "--to-list", "901"}, "111 and 457 are both to-do #457"}, + "the same id twice": {[]string{"copy", "456", "456", "--to-list", "901"}, "456 and 456 are both to-do #456"}, + } { + t.Run(name, func(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), tc.args...) + e := requireUsageRefusal(t, run, transport) + + assert.Equal(t, output.CodeUsage, e.Code) + assert.Equal(t, tc.message, e.Message) + }) + } +} + +// A follow-up a filing prints must reach the same profile and account in a +// fresh shell: a copy's recovery listing that ran against the default account +// would miss the copy and invite a second one. +func TestFilingFollowUpsNameTheProfileAndAnAccountGivenThisRun(t *testing.T) { + const scope = " --profile work --account 99999" + todoListing := "basecamp recordings list Todo --in 999 --status active --all" + scope + for name, tc := range map[string]struct { + transport http.RoundTripper + group func() *cobra.Command + args []string + want string + }{ + "a copy that may have started": { + &mockTodoFilingTransport{drop: map[string]bool{"456": true}}, NewTodosCmd, + []string{"copy", "456", "--to-list", "901"}, todoListing, + }, + "several copies, one may have started": { + &mockTodoFilingTransport{drop: map[string]bool{"457": true}}, NewTodosCmd, + []string{"copy", "456", "457", "--to-list", "901"}, todoListing, + }, + "a move not read back": { + &mockTodoFilingTransport{unreadable: map[string]bool{"4561": true}}, NewTodosCmd, + []string{"move", "456", "--to-list", "901"}, "basecamp todos show 4561" + scope, + }, + "several moves, one not read back": { + &mockTodoFilingTransport{unreadable: map[string]bool{"4571": true}}, NewTodosCmd, + []string{"move", "456", "457", "--to-list", "901"}, "basecamp todos show 4571" + scope, + }, + "a moved to-do's breadcrumb": { + &mockTodoFilingTransport{}, NewTodosCmd, + []string{"move", "456", "--to-list", "901"}, "basecamp todos show 4561" + scope, + }, + "a list copy that may have started": { + &mockTodoFilingTransport{postStatus: 502}, NewTodolistsCmd, + []string{"copy", "310", "--to-project", "Dest"}, + "basecamp recordings list Todolist --in 999 --status active --all" + scope, + }, + "a card copy that may have started": { + &mockFilingTransport{postStatus: 502}, NewCardsCmd, + []string{"copy", "456", "--to-project", "Dest"}, + "basecamp recordings list Kanban::Card --in 999 --status active --all" + scope, + }, + "a card move's breadcrumb": { + &mockFilingTransport{statuses: []string{"completed"}}, NewCardsCmd, + []string{"move", "456", "--to-project", "Dest"}, "basecamp cards show 4560 --in 999" + scope, + }, + "a copy still running": { + &mockTodoFilingTransport{stuck: map[string]bool{"456": true}}, NewTodosCmd, + []string{"copy", "456", "--to-list", "901"}, "basecamp filings show 456" + scope + ",", + }, + "a message copy that may have started": { + func() *familyTransport { + transport := messagesTransport() + transport.refuse = map[int64]int{456: 502} + return transport + }(), NewMessagesCmd, + []string{"copy", "456", "--to-project", "Dest"}, + "basecamp recordings list Message --in 999 --status active --all" + scope + ", then", + }, + "a message move still running": { + func() *familyTransport { + transport := messagesTransport() + transport.stuck = map[int64]bool{456: true} + return transport + }(), NewMessagesCmd, + []string{"move", "456", "--to-project", "Dest"}, "basecamp filings show 456" + scope + ",", + }, + } { + t.Run(name, func(t *testing.T) { + shortFilingWait(t) + app, buf := newTestAppWithTransport(t, tc.transport) + app.Flags.Hints = true + app.Config.ActiveProfile = "work" + app.Config.Sources = map[string]string{"account_id": "flag"} + + err := executeCommand(tc.group(), app, tc.args...) + shown := buf.String() + if err != nil { + shown = output.AsError(err).Hint + } + assert.Contains(t, shown, tc.want) + }) + } +} + +// A 401 partway through several filings must keep the remedy that fits the +// credential sent, as a single filing's does: under a profile, a login to the +// default one fixes nothing, and under BASECAMP_TOKEN no login does. +func TestTodosFilingSeveralKeepsTheAuthRemedyForTheCredentialSent(t *testing.T) { + for name, tc := range map[string]struct { + envToken string + want string + }{ + "a profile": {"", "Run: basecamp auth login -P work"}, + "an env token": {"bc_at_env", "BASECAMP_TOKEN is set"}, + } { + t.Run(name, func(t *testing.T) { + t.Setenv("BASECAMP_TOKEN", tc.envToken) + shortFilingWait(t) + transport := &mockTodoFilingTransport{postStatus: http.StatusUnauthorized} + app, buf := newTestAppWithTransport(t, transport) + app.Config.ActiveProfile = "work" + + err := executeCommand(NewTodosCmd(), app, "copy", "456", "457", "--to-list", "901") + require.Error(t, err) + assert.Len(t, transport.posts, 2) + require.NoError(t, app.Err(err)) + + var envelope struct { + Code string `json:"code"` + Hint string `json:"hint"` + } + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope), buf.String()) + assert.Equal(t, output.CodeAuth, envelope.Code) + assert.Contains(t, envelope.Hint, tc.want) + assert.NotRegexp(t, `basecamp auth login(\n|$)`, envelope.Hint, "the bare login hint is replaced") + assert.Contains(t, envelope.Hint, "run the command again for #456, #457 alone") + }) + } +} diff --git a/internal/commands/todos_test.go b/internal/commands/todos_test.go index dc08e39a1..859315170 100644 --- a/internal/commands/todos_test.go +++ b/internal/commands/todos_test.go @@ -196,7 +196,7 @@ func TestTodosPositionRejectsCrossProjectListURL(t *testing.T) { "--list", "https://3.basecamp.com/99999/buckets/200/todolists/321", ) require.Error(t, err) - assert.Contains(t, err.Error(), "Cannot move a todo to a list in a different project") + assert.Contains(t, err.Error(), "To-do list 321 is in project 200, not in the to-do's project 100") } // TestTodosPositionAcceptsSameProjectListURL tests that --list with a URL from the @@ -233,25 +233,6 @@ func TestTodosPositionListNameRequiresProject(t *testing.T) { assert.Contains(t, err.Error(), "--in is required to resolve todolist names") } -// TestTodosPositionBareIDSkipsCrossProjectGuard tests that the cross-project -// guard does not fire when the todo is a bare ID. Config project is a default -// context that may not match where the todo actually lives. -func TestTodosPositionBareIDSkipsCrossProjectGuard(t *testing.T) { - app, _ := setupTodosTestApp(t) - app.Config.ProjectID = "100" - - cmd := NewTodosCmd() - - // Todo is bare ID (config project = "100"), list URL has project 200. - // Should NOT reject — bare ID means we don't know the todo's project. - err := executeTodosCommand(cmd, app, "position", "789", - "--to", "1", - "--list", "https://3.basecamp.com/99999/buckets/200/todolists/321", - ) - require.Error(t, err) - assert.NotContains(t, err.Error(), "different project") -} - // TestTodosPositionRejectsNonTodolistURL tests that --list rejects URLs that // aren't todolist URLs (e.g. todo URLs, project URLs). func TestTodosPositionRejectsNonTodolistURL(t *testing.T) { diff --git a/internal/commands/tools.go b/internal/commands/tools.go index b282e6d9f..5f6b31e7d 100644 --- a/internal/commands/tools.go +++ b/internal/commands/tools.go @@ -1,7 +1,9 @@ package commands import ( + "context" "fmt" + "slices" "strconv" "strings" "unicode/utf8" @@ -47,6 +49,7 @@ Disabling a tool hides it from the dock but preserves its content.`, newToolsEnableCmd(&project), newToolsDisableCmd(&project), newToolsRepositionCmd(&project), + newToolsMoveCmd(&project), ) return cmd @@ -600,53 +603,196 @@ func newToolsRepositionCmd(project *string) *cobra.Command { var position int cmd := &cobra.Command{ - Use: "reposition <id>", - Aliases: []string{"move"}, - Short: "Change a tool's position in the dock", - Long: "Move a tool to a different position in the project dock.", - Args: cobra.ExactArgs(1), + Use: "reposition <id>", + Short: "Change a tool's position in the dock", + Long: "Move a tool to a different position in the project dock.", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if position == 0 { - return output.ErrUsage("--position is required (1-based)") - } + return runToolsReposition(cmd, *project, args[0], position) + }, + } - app := appctx.FromContext(cmd.Context()) + cmd.Flags().IntVar(&position, "position", 0, "New position, 1-based (required)") + cmd.Flags().IntVar(&position, "pos", 0, "New position (alias)") - if err := ensureAccount(cmd, app); err != nil { - return err - } + return guardFilingFlags(cmd) +} - toolID, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return output.ErrUsage("Invalid tool ID") - } +func runToolsReposition(cmd *cobra.Command, project, arg string, position int) error { + if position == 0 { + return output.ErrUsage("--position is required (1-based)") + } - resolvedProjectID, err := resolveToolsProject(cmd, app, *project) - if err != nil { - return err - } - inFlag := toolBreadcrumbFlag(resolvedProjectID) + app := appctx.FromContext(cmd.Context()) - err = app.Account().Tools().Reposition(cmd.Context(), toolID, position) - if err != nil { - return convertSDKError(err) - } + if err := ensureAccount(cmd, app); err != nil { + return err + } - return app.OK(map[string]any{"repositioned": true, "position": position}, - output.WithSummary(fmt.Sprintf("Tool %d moved to position %d", toolID, position)), - output.WithBreadcrumbs( - output.Breadcrumb{ - Action: "tool", - Cmd: fmt.Sprintf("basecamp tools show %d%s", toolID, inFlag), - Description: "View tool", - }, - ), - ) + toolID, err := strconv.ParseInt(arg, 10, 64) + if err != nil { + return output.ErrUsage("Invalid tool ID") + } + + resolvedProjectID, err := resolveToolsProject(cmd, app, project) + if err != nil { + return err + } + inFlag := toolBreadcrumbFlag(resolvedProjectID) + + err = app.Account().Tools().Reposition(cmd.Context(), toolID, position) + if err != nil { + return convertSDKError(err) + } + + return app.OK(map[string]any{"repositioned": true, "position": position}, + output.WithSummary(fmt.Sprintf("Tool %d moved to position %d", toolID, position)), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "tool", + Cmd: fmt.Sprintf("basecamp tools show %d%s", toolID, inFlag), + Description: "View tool", + }, + ), + ) +} + +// newToolsMoveCmd repositions a tool in its dock, as tools reposition does, +// or with --to-project moves it with everything in it to another project. +func newToolsMoveCmd(project *string) *cobra.Command { + var position int + var toProject string + + cmd := &cobra.Command{ + Use: "move <id|url>", + Short: "Move a tool to another position in the dock, or to another project", + Long: `Move a dock tool to another position in its project's dock, or move the whole +tool, with everything in it, to another project. + +--position reorders the dock, as tools reposition does: + basecamp tools move 1069479999 --position 1 --in my-project + +--to-project moves the tool to another project, as the web's Move… does for a +Message Board, Card Table, To-dos, Docs & Files, Schedule, Automatic Check-ins, +Chat or Email Forwards: + basecamp tools move 1069479999 --to-project "Marketing" + basecamp tools move https://3.basecamp.com/123/buckets/456/message_boards/1069479999 --to-project "Marketing" + +Find a tool's ID in its project's dock with basecamp projects show <id>, which +takes the project's ID (basecamp projects list gives it). A tool cannot be +copied, and Basecamp does not move a door (an external link) to another project.`, + Args: cobra.ExactArgs(1), + Annotations: map[string]string{"agent_notes": "--position reorders the tool in its own dock (same as tools reposition). " + + "--to-project <project> (ID or name) moves the whole tool and everything in it to that project's dock under a NEW id: output data.id is the new tool id and data.source_id the old one; the old id stops taking changes. " + + "--position cannot be combined with --to-project. The tool ID comes from the project's dock (basecamp projects show <id>, which takes the project's numeric ID from basecamp projects list), or pass the tool's URL. " + + "Tools cannot be copied; doors (external links) cannot move to another project. To move one message, file or card instead, use that family's move command. " + + "The old id does not follow the move (tools show <old id> reads it as deleted). An in_progress error names its filing: do not move it again; basecamp filings show <filing id> gives its new id once it lands."}, + RunE: func(cmd *cobra.Command, args []string) error { + positionSet := cmd.Flags().Changed("position") || cmd.Flags().Changed("pos") + if !cmd.Flags().Changed("to-project") { + return runToolsReposition(cmd, *project, args[0], position) + } + if positionSet { + return output.ErrUsageHint("--position reorders the tool in its own dock, and cannot be combined with --to-project", + "Move it to the other project first, then reorder it there with --position") + } + if toProject == "" { + return output.ErrUsage("--to-project needs a project ID or name") + } + return moveToolToProject(cmd, *project, args[0], toProject) }, } - cmd.Flags().IntVar(&position, "position", 0, "New position, 1-based (required)") + cmd.Flags().IntVar(&position, "position", 0, "New position in the dock, 1-based") cmd.Flags().IntVar(&position, "pos", 0, "New position (alias)") + cmd.Flags().StringVar(&toProject, "to-project", "", "Move the tool, with everything in it, to this project (ID or name)") - return cmd + return guardFilingFlags(cmd) +} + +// movableToolTypes are the dock tools the web moves to another project. +var movableToolTypes = []string{"Message::Board", "Kanban::Board", "Todoset", "Vault", "Schedule", "Questionnaire", "Chat::Transcript", "Inbox"} + +// toolURLKinds are the URL kinds that name a dock tool. +var toolURLKinds = []string{"tools", "message_boards", "card_tables", "todosets", "vaults", "schedules", "questionnaires", "chats", "inboxes"} + +func moveToolToProject(cmd *cobra.Command, flagProject, arg, toProject string) error { + app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) + if err := ensureAccount(cmd, app); err != nil { + return err + } + ref, err := parseFilingRefOf(app, arg, "tool", "…/message_boards/<id>, …/card_tables/<id>, …/vaults/<id> and so on, or …/dock/tools/<id>", toolURLKinds) + if err != nil { + return err + } + tool, err := app.Account().Tools().Get(cmd.Context(), ref.id) + if err != nil { + return convertSDKError(err) + } + if tool.Bucket == nil { + return output.ErrNotFound("tool", strconv.FormatInt(ref.id, 10)) + } + if tool.Status == "deleted" { + return output.ErrUsageHint( + fmt.Sprintf("Tool %d (%s) is deleted, or was moved to another project under a new ID", tool.ID, tool.Title), + "If it moved, find its new ID on the destination project's dock: basecamp projects show <id>, with the project's ID from basecamp projects list", + ) + } + if tool.Type == "Door" { + return output.ErrUsage(fmt.Sprintf("Tool %d (%s) is a door, an external link, which Basecamp does not move to another project", tool.ID, tool.Title)) + } + if !slices.Contains(movableToolTypes, tool.Type) || tool.Parent != nil { + return output.ErrUsageHint( + fmt.Sprintf("%d is a %s, not a tool on a project's dock", ref.id, tool.Type), + fmt.Sprintf("Pass the ID of a dock tool from: basecamp projects show %d", tool.Bucket.ID), + ) + } + if err := rejectProjectConflict(cmd, app, "tool", flagProject, ref.project, tool.Bucket.ID); err != nil { + return err + } + + projectID, projectName, err := app.Names.ResolveProject(cmd.Context(), toProject) + if err != nil { + return err + } + destID, err := strconv.ParseInt(projectID, 10, 64) + if err != nil { + return output.ErrUsage("Project ID must be numeric") + } + projectName = projectLabel(cmd, app, projectID, projectName) + if destID == tool.Bucket.ID { + return output.ErrUsageHint( + fmt.Sprintf("Tool %d (%s) is already in %s", tool.ID, tool.Title, projectName), + fmt.Sprintf("Reorder it in that dock with: basecamp tools move %d --position <n> --in %d", tool.ID, destID), + ) + } + + return runFiling(cmd, app, scope, recordingFiling{ + kind: basecamp.FilingMove, + sourceID: tool.ID, + noun: "tool", + toProjectID: destID, + destination: "project " + projectName, + showCmd: "basecamp tools show", + read: func(ctx context.Context, id int64) (filedRecording, error) { + moved, err := app.Account().Tools().Get(ctx, id) + if err != nil { + return filedRecording{}, err + } + var crumbs []output.Breadcrumb + if moved.Bucket != nil { + crumbs = []output.Breadcrumb{ + {Action: "show", Cmd: fmt.Sprintf("basecamp tools show %d --in %d", moved.ID, moved.Bucket.ID), Description: "View the tool"}, + {Action: "project", Cmd: fmt.Sprintf("basecamp projects show %d", moved.Bucket.ID), Description: "View the project's dock"}, + } + } + return filedRecording{ + result: withSourceID(moved, tool.ID), + id: moved.ID, + bucket: moved.Bucket, + breadcrumbs: crumbs, + }, nil + }, + }) } diff --git a/internal/commands/tools_filing_test.go b/internal/commands/tools_filing_test.go new file mode 100644 index 000000000..bdffa68f2 --- /dev/null +++ b/internal/commands/tools_filing_test.go @@ -0,0 +1,119 @@ +package commands + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" +) + +// toolsFilingTransport has message board 456 and door 457 on Source's (123) +// dock; the board moved to Dest (999) reads back as tool 4560. +func toolsFilingTransport() *familyTransport { + return &familyTransport{gets: map[string]string{ + "/projects": filingProjects, + "/dock/tools/456": `{"id":456,"type":"Message::Board","title":"Message Board","bucket":{"id":123,"name":"Source"}}`, + "/dock/tools/457": `{"id":457,"type":"Door","title":"Figma","bucket":{"id":123,"name":"Source"}}`, + "/dock/tools/458": `{"id":458,"type":"Vault","title":"Specs","bucket":{"id":123,"name":"Source"},"parent":{"id":500,"title":"Docs & Files","type":"Vault"}}`, + "/dock/tools/4560": `{"id":4560,"type":"Message::Board","title":"Message Board","bucket":{"id":999,"name":"Dest"}}`, + }} +} + +func TestToolsMoveToProjectSendsTheProject(t *testing.T) { + transport := toolsFilingTransport() + run := runFamilyCommand(t, transport, NewToolsCmd(), "move", "456", "--to-project", "Dest") + require.NoError(t, run.err) + + assert.Equal(t, []string{"/99999/recordings/456/moves"}, transport.posts) + assert.Equal(t, float64(999), transport.bodies[0]["destination_project_id"]) + assert.NotContains(t, transport.bodies[0], "filing_destination_id") + data := run.data(t) + assert.Equal(t, float64(4560), data["id"]) + assert.Equal(t, float64(456), data["source_id"]) + assert.Equal(t, "Moved tool #456 to Dest as tool #4560", run.envelope.Summary) + assert.Equal(t, "basecamp tools show 4560 --in 999", run.envelope.Breadcrumbs[0].Cmd) +} + +func TestToolsMoveByToolURL(t *testing.T) { + transport := toolsFilingTransport() + run := runFamilyCommand(t, transport, NewToolsCmd(), "move", "https://3.basecamp.com/99999/buckets/123/message_boards/456", "--to-project", "999") + require.NoError(t, run.err) + assert.Equal(t, []string{"/99999/recordings/456/moves"}, transport.posts) +} + +func TestToolsMoveRefusesADoor(t *testing.T) { + transport := toolsFilingTransport() + run := runFamilyCommand(t, transport, NewToolsCmd(), "move", "457", "--to-project", "Dest") + e := run.failure(t) + assert.Equal(t, "Tool 457 (Figma) is a door, an external link, which Basecamp does not move to another project", e.Message) + assert.Empty(t, transport.posts) +} + +func TestToolsMoveRefusesAFolderThatIsNotOnTheDock(t *testing.T) { + transport := toolsFilingTransport() + run := runFamilyCommand(t, transport, NewToolsCmd(), "move", "458", "--to-project", "Dest") + e := run.failure(t) + assert.Equal(t, output.CodeUsage, e.Code) + assert.Contains(t, e.Message, "not a tool on a project's dock") + assert.Equal(t, "Pass the ID of a dock tool from: basecamp projects show 123", e.Hint) + assert.Empty(t, transport.posts) +} + +// projects show takes a numeric project ID, never a name, so the help that +// sends an agent there says so and names where the ID comes from. +func TestToolsMoveHelpAsksProjectsShowForAnID(t *testing.T) { + cmd := newToolsMoveCmd(new(string)) + for _, text := range []string{cmd.Long, cmd.Annotations["agent_notes"]} { + assert.NotContains(t, text, "projects show <project>") + assert.Contains(t, text, "basecamp projects show <id>") + assert.Contains(t, text, "basecamp projects list") + } +} + +func TestToolsMoveToItsOwnProjectPointsAtPosition(t *testing.T) { + transport := toolsFilingTransport() + run := runFamilyCommand(t, transport, NewToolsCmd(), "move", "456", "--to-project", "Source") + e := run.failure(t) + assert.Equal(t, "Reorder it in that dock with: basecamp tools move 456 --position <n> --in 123", e.Hint) + assert.Empty(t, transport.posts) +} + +func TestToolsMoveRefusesPositionBesideToProject(t *testing.T) { + transport := toolsFilingTransport() + run := runFamilyCommand(t, transport, NewToolsCmd(), "move", "456", "--position", "2", "--to-project", "Dest") + e := run.failure(t) + assert.Contains(t, e.Message, "cannot be combined with --to-project") + assert.Empty(t, transport.posts) +} + +func TestToolsMoveWithPositionStillRepositionsInTheDock(t *testing.T) { + transport := toolsFilingTransport() + transport.gets["/recordings/456/position"] = `{}` + run := runFamilyCommand(t, transport, NewToolsCmd(), "move", "456", "--position", "2") + require.NoError(t, run.err) + assert.Empty(t, transport.posts, "a reposition is not a filing") + assert.Equal(t, "Tool 456 moved to position 2", run.envelope.Summary) +} + +// A moved tool's old id reads as deleted rather than leading to the new one, +// so a move still running is checked by its filing. +func TestToolsMoveStillRunningPointsAtItsFiling(t *testing.T) { + transport := toolsFilingTransport() + transport.stuck = map[int64]bool{456: true} + run := runFamilyCommand(t, transport, NewToolsCmd(), "move", "456", "--to-project", "Dest") + e := run.failure(t) + assert.Equal(t, output.CodeInProgress, e.Code) + assert.Equal(t, "Do not move it again. Check it with: basecamp filings show 456, which names the tool's new id once it lands", e.Hint) +} + +func TestToolsMoveRefusesADeletedOrMovedTool(t *testing.T) { + transport := toolsFilingTransport() + transport.gets["/dock/tools/456"] = `{"id":456,"status":"deleted","type":"Message::Board","title":"Message Board","bucket":{"id":123,"name":"Source"}}` + run := runFamilyCommand(t, transport, NewToolsCmd(), "move", "456", "--to-project", "Dest") + e := run.failure(t) + assert.Contains(t, e.Message, "was moved to another project under a new ID") + assert.Equal(t, "If it moved, find its new ID on the destination project's dock: basecamp projects show <id>, with the project's ID from basecamp projects list", e.Hint) + assert.Empty(t, transport.posts) +} diff --git a/internal/mcpserver/catalog_test.go b/internal/mcpserver/catalog_test.go index d785902aa..f1f2975ad 100644 --- a/internal/mcpserver/catalog_test.go +++ b/internal/mcpserver/catalog_test.go @@ -59,6 +59,7 @@ func TestCatalogExcludesUnservedOperations(t *testing.T) { "CreateCampfireUpload": true, "UpdateAccountLogo": true, "UpdateProjectWallpaper": true, + "UpdateProjectLogo": true, "CreateStreamTicket": true, } model, composite := 0, 0 @@ -72,7 +73,7 @@ func TestCatalogExcludesUnservedOperations(t *testing.T) { assert.False(t, excluded[op.ID], "operation %q should be excluded from the vendored model", op.ID) } } - assert.Equal(t, 282, model, "served model operation count") + assert.Equal(t, 286, model, "served model operation count") assert.Equal(t, 1, composite, "served composite operation count") } diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 6b6e0cefe..f1e5d3018 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "0b66bbc3846d605d8d684a395fe6458e289ea3c7", - "ref": "v0.29.1", + "commit": "b2e9356f386d7ca5fc87d0ba89ad197ffff9af5c", + "ref": "v0.31.0", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script" diff --git a/internal/mcpserver/model/behavior-model.json b/internal/mcpserver/model/behavior-model.json index 9d8057658..473ba6e4c 100644 --- a/internal/mcpserver/model/behavior-model.json +++ b/internal/mcpserver/model/behavior-model.json @@ -51,6 +51,17 @@ ] } }, + "CopyRecording": { + "retry": { + "max": 2, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "CreateAnswer": { "retry": { "max": 2, @@ -1321,6 +1332,18 @@ ] } }, + "GetFiling": { + "readonly": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "GetFolder": { "readonly": true, "retry": { @@ -2687,6 +2710,17 @@ ] } }, + "MoveRecording": { + "retry": { + "max": 2, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "MoveRecordingToVault": { "retry": { "max": 2, @@ -2789,6 +2823,18 @@ ] } }, + "RemoveProjectLogo": { + "idempotent": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "RemoveProjectWallpaper": { "idempotent": true, "retry": { diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index 8cf097de9..920347ad7 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Basecamp", - "version": "2026-10-19", + "version": "2026-10-27", "description": "Basecamp API", "contact": { "name": "Basecamp", @@ -1137,7 +1137,7 @@ }, "/{accountId}/buckets/{bucketId}/card_tables/{cardTableId}/wormholes.json": { "post": { - "description": "Add a wormhole to a card table: a standing shortcut that sends cards to a column on another card table, usually in another project.\n\nA wormhole is the only mechanism for moving a card to a different project: its\nid is a valid `column_id` for MoveCard, teleporting the card there. It is not\nneeded for a one-off move within a project; MoveCard goes to any column there\ndirectly. A card table holds at most four wormholes.\n`destinationRecordingId` is the id of a column on another accessible card table.", + "description": "Add a wormhole to a card table: a standing shortcut that sends cards to a column on another card table, usually in another project.\n\nA wormhole's id is a valid `column_id` for MoveCard, teleporting the card to\nits destination column. It is not needed for a one-off move: MoveCard goes to\nany column in the card's project directly, and MoveRecording moves a card to\nanother project's card table. Create one only when the card table should keep\nthat shortcut. A card table holds at most four wormholes.\n`destinationRecordingId` is the id of a column on another accessible card table.", "operationId": "CreateWormhole", "requestBody": { "content": { @@ -4211,7 +4211,7 @@ }, "/{accountId}/card_tables/cards/{cardId}/moves.json": { "post": { - "description": "Move a card to any column in its project, including on another card table there, or through a linked wormhole to another project (queued: the card gets a new id, and position, if sent, must still be positive but doesn't set placement).\n\n`column_id` may be any column in the card's project, including a column on\nanother card table in the same project; the card moves there directly, with\nno wormhole needed. To send the card to another project, pass the id of a\nlinked wormhole (`linked: true`) the card table already has, listed in\nGetCardTable's `wormholes`; an unlinked or unreachable one returns not found.\nA move through a wormhole is queued: the card arrives under a new id and its\nold id then returns not found. `position` does not control where it lands,\nbut if supplied it must still be a positive integer.", + "description": "Move a card to any column in its project, including on another card table there, or through a linked wormhole to another project (queued: the card gets a new id, and position, if sent, must still be positive but doesn't set placement).\n\n`column_id` may be any column in the card's project, including a column on\nanother card table in the same project; the card moves there directly, with\nno wormhole needed. To send the card to another project, pass the id of a\nlinked wormhole (`linked: true`) the card table already has, listed in\nGetCardTable's `wormholes`; an unlinked or unreachable one returns not found.\nA move through a wormhole is queued: the card arrives under a new id and its\nold id then returns not found. `position` does not control where it lands,\nbut if supplied it must still be a positive integer.\n\nNo wormhole is needed to move a card to another project's card table either:\nMoveRecording with that card table as `filing_destination_id` moves it, and\nthe card lands in Triage, or in Done if it is completed. Create a wormhole\nonly when the card table should keep that shortcut.", "operationId": "MoveCard", "requestBody": { "content": { @@ -8517,7 +8517,7 @@ }, "/{accountId}/dock/tools/{toolId}": { "delete": { - "description": "Remove a tool from the dock: deletes an external link or an empty extra tool, and disables any other tool (returns 204 No Content either way).\nDeleted: an external link, or a tool that is empty while the project has another\ntool of the same type, enabled or disabled. Empty means nothing inside it is still\nin place: items already in the trash don't count, archived and draft items do, and\nfor a Card Table only cards count, not columns. A deleted tool does not go to the\ntrash: Basecamp permanently destroys it, with everything inside it, about five days\nlater. Until then, a deleted external link can be restored in the Basecamp web app.\nDisabled: every other tool, such as the only tool of its type or a tool with\ncontent, exactly as DisableTool does. It keeps its content, and EnableTool restores it.\nTo tell which happened, get the tool afterwards: a deleted tool has status \"deleted\",\nwhile a disabled one keeps its status and has no position.", + "description": "Remove a tool from the dock: deletes an external link or an empty extra tool, and disables any other tool (returns 204 No Content either way).\nDeleted: an external link, or a tool that is empty while the project has another\ntool of the same type, enabled or disabled. Empty means nothing inside it at all,\nnot even in the trash; for a Card Table only cards count, not columns. A deleted\ntool does not go to the trash: Basecamp permanently destroys it, with everything\ninside it, about five days later. Until then, a deleted external link can be\nrestored in the Basecamp web app.\nDisabled: every other tool, such as the only tool of its type or a tool with\nanything inside it, archived, draft and trashed items included, exactly as\nDisableTool does. It keeps its content, and EnableTool restores it. Items already\nin the trash stay there and can still be restored for the rest of their 25 days.\nTo tell which happened, get the tool afterwards: a deleted tool has status \"deleted\",\nwhile a disabled one keeps its status and has no position.", "operationId": "DeleteTool", "parameters": [ { @@ -9544,6 +9544,108 @@ } } }, + "/{accountId}/filings/{filingId}": { + "get": { + "description": "Get the state of a move or copy started by MoveRecording or CopyRecording.\n\n`status` is `pending` or `processing` while the work runs, `completed` when\nit is done, and `failed` if it could not be done. Poll until it leaves\n`pending` and `processing`. A completed filing carries the recording it made\nunder `destination_recording`; for a move, that is the moved recording's new\nid. 404 for a filing someone else started, and for a completed one whose\nrecording has since been permanently deleted.", + "operationId": "GetFiling", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "filingId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "GetFiling 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetFilingResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Recordings" + ], + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, "/{accountId}/forwards.json": { "get": { "description": "Get every inbox forward across all accessible projects, newest-first (paginated).\nEach item embeds its `bucket`.", @@ -15406,6 +15508,104 @@ } } }, + "/{accountId}/projects/{projectId}/logo.json": { + "delete": { + "description": "Remove the project's logo (returns 204 even when absent). Wallpaper is unchanged.\nPeople and agents who can edit the project may use this operation; others get 403, and projects outside the caller's visibility get 404.", + "operationId": "RemoveProjectLogo", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "projectId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "RemoveProjectLogo 204 response" + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Projects" + ], + "x-basecamp-idempotent": { + "natural": true + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, "/{accountId}/projects/{projectId}/people.json": { "get": { "description": "List all active people on a project\n\n**Pagination**: Uses Link header (RFC5988). Follow the `next` rel URL\nto fetch additional pages. X-Total-Count header provides total count.", @@ -19284,10 +19484,19 @@ } } }, - "/{accountId}/recordings/{recordingId}/events.json": { - "get": { - "description": "List all events for a recording\n\n**Pagination**: Uses Link header (RFC5988). Follow the `next` rel URL\nto fetch additional pages. X-Total-Count header provides total count.", - "operationId": "ListEvents", + "/{accountId}/recordings/{recordingId}/copies.json": { + "post": { + "description": "Start copying a recording into another project; poll GetFiling for the result.\n\nWorks like MoveRecording, and takes the same destinations, except the\noriginal stays where it is. Name the destination with\n`filing_destination_id`, a container of the kind the recording sits in now,\nchosen as for MoveRecording. A copied card lands in Triage, or in Done if it\nis completed, as a moved one does. A to-do or to-do list group copies into a\nto-do list in its own project or another one. A dock tool is copied by\n`destination_project_id`, or by naming the destination project's dock as\n`filing_destination_id`. Poll GetFiling with the returned filing's id until\n`status` leaves `pending` and `processing`; a completed filing's\n`destination_recording.id` is the copy.\n\nThe same recordings copy on their own as move on their own, except the\nEmail Forwards tool, which can't be copied. Everything else copies only\nalong with its parent and answers 403, as for MoveRecording: comments,\nattachments, card steps, check-in answers, hill chart updates, external\nlinks, the card table columns every card table has, and anything outside a\ntool, such as client correspondence and approvals.\n\nNot safe to retry blindly: each request makes another copy. Check the first\nfiling's status before trying again.\n\n400 when neither destination is given. 403 when the recording can't be\ncopied on its own. 404 when the destination is not one the caller can copy\nit into. 422 when the copy cannot start, such as when the recording is being\nmoved or the destination cannot take it.", + "operationId": "CopyRecording", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CopyRecordingRequestContent" + } + } + } + }, "parameters": [ { "name": "accountId", @@ -19308,25 +19517,25 @@ "format": "int64" }, "required": true - }, - { - "name": "page", - "in": "query", - "description": "Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.", - "schema": { - "type": "integer", - "description": "Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.", - "format": "int32" - } } ], "responses": { - "200": { - "description": "ListEvents 200 response", + "201": { + "description": "CopyRecording 201 response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListEventsResponseContent" + "$ref": "#/components/schemas/CopyRecordingResponseContent" + } + } + } + }, + "400": { + "description": "BadRequestError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestErrorResponseContent" } } } @@ -19342,11 +19551,24 @@ } }, "403": { - "description": "ForbiddenError 403 response", + "description": "BareForbiddenError 403 response" + }, + "404": { + "description": "NotFoundError 404 response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "422": { + "description": "ValidationError 422 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponseContent" } } } @@ -19375,13 +19597,8 @@ "tags": [ "Recordings" ], - "x-basecamp-pagination": { - "style": "link", - "totalCountHeader": "X-Total-Count", - "maxPageSize": 50 - }, "x-basecamp-retry": { - "maxAttempts": 3, + "maxAttempts": 2, "baseDelayMs": 1000, "backoff": "exponential", "retryOn": [ @@ -19391,10 +19608,117 @@ } } }, - "/{accountId}/recordings/{recordingId}/events/{eventId}/boosts.json": { + "/{accountId}/recordings/{recordingId}/events.json": { "get": { - "description": "List boosts on a specific event within a recording. Only boostable events carry boosts: those whose action is completed, adopted, column_changed or moved_in (their JSON includes boosts_count and boosts_url). Any other event answers 403 Forbidden; an event on a recording the person cannot see answers 404.", - "operationId": "ListEventBoosts", + "description": "List all events for a recording\n\n**Pagination**: Uses Link header (RFC5988). Follow the `next` rel URL\nto fetch additional pages. X-Total-Count header provides total count.", + "operationId": "ListEvents", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "recordingId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + }, + { + "name": "page", + "in": "query", + "description": "Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.", + "schema": { + "type": "integer", + "description": "Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "ListEvents 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListEventsResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Recordings" + ], + "x-basecamp-pagination": { + "style": "link", + "totalCountHeader": "X-Total-Count", + "maxPageSize": 50 + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, + "/{accountId}/recordings/{recordingId}/events/{eventId}/boosts.json": { + "get": { + "description": "List boosts on a specific event within a recording. Only boostable events carry boosts: those whose action is completed, adopted, column_changed or moved_in (their JSON includes boosts_count and boosts_url). Any other event answers 403 Forbidden; an event on a recording the person cannot see answers 404.", + "operationId": "ListEventBoosts", "parameters": [ { "name": "accountId", @@ -19737,6 +20061,130 @@ } } }, + "/{accountId}/recordings/{recordingId}/moves.json": { + "post": { + "description": "Start moving a recording into another project; poll GetFiling for the result.\n\nThe move runs in the background and this answers 201 with a filing. Poll\nGetFiling with its id until `status` leaves `pending` and `processing`. Once\nthe move completes, the recording lives in the destination project under a\nnew id, the completed filing's `destination_recording.id`; its old id no\nlonger reaches it. A move always gives the recording a new id, even within\nits project. To move a to-do into another list in its own project and keep\nits id, use RepositionTodo with `parent_id` instead.\n\nName the destination with `filing_destination_id`: the container the\nrecording moves into, of the same kind it sits in now. Find tools in the\ndestination project's `dock` (GetProject). A message moves into a\n`message_board`. A card moves into a card table (`kanban_board`) and lands\nin Triage, or in Done if it is completed; a card table column moves into a\ncard table too. A document, upload, cloud file, Google document or folder\nmoves into a `vault` (Docs & Files). A schedule entry moves into a\n`schedule`, a check-in question into a `questionnaire` (Automatic\nCheck-ins), an email forward into an `inbox` (Email Forwards), and a to-do\nlist into a `todoset` (To-dos). A to-do, or a to-do list group, moves into a\nto-do list, found with ListTodolists. The list may be in the to-do's own\nproject or another one, but it can't be a group, an archived or trashed\nlist, or the list the to-do is already in. A group moved into a list\nbecomes a group in that list.\n\nA dock tool itself (a message board, card table, Docs & Files, schedule, and\nso on) has no container to name. Move it by `destination_project_id`, or\nname the destination project's dock as `filing_destination_id`.\n\nOnly the recordings above, and the dock tools, move on their own. Everything\nelse moves only along with its parent and answers 403: comments,\nattachments, card steps, check-in answers, hill chart updates, external\nlinks, the card table columns every card table has (Triage, Not now, Done\nand On hold), and anything outside a tool, such as client correspondence and\napprovals. To move one, move the recording or tool it belongs to.\n\n400 when neither destination is given. 403 when the recording can't be\nmoved on its own, or the caller may not move it. 404 when the destination\nis not one the caller can move it into. 422 when the move cannot start,\nsuch as when the recording is already being moved or the destination\ncannot take it. A repeated move is harmless: while the first is pending it\nis 422, and once it is done, 404, because the recording has moved.", + "operationId": "MoveRecording", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveRecordingRequestContent" + } + } + } + }, + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "recordingId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "201": { + "description": "MoveRecording 201 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveRecordingResponseContent" + } + } + } + }, + "400": { + "description": "BadRequestError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestErrorResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "BareForbiddenError 403 response" + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "422": { + "description": "ValidationError 422 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Recordings" + ], + "x-basecamp-retry": { + "maxAttempts": 2, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, "/{accountId}/recordings/{recordingId}/spotlight.json": { "delete": { "description": "Remove a recording from the spotlight area.\nIdempotent: removing an absent spotlight also returns 204.", @@ -27895,7 +28343,7 @@ }, "/{accountId}/todos/{todoId}/position.json": { "put": { - "description": "Reposition a todo within its todolist", + "description": "Reposition a todo within its todolist, or move it into another list or the to-do set in the same project.\n\n`parent_id` names a to-do list, or the project's to-do set, to move the todo\ninto; the todo keeps its id. It must be in the same project: anything else\nanswers 422. To move a todo into a list in another project, use\nMoveRecording.", "operationId": "RepositionTodo", "requestBody": { "content": { @@ -31887,6 +32335,33 @@ "visible_to_clients" ] }, + "CopyRecordingRequestContent": { + "type": "object", + "properties": { + "filing_destination_id": { + "type": "integer", + "description": "The container to copy into, chosen as for MoveRecording's\nfiling_destination_id: a container of the kind the recording sits in now,\nsuch as a to-do list, in this project or another, for a to-do. For a dock\ntool, the destination project's dock. Give this or destination_project_id.", + "format": "int64" + }, + "destination_project_id": { + "type": "integer", + "description": "The project to copy a dock tool to. Applies to tools only, and wins over\nfiling_destination_id when a request for a tool gives both.", + "format": "int64" + }, + "parent_id": { + "type": "integer", + "description": "When copying into a vault, a folder inside it to put the copy in.", + "format": "int64" + }, + "include_comments": { + "type": "boolean", + "description": "Copy the recording's comments too. Defaults to false." + } + } + }, + "CopyRecordingResponseContent": { + "$ref": "#/components/schemas/Filing" + }, "CreateAnswerResponseContent": { "$ref": "#/components/schemas/QuestionAnswer" }, @@ -33679,6 +34154,67 @@ "FieldValidationErrorResponseContent": { "$ref": "#/components/schemas/FieldKeyedErrors" }, + "Filing": { + "type": "object", + "description": "A move or copy of a recording into another project, as MoveRecording,\nCopyRecording and GetFiling answer it.", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string", + "description": "pending|processing|completed|failed" + }, + "type": { + "type": "string", + "description": "Move|Copy" + }, + "source_recording_id": { + "type": "integer", + "format": "int64" + }, + "url": { + "type": "string", + "description": "The filing's own API URL, for polling." + }, + "destination_recording": { + "$ref": "#/components/schemas/FilingDestinationRecording" + } + }, + "required": [ + "id", + "source_recording_id", + "status", + "type", + "url" + ] + }, + "FilingDestinationRecording": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "type": { + "type": "string", + "description": "The recordable type, such as `Kanban::Card`, `Todo` or `Message`." + }, + "url": { + "type": "string" + }, + "app_url": { + "type": "string" + } + }, + "required": [ + "app_url", + "id", + "type", + "url" + ] + }, "FirstWeekDay": { "type": "string", "enum": [ @@ -34485,6 +35021,9 @@ "$ref": "#/components/schemas/BucketTodosGroup" } }, + "GetFilingResponseContent": { + "$ref": "#/components/schemas/Filing" + }, "GetFolderResponseContent": { "$ref": "#/components/schemas/FolderWithProjects" }, @@ -35702,6 +36241,29 @@ "column_id" ] }, + "MoveRecordingRequestContent": { + "type": "object", + "properties": { + "filing_destination_id": { + "type": "integer", + "description": "The container to move into, of the kind the recording sits in now: a card\ntable for a card, a message board for a message, a vault for a document,\nupload, cloud file, Google document or folder, a schedule for a schedule\nentry, a questionnaire for a check-in question, an inbox for an email\nforward, a to-do set for a to-do list, and a to-do list, in this project\nor another, for a to-do or to-do list group. For a dock tool, the\ndestination project's dock. Give this or destination_project_id.", + "format": "int64" + }, + "destination_project_id": { + "type": "integer", + "description": "The project to move a dock tool to. Applies to tools only, and wins over\nfiling_destination_id when a request for a tool gives both.", + "format": "int64" + }, + "parent_id": { + "type": "integer", + "description": "When moving into a vault, a folder inside it to put the recording in.", + "format": "int64" + } + } + }, + "MoveRecordingResponseContent": { + "$ref": "#/components/schemas/Filing" + }, "MoveRecordingToVaultRequestContent": { "type": "object", "properties": { @@ -36633,6 +37195,9 @@ "wallpaper": { "$ref": "#/components/schemas/ProjectWallpaper" }, + "logo": { + "$ref": "#/components/schemas/ProjectLogo" + }, "clientside": { "$ref": "#/components/schemas/ClientSide", "deprecated": true, @@ -36735,6 +37300,19 @@ "error" ] }, + "ProjectLogo": { + "type": "object", + "description": "The project's logo, included only in single-project responses when attached.\nProject lists and home responses omit it. Absent logos omit this member, not null.", + "properties": { + "url": { + "type": "string", + "description": "An authenticated API-host URL that redirects to storage, not a public image link.\nUse the SDK's authenticated download helper rather than an image element." + } + }, + "required": [ + "url" + ] + }, "ProjectWallpaper": { "type": "object", "description": "The project's wallpaper, included only in single-project responses when attached.\nProject lists and home responses omit it.", @@ -37596,7 +38174,7 @@ }, "parent_id": { "type": "integer", - "description": "Optional todolist ID to move the todo to a different parent", + "description": "A to-do list, or the project's to-do set, in the same project, to move the\ntodo into instead of its current parent. 422 when it is neither.", "format": "int64" } }, @@ -40408,6 +40986,10 @@ "UpdateProjectClientAccessResponseContent": { "$ref": "#/components/schemas/ProjectAccessResult" }, + "UpdateProjectLogoInputPayload": { + "type": "string", + "contentEncoding": "byte" + }, "UpdateProjectRequestContent": { "type": "object", "properties": { diff --git a/internal/mcpserver/testdata/catalog_snapshot.txt b/internal/mcpserver/testdata/catalog_snapshot.txt index e71d07449..ade56d087 100644 --- a/internal/mcpserver/testdata/catalog_snapshot.txt +++ b/internal/mcpserver/testdata/catalog_snapshot.txt @@ -11,6 +11,7 @@ ACTIONS (RO = read-only): - list_projects (RO, paginated): List projects (active by default; optionally archived/trashed) - list_recent_projects (RO): List the projects the current user has most recently visited, most recent visit first - record_project_visit: Record that the current user visited a project, moving it to the front of ListRecentProjects (returns 204 No Content) +- remove_project_logo: Remove the project's logo (returns 204 even when absent) - remove_project_wallpaper: Remove the project's wallpaper (returns 204 No Content) - trash_project: Trash a project (returns 204 No Content) - unarchive_project: Restore a project to active status from trash as well as from the archive (returns 204 No Content) @@ -42,7 +43,7 @@ ACTIONS (RO = read-only): - list_todos (RO, paginated): List todos in a todolist - replace_todo: Replace a todo with a new complete representation - reposition_subtask: Move a subtask to a new position among its siblings -- reposition_todo: Reposition a todo within its todolist +- reposition_todo: Reposition a todo within its todolist, or move it into another list or the to-do set in the same project - reposition_todolist: Reposition a to-do list within its to-do set - reposition_todolist_group: Reposition a todolist group - uncomplete_subtask: Mark a subtask as not completed @@ -240,6 +241,7 @@ Call {"action": "describe", "params": {"action": "NAME"}} for an action's full p ACTIONS (RO = read-only): - archive_recording: Archive a recording; bc3 answers 403 for types it never lets be archived, timesheet entries among them +- copy_recording: Start copying a recording into another project; poll GetFiling for the result - create_answer: Create a new answer for a question - create_lineup_marker: Create a new lineup marker - create_project_from_template: Create a project from a template (asynchronous) @@ -259,6 +261,7 @@ ACTIONS (RO = read-only): - enable_tool: Enable a tool (show it on the project dock) - get_answer (RO): Get a single answer by id - get_answers_by_person (RO, paginated): Get all answers from a specific person for a question +- get_filing (RO): Get the state of a move or copy started by MoveRecording or CopyRecording - get_project_construction (RO): Get the status of a project construction - get_question (RO): Get a single question by id - get_question_reminders (RO, paginated): Get pending check-in reminders for the current user @@ -279,6 +282,7 @@ ACTIONS (RO = read-only): - list_recordings (RO, paginated): List recordings of a given type across projects - list_templates (RO, paginated): List all templates visible to the current user - list_webhooks (RO, paginated): List all webhooks for a project +- move_recording: Start moving a recording into another project; poll GetFiling for the result - move_recording_to_vault: Move a document, upload or vault into another vault in its project, or reorder it in its own vault - pause_question: Pause a check-in question (stops sending reminders) - reposition_tool: Reposition a tool on the project dock diff --git a/internal/output/codes.go b/internal/output/codes.go index 2882f303c..905b1045d 100644 --- a/internal/output/codes.go +++ b/internal/output/codes.go @@ -64,6 +64,12 @@ const ( CodeLockUnavailable = "lock_unavailable" ) +// CodeInProgress marks work Basecamp accepted and is still doing in the +// background when the command stopped waiting for it, such as a card being +// moved or copied to another project. It is not a failure to retry: the hint +// says how to see the result once the work is done. +const CodeInProgress = "in_progress" + // ExitCodeFor returns the exit code for a given error code. func ExitCodeFor(code string) int { switch code { diff --git a/internal/presenter/format.go b/internal/presenter/format.go index c99668dc3..e3901e9a8 100644 --- a/internal/presenter/format.go +++ b/internal/presenter/format.go @@ -30,6 +30,8 @@ func FormatField(spec FieldSpec, key string, val any, locale Locale) string { return formatDock(val) case "steps": return formatSteps(val) + case "logo": + return formatLogo(val) case "wallpaper": return formatWallpaper(val) default: @@ -48,6 +50,15 @@ func formatWallpaper(val any) string { return "" } +// formatLogo says a project has a logo. The logo has nothing to show in a +// terminal but its URL, which is a signed-in redirect. +func formatLogo(val any) string { + if m, ok := val.(map[string]any); ok && m["url"] != nil { + return "set" + } + return "" +} + // formatSteps renders a CardStep array as a multi-line checklist. // Each step prefixes its title with [x] for completed, [ ] for active. func formatSteps(val any) string { diff --git a/internal/presenter/presenter_test.go b/internal/presenter/presenter_test.go index 03b87bd06..381ab5bdd 100644 --- a/internal/presenter/presenter_test.go +++ b/internal/presenter/presenter_test.go @@ -705,6 +705,20 @@ func TestProjectDetailNamesTheWallpaperMode(t *testing.T) { assert.NotContains(t, with.String(), "/wallpaper") } +func TestProjectDetailSaysWhetherThereIsALogo(t *testing.T) { + project := map[string]any{"id": float64(123), "name": "Launch", "status": "active"} + + var without strings.Builder + require.True(t, PresentWithTheme(&without, project, "project", ModeMarkdown, tui.NoColorTheme(), enUS)) + assert.NotContains(t, without.String(), "Logo") + + project["logo"] = map[string]any{"url": "https://3.basecampapi.com/1/projects/123/logo?v=1"} + var with strings.Builder + require.True(t, PresentWithTheme(&with, project, "project", ModeMarkdown, tui.NoColorTheme(), enUS)) + assert.Contains(t, with.String(), "**Logo:** set") + assert.NotContains(t, with.String(), "/logo") +} + // ============================================================================= // Collapse Tests // ============================================================================= diff --git a/internal/presenter/schemas/project.yaml b/internal/presenter/schemas/project.yaml index 900bb1185..17bbd4a6d 100644 --- a/internal/presenter/schemas/project.yaml +++ b/internal/presenter/schemas/project.yaml @@ -29,6 +29,10 @@ fields: role: detail format: text + logo: + role: detail + format: logo + wallpaper: role: detail format: wallpaper @@ -56,7 +60,7 @@ views: sections: - fields: [name, description] - heading: Info - fields: [status, bookmarked, wallpaper] + fields: [status, bookmarked, logo, wallpaper] - heading: Dock fields: [dock] - heading: Metadata diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index f547ddbf6..bb75db60d 100644 --- a/internal/version/sdk-provenance.json +++ b/internal/version/sdk-provenance.json @@ -1,13 +1,13 @@ { "sdk": { "module": "github.com/basecamp/basecamp-sdk/go", - "version": "v0.29.1", - "revision": "0b66bbc3846d", - "updated_at": "2026-10-26T18:18:02Z" + "version": "v0.31.0", + "revision": "b2e9356f386d", + "updated_at": "2026-10-28T04:21:17Z" }, "api": { "repo": "basecamp/bc3", - "revision": "4958288166a19b98894fce8d7deb5edbdd34424b", - "synced_at": "2026-10-19" + "revision": "2a47a419594f666865f8870541e90b14ec6b0b04", + "synced_at": "2026-10-27" } } diff --git a/nix/package.nix b/nix/package.nix index 6ca71a5e7..b002e4073 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -8,7 +8,7 @@ buildGoModule.override { go = go_1_26; } (finalAttrs: { src = lib.cleanSource ./..; # To update: set to lib.fakeHash, run `nix build`, use the hash from the error. - vendorHash = "sha256-kXbdeAuZlHob+KM5z4aEbZHrkD/nreaPyaBKucFu9WI="; + vendorHash = "sha256-llyHSDixJm3ZlC+Uhbv0QFcKxJeLkTHslKhQVOXXU4Y="; subPackages = [ "cmd/basecamp" ]; diff --git a/scripts/sync-mcp-model.sh b/scripts/sync-mcp-model.sh index 894991fbc..8977233d3 100755 --- a/scripts/sync-mcp-model.sh +++ b/scripts/sync-mcp-model.sh @@ -62,6 +62,7 @@ EXCLUDED_OPERATIONS = { "CreateCampfireUpload", "UpdateAccountLogo", "UpdateProjectWallpaper", + "UpdateProjectLogo", } # Excluded by policy rather than by body shape, so these are not held to the diff --git a/skill-evals/cases/checkins-move-copy.yml b/skill-evals/cases/checkins-move-copy.yml new file mode 100644 index 000000000..72e0bddd8 --- /dev/null +++ b/skill-evals/cases/checkins-move-copy.yml @@ -0,0 +1,11 @@ +task: Copy check-in question 2468 to the Marketing project's automatic check-ins +tags: [move-copy, workflow] +mocks: + - match: '^(checkins|checkin) question copy(?!.*--help)' + output: '{"ok":true,"data":{"id":24680,"source_id":2468,"type":"Question"},"summary":"Copied question #2468 to Marketing › Automatic Check-ins as question #24680"}' +accept: + - '^(checkins|checkin) question copy (?=.*\b2468\b)(?=.*--to-project \W?Marketing)(?!.*--help)' +reject: + - '^(checkins|checkin) question (create|update)' + - '^recordings\b' +max_commands: 4 diff --git a/skill-evals/cases/columns-move-copy.yml b/skill-evals/cases/columns-move-copy.yml new file mode 100644 index 000000000..9d0137f35 --- /dev/null +++ b/skill-evals/cases/columns-move-copy.yml @@ -0,0 +1,11 @@ +task: Copy the card table column 4321 to the Marketing project's card table, comments and all +tags: [move-copy, workflow] +mocks: + - match: '^cards column copy(?!.*--help)' + output: '{"ok":true,"data":{"id":43210,"source_id":4321,"type":"Kanban::Column"},"summary":"Copied column #4321 to Marketing › Roadmap as column #43210"}' +accept: + - '^cards column copy (?=.*\b4321\b)(?=.*--to-project \W?Marketing)(?=.*--include-comments)(?!.*--help)' +reject: + - '^cards (column create|create|move)' + - '^recordings\b' +max_commands: 4 diff --git a/skill-evals/cases/files-move-copy.yml b/skill-evals/cases/files-move-copy.yml new file mode 100644 index 000000000..bcbccbbe7 --- /dev/null +++ b/skill-evals/cases/files-move-copy.yml @@ -0,0 +1,11 @@ +task: Copy files 111 and 222 into the Contracts folder in the Marketing project's Docs & Files +tags: [move-copy, workflow] +mocks: + - match: '^(files|docs|uploads|vaults) copy(?!.*--help)' + output: '{"ok":true,"data":[{"id":1110,"source_id":111},{"id":2220,"source_id":222}],"summary":"Copied 2 files to folder 950 (Contracts) in Docs & Files 900 in Marketing: #111 as #1110, #222 as #2220"}' +accept: + - '^(files|docs|uploads|vaults) copy (?=.*\b111\b)(?=.*\b222\b)(?=.*--to-project \W?Marketing)(?=.*--to-folder \W?Contracts)(?!.*--help)' +reject: + - '^(files|docs|uploads|vaults) (move|download|uploads create)' + - '^recordings\b' +max_commands: 4 diff --git a/skill-evals/cases/forwards-move-copy.yml b/skill-evals/cases/forwards-move-copy.yml new file mode 100644 index 000000000..7e2fa77cf --- /dev/null +++ b/skill-evals/cases/forwards-move-copy.yml @@ -0,0 +1,11 @@ +task: Move email forward 1357 into the Marketing project's email forwards +tags: [move-copy, workflow] +mocks: + - match: '^forwards move(?!.*--help)' + output: '{"ok":true,"data":{"id":13570,"source_id":1357,"type":"Inbox::Forward"},"summary":"Moved forward #1357 to Marketing › Forwards as forward #13570"}' +accept: + - '^forwards move (?=.*\b1357\b)(?=.*--to-project \W?Marketing)(?!.*--help)' +reject: + - '^forwards (reply|replies)' + - '^recordings\b' +max_commands: 4 diff --git a/skill-evals/cases/messages-move-copy.yml b/skill-evals/cases/messages-move-copy.yml new file mode 100644 index 000000000..7b12e1b0b --- /dev/null +++ b/skill-evals/cases/messages-move-copy.yml @@ -0,0 +1,12 @@ +task: Move message 789 over to the Marketing project's message board +tags: [move-copy, workflow] +mocks: + - match: '^messages move(?!.*--help)' + output: '{"ok":true,"data":{"id":7890,"source_id":789,"bucket":{"id":222,"name":"Marketing"}},"summary":"Moved message #789 to Marketing › Message Board as message #7890"}' +accept: + - '^messages move (?=.*\b789\b)(?=.*--to-project \W?Marketing)(?!.*--help)' +reject: + - '^messages (create|trash|update)' + - '^recordings\b' + - '^cards move' +max_commands: 4 diff --git a/skill-evals/cases/project-logo.yml b/skill-evals/cases/project-logo.yml new file mode 100644 index 000000000..f567907c8 --- /dev/null +++ b/skill-evals/cases/project-logo.yml @@ -0,0 +1,9 @@ +task: Use ./brand.png as the logo for project 55555 +tags: [domain, update] +accept: + - 'projects? logo set .*brand\.png' + - '(--in|--project|-p) 55555' +reject: + - 'projects? wallpaper' + - 'projects? update' + - 'basecamp api' diff --git a/skill-evals/cases/schedule-move-copy.yml b/skill-evals/cases/schedule-move-copy.yml new file mode 100644 index 000000000..6df646b68 --- /dev/null +++ b/skill-evals/cases/schedule-move-copy.yml @@ -0,0 +1,12 @@ +task: Move the calendar event 5678 to the Marketing project's schedule +tags: [move-copy, workflow] +mocks: + - match: '^schedule move(?!.*--help)' + output: '{"ok":true,"data":{"id":56780,"source_id":5678,"type":"Schedule::Entry"},"summary":"Moved schedule entry #5678 to Marketing › Schedule as schedule entry #56780"}' +accept: + - '^schedule move (?=.*\b5678\b)(?=.*--to-project \W?Marketing)(?!.*--help)' +reject: + - '^schedule (create|update)' + - '^calendars\b' + - '^recordings\b' +max_commands: 4 diff --git a/skill-evals/cases/todos-move-copy-bulk.yml b/skill-evals/cases/todos-move-copy-bulk.yml new file mode 100644 index 000000000..0e507d5ec --- /dev/null +++ b/skill-evals/cases/todos-move-copy-bulk.yml @@ -0,0 +1,7 @@ +task: Move to-dos 111, 222 and 333 into the Backlog list in the Ops project +tags: [domain, move] +accept: + - '^(?=.*\btodos (move|position) )(?=.*\b111\b)(?=.*\b222\b)(?=.*\b333\b)(?=.*--to-project[ =]["'']?Ops)(?=.*--to-list[ =]["'']?Backlog)' +reject: + - '(^|\s)--list[ =]' + - 'basecamp api' diff --git a/skill-evals/cases/todos-move-copy-copy.yml b/skill-evals/cases/todos-move-copy-copy.yml new file mode 100644 index 000000000..64c20c7c2 --- /dev/null +++ b/skill-evals/cases/todos-move-copy-copy.yml @@ -0,0 +1,7 @@ +task: Copy to-do 4567, with its comments, into the Launch list of the Marketing project +tags: [domain, copy] +accept: + - '^(?=.*\btodos copy )(?=.*\b4567\b)(?=.*--to-project[ =]["'']?Marketing)(?=.*--to-list[ =]["'']?Launch)(?=.*--include-comments)' +reject: + - '\btodos (move|position) ' + - 'basecamp api' diff --git a/skill-evals/cases/todos-move-copy-group.yml b/skill-evals/cases/todos-move-copy-group.yml new file mode 100644 index 000000000..55573163e --- /dev/null +++ b/skill-evals/cases/todos-move-copy-group.yml @@ -0,0 +1,8 @@ +task: Move the to-do group 789 into the Launch list in the Marketing project +tags: [domain, move] +accept: + - '^(?=.*\b(todolists?|todolistgroups?|tlgroups?) (move|position) )(?=.*\b789\b)(?=.*--to-project[ =]["'']?Marketing)(?=.*--to-list[ =]["'']?Launch)' +reject: + - '--to-todoset' + - '\btodos (move|position) ' + - 'basecamp api' diff --git a/skill-evals/cases/todos-move-copy-list.yml b/skill-evals/cases/todos-move-copy-list.yml new file mode 100644 index 000000000..bc7eabe7f --- /dev/null +++ b/skill-evals/cases/todos-move-copy-list.yml @@ -0,0 +1,8 @@ +task: Move the to-do list 555 to the Marketing project +tags: [domain, move] +accept: + - '^(?=.*\btodolists? (move|position) )(?=.*\b555\b)(?=.*--to-project[ =]["'']?Marketing)' +reject: + - '--to-list' + - '(^|\s)--to[ =]\d' + - 'basecamp api' diff --git a/skill-evals/cases/todos-move-copy-project.yml b/skill-evals/cases/todos-move-copy-project.yml new file mode 100644 index 000000000..584126401 --- /dev/null +++ b/skill-evals/cases/todos-move-copy-project.yml @@ -0,0 +1,8 @@ +task: Move to-do 4567 to the Launch list in the Marketing project +tags: [domain, move] +accept: + - '^(?=.*\btodos (move|position) )(?=.*\b4567\b)(?=.*--to-project[ =]["'']?Marketing)(?=.*--to-list[ =]["'']?Launch)' +reject: + - '(^|\s)--list[ =]' + - '(^|\s)--to[ =]\d' + - 'basecamp api' diff --git a/skill-evals/cases/tools-move.yml b/skill-evals/cases/tools-move.yml new file mode 100644 index 000000000..18d0b8c75 --- /dev/null +++ b/skill-evals/cases/tools-move.yml @@ -0,0 +1,12 @@ +task: Move the whole Docs & Files tool (dock tool 9753) from its project to the Marketing project +tags: [move-copy, workflow] +mocks: + - match: '^tools move(?!.*--help)' + output: '{"ok":true,"data":{"id":97530,"source_id":9753,"type":"Vault","bucket":{"id":222,"name":"Marketing"}},"summary":"Moved tool #9753 to Marketing as tool #97530"}' +accept: + - '^tools move (?=.*\b9753\b)(?=.*--to-project \W?Marketing)(?!.*--help)' +reject: + - '^tools (reposition|create|trash)' + - '^(files|docs|vaults) (move|copy)' + - '--position' +max_commands: 4 diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index b954b2c23..b5503556a 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -11,6 +11,7 @@ triggers: - basecamp subtasks - basecamp project - project wallpaper + - project logo - basecamp cards - basecamp chat - basecamp campfire @@ -261,6 +262,28 @@ basecamp projects wallpaper remove --in "<project>" --json The URL requires authentication and redirects to the image; it is not a public image link. +## Project logo + +The logo is the image beside a project's name, separate from its wallpaper. The +`projects logo` group sets and removes it, for the project in `--in`/`--project` +or the configured default: + +```bash +basecamp projects logo set brand.png --in "<project>" --json +basecamp projects logo remove --in "<project>" --json +``` + +- People and agents who can edit the project can change its logo. Others get + 403, and a project the caller cannot see gets 404; do not retry or route + around either refusal. +- The CLI checks the file's size and MIME hint before uploading; leaf help + names the limits. Server image validations still apply. Removing a logo from + a project that has none succeeds. +- Each command returns the project. `.data.logo` is `{url}`, and is absent when + the project has none; `projects show` carries the same field. The URL + requires authentication and redirects to the image; it is not a public image + link. + ## Content and stdin Command help declares content arguments and stdin support. Titles are generally