From 7ad9c98416441baaf3bb6f40b69e908d8261f498 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 27 Oct 2026 11:53:27 -0400 Subject: [PATCH 01/25] Bump basecamp-sdk to card-moves-filings for recording moves and copies --- go.mod | 2 +- go.sum | 4 +- internal/mcpserver/catalog_test.go | 2 +- internal/mcpserver/model/PROVENANCE.json | 4 +- internal/mcpserver/model/behavior-model.json | 34 ++ internal/mcpserver/model/openapi.json | 464 ++++++++++++++++++ .../mcpserver/testdata/catalog_snapshot.txt | 3 + internal/version/sdk-provenance.json | 6 +- nix/package.nix | 2 +- 9 files changed, 511 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index a66172eca..7d601e61f 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.29.2-0.20261027154747-dfac02e4936f 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..00ab998a6 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.29.2-0.20261027154747-dfac02e4936f h1:nWTQhK9pRyep0qRdZbjOq2bBg2OWm44dDPq8H12UZ2w= +github.com/basecamp/basecamp-sdk/go v0.29.2-0.20261027154747-dfac02e4936f/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/mcpserver/catalog_test.go b/internal/mcpserver/catalog_test.go index d785902aa..93c2a8c56 100644 --- a/internal/mcpserver/catalog_test.go +++ b/internal/mcpserver/catalog_test.go @@ -72,7 +72,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, 285, 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..7fcee62e9 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": "dfac02e4936fdefb092ab987bfb9cc22cbcb7753", + "ref": "go/v0.29.1-5-gdfac02e4", "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..d1fbe67be 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, diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index 8cf097de9..d7907422e 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -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`.", @@ -19284,6 +19386,130 @@ } } }, + "/{accountId}/recordings/{recordingId}/copies.json": { + "post": { + "description": "Start copying a recording into another project; poll GetFiling for the result.\n\nWorks like MoveRecording, except the original stays where it is. Name the\ndestination with `filing_destination_id`, a container of the kind the\nrecording sits in now, from the destination project's `dock` (GetProject): a\ncard copies into a card table (`kanban_board`) and lands in Triage, or in\nDone if it is completed. A dock tool is copied by `destination_project_id`\ninstead. Poll GetFiling with the returned filing's id until `status` leaves\n`pending` and `processing`; a completed filing's `destination_recording.id`\nis the copy.\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 only copies\nalong with its parent. 404 when the destination is not one the caller can\ncopy it into. 422 when the copy cannot start, such as when the recording is\nbeing moved or the destination cannot take it.", + "operationId": "CopyRecording", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CopyRecordingRequestContent" + } + } + } + }, + "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": "CopyRecording 201 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CopyRecordingResponseContent" + } + } + } + }, + "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}/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.", @@ -19737,6 +19963,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.\n\nName the destination with `filing_destination_id`: the container the\nrecording moves into, of the same kind it sits in now, found in the\ndestination project's `dock` (GetProject). A card moves into a card table\n(`kanban_board`) and lands in Triage, or in Done if it is completed; a card\ntable column moves into a card table too. A message moves into a\n`message_board`; a document, upload or folder into a `vault`; a schedule\nentry into a `schedule`. A dock tool itself (a message board, card table,\nDocs & Files, schedule) is moved by `destination_project_id` instead.\n\n400 when neither destination is given. 403 when the recording only moves\nalong with its parent, or the caller may not move it. 404 when the\ndestination is not one the caller can move it into. 422 when the move\ncannot start, such as when the recording is already being moved or the\ndestination cannot take it. A repeated move is harmless: while the first is\npending it is 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.", @@ -31887,6 +32237,33 @@ "visible_to_clients" ] }, + "CopyRecordingRequestContent": { + "type": "object", + "properties": { + "filing_destination_id": { + "type": "integer", + "description": "The container to copy into, from the destination project's dock: a card\ntable for a card, a message board for a message, a vault for a document,\nupload or folder, a schedule for a schedule entry. Give this or\ndestination_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 +34056,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 +34923,9 @@ "$ref": "#/components/schemas/BucketTodosGroup" } }, + "GetFilingResponseContent": { + "$ref": "#/components/schemas/Filing" + }, "GetFolderResponseContent": { "$ref": "#/components/schemas/FolderWithProjects" }, @@ -35702,6 +36143,29 @@ "column_id" ] }, + "MoveRecordingRequestContent": { + "type": "object", + "properties": { + "filing_destination_id": { + "type": "integer", + "description": "The container to move into, from the destination project's dock: a card\ntable for a card, a message board for a message, a vault for a document,\nupload or folder, a schedule for a schedule entry. Give this or\ndestination_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": { diff --git a/internal/mcpserver/testdata/catalog_snapshot.txt b/internal/mcpserver/testdata/catalog_snapshot.txt index e71d07449..3c866d9fb 100644 --- a/internal/mcpserver/testdata/catalog_snapshot.txt +++ b/internal/mcpserver/testdata/catalog_snapshot.txt @@ -240,6 +240,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 +260,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 +281,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/version/sdk-provenance.json b/internal/version/sdk-provenance.json index f547ddbf6..94a26c722 100644 --- a/internal/version/sdk-provenance.json +++ b/internal/version/sdk-provenance.json @@ -1,9 +1,9 @@ { "sdk": { "module": "github.com/basecamp/basecamp-sdk/go", - "version": "v0.29.1", - "revision": "0b66bbc3846d", - "updated_at": "2026-10-26T18:18:02Z" + "version": "v0.29.2-0.20261027154747-dfac02e4936f", + "revision": "dfac02e4936f", + "updated_at": "2026-10-27T15:47:47Z" }, "api": { "repo": "basecamp/bc3", diff --git a/nix/package.nix b/nix/package.nix index 6ca71a5e7..7ecd5b278 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-6RHqvaXnwckfF4WSqUhQ8q7bA3ayZ+lIZx3drlxlN8I="; subPackages = [ "cmd/basecamp" ]; From 769a9ff94fd3d42e8b6e42a5745c2cdafae75276 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 27 Oct 2026 11:57:17 -0400 Subject: [PATCH 02/25] Add a bounded backoff wait for background card filings --- internal/commands/cards.go | 33 +++++++++++++++ internal/commands/cards_filing_test.go | 57 ++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 internal/commands/cards_filing_test.go diff --git a/internal/commands/cards.go b/internal/commands/cards.go index e10e19d32..b956795c5 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -7,6 +7,7 @@ import ( "math" "strconv" "strings" + "time" "github.com/spf13/cobra" @@ -1769,6 +1770,38 @@ func parseColumnID(ref string) (int64, error) { return id, nil } +// cardFilingWait bounds the wait for a card that Basecamp moves or copies in +// the background: checks start first apart, back off to max, and stop after +// total. Tests shorten it. +var cardFilingWait = struct{ first, max, total time.Duration }{ + first: 250 * time.Millisecond, + max: 2 * time.Second, + total: time.Minute, +} + +// pollUntil runs check, backing off between runs, until it reports done or +// cardFilingWait.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, cardFilingWait.total) + defer cancel() + + for delay := cardFilingWait.first; ; delay = min(2*delay, cardFilingWait.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): + } + } +} + func newCardsDoneCmd(project, cardTable *string) *cobra.Command { cmd := &cobra.Command{ Use: "done ", diff --git a/internal/commands/cards_filing_test.go b/internal/commands/cards_filing_test.go new file mode 100644 index 000000000..706ecff70 --- /dev/null +++ b/internal/commands/cards_filing_test.go @@ -0,0 +1,57 @@ +package commands + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func shortFilingWait(t *testing.T) { + t.Helper() + saved := cardFilingWait + cardFilingWait.first = time.Millisecond + cardFilingWait.max = 2 * time.Millisecond + cardFilingWait.total = 50 * time.Millisecond + t.Cleanup(func() { cardFilingWait = 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) + cardFilingWait.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) +} From 8f771a05300f1d7b3a6b1768bf93f6d70631ce87 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 27 Oct 2026 12:00:15 -0400 Subject: [PATCH 03/25] Move a card to another project's card table with cards move --to-project --- internal/commands/cards.go | 286 ++++++++++++++++++++++++- internal/commands/cards_filing_test.go | 213 ++++++++++++++++++ internal/output/codes.go | 6 + 3 files changed, 497 insertions(+), 8 deletions(-) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index b956795c5..b41a8ea26 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -1303,6 +1303,7 @@ 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 ", @@ -1346,8 +1347,16 @@ the project doesn't need one.`, 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 := validateFilingFlags(*cardTable, 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") } @@ -1382,6 +1391,10 @@ the project doesn't need one.`, return output.ErrUsage("Invalid card ID") } + if filingSet { + return moveCardToCardTable(cmd, app, cardID, toProject, toCardTable) + } + // --to-wormhole: teleport across projects. Self-contained — it derives // the source project and card table from the fetched card, so it runs // before (and instead of) the generic project resolution below. @@ -1515,6 +1528,7 @@ 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 } @@ -1770,6 +1784,256 @@ func parseColumnID(ref string) (int64, error) { return id, nil } +// 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)") +} + +// validateFilingFlags checks --to-project and --to-card-table before any +// request. The group's --card-table names the card's own table, so it is +// refused here rather than mistaken for the destination. +func validateFilingFlags(sourceCardTable, toProject, toCardTable string) error { + if sourceCardTable != "" { + return output.ErrUsageHint( + "--card-table names the card's own card table, not where it goes", + "Use --to-card-table for the destination card table", + ) + } + 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 , or pass the card table's ID or URL", + ) + } + 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, cardID int64, toProject, toCardTable string) error { + card, err := app.Account().Cards().Get(cmd.Context(), cardID) + if err != nil { + return convertSDKError(err) + } + table, err := resolveFilingTable(cmd, app, toProject, toCardTable) + if 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 --card-table %d --in %d", cardID, table.ID, cardTableProjectID(table)), + ) + } + return runCardFiling(cmd, app, cardFiling{kind: basecamp.FilingMove, card: card, table: table}) +} + +// resolveFilingTable finds the card table named by --to-project and +// --to-card-table: a card table ID or URL on its own, a project's only card +// table, or one of a project's card tables picked by ID or title. Only card +// tables on the project's dock count, as in the web's dialogs. +func resolveFilingTable(cmd *cobra.Command, app *appctx.App, toProject, toCardTable string) (*basecamp.CardTable, error) { + if urlarg.IsURL(toCardTable) { + parsed := urlarg.Parse(toCardTable) + if parsed == nil || parsed.Type != "card_tables" || parsed.IsCollection { + return nil, output.ErrUsageHint( + fmt.Sprintf("Invalid card table %q", toCardTable), + "Pass a card table ID, a Basecamp card table URL (.../card_tables/{id}), or a card table name with --to-project", + ) + } + toCardTable = parsed.RecordingID + } + + if toProject == "" { + tableID, err := strconv.ParseInt(toCardTable, 10, 64) + if err != nil || tableID <= 0 { + return nil, output.ErrUsage("--to-card-table must be a positive card table ID") + } + return getCardTable(cmd, app, tableID) + } + + projectID, _, err := app.Names.ResolveProject(cmd.Context(), toProject) + if err != nil { + return nil, err + } + all, err := listProjectCardTables(cmd, app, projectID) + if err != nil { + return nil, err + } + var tables []projectCardTable + for _, ct := range all { + if ct.Enabled { + tables = append(tables, ct) + } + } + + if toCardTable == "" { + switch len(tables) { + case 0: + return nil, output.ErrNotFound("card table", projectID) + case 1: + return getCardTable(cmd, app, tables[0].ID) + default: + return nil, ambiguousCardTablesErrorFor("--to-card-table", tables) + } + } + for _, ct := range tables { + if strconv.FormatInt(ct.ID, 10) == toCardTable || strings.EqualFold(ct.Title, toCardTable) { + return getCardTable(cmd, app, ct.ID) + } + } + return nil, output.ErrUsageHint( + fmt.Sprintf("Card table '%s' not found in project %s", toCardTable, projectID), + fmt.Sprintf("Available card tables: %s", formatCardTableIDs(tables)), + ) +} + +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 one 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. Basecamp files it in the background, so runCardFiling waits. +type cardFiling struct { + kind basecamp.FilingType + card *basecamp.Card + table *basecamp.CardTable + includeComments bool +} + +func (f cardFiling) start(ctx context.Context, app *appctx.App) (*basecamp.Filing, error) { + recordings := app.Account().Recordings() + if f.kind == basecamp.FilingCopy { + return recordings.Copy(ctx, f.card.ID, &basecamp.CopyRecordingRequest{ + FilingDestinationID: f.table.ID, + IncludeComments: f.includeComments, + }) + } + return recordings.Move(ctx, f.card.ID, &basecamp.MoveRecordingRequest{FilingDestinationID: f.table.ID}) +} + +// verbs returns the filing's verb as an infinitive, a progressive and a past. +func (f cardFiling) verbs() (string, string, string) { + if f.kind == basecamp.FilingCopy { + return "copy", "copying", "Copied" + } + return "move", "moving", "Moved" +} + +// runCardFiling starts the filing once, never retrying it (each copy request +// makes another copy), waits for Basecamp to finish it, and outputs the card it +// made. +func runCardFiling(cmd *cobra.Command, app *appctx.App, f cardFiling) error { + filing, err := f.start(cmd.Context(), app) + if err != nil { + return convertSDKError(err) + } + + finished := func() bool { + return filing.Status == basecamp.FilingCompleted || filing.Status == basecamp.FilingFailed + } + _, waitErr := pollUntil(cmd.Context(), 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, progressive, past := f.verbs() + switch { + case filing.Status == basecamp.FilingFailed: + return &output.Error{ + Code: output.CodeAPI, + Message: fmt.Sprintf("Basecamp could not %s card #%d to card table %d (%s)", verb, f.card.ID, f.table.ID, f.table.Title), + Hint: fmt.Sprintf("Check the card with: basecamp cards show %d", f.card.ID), + } + case filing.Status != basecamp.FilingCompleted || filing.DestinationRecording == nil: + hint := fmt.Sprintf("Once it lands, basecamp cards show %d returns the card at its new id", f.card.ID) + if f.kind == basecamp.FilingCopy { + hint = fmt.Sprintf("Do not copy it again, which makes another copy. The copy lands in that table's Triage, or Done if completed: basecamp cards list --in %d --card-table %d", + cardTableProjectID(f.table), f.table.ID) + } + return &output.Error{ + Code: output.CodeInProgress, + Message: fmt.Sprintf("Basecamp is still %s card #%d to card table %d (%s)", progressive, f.card.ID, f.table.ID, f.table.Title), + Hint: hint, + Cause: waitErr, + } + } + + filed, err := app.Account().Cards().Get(cmd.Context(), filing.DestinationRecording.ID) + if err != nil { + return &output.Error{ + Code: output.CodeAPI, + Message: fmt.Sprintf("%s card #%d as card #%d, but could not read the new card", past, f.card.ID, filing.DestinationRecording.ID), + Hint: fmt.Sprintf("Show it with: basecamp cards show %d", filing.DestinationRecording.ID), + Cause: err, + } + } + return filedCardOK(app, filed, f.card.ID, past) +} + +// 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 filedCardOK(app *appctx.App, card *basecamp.Card, sourceID int64, past string) error { + place := []string{} + if card.Bucket != nil { + place = append(place, card.Bucket.Name) + } + if card.Parent != nil { + place = append(place, card.Parent.Title) + } + summary := fmt.Sprintf("%s card #%d to %s as card #%d", past, sourceID, strings.Join(place, " › "), card.ID) + + var projectFlag string + if card.Bucket != nil { + projectFlag = fmt.Sprintf(" --in %d", card.Bucket.ID) + } + return app.OK(filedCardResult{Card: card, SourceID: sourceID}, + output.WithSummary(summary), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "view", + Cmd: fmt.Sprintf("basecamp cards show %d%s", card.ID, projectFlag), + Description: "View the card", + }, + output.Breadcrumb{ + Action: "move", + Cmd: fmt.Sprintf("basecamp cards move %d --to %s", card.ID, projectFlag), + Description: "Move it to a column", + }, + ), + ) +} + // cardFilingWait bounds the wait for a card that Basecamp moves or copies in // the background: checks start first apart, back off to max, and stop after // total. Tests shorten it. @@ -3401,8 +3665,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 @@ -3534,9 +3799,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 { @@ -3546,7 +3812,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}) } } @@ -3668,6 +3934,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 @@ -3679,7 +3949,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 :\n%s", strings.Join(lines, "\n")), + Hint: fmt.Sprintf("Specify one with %s :\n%s", flag, strings.Join(lines, "\n")), } } diff --git a/internal/commands/cards_filing_test.go b/internal/commands/cards_filing_test.go index 706ecff70..cb21b52cb 100644 --- a/internal/commands/cards_filing_test.go +++ b/internal/commands/cards_filing_test.go @@ -2,11 +2,20 @@ package commands import ( "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" ) func shortFilingWait(t *testing.T) { @@ -55,3 +64,207 @@ func TestPollUntilHonoursCancellation(t *testing.T) { 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. +// - 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 + statuses []string + + mu sync.Mutex + 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() + 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) + } + 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"}]`) + 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}` + } + 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 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/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) +} + +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"]) + }) + } +} + +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 ") + 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 cards show 456") + assert.Len(t, transport.posts, 1, "the move is never sent twice") + assert.Greater(t, transport.filingGet, 1) +} 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 { From 42db95f0c1146524fd656d5431d511333990df6e Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 27 Oct 2026 12:09:37 -0400 Subject: [PATCH 04/25] Add cards copy, onto the card's own table or another project's --- internal/commands/cards.go | 83 ++++++++++++++++++++++++++ internal/commands/cards_filing_test.go | 44 ++++++++++++++ internal/commands/commands.go | 2 +- 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index b41a8ea26..8cb2ac1c7 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -43,6 +43,7 @@ func NewCardsCmd() *cobra.Command { newCardsCreateCmd(&project, &cardTable), newCardsUpdateCmd(), newCardsMoveCmd(&project, &cardTable), + newCardsCopyCmd(&cardTable), newCardsDoneCmd(&project, &cardTable), newCardsColumnsCmd(&project, &cardTable), newCardsColumnCmd(&project, &cardTable), @@ -1784,6 +1785,88 @@ func parseColumnID(ref string) (int64, error) { return id, nil } +func newCardsCopyCmd(cardTable *string) *cobra.Command { + var toProject, toCardTable string + var includeComments bool + + cmd := &cobra.Command{ + Use: "copy ", + 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.", + }, + RunE: func(cmd *cobra.Command, args []string) error { + filingSet := cmd.Flags().Changed("to-project") || cmd.Flags().Changed("to-card-table") + if filingSet || *cardTable != "" { + if err := validateFilingFlags(*cardTable, toProject, toCardTable); err != nil { + return err + } + } + + app := appctx.FromContext(cmd.Context()) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + cardIDStr, err := extractID(args[0]) + if err != nil { + return err + } + cardID, err := strconv.ParseInt(cardIDStr, 10, 64) + if err != nil { + return output.ErrUsage("Invalid card ID") + } + + card, err := app.Account().Cards().Get(cmd.Context(), cardID) + if err != nil { + return convertSDKError(err) + } + + var table *basecamp.CardTable + if filingSet { + table, err = resolveFilingTable(cmd, app, toProject, toCardTable) + } else { + if card.Bucket == nil { + return output.ErrNotFound("card", cardIDStr) + } + _, table, err = resolveCardTableForCard(cmd, app, strconv.FormatInt(card.Bucket.ID, 10), "", card) + } + if err != nil { + return err + } + + return runCardFiling(cmd, app, cardFiling{ + kind: basecamp.FilingCopy, + card: card, + table: table, + includeComments: includeComments, + }) + }, + } + + addFilingDestinationFlags(cmd, &toProject, &toCardTable, "Copy") + cmd.Flags().BoolVar(&includeComments, "include-comments", false, "Copy the card's comments too") + + return 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) { diff --git a/internal/commands/cards_filing_test.go b/internal/commands/cards_filing_test.go index cb21b52cb..77da93827 100644 --- a/internal/commands/cards_filing_test.go +++ b/internal/commands/cards_filing_test.go @@ -268,3 +268,47 @@ func TestCardsMoveStillRunningWhenTheWaitRunsOut(t *testing.T) { 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 cards list --in 999 --card-table 900") + assert.Len(t, transport.posts, 1) +} + +func TestCardsCopyRefusesTheSourceCardTableFlag(t *testing.T) { + transport := &mockFilingTransport{} + run := runFilingCommand(t, transport, "copy", "456", "--card-table", "555") + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeUsage, e.Code) + assert.Contains(t, e.Hint, "--to-card-table") + assert.Empty(t, transport.posts) +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 7936ac2d7..99f78aa6e 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -45,7 +45,7 @@ func CommandCategories() []CommandCategory { {Name: "messages", Category: "core", Description: "Manage messages", Actions: []string{"list", "show", "create", "update", "publish", "pin", "unpin", "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: "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", "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"}}, From 333b42cbdcda7b2cfc30ec393a61790092845c2e Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 27 Oct 2026 12:10:29 -0400 Subject: [PATCH 05/25] Show where a card teleported through a wormhole landed --- internal/commands/cards.go | 32 ++++++++++++++++++------ internal/commands/cards_test.go | 44 ++++++++++++++++++++++++++++++--- 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index 8cb2ac1c7..06376a240 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "net/http" "strconv" "strings" "time" @@ -1543,15 +1544,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) @@ -1622,13 +1623,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 filedCardOK(app, landed, cardID, "Teleported") + } + 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), diff --git a/internal/commands/cards_test.go b/internal/commands/cards_test.go index 2311cc0a1..2cf60f265 100644 --- a/internal/commands/cards_test.go +++ b/internal/commands/cards_test.go @@ -2054,6 +2054,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 @@ -2088,6 +2090,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"}}` @@ -2116,6 +2121,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 @@ -2163,16 +2169,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 From de0e6b73a48c59978e09a34a0947c71930fdaf31 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 27 Oct 2026 12:10:59 -0400 Subject: [PATCH 06/25] Point a not-found cards move --to at the cross-project moves --- internal/commands/cards.go | 7 +++++++ internal/commands/cards_filing_test.go | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index 06376a240..61405a3bf 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -1488,6 +1488,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 (or --to-wormhole)", + cardIDStr)) + } if err != nil { return convertSDKError(err) } diff --git a/internal/commands/cards_filing_test.go b/internal/commands/cards_filing_test.go index 77da93827..ca14aa0a3 100644 --- a/internal/commands/cards_filing_test.go +++ b/internal/commands/cards_filing_test.go @@ -71,6 +71,7 @@ func TestPollUntilHonoursCancellation(t *testing.T) { // - 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. +// - 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 { @@ -100,6 +101,10 @@ func (m *mockFilingTransport) RoundTrip(req *http.Request) (*http.Response, erro m.body = map[string]any{} _ = json.Unmarshal(raw, &m.body) } + 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" @@ -312,3 +317,14 @@ func TestCardsCopyRefusesTheSourceCardTableFlag(t *testing.T) { assert.Contains(t, e.Hint, "--to-card-table") assert.Empty(t, transport.posts) } + +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 ") + assert.Contains(t, e.Hint, "--to-wormhole") +} From c6ee2bf1d43a0aa0323dc90ab159498a837d265e Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 27 Oct 2026 12:11:59 -0400 Subject: [PATCH 07/25] Document cross-project card moves and copies, and refresh the surface --- .surface | 32 ++++++++++++++++++++++++++++++++ API-COVERAGE.md | 16 +++++++++++----- e2e/smoke/smoke_cards_write.bats | 13 +++++++++++++ internal/commands/cards.go | 30 ++++++++++++++++++++++-------- 4 files changed, 78 insertions(+), 13 deletions(-) diff --git a/.surface b/.surface index 1b6b8238d..d565b1c40 100644 --- a/.surface +++ b/.surface @@ -59,6 +59,7 @@ ARG basecamp cards column show 00 ARG basecamp cards column unwatch 00 ARG basecamp cards column update 00 ARG basecamp cards column watch 00 +ARG basecamp cards copy 00 ARG basecamp cards create 00 ARG basecamp cards create 01 [body] ARG basecamp cards done 00 <id|url> @@ -624,6 +625,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 @@ -3358,6 +3360,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 +3495,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 +3524,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 @@ -19647,6 +19678,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 diff --git a/API-COVERAGE.md b/API-COVERAGE.md index bb0d99b41..016bd3b07 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -6,12 +6,17 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. | Status | Sections | Endpoints | |--------|----------|-----------| -| ✅ Implemented | 55 | 213 | +| ✅ Implemented | 56 | 216 | | ⚠️ 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** | **60** | **228** | + +**216 of 216 tracked in-scope endpoints.** 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`), 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`. 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)) @@ -231,7 +236,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` | ✅ | 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. `--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. Moving or copying other recording types is not exposed yet | | **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 | 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/internal/commands/cards.go b/internal/commands/cards.go index 61405a3bf..791591f88 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -1309,8 +1309,9 @@ func newCardsMoveCmd(project, cardTable *string) *cobra.Command { 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 @@ -1330,10 +1331,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{ @@ -1343,8 +1354,11 @@ 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 means Basecamp is still moving it: basecamp cards show <old id> returns the new card once it lands.", }, RunE: func(cmd *cobra.Command, args []string) error { positionSet := cmd.Flags().Changed("position") || cmd.Flags().Changed("pos") From 5e2769a394cd073c5a7e94f733ca1cb674c0ae7b Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 27 Oct 2026 12:28:30 -0400 Subject: [PATCH 08/25] Name the project when --to-project has no card table to file into --- internal/commands/cards.go | 16 ++++++++++++++-- internal/commands/cards_filing_test.go | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index 791591f88..fd8658f2c 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -1978,7 +1978,7 @@ func resolveFilingTable(cmd *cobra.Command, app *appctx.App, toProject, toCardTa return getCardTable(cmd, app, tableID) } - projectID, _, err := app.Names.ResolveProject(cmd.Context(), toProject) + projectID, projectName, err := app.Names.ResolveProject(cmd.Context(), toProject) if err != nil { return nil, err } @@ -1996,7 +1996,19 @@ func resolveFilingTable(cmd *cobra.Command, app *appctx.App, toProject, toCardTa if toCardTable == "" { switch len(tables) { case 0: - return nil, output.ErrNotFound("card table", projectID) + if projectName == "" { + projectName = projectID + if id, perr := strconv.ParseInt(projectID, 10, 64); perr == nil { + if name, nerr := app.Names.ProjectName(cmd.Context(), id); nerr == nil && name != "" { + projectName = name + } + } + } + return nil, &output.Error{ + Code: output.CodeNotFound, + Message: fmt.Sprintf("Project %s has no card table", projectName), + Hint: "Pick a project that has one, or add a card table there first", + } case 1: return getCardTable(cmd, app, tables[0].ID) default: diff --git a/internal/commands/cards_filing_test.go b/internal/commands/cards_filing_test.go index ca14aa0a3..829648834 100644 --- a/internal/commands/cards_filing_test.go +++ b/internal/commands/cards_filing_test.go @@ -114,7 +114,9 @@ func (m *mockFilingTransport) RoundTrip(req *http.Request) (*http.Response, erro switch { case strings.HasSuffix(path, "/projects"): - return respond(200, `[{"id":123,"name":"Source"},{"id":999,"name":"Dest"}]`) + return respond(200, `[{"id":123,"name":"Source"},{"id":999,"name":"Dest"},{"id":888,"name":"Bare"}]`) + 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/123"): return respond(200, `{"id":123,"dock":[{"name":"kanban_board","id":555,"title":"Board","enabled":true}]}`) case strings.HasSuffix(path, "/projects/999"): @@ -209,6 +211,17 @@ func TestCardsMoveToProjectWithSeveralCardTablesIsAmbiguous(t *testing.T) { assert.Empty(t, transport.posts) } +func TestCardsMoveToProjectWithoutCardTableNamesTheProject(t *testing.T) { + transport := &mockFilingTransport{} + run := runFilingCommand(t, transport, "move", "456", "--to-project", "Bare") + e := requireFilingError(t, run) + + assert.Equal(t, output.CodeNotFound, e.Code) + assert.Equal(t, "Project Bare has no card table", e.Message) + assert.NotEmpty(t, e.Hint) + assert.Empty(t, transport.posts) +} + func TestCardsMoveToExplicitCardTable(t *testing.T) { for name, args := range map[string][]string{ "id": {"--to-card-table", "950"}, From afe17a5b2f43ccdc3639e8b9e9f14a6cd5dec1f4 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 27 Oct 2026 12:36:54 -0400 Subject: [PATCH 09/25] Never invite a retry of a copy that may have started, and refuse a card table name two tables share A 5xx or dropped connection on the copy request can follow a copy Basecamp already started, so the error is no longer retryable and says how to check. A rate limit stays retryable: Basecamp refuses it before copying. A --to-card-table name that matches two tables now lists both instead of taking the first. --- internal/commands/cards.go | 31 ++++++++++++++++-- internal/commands/cards_filing_test.go | 44 ++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index fd8658f2c..7d8084395 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -2015,10 +2015,21 @@ func resolveFilingTable(cmd *cobra.Command, app *appctx.App, toProject, toCardTa return nil, ambiguousCardTablesErrorFor("--to-card-table", tables) } } + var named []projectCardTable for _, ct := range tables { - if strconv.FormatInt(ct.ID, 10) == toCardTable || strings.EqualFold(ct.Title, toCardTable) { + if strconv.FormatInt(ct.ID, 10) == toCardTable { return getCardTable(cmd, app, ct.ID) } + if strings.EqualFold(ct.Title, toCardTable) { + named = append(named, ct) + } + } + switch len(named) { + case 1: + return getCardTable(cmd, app, named[0].ID) + case 0: + default: + return nil, ambiguousCardTablesErrorFor("--to-card-table", named) } return nil, output.ErrUsageHint( fmt.Sprintf("Card table '%s' not found in project %s", toCardTable, projectID), @@ -2062,6 +2073,22 @@ func (f cardFiling) start(ctx context.Context, app *appctx.App) (*basecamp.Filin return recordings.Move(ctx, f.card.ID, &basecamp.MoveRecordingRequest{FilingDestinationID: f.table.ID}) } +// copyStartError keeps a failed copy request from inviting a retry. A 5xx or a +// dropped connection can arrive after Basecamp already started the copy, so +// retrying would make a second copy. A rate limit is the one retryable failure +// Basecamp answers before doing anything. +func copyStartError(f cardFiling, err error) error { + e := output.AsError(err) + if f.kind != basecamp.FilingCopy || e == nil || !e.Retryable || e.Code == output.CodeRateLimit { + return err + } + unsure := *e + unsure.Retryable = false + unsure.Hint = fmt.Sprintf("Basecamp may have started the copy before this failed, and copying again makes another copy. Check first: basecamp cards list --in %d --card-table %d", + cardTableProjectID(f.table), f.table.ID) + return &unsure +} + // verbs returns the filing's verb as an infinitive, a progressive and a past. func (f cardFiling) verbs() (string, string, string) { if f.kind == basecamp.FilingCopy { @@ -2076,7 +2103,7 @@ func (f cardFiling) verbs() (string, string, string) { func runCardFiling(cmd *cobra.Command, app *appctx.App, f cardFiling) error { filing, err := f.start(cmd.Context(), app) if err != nil { - return convertSDKError(err) + return copyStartError(f, convertSDKError(err)) } finished := func() bool { diff --git a/internal/commands/cards_filing_test.go b/internal/commands/cards_filing_test.go index 829648834..2795cb228 100644 --- a/internal/commands/cards_filing_test.go +++ b/internal/commands/cards_filing_test.go @@ -75,8 +75,10 @@ func TestPollUntilHonoursCancellation(t *testing.T) { // - 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 - statuses []string + twoTables bool + duplicateTitle bool + postStatus int + statuses []string mu sync.Mutex posts []string @@ -101,6 +103,9 @@ func (m *mockFilingTransport) RoundTrip(req *http.Request) (*http.Response, erro m.body = map[string]any{} _ = json.Unmarshal(raw, &m.body) } + 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"}`) @@ -124,6 +129,9 @@ func (m *mockFilingTransport) RoundTrip(req *http.Request) (*http.Response, erro 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}` + } 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"}]}`) @@ -222,6 +230,38 @@ func TestCardsMoveToProjectWithoutCardTableNamesTheProject(t *testing.T) { 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 cards list --in 999 --card-table 900") + 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 TestCardsMoveToExplicitCardTable(t *testing.T) { for name, args := range map[string][]string{ "id": {"--to-card-table", "950"}, From 1a47d1b277049f85990635ec5471676b4195a66d Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 27 Oct 2026 12:40:00 -0400 Subject: [PATCH 10/25] Keep an accepted copy's no-retry error when the wait hits the rate-limit gate output.AsError renders a gate error found anywhere in the cause chain in place of the error around it, so a gate trip while polling turned "do not copy it again" into a retryable rate limit. The in-progress and read-back errors now keep their cause as text. --- internal/commands/cards.go | 41 ++++++++++++++++++-------- internal/commands/cards_filing_test.go | 17 +++++++++++ 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index 7d8084395..4f6cc3699 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -2130,17 +2130,7 @@ func runCardFiling(cmd *cobra.Command, app *appctx.App, f cardFiling) error { Hint: fmt.Sprintf("Check the card with: basecamp cards show %d", f.card.ID), } case filing.Status != basecamp.FilingCompleted || filing.DestinationRecording == nil: - hint := fmt.Sprintf("Once it lands, basecamp cards show %d returns the card at its new id", f.card.ID) - if f.kind == basecamp.FilingCopy { - hint = fmt.Sprintf("Do not copy it again, which makes another copy. The copy lands in that table's Triage, or Done if completed: basecamp cards list --in %d --card-table %d", - cardTableProjectID(f.table), f.table.ID) - } - return &output.Error{ - Code: output.CodeInProgress, - Message: fmt.Sprintf("Basecamp is still %s card #%d to card table %d (%s)", progressive, f.card.ID, f.table.ID, f.table.Title), - Hint: hint, - Cause: waitErr, - } + return stillFilingError(f, progressive, waitErr) } filed, err := app.Account().Cards().Get(cmd.Context(), filing.DestinationRecording.ID) @@ -2149,12 +2139,39 @@ func runCardFiling(cmd *cobra.Command, app *appctx.App, f cardFiling) error { Code: output.CodeAPI, Message: fmt.Sprintf("%s card #%d as card #%d, but could not read the new card", past, f.card.ID, filing.DestinationRecording.ID), Hint: fmt.Sprintf("Show it with: basecamp cards show %d", filing.DestinationRecording.ID), - Cause: err, + Cause: textCause(err), } } return filedCardOK(app, filed, f.card.ID, past) } +// stillFilingError reports a move or copy that Basecamp accepted but had not +// finished when the wait ended. +func stillFilingError(f cardFiling, progressive string, waitErr error) error { + hint := fmt.Sprintf("Once it lands, basecamp cards show %d returns the card at its new id", f.card.ID) + if f.kind == basecamp.FilingCopy { + hint = fmt.Sprintf("Do not copy it again, which makes another copy. The copy lands in that table's Triage, or Done if completed: basecamp cards list --in %d --card-table %d", + cardTableProjectID(f.table), f.table.ID) + } + return &output.Error{ + Code: output.CodeInProgress, + Message: fmt.Sprintf("Basecamp is still %s card #%d to card table %d (%s)", progressive, f.card.ID, f.table.ID, f.table.Title), + 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()) +} + // filedCardResult is a card that a move, copy or teleport made, with the id of // the card it came from. type filedCardResult struct { diff --git a/internal/commands/cards_filing_test.go b/internal/commands/cards_filing_test.go index 2795cb228..56ba7957f 100644 --- a/internal/commands/cards_filing_test.go +++ b/internal/commands/cards_filing_test.go @@ -12,10 +12,12 @@ import ( "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) { @@ -262,6 +264,21 @@ func TestCardsCopyThatIsRateLimitedStaysRetryable(t *testing.T) { assert.NotContains(t, e.Hint, "may have started the copy") } +func TestAcceptedCopyStaysNotRetryableWhenTheWaitHitsTheRateLimitGate(t *testing.T) { + f := cardFiling{ + kind: basecamp.FilingCopy, + card: &basecamp.Card{ID: 456}, + table: &basecamp.CardTable{ID: 900, Title: "Ideas", Bucket: &basecamp.Bucket{ID: 999}}, + } + gate := &resilience.GateError{Message: "rate limited by a recent 429", Hint: "Re-run in 30s"} + + e := output.AsError(stillFilingError(f, "copying", 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 TestCardsMoveToExplicitCardTable(t *testing.T) { for name, args := range map[string][]string{ "id": {"--to-card-table", "950"}, From 7bd65f6f61e0a55d2f40abffbb1bb262efce1e91 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 27 Oct 2026 12:48:13 -0400 Subject: [PATCH 11/25] Follow a moved card or to-do to its new id in show and steps Reading a moved record's old id redirects to the record at its new id, but cards show, cards steps and todos show kept using the old id for the full step list, comments and follow-up commands, which then 404. --- internal/commands/cards.go | 15 +++++++++ internal/commands/subtasks_test.go | 50 ++++++++++++++++++++++++++++++ internal/commands/todos.go | 6 ++++ 3 files changed, 71 insertions(+) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index 4f6cc3699..c63ac718f 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -813,6 +813,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 } @@ -3432,6 +3438,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 diff --git a/internal/commands/subtasks_test.go b/internal/commands/subtasks_test.go index d348c3cd6..cf2271728 100644 --- a/internal/commands/subtasks_test.go +++ b/internal/commands/subtasks_test.go @@ -515,3 +515,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/todos.go b/internal/commands/todos.go index 100d324aa..89d6eb449 100644 --- a/internal/commands/todos.go +++ b/internal/commands/todos.go @@ -1162,6 +1162,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 } From 12d41d2680267feb8f32117057b3adfa69ca6dc9 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 27 Oct 2026 13:41:03 -0400 Subject: [PATCH 12/25] Refuse a cross-table move or copy when --in or the card URL names another project cards move --to-project/--to-card-table and cards copy took the card id and ignored an explicit --in, --project or card URL bucket, so a card in project 123 moved even when the caller said 888. They now check those against the card's own project before filing it, as the wormhole move already did. The check is renamed rejectCardProjectConflict and its hint no longer names wormholes. Config and interactive defaults are not constraints. --- internal/commands/cards.go | 40 ++++++++++++++++---------- internal/commands/cards_filing_test.go | 40 ++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index c63ac718f..5ccd0304c 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -44,7 +44,7 @@ func NewCardsCmd() *cobra.Command { newCardsCreateCmd(&project, &cardTable), newCardsUpdateCmd(), newCardsMoveCmd(&project, &cardTable), - newCardsCopyCmd(&cardTable), + newCardsCopyCmd(&project, &cardTable), newCardsDoneCmd(&project, &cardTable), newCardsColumnsCmd(&project, &cardTable), newCardsColumnCmd(&project, &cardTable), @@ -1414,7 +1414,7 @@ Basecamp to finish the move and shows the card where it landed.`, } if filingSet { - return moveCardToCardTable(cmd, app, cardID, toProject, toCardTable) + return moveCardToCardTable(cmd, app, cardID, *project, urlProjectID, toProject, toCardTable) } // --to-wormhole: teleport across projects. Self-contained — it derives @@ -1609,7 +1609,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 := rejectCardProjectConflict(cmd, app, flagProject, urlProjectID, card.Bucket.ID); err != nil { return err } @@ -1702,11 +1702,12 @@ func wormholeMoveBreadcrumbs(sourceProjectID, cardTableID string) []output.Bread }} } -// rejectWormholeProjectConflict fails when an explicitly supplied project (the +// rejectCardProjectConflict 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 { +// than the card's own bucket, before a wormhole move, a move into another card +// table, or a copy acts on the card. Config/interactive defaults are not +// checked — only explicit caller context can conflict. +func rejectCardProjectConflict(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 == "" { @@ -1719,7 +1720,7 @@ func rejectWormholeProjectConflict(cmd *cobra.Command, app *appctx.App, flagProj 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", + "Omit the conflicting project — the card's own project is always used", ) } return nil @@ -1730,7 +1731,7 @@ func rejectWormholeProjectConflict(cmd *cobra.Command, app *appctx.App, flagProj // 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. + // past into a move or copy. if err := check(app.Flags.Project, "--project"); err != nil { return err } @@ -1828,7 +1829,7 @@ func parseColumnID(ref string) (int64, error) { return id, nil } -func newCardsCopyCmd(cardTable *string) *cobra.Command { +func newCardsCopyCmd(project, cardTable *string) *cobra.Command { var toProject, toCardTable string var includeComments bool @@ -1868,7 +1869,7 @@ the new card.`, return err } - cardIDStr, err := extractID(args[0]) + cardIDStr, urlProjectID, err := extractWithProject(args[0]) if err != nil { return err } @@ -1881,14 +1882,17 @@ the new card.`, if err != nil { return convertSDKError(err) } + if card.Bucket == nil { + return output.ErrNotFound("card", cardIDStr) + } + if err := rejectCardProjectConflict(cmd, app, *project, urlProjectID, card.Bucket.ID); err != nil { + return err + } var table *basecamp.CardTable if filingSet { table, err = resolveFilingTable(cmd, app, toProject, toCardTable) } else { - if card.Bucket == nil { - return output.ErrNotFound("card", cardIDStr) - } _, table, err = resolveCardTableForCard(cmd, app, strconv.FormatInt(card.Bucket.ID, 10), "", card) } if err != nil { @@ -1942,11 +1946,17 @@ func validateFilingFlags(sourceCardTable, toProject, toCardTable string) error { // 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, cardID int64, toProject, toCardTable string) error { +func moveCardToCardTable(cmd *cobra.Command, app *appctx.App, cardID int64, flagProject, urlProjectID, toProject, toCardTable string) error { card, err := app.Account().Cards().Get(cmd.Context(), cardID) if err != nil { return convertSDKError(err) } + if card.Bucket == nil { + return output.ErrNotFound("card", strconv.FormatInt(cardID, 10)) + } + if err := rejectCardProjectConflict(cmd, app, flagProject, urlProjectID, card.Bucket.ID); err != nil { + return err + } table, err := resolveFilingTable(cmd, app, toProject, toCardTable) if err != nil { return err diff --git a/internal/commands/cards_filing_test.go b/internal/commands/cards_filing_test.go index 56ba7957f..528c34a42 100644 --- a/internal/commands/cards_filing_test.go +++ b/internal/commands/cards_filing_test.go @@ -398,3 +398,43 @@ func TestCardsMoveToAForeignColumnPointsAtCrossProjectMoves(t *testing.T) { 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"]) + }) + } +} From f552b84a3841201de1d1d07a975c2cfbeafc7f69 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 27 Oct 2026 13:42:14 -0400 Subject: [PATCH 13/25] Refuse a card URL from another host, account or recording type in cross-table moves and copies cards copy and cards move --to-project/--to-card-table reduced a pasted URL to its trailing number, so a look-alike host, another account's card, a to-do or a cards listing filed whichever card in this account had that number. The URL must now be a single card on a trusted Basecamp host in the session's account, checked before any request, as files replace and boost update do. --- internal/commands/cards.go | 41 ++++++++++++++++++-------- internal/commands/cards_filing_test.go | 24 +++++++++++++++ 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index 5ccd0304c..f46345028 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -1403,6 +1403,14 @@ Basecamp to finish the move and shows the card where it landed.`, 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, card.id, *project, card.project, toProject, toCardTable) + } + cardIDStr, urlProjectID, err := extractWithProject(args[0]) if err != nil { return err @@ -1413,10 +1421,6 @@ Basecamp to finish the move and shows the card where it landed.`, return output.ErrUsage("Invalid card ID") } - if filingSet { - return moveCardToCardTable(cmd, app, cardID, *project, urlProjectID, toProject, toCardTable) - } - // --to-wormhole: teleport across projects. Self-contained — it derives // the source project and card table from the fetched card, so it runs // before (and instead of) the generic project resolution below. @@ -1869,23 +1873,19 @@ the new card.`, return err } - cardIDStr, urlProjectID, err := extractWithProject(args[0]) + ref, err := parseFilingRef(app, args[0], "card", "cards", "…/card_tables/cards/<id>") if err != nil { return err } - cardID, err := strconv.ParseInt(cardIDStr, 10, 64) - if err != nil { - return output.ErrUsage("Invalid card ID") - } - card, err := app.Account().Cards().Get(cmd.Context(), cardID) + card, err := app.Account().Cards().Get(cmd.Context(), ref.id) if err != nil { return convertSDKError(err) } if card.Bucket == nil { - return output.ErrNotFound("card", cardIDStr) + return output.ErrNotFound("card", strconv.FormatInt(ref.id, 10)) } - if err := rejectCardProjectConflict(cmd, app, *project, urlProjectID, card.Bucket.ID); err != nil { + if err := rejectCardProjectConflict(cmd, app, *project, ref.project, card.Bucket.ID); err != nil { return err } @@ -1914,6 +1914,23 @@ the new card.`, return cmd } +// parseFilingRef reads the card or card table a cross-table 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) { + ref, err := parseFilesMoveRef(app, arg, []string{kind}) + 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 +} + // 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) { diff --git a/internal/commands/cards_filing_test.go b/internal/commands/cards_filing_test.go index 528c34a42..9565e6afc 100644 --- a/internal/commands/cards_filing_test.go +++ b/internal/commands/cards_filing_test.go @@ -83,6 +83,7 @@ type mockFilingTransport struct { statuses []string mu sync.Mutex + requests int posts []string body map[string]any filingGet int @@ -91,6 +92,7 @@ type mockFilingTransport struct { 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) @@ -438,3 +440,25 @@ func TestCardsFilingAcceptsTheCardsOwnProject(t *testing.T) { }) } } + +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") + }) + } + } +} From 93e7f1f1ca54c565f720b053766677b7ef965551 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 27 Oct 2026 13:43:09 -0400 Subject: [PATCH 14/25] Hold a --to-card-table URL to the host and account checks, and to --to-project A destination card table URL was reduced to its number without checking the host or account, and its project was ignored when --to-project named another. It is now held to the same checks as the card URL, a project that disagrees with --to-project is refused, and the destination is resolved before the card is read so a bad URL is refused before any request. --- internal/commands/cards.go | 49 +++++++++++++++----------- internal/commands/cards_filing_test.go | 43 ++++++++++++++++++++++ 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index f46345028..38c57139f 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -1878,6 +1878,13 @@ the new card.`, 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) @@ -1889,14 +1896,10 @@ the new card.`, return err } - var table *basecamp.CardTable - if filingSet { - table, err = resolveFilingTable(cmd, app, toProject, toCardTable) - } else { - _, table, err = resolveCardTableForCard(cmd, app, strconv.FormatInt(card.Bucket.ID, 10), "", card) - } - if 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 runCardFiling(cmd, app, cardFiling{ @@ -1964,6 +1967,10 @@ func validateFilingFlags(sourceCardTable, toProject, toCardTable string) error { // 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, 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) @@ -1974,10 +1981,6 @@ func moveCardToCardTable(cmd *cobra.Command, app *appctx.App, cardID int64, flag if err := rejectCardProjectConflict(cmd, app, flagProject, urlProjectID, card.Bucket.ID); err != nil { return err } - table, err := resolveFilingTable(cmd, app, toProject, toCardTable) - if 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), @@ -1990,17 +1993,17 @@ func moveCardToCardTable(cmd *cobra.Command, app *appctx.App, cardID int64, flag // resolveFilingTable finds the card table named by --to-project and // --to-card-table: a card table ID or URL on its own, a project's only card // table, or one of a project's card tables picked by ID or title. Only card -// tables on the project's dock count, as in the web's dialogs. +// tables 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 resolveFilingTable(cmd *cobra.Command, app *appctx.App, toProject, toCardTable string) (*basecamp.CardTable, error) { + var urlProjectID string if urlarg.IsURL(toCardTable) { - parsed := urlarg.Parse(toCardTable) - if parsed == nil || parsed.Type != "card_tables" || parsed.IsCollection { - return nil, output.ErrUsageHint( - fmt.Sprintf("Invalid card table %q", toCardTable), - "Pass a card table ID, a Basecamp card table URL (.../card_tables/{id}), or a card table name with --to-project", - ) + ref, err := parseFilingRef(app, toCardTable, "card table", "card_tables", "…/card_tables/<id>") + if err != nil { + return nil, err } - toCardTable = parsed.RecordingID + toCardTable, urlProjectID = strconv.FormatInt(ref.id, 10), ref.project } if toProject == "" { @@ -2015,6 +2018,12 @@ func resolveFilingTable(cmd *cobra.Command, app *appctx.App, toProject, toCardTa if err != nil { return nil, err } + if urlProjectID != "" && !sameNumericID(urlProjectID, projectID) { + return nil, output.ErrUsageHint( + fmt.Sprintf("The --to-card-table URL is in project %s, but --to-project points at project %s", urlProjectID, projectID), + "Drop --to-project, since the card table URL names its own project", + ) + } all, err := listProjectCardTables(cmd, app, projectID) if err != nil { return nil, err diff --git a/internal/commands/cards_filing_test.go b/internal/commands/cards_filing_test.go index 9565e6afc..fbb18b271 100644 --- a/internal/commands/cards_filing_test.go +++ b/internal/commands/cards_filing_test.go @@ -462,3 +462,46 @@ func TestCardsFilingRefusesASourceURLThatIsNotThisAccountsCard(t *testing.T) { } } } + +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 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"]) +} From 79a1a2f170169f3561d764b4d42b9a431d3d7332 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 27 Oct 2026 13:43:51 -0400 Subject: [PATCH 15/25] Warn that a copy may have started unless Basecamp answered with a 4xx A dropped connection or an unreadable answer to the copy request came back as a plain error, which skipped the "may have started the copy" warning because only retryable errors got it. The rule is inverted: every failed copy request gets the warning and is not retryable, except a 4xx answer, which shows Basecamp refused it, and a rate limit, which stays retryable. Moves are unchanged. --- internal/commands/cards.go | 12 ++++--- internal/commands/cards_filing_test.go | 45 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index 38c57139f..5286a08ea 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -2115,13 +2115,15 @@ func (f cardFiling) start(ctx context.Context, app *appctx.App) (*basecamp.Filin return recordings.Move(ctx, f.card.ID, &basecamp.MoveRecordingRequest{FilingDestinationID: f.table.ID}) } -// copyStartError keeps a failed copy request from inviting a retry. A 5xx or a -// dropped connection can arrive after Basecamp already started the copy, so -// retrying would make a second copy. A rate limit is the one retryable failure -// Basecamp answers before doing anything. +// 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 cardFiling, err error) error { e := output.AsError(err) - if f.kind != basecamp.FilingCopy || e == nil || !e.Retryable || e.Code == output.CodeRateLimit { + refused := e.HTTPStatus >= 400 && e.HTTPStatus < 500 + if f.kind != basecamp.FilingCopy || refused || e.Code == output.CodeRateLimit { return err } unsure := *e diff --git a/internal/commands/cards_filing_test.go b/internal/commands/cards_filing_test.go index fbb18b271..4002bd074 100644 --- a/internal/commands/cards_filing_test.go +++ b/internal/commands/cards_filing_test.go @@ -80,6 +80,8 @@ type mockFilingTransport struct { twoTables bool duplicateTitle bool postStatus int + postErr error + postBody string statuses []string mu sync.Mutex @@ -107,6 +109,12 @@ func (m *mockFilingTransport) RoundTrip(req *http.Request) (*http.Response, erro 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"}`) } @@ -257,6 +265,43 @@ func TestCardsCopyThatFailsUpstreamIsNotRetryable(t *testing.T) { 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 cards list --in 999 --card-table 900") + 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") From a276330fa31b57210cd1e7f759a3ed499a1708b8 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 27 Oct 2026 14:22:18 -0400 Subject: [PATCH 16/25] Refuse to move or copy a record that the card read shows 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. --- internal/commands/cards.go | 21 +++++++++++++++++---- internal/commands/cards_filing_test.go | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index 5286a08ea..fbc2371fa 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -1889,8 +1889,8 @@ the new card.`, if err != nil { return convertSDKError(err) } - if card.Bucket == nil { - return output.ErrNotFound("card", strconv.FormatInt(ref.id, 10)) + if err := requireFilableCard(card, ref.id); err != nil { + return err } if err := rejectCardProjectConflict(cmd, app, *project, ref.project, card.Bucket.ID); err != nil { return err @@ -1966,6 +1966,19 @@ func validateFilingFlags(sourceCardTable, toProject, toCardTable string) error { // 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. +// 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 +} + func moveCardToCardTable(cmd *cobra.Command, app *appctx.App, cardID int64, flagProject, urlProjectID, toProject, toCardTable string) error { table, err := resolveFilingTable(cmd, app, toProject, toCardTable) if err != nil { @@ -1975,8 +1988,8 @@ func moveCardToCardTable(cmd *cobra.Command, app *appctx.App, cardID int64, flag if err != nil { return convertSDKError(err) } - if card.Bucket == nil { - return output.ErrNotFound("card", strconv.FormatInt(cardID, 10)) + if err := requireFilableCard(card, cardID); err != nil { + return err } if err := rejectCardProjectConflict(cmd, app, flagProject, urlProjectID, card.Bucket.ID); err != nil { return err diff --git a/internal/commands/cards_filing_test.go b/internal/commands/cards_filing_test.go index 4002bd074..ef5d78c3b 100644 --- a/internal/commands/cards_filing_test.go +++ b/internal/commands/cards_filing_test.go @@ -153,6 +153,9 @@ func (m *mockFilingTransport) RoundTrip(req *http.Request) (*http.Response, erro 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"): @@ -326,6 +329,23 @@ func TestAcceptedCopyStaysNotRetryableWhenTheWaitHitsTheRateLimitGate(t *testing 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"}, From 941beca42124526112a9736a579036e4926a1568 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 27 Oct 2026 14:25:21 -0400 Subject: [PATCH 17/25] Stop calling a wormhole the only way to move a card across projects --- internal/commands/cards.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index fbc2371fa..ff208f98e 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -3177,19 +3177,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 From 3a1d3d1f42f4d3fc73f51c99ab15847405d12ad6 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 27 Oct 2026 14:37:34 -0400 Subject: [PATCH 18/25] Refuse a --to-card-table URL whose project is not the card table's --- internal/commands/cards.go | 9 ++++++++- internal/commands/cards_filing_test.go | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index ff208f98e..0ec254276 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -2024,7 +2024,14 @@ func resolveFilingTable(cmd *cobra.Command, app *appctx.App, toProject, toCardTa if err != nil || tableID <= 0 { return nil, output.ErrUsage("--to-card-table must be a positive card table ID") } - return getCardTable(cmd, app, tableID) + table, err := getCardTable(cmd, app, tableID) + if err != nil { + return nil, err + } + if urlProjectID != "" && !sameNumericID(urlProjectID, strconv.FormatInt(cardTableProjectID(table), 10)) { + return nil, output.ErrUsage(fmt.Sprintf("The --to-card-table URL names project %s, but card table %d is in project %d", urlProjectID, table.ID, cardTableProjectID(table))) + } + return table, nil } projectID, projectName, err := app.Names.ResolveProject(cmd.Context(), toProject) diff --git a/internal/commands/cards_filing_test.go b/internal/commands/cards_filing_test.go index ef5d78c3b..14ea5ff38 100644 --- a/internal/commands/cards_filing_test.go +++ b/internal/commands/cards_filing_test.go @@ -563,6 +563,29 @@ func TestCardsFilingRefusesACardTableURLInAnotherProjectThanToProject(t *testing } } +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", From 539bc144da387cb549c1ccf22889e9b102e97200 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Tue, 27 Oct 2026 15:55:47 -0400 Subject: [PATCH 19/25] Bump basecamp-sdk to v0.30.0 v0.30.0 is the release of the move, copy and filing operations this branch used from a pre-release pin. It also brings project logos: the built-in MCP server serves remove_project_logo and skips the multipart UpdateProjectLogo, as it skips the other uploads. --- go.mod | 2 +- go.sum | 4 +- internal/mcpserver/catalog_test.go | 3 +- internal/mcpserver/model/PROVENANCE.json | 4 +- internal/mcpserver/model/behavior-model.json | 12 ++ internal/mcpserver/model/openapi.json | 118 ++++++++++++++++++ .../mcpserver/testdata/catalog_snapshot.txt | 1 + internal/version/sdk-provenance.json | 6 +- nix/package.nix | 2 +- scripts/sync-mcp-model.sh | 1 + 10 files changed, 143 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 7d601e61f..7934c47c7 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.2-0.20261027154747-dfac02e4936f + github.com/basecamp/basecamp-sdk/go v0.30.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 00ab998a6..5c9c7f7df 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.2-0.20261027154747-dfac02e4936f h1:nWTQhK9pRyep0qRdZbjOq2bBg2OWm44dDPq8H12UZ2w= -github.com/basecamp/basecamp-sdk/go v0.29.2-0.20261027154747-dfac02e4936f/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU= +github.com/basecamp/basecamp-sdk/go v0.30.0 h1:aHNCJvYEKni/yRq1YjeyNvilMRgKnBF92QQHkBMpHxU= +github.com/basecamp/basecamp-sdk/go v0.30.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/mcpserver/catalog_test.go b/internal/mcpserver/catalog_test.go index 93c2a8c56..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, 285, 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 7fcee62e9..c0ddd51c0 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "dfac02e4936fdefb092ab987bfb9cc22cbcb7753", - "ref": "go/v0.29.1-5-gdfac02e4", + "commit": "5549ff18aa0878027d019f41d2ac2fdcd4e61be5", + "ref": "v0.30.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 d1fbe67be..473ba6e4c 100644 --- a/internal/mcpserver/model/behavior-model.json +++ b/internal/mcpserver/model/behavior-model.json @@ -2823,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 d7907422e..7dd2cb898 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -15508,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.", @@ -37097,6 +37195,9 @@ "wallpaper": { "$ref": "#/components/schemas/ProjectWallpaper" }, + "logo": { + "$ref": "#/components/schemas/ProjectLogo" + }, "clientside": { "$ref": "#/components/schemas/ClientSide", "deprecated": true, @@ -37199,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.", @@ -40872,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 3c866d9fb..82f79ac04 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) diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index 94a26c722..ac96f39e7 100644 --- a/internal/version/sdk-provenance.json +++ b/internal/version/sdk-provenance.json @@ -1,9 +1,9 @@ { "sdk": { "module": "github.com/basecamp/basecamp-sdk/go", - "version": "v0.29.2-0.20261027154747-dfac02e4936f", - "revision": "dfac02e4936f", - "updated_at": "2026-10-27T15:47:47Z" + "version": "v0.30.0", + "revision": "5549ff18aa08", + "updated_at": "2026-10-27T19:39:17Z" }, "api": { "repo": "basecamp/bc3", diff --git a/nix/package.nix b/nix/package.nix index 7ecd5b278..3da7fb241 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-6RHqvaXnwckfF4WSqUhQ8q7bA3ayZ+lIZx3drlxlN8I="; + vendorHash = "sha256-4MRvwdsjSoeG7C1eJax2Df9uJhQ11cnvGHKQpKtWt74="; 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 From 6e584039bd3d024cb536cfe2957ad9698c411193 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Wed, 28 Oct 2026 00:36:06 -0400 Subject: [PATCH 20/25] Bump basecamp-sdk to v0.31.0 v0.31.0 moves the API version to 2026-10-27 and documents the moves and copies this branch builds on: MoveCard and CreateWormhole now say no wormhole is needed to reach another project's card table, MoveRecording and CopyRecording list the to-do and to-do list group destinations, and RepositionTodo describes moving a to-do between lists in its project. No operation is added or removed, so the served catalog stays at 286; only reposition_todo's catalog line changes. --- go.mod | 2 +- go.sum | 4 ++-- internal/mcpserver/model/PROVENANCE.json | 4 ++-- internal/mcpserver/model/openapi.json | 20 +++++++++---------- .../mcpserver/testdata/catalog_snapshot.txt | 2 +- internal/version/sdk-provenance.json | 10 +++++----- nix/package.nix | 2 +- 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index 7934c47c7..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.30.0 + 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 5c9c7f7df..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.30.0 h1:aHNCJvYEKni/yRq1YjeyNvilMRgKnBF92QQHkBMpHxU= -github.com/basecamp/basecamp-sdk/go v0.30.0/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/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index c0ddd51c0..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": "5549ff18aa0878027d019f41d2ac2fdcd4e61be5", - "ref": "v0.30.0", + "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/openapi.json b/internal/mcpserver/model/openapi.json index 7dd2cb898..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": [ { @@ -19486,7 +19486,7 @@ }, "/{accountId}/recordings/{recordingId}/copies.json": { "post": { - "description": "Start copying a recording into another project; poll GetFiling for the result.\n\nWorks like MoveRecording, except the original stays where it is. Name the\ndestination with `filing_destination_id`, a container of the kind the\nrecording sits in now, from the destination project's `dock` (GetProject): a\ncard copies into a card table (`kanban_board`) and lands in Triage, or in\nDone if it is completed. A dock tool is copied by `destination_project_id`\ninstead. Poll GetFiling with the returned filing's id until `status` leaves\n`pending` and `processing`; a completed filing's `destination_recording.id`\nis the copy.\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 only copies\nalong with its parent. 404 when the destination is not one the caller can\ncopy it into. 422 when the copy cannot start, such as when the recording is\nbeing moved or the destination cannot take it.", + "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": { @@ -20063,7 +20063,7 @@ }, "/{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.\n\nName the destination with `filing_destination_id`: the container the\nrecording moves into, of the same kind it sits in now, found in the\ndestination project's `dock` (GetProject). A card moves into a card table\n(`kanban_board`) and lands in Triage, or in Done if it is completed; a card\ntable column moves into a card table too. A message moves into a\n`message_board`; a document, upload or folder into a `vault`; a schedule\nentry into a `schedule`. A dock tool itself (a message board, card table,\nDocs & Files, schedule) is moved by `destination_project_id` instead.\n\n400 when neither destination is given. 403 when the recording only moves\nalong with its parent, or the caller may not move it. 404 when the\ndestination is not one the caller can move it into. 422 when the move\ncannot start, such as when the recording is already being moved or the\ndestination cannot take it. A repeated move is harmless: while the first is\npending it is 422, and once it is done, 404, because the recording has moved.", + "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": { @@ -28343,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": { @@ -32340,7 +32340,7 @@ "properties": { "filing_destination_id": { "type": "integer", - "description": "The container to copy into, from the destination project's dock: a card\ntable for a card, a message board for a message, a vault for a document,\nupload or folder, a schedule for a schedule entry. Give this or\ndestination_project_id.", + "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": { @@ -36246,7 +36246,7 @@ "properties": { "filing_destination_id": { "type": "integer", - "description": "The container to move into, from the destination project's dock: a card\ntable for a card, a message board for a message, a vault for a document,\nupload or folder, a schedule for a schedule entry. Give this or\ndestination_project_id.", + "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": { @@ -38174,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" } }, diff --git a/internal/mcpserver/testdata/catalog_snapshot.txt b/internal/mcpserver/testdata/catalog_snapshot.txt index 82f79ac04..ade56d087 100644 --- a/internal/mcpserver/testdata/catalog_snapshot.txt +++ b/internal/mcpserver/testdata/catalog_snapshot.txt @@ -43,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 diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index ac96f39e7..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.30.0", - "revision": "5549ff18aa08", - "updated_at": "2026-10-27T19:39:17Z" + "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 3da7fb241..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-4MRvwdsjSoeG7C1eJax2Df9uJhQ11cnvGHKQpKtWt74="; + vendorHash = "sha256-llyHSDixJm3ZlC+Uhbv0QFcKxJeLkTHslKhQVOXXU4Y="; subPackages = [ "cmd/basecamp" ]; From d0c4cac1807106a1a4b2ef86363a0c6ede68b2d2 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@zolkos.com> Date: Wed, 28 Oct 2026 00:28:50 -0500 Subject: [PATCH 21/25] refactor(commands): share the move and copy flow across recording types (#975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Extract the move and copy flow into a shared recording filing Cards move and copy start a filing through Basecamp's generic recording moves and copies, wait for it, and read back what it made. None of that is specific to cards, and the next families (to-dos, messages, Docs & Files, schedule entries, dock tools) file the same way. recordingFiling now carries one filing as data: kind, source id and noun, destination container or (for a dock tool) destination project, include comments, and the per-type strings and read-back hook. runFiling owns the flow and its invariants: start once, a copy that fails without a 4xx or rate limit is reported non-retryable with where to look, a bounded wait, an in_progress error whose cause cannot turn retryable, and a read-back that names the new id when it fails. parseFilingRef and the project conflict check move with it; the conflict check takes the noun. Cards keep their output, errors, hints and exit codes unchanged. * feat(todos): move and copy to-dos, lists and groups to other projects (#976) * Move to-dos into a list in another project, and stop a foreign --list passing todos move (todos position) gains --to-project and --to-list, which file to-dos into a to-do list in any project through Basecamp's recording moves, as the web's Move… does. --to-list takes a list ID, URL, or a name looked up in --to-project; --to-project alone needs a project with one list, and several lists or a name two lists share are refused with their IDs. A group or an archived list is refused, as Basecamp refuses it, and so is the list the to-do is already in (or the list holding its group), with the reorder command as the hint. Several to-dos file one by one, as the web's bulk Move does: every one is read before any is filed, each is reported, and any failure fails the command naming what moved by its new id. A --list naming a list in another project used to reach the reposition endpoint, which older Basecamp answered with success while nothing moved and current Basecamp answers with a bare 422. The command now reads the to-do and the list first and refuses, naming the todos move --to-project --to-list command that does it. Reordering, and moving to another list in the same project with --to/--list, are unchanged; --to with several to-dos is refused instead of silently using the first. The shared filing gains fileRecording, which files one recording and returns what it made, and runFilings, which files several and reports each. * Copy to-dos into a list in this or another project todos copy files copies of to-dos into a to-do list, as the web's Copy… does, with the same --to-project and --to-list as todos move. With no destination each copy goes into its to-do's own list; a to-do in a group is copied into the group's list, since Basecamp files into lists, not groups. --include-comments copies the comments too. Several to-dos copy one by one and each is reported; no copy is ever retried, and one that fails without a 4xx says where to look before copying again. * Move a to-do list or group to another project todolists move (todolists position) and todolistgroups move (todolistgroups position) gain --to-project, --to-todoset and --to-list, which file a list or a group through Basecamp's recording moves, as the web's Move… does. Which it is decides where it goes: a top-level list into a project's To-dos (the only enabled one, or --to-todoset when there are several), a group into a to-do list, where it stays a group. --to-list for a list and --to-todoset for a group are refused with the flag that fits, as are the reorder flags alongside them and the group's own --list. Moving onto the To-dos or list it is already in is refused with the reorder command. A project with its To-dos turned off is named, with the tools enable command. Every alias group (todolist, todolistgroup, tlgroups, tlgroup) reaches the same command. Reordering without the new flags is unchanged. * Copy a to-do list or group to this or another project todolists copy, reached from every to-do list and group alias, files a copy of a list or a group with its to-dos, as the web's Copy… does. A list goes into a project's To-dos, a group into a to-do list, with the same flags as todolists move; with no destination the copy goes next to the original. --include-comments copies the comments too, and a copy is never retried. * Record the to-do moves and copies in the surface, coverage, smoke and evals .surface takes the new flags and copy leaves. todos move/position/reorder now take several IDs, so their old single-ID ARG lines are acknowledged in .surface-breaking, and the e2e usage error now reads "<id|url>... required". API-COVERAGE lists the to-do, list and group filings under recordings moves and copies. Smoke covers todos copy, todolists copy and todolistgroups copy with same-project copies on QA data, trashing the to-do and list copies. Skill-eval cases ask, in plain words, to move a to-do across projects by name, copy one with comments, move three at once, move a group and move a list. * Ask to run a bulk move or copy again only for what Basecamp refused A bulk copy labelled a copy that was made but not read back, or one that may have started, as failed, and its hint said to run the failed ones again, which makes another copy. Each filing now reports how far it got: filed, filed but not read back, not settled, or refused before filing. The hint tells the agent not to repeat the first three, naming each, and offers a rerun only for the refusals, by id. * Move and copy loose to-dos into a list A loose to-do sits on its project's To-dos rather than in a list, and reading its parent as a to-do list answered 404 before anything was filed. A loose to-do now has no list to exclude, so it moves or copies into any list, as Basecamp allows. A copy with no destination has no list of its own to go into, so it is refused with the --to-list command to run. * Refuse a to-do named twice by its old and new ids A moved to-do's old id reads as its new one, so after a move from 111 to 222, copying 111 and 222 filed to-do 222 twice: the duplicate check compared the ids as typed. The check now compares the to-dos read before anything is filed, and names both arguments. * Point copy recovery at a listing that shows the copy A copy keeps the source's completion and status, and the list commands show only incomplete, active items one page at a time unless asked. The hint now asks for every page in the source's state, so a copy that did land is found. * Point every copy recovery at one listing that finds the copy in any status Patching each list command for completed, archived and trashed copies kept missing a state. Basecamp's recordings listing filters by type and status, so a copy of a to-do, list, group or card is found newest first whatever state it was made in. * Show a to-do list by its ID without a project todolists show read the list by its account-wide ID but still demanded a project, so the filing recovery that points at it failed with no default project configured. * Refuse a group's --list on a copy with no destination todolistgroups copy 311 --list 901 copied the group next to the original and ignored --list, so a caller who meant list 901 got a copy somewhere else and a success. --list names the group's own list on that command group, so a filing now refuses it whether or not a destination is set, with the --to-list hint, before anything is read or created. cards copy already refuses --card-table the same way. * Carry --profile and --account into every command a filing prints A copy run with --account or --profile that failed ambiguously told the caller to check a recordings listing that, pasted into a fresh shell, ran against the default profile and account. It could miss a copy Basecamp had made and invite a second one. The filing helper now takes the follow-up scope, read before the command resolves its account as the template copy does, and adds it to every command it prints: the copy recovery listing, the show commands in failure, still-running and not-read-back hints, the bulk report, and the breadcrumbs of what it filed. runFiling and runFilings require the scope, so a family that files through them cannot leave it out. * Keep the auth remedy for the credential sent when several filings fail A 401 while filing several to-dos ended in a report that kept only each failure's text, so App.Err no longer saw the SDK error and left the bare "Run: basecamp auth login" in the hint. Under --profile work that logs into the default profile; under BASECAMP_TOKEN no login helps. runFilings now settles the remedy on each failure before the report drops the cause, through App.WithAuthRemedy, which App.Err already used. The report stays non-retryable. A 401 does not stop the remaining to-dos. Every to-do is read before any is filed, so a rejected credential fails there, before the loop; one rejected mid-run is refused before anything is filed for each later to-do too, and the report already asks to run those again. * Find an unanswered copy by its title, since it keeps its original's dates A copy whose start got no answer told the caller to list the destination's recordings sorted by created_at, newest first, as if the copy would top the list. Basecamp's Recording::Copier copies created_at from the original, so the copy sorts where the original would, and on a busy list or card table the caller looked at the top, saw nothing, and could copy again. The hint now lists the destination's recordings in the source's status without a sort, then says to look for the original's title in the destination container by name and id. The --profile and --account scope stays on the listing command, ahead of that sentence. * Refuse a container flag that a move or copy would ignore todos copy 456 --todolist 901 copied the to-do into its own list and reported success: --todolist is a root flag todos copy never reads. todolistgroups copy --list failed the same way and was fixed on its own, as were cards copy --card-table and files move --vault. Every command that moves or copies recordings now goes through guardFilingFlags, which names the container flags the command inherits and reads. Any other one that was set, the root's --todolist or a flag the command's group adds (--card-table, --vault, --folder, --list), is refused before anything is read or sent, with the command's own --to-<container> flags as the hint. The three one-off refusals go. --project and --in stay with each command, which holds a filed recording to them. The same-project modes are covered too: todos move --to 1 --todolist and todolistgroups position --list, which ignored the flag, now refuse it. cards move and cards column move read --card-table, so they keep it. The test walks the whole command tree: every move or copy command declares what it reads, and each container flag it inherits but does not read is refused with no request sent. * feat: move and copy messages, files, columns, events, check-ins and forwards to other projects (#977) * Generalize the recording filing for every family the web moves and copies The next families (messages, Docs & Files, columns, schedule entries, check-in questions, email forwards) file the same way cards and to-do lists do, into a tool on a project's dock. The filing helper grows what they share: - resolveFilingTarget finds the container named by --to-project and the family's own --to-<container> flag: a project's only enabled tool of the kind, one picked by ID or title, or an ID or URL alone. Ambiguous and duplicate titles list the choices; a project without one is named, and a disabled one says how to turn it on. The To-dos tool a to-do list goes into is found the same way, so resolveFilingTodoset is now a container description over it. - filingFamily describes a family as data (its noun, types, URL kinds, destination, read), and newFilingCmd builds its move and copy commands from it: own-container flags are refused, a move onto the container the recording is in is refused, a copy with no destination goes onto it. Several recordings go through runFilings, as to-dos do. - A filing carries parent_id for a folder inside Docs & Files, and parseFilingRefOf accepts a URL of several kinds. * Move and copy messages to another project's message board messages move <id|url> --to-project <project> [--to-board <board>] files a message onto a message board in another project, as the web's Move... does, and messages copy does the same as Copy..., onto the message's own board when no destination is given, with --include-comments. Both wait for Basecamp's background filing and print the new message with its source_id. A move onto the board the message is on, the group's --message-board (which names the message's own board), an id that reads back as another type, and a project the --in flag contradicts are refused before anything is sent. The old id redirects to the new one, so the helper's move-still-running hint holds for messages. * Move and copy Docs & Files items to another project files move <id|url>... --to-project <project> [--to-folder <folder>] files documents, uploads, cloud files, Google documents and folders into another project's Docs & Files, as the web's Move... does, and files copy does the same as Copy..., into the original's own folder when no destination is given. Several IDs file one by one, as the web's bulk Move/Copy does, and the run fails naming what each became if any failed. --to-folder takes any folder by ID or URL, nested ones included: the filing goes to the project's Docs & Files with the folder as parent_id, found by walking the folder's parents to the docked vault. With --to-project it also takes a top-level folder's title, and a number that names no folder is read as a title, since folders can be called 2026. files move --to keeps moving within the project in place, unchanged. A --to folder in another project now points at --to-folder instead of the web's Move menu, and --to-folder in the item's own project points back at --to, which keeps the item's ID. The same commands are on uploads, docs, vaults and their aliases, so an agent reaching for any of them lands on them. The old ids of moved documents and uploads redirect, so the move-still-running hint holds. * Move and copy card table columns to another project cards column move <id|url> --to-project <project> [--to-card-table <table>] moves a column with its cards onto a card table in another project, as the web's Move... does, and cards column copy does the same as Copy..., onto the column's own table when no destination is given. --position keeps repositioning a column within its table, unchanged, and cannot be combined with the new flags. Triage, Not now, Done and On hold, which Basecamp refuses to file alone, are refused before the request with a pointer at moving their cards; moving onto the column's own table points at --position. The moved column's old id redirects, so the move-still-running hint holds. * Move and copy schedule entries to another project's schedule schedule move <id|url> --to-project <project> [--to-schedule <schedule>] moves an event onto a schedule in another project, as the web's Move... does, and schedule copy does the same as Copy..., onto the event's own schedule when no destination is given. A recurring event moves and copies as a whole series. Basecamp answers its read with an occurrence (redirecting, and minting one), whose parent is the event, so the event files by that parent and the output names the new event by its id with the occurrence read as occurrence_id; its own schedule is its project's. An occurrence URL files its series. The group's --schedule, which names the event's own schedule, is refused. The old id of a moved event redirects, so the move-still-running hint holds. * Move and copy check-in questions to another project's check-ins checkins question move <id|url> --to-project <project> [--to-questionnaire <id|url>] moves a question, with its answers, into another project's Automatic Check-ins, as the web's Move... does, and checkins question copy does the same as Copy..., into the question's own Automatic Check-ins when no destination is given. The group's --questionnaire, which names the question's own check-ins, is refused, as is a move into the check-ins the question is in. The old id of a moved question redirects, so the move-still-running hint holds. * Move and copy email forwards to another project's Email Forwards forwards move <id|url> --to-project <project> [--to-inbox <id|url>] moves an email forward, with its replies, into another project's Email Forwards, as the web's Move... does, and forwards copy does the same as Copy..., into the forward's own Email Forwards when no destination is given. Email Forwards must be turned on in the destination. When it is off, the command says so and names the tools enable command that turns it on, rather than reporting that the project has none. The group's --inbox, which names the forward's own Email Forwards, is refused, as is a move into the one it is in. The old id of a moved forward redirects, so the move-still-running hint holds. * Move a whole dock tool to another project with tools move --to-project tools move <id|url> --to-project <project> moves a Message Board, Card Table, To-dos, Docs & Files, Schedule, Automatic Check-ins, Chat or Email Forwards, with everything in it, to another project's dock, as the web's Move... does, sending destination_project_id. Tools are not copied, as on the web. tools move was an alias of tools reposition. It is now its own command: with --position (and no --to-project) it still reorders the dock exactly as before, and the two cannot be combined. Doors, which Basecamp refuses to move, a nested folder that is not on the dock, a move to the tool's own project, and an old id whose tool already moved are refused before the request. A moved tool's old id does not redirect: /dock/tools/<old> reads it as deleted. So the filing gains movedHint, and a tool move still running points at the destination project's dock rather than at tools show <old id>, which would never find it. * Refuse a recording named twice in any bulk move or copy files copy 456 456 --to-project Dest made two copies, and naming a moved file by its old id beside its new one filed it twice: only to-dos checked for duplicates. The check to-dos made, comparing the recordings each argument read as rather than the arguments themselves, is now filingNames, shared by to-dos and every family's move and copy, and runs before anything is filed. * Find a copied cloud file or Google document, and every copy in any status files show probed only folders, uploads and documents, and files list lists only those, so a cloud file or Google document that a move or copy made could not be shown by the breadcrumb the command printed, and the "check before copying again" listing left the copy out. files show now also reads cloud files and Google documents (and takes them as --type). A copy of a cloud file is looked for in its project's timeline, every page, where it shows as reposted. No API listing or timeline shows a Google document, so a copy of one points at its folder's app_url to check in Basecamp. A copy keeps its source's status, and the listings the other families pointed at showed only active items one page at a time. Messages, Docs & Files items and schedule entries now point at recordingCopyLookFor in the source's status. Check-in questions and email forwards, which the recordings listing does not serve, ask for every page of their own listing, or the timeline for an archived one, which their listing leaves out. The Docs & Files help no longer tells agents to copy again what "failed": only what Basecamp refused. * File a recurring event from the schedule it is actually on A recurring event's read answers with an occurrence, which names the event but not its schedule, and the schedule was taken from the project's dock through /projects/<bucket>.json. An employee calendar is a Calendar bucket, not a project, so a recurring calendar event could not be moved or copied, and filing a recurring event into a calendar failed at the read-back after Basecamp had already moved it. A project with several schedules gave no schedule, so a copy with no destination was refused. seriesSchedule now reads a calendar's schedule from the calendar, and of a project's several schedules picks the one whose entries list the event. A copy into a calendar is looked for in that schedule's entries, since the recordings listing does not serve calendars. * Refuse a --to-folder title shared by the Docs & Files and a folder --to-folder "<title>" with --to-project matched the Docs & Files' own title before looking at its folders, so a folder named like the tool was never picked and the item silently landed at the top. The Docs & Files is now one candidate among the top-level folders: one match files there, several are refused listing each ID, the top marked as such. * Follow up every item a bulk move or copy filed, not only the first With no destination, files copy puts each copy in its own original's folder, so a bulk run can land items in several folders. The summary already names where each landed; the breadcrumbs came from the first item alone, which pointed the next step at one folder. They now gather each filed item's follow-ups, without repeats. * Find an archived recurring series' schedule among several A recurring event's read names no schedule, so with several schedules in its project the series' schedule is the one whose entries list it. Those listings show active entries unless asked for another status, so an archived (or trashed) series was found on none of them, and copying or moving it without a destination failed with "Could not tell which schedule". The lookup now lists entries in the event's own status. * Look for a drafted copy among drafts, not in an unsupported listing A copy keeps its source's status, and the copy recovery hints listed the destination's recordings in that status. The recordings listing serves only active, archived and trashed, so a drafted message's or document's copy that timed out pointed at a command Basecamp answers with 400. recordingCopyLookFor now emits only those three statuses and sends a drafted message, document or upload to basecamp drafts list --all, where the copy shows. No API listing shows a drafted schedule entry, so its copy, on a project schedule or a calendar, is to be checked in Basecamp. * Show a filed record by its ID without a project files show, schedule show, cards column show, checkins question show and forwards show read the record by its account-wide ID but still demanded a project, so the move and copy hints that point at them ("once it lands, … show <id> returns it", "show it with: …") failed with "Project ID required" when no default project was configured. Each now reads by ID alone and takes the project its breadcrumbs name from the record, as todolists show does. * Follow up a moved record on the id its show read back Reading a moved record's old id redirects to the record at its new id, but cards column show, files show, schedule show, checkins question show, forwards show, messages show and todolists show still named the requested old id in their follow-ups and comment reads, and the old id takes no writes. Each now uses the id the read returned, as cards show and todos show already do; a recurring event's follow-ups name the event, not the occurrence its read answers with. * Add filings show to read a move or copy by its filing A move or copy runs in the background, and when the command's wait ends first there was no way to read where it got to. filings show reads GET /filings/:id with no project and reports the status, type and source, and once completed the recording it made with the command that shows it. A failed move reads as safe to try again, as Basecamp's docs say a move is; a failed copy does not, since the docs say nothing of what one leaves behind. * Check a still-running move or copy by its filing, not by a listing Three review rounds in a row found a copy-recovery listing that missed some status for some type. When a move or copy is still running as the wait ends, Basecamp has already named its filing, which says where it got to whatever the recording's type and status, so every family's in_progress hint now names it and says to check it with basecamp filings show <id>. That replaces the per-type listings there, the moved-tool dock hint and the copy landing text. Listings remain only where no filing is known: a copy whose start request went unanswered. Columns, check-in questions and email forwards keep their own listings for an active copy and the timeline for an archived question or forward; where no listing shows the copy's status (an archived column, a trashed question or forward) the hint says to open the destination in Basecamp at its app_url. * Find an unanswered copy from one table of where each copy shows A copy whose start got no answer is checked for before copying again, and each family chose its own listing for that. Four review rounds each found a type and status whose listing could not show the copy; the latest was a trashed cloud file sent to the timeline, which leaves out trashed recordings. Checking the rest against bc3 found another: the timeline never shows a copied email forward, archived or not. copyListings in filing.go now holds every answer: for a recordable type, the status the copy keeps, and whether it is on a calendar, a listing known to show that copy, with the reason beside each listing. Every other place, a new type or status included, says to open the destination in Basecamp at its app_url, which for a nested folder is now the folder's own. The families' lookFor functions are gone. TestEveryCopyRecoveryCanShowTheCopy walks every type each family files in every status, on a project and a calendar, and accepts only the app_url form or a listing whose own evidence covers that place. * Refuse a destination URL that names nothing, never reading it as a title files copy --to-project Dest --to-folder https://3.basecampapi.com/99999/vaults/2026.json filed into Dest's folder titled "2026" when vault 2026 answered 404: the fallback to a title was kept from URLs that name a project, and a flat API URL names none. The same reading happened without any 404 in the two other resolvers that take an ID, URL or title with --to-project: a URL's id was matched against dock tool titles (--to-board, --to-schedule, --to-questionnaire, --to-inbox, --to-todoset, a column's --to-card-table) and against card table titles (a card's --to-card-table). A URL now names only an id. When that id is not a destination, the command fails with not_found naming the URL, and files nothing. * Tell a copy apart by its title, since it keeps its original's dates Basecamp's copier keeps the original's created_at and updated_at (read back live for a to-do, cloud file, question and email forward), so the recovery listing's --sort created_at --direction desc never put the copy first, and reading its top row as the copy was wrong. The listing drops the sort, and every recovery, listing or app_url, ends with what to match on: the original's title in the container the copy lands in. * Say once in a bulk line not to move or copy a still-running item again A still-running item's bulk line read "Do not move #11 again yet. Do not move it again. Check it with: basecamp filings show 11…": the bulk prefix repeated what the filing's own hint already says. A line whose hint already says it (a still-running filing's) now names the item and gives that hint: "#11: Do not move it again. Check it with: basecamp filings show 11…". Other unsettled lines keep their prefix. * Carry --profile and --account into what filings show prints A move or copy still running when its wait ended pointed at basecamp filings show <id>, and that command's own follow-ups (check again, or show the record it made) named neither profile nor account. Pasted into a fresh shell they read the default account, where the filing is not found, and a caller could take that as nothing having started. filings show now reads the follow-up scope before resolving its account, as every filing command does, and adds it to its breadcrumbs. The still-running hints already carry it, and each listing in the copy recovery table carries it ahead of the title to look for; the destination-page fallback needs none. The follow-ups test covers a message family's copy recovery and a still-running move and copy, and the table enumeration checks the scope on every listing. * Take a moved file's kind from what its old id reads back as files show <old id> of a moved document reported a folder. Basecamp redirects every kind's read of a moved item's old id to the item at its new id, so the folder read files show tried first answered with the document, decoded as a vault. The show dropped the document's content and advertised a folder-contents breadcrumb, and moved items are read by their old id whenever a caller follows up on a move. Auto-detection now goes through getFileItem, which the move and copy commands already use: it tries each kind and keeps the one whose record comes back as that kind. * Send a drafted copy missing from drafts list to the destination too A drafted message, document or upload whose copy got no answer was looked for in basecamp drafts list --all, which holds only the 250 most recently updated drafts. A copy keeps its original's updated_at, so the copy of an older draft can sit past that cap: the listing showed nothing, and a caller could copy again. A row of copyListings can now be marked capped. The drafts row is, and its hint adds that a copy missing from the listing is checked for on the destination's page before copying again. The table enumeration asserts that only the drafts listing carries it. * Refuse a container flag every web-parity move or copy would ignore Each family's move and copy refused the group flags in its own ownFlags list (--message-board, --schedule, --inbox, --questionnaire, --vault/--folder, --card-table) and let the root's --todolist through, which messages copy ignored and still reported success. newFilingCmd and tools move now go through guardFilingFlags, which refuses every container flag a command inherits but does not read, so ownFlags and its loop go. cards column move reads --card-table to reposition a column, so it keeps a refusal of that flag when moving to another card table, shared with cards move. The guard's table test now covers all 28 move and copy commands. * Name a calendar event's schedule in its entries follow-up schedule show of an event in an employee calendar suggested schedule entries --project <calendar>, which looks for the schedule on a dock the calendar does not have, and fails. The follow-up now names the schedule: the event's parent, or for a recurring event, whose parent is its series, the schedule the calendar links to. A project's events keep the follow-up as it was. * Ask projects show for a project ID in tools move's help and hints tools move's help, agent notes and two refusals sent agents to basecamp projects show <project>, but projects show takes only a numeric project ID. In a live agent eval, agents passed project names there and failed five times. The help and notes now say projects show <id> and point at basecamp projects list for the ID. The refusal of a recording that is not a dock tool names its project's ID, which the read already has. The refusal of a deleted or moved tool cannot know where the tool went, so it says <id> and names projects list too. * Say a card table is turned off, not missing, on a cross-project card move cards move and copy with --to-project into a project whose card table is turned off said the project has no card table and to add one there first, so an agent following it would make a second card table. Every other family's move and copy says the tool is turned off and how to turn it back on with basecamp tools enable. resolveFilingTable, written for cards before the shared filing helper existed, now goes through resolveFilingTarget with the card table container that cards column move already uses, then reads the card table in full as before. Cards get the shared refusals: turned off versus absent, ambiguous and unmatched names. Naming a card table by ID or URL alone now reads it twice, once to place it and once in full. * Name several To-dos tools without a doubled s, and make every container spell its plural A cross-project to-do list move into a project with two To-dos tools refused with "Dest has 2 To-doss that match": the To-dos container set no plural, so the message appended "s" to "To-dos". The container came in with todo-moves (#976); it is fixed here, at the top of the stack, rather than on that branch. The container now says "To-dos tools". The fallback that appended "s" is gone: every filingContainer spells its plural out, and a test reads the package's source and fails on any filingContainer literal without one, so the next container cannot fall into the same message. * Say what the schedule commands do when a project has several schedules The schedule group's agent notes said each project has one schedule. A project can have several Schedule tools, and the live agent eval met one. With one, the commands find it; with several, they refuse and list the schedule IDs to pass with --schedule, or --to-schedule for move and copy. The notes now say so. * Name the schedule in an event's entries command whenever its parent is one A project can have several Schedule tools, so after a --to-schedule move the suggested `schedule entries --project P` failed with "Multiple schedules found". The calendar-only lookup for a recurring event's schedule is unchanged. --- .surface | 953 +++++++++++++++++- .surface-breaking | 11 + API-COVERAGE.md | 20 +- e2e/cards_columns_steps.bats | 9 - e2e/errors.bats | 2 +- e2e/smoke/smoke_aliases.bats | 4 - e2e/smoke/smoke_cards_column_write.bats | 24 + e2e/smoke/smoke_checkins_write.bats | 26 + e2e/smoke/smoke_communication_write.bats | 30 + e2e/smoke/smoke_files_write.bats | 50 + e2e/smoke/smoke_filings.bats | 15 + e2e/smoke/smoke_lifecycle.bats | 8 + e2e/smoke/smoke_messages_write.bats | 29 + e2e/smoke/smoke_schedule_write.bats | 26 + e2e/smoke/smoke_todolistgroups_write.bats | 13 + e2e/smoke/smoke_todos_write.bats | 34 + e2e/smoke/smoke_tools.bats | 24 + internal/appctx/context.go | 6 +- internal/cli/root.go | 1 + internal/commands/cards.go | 585 ++++------- internal/commands/cards_column_filing_test.go | 101 ++ internal/commands/cards_filing_test.go | 86 +- internal/commands/cards_test.go | 17 - internal/commands/checkins.go | 126 ++- internal/commands/checkins_filing_test.go | 56 + internal/commands/commands.go | 23 +- internal/commands/commands_test.go | 1 + internal/commands/files.go | 543 ++++++++-- internal/commands/files_filing_test.go | 324 ++++++ internal/commands/files_test.go | 4 +- internal/commands/filing.go | 700 +++++++++++++ internal/commands/filing_family.go | 499 +++++++++ internal/commands/filing_family_test.go | 607 +++++++++++ internal/commands/filing_guard_test.go | 206 ++++ internal/commands/filing_show_test.go | 82 ++ internal/commands/filing_test.go | 83 ++ internal/commands/filings.go | 151 +++ internal/commands/filings_test.go | 144 +++ internal/commands/forwards.go | 118 ++- internal/commands/forwards_filing_test.go | 57 ++ internal/commands/helpers.go | 12 + internal/commands/messages.go | 84 ++ internal/commands/messages_filing_test.go | 163 +++ internal/commands/schedule.go | 242 ++++- internal/commands/schedule_filing_test.go | 218 ++++ internal/commands/todolistgroups.go | 33 +- internal/commands/todolistgroups_test.go | 6 +- internal/commands/todolists.go | 536 +++++++++- internal/commands/todolists_filing_test.go | 179 ++++ internal/commands/todos.go | 322 +++++- internal/commands/todos_filing_test.go | 695 +++++++++++++ internal/commands/todos_test.go | 21 +- internal/commands/tools.go | 220 +++- internal/commands/tools_filing_test.go | 119 +++ skill-evals/cases/checkins-move-copy.yml | 11 + skill-evals/cases/columns-move-copy.yml | 11 + skill-evals/cases/files-move-copy.yml | 11 + skill-evals/cases/forwards-move-copy.yml | 11 + skill-evals/cases/messages-move-copy.yml | 12 + skill-evals/cases/schedule-move-copy.yml | 12 + skill-evals/cases/todos-move-copy-bulk.yml | 7 + skill-evals/cases/todos-move-copy-copy.yml | 7 + skill-evals/cases/todos-move-copy-group.yml | 8 + skill-evals/cases/todos-move-copy-list.yml | 8 + skill-evals/cases/todos-move-copy-project.yml | 8 + skill-evals/cases/tools-move.yml | 12 + 66 files changed, 7971 insertions(+), 795 deletions(-) create mode 100644 e2e/smoke/smoke_filings.bats create mode 100644 internal/commands/cards_column_filing_test.go create mode 100644 internal/commands/checkins_filing_test.go create mode 100644 internal/commands/files_filing_test.go create mode 100644 internal/commands/filing.go create mode 100644 internal/commands/filing_family.go create mode 100644 internal/commands/filing_family_test.go create mode 100644 internal/commands/filing_guard_test.go create mode 100644 internal/commands/filing_show_test.go create mode 100644 internal/commands/filing_test.go create mode 100644 internal/commands/filings.go create mode 100644 internal/commands/filings_test.go create mode 100644 internal/commands/forwards_filing_test.go create mode 100644 internal/commands/messages_filing_test.go create mode 100644 internal/commands/schedule_filing_test.go create mode 100644 internal/commands/todolists_filing_test.go create mode 100644 internal/commands/todos_filing_test.go create mode 100644 internal/commands/tools_filing_test.go create mode 100644 skill-evals/cases/checkins-move-copy.yml create mode 100644 skill-evals/cases/columns-move-copy.yml create mode 100644 skill-evals/cases/files-move-copy.yml create mode 100644 skill-evals/cases/forwards-move-copy.yml create mode 100644 skill-evals/cases/messages-move-copy.yml create mode 100644 skill-evals/cases/schedule-move-copy.yml create mode 100644 skill-evals/cases/todos-move-copy-bulk.yml create mode 100644 skill-evals/cases/todos-move-copy-copy.yml create mode 100644 skill-evals/cases/todos-move-copy-group.yml create mode 100644 skill-evals/cases/todos-move-copy-list.yml create mode 100644 skill-evals/cases/todos-move-copy-project.yml create mode 100644 skill-evals/cases/tools-move.yml diff --git a/.surface b/.surface index d565b1c40..eccbb7daf 100644 --- a/.surface +++ b/.surface @@ -51,6 +51,7 @@ ARG basecamp campfire update 01 [content] ARG basecamp campfire upload 00 <file> ARG basecamp cards archive 00 <id|url> ARG basecamp cards column color 00 <id|url> +ARG basecamp cards column copy 00 <id|url> ARG basecamp cards column create 00 <title> ARG basecamp cards column move 00 <id|url> ARG basecamp cards column no-on-hold 00 <id|url> @@ -96,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> @@ -112,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> @@ -136,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> @@ -145,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> @@ -160,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> @@ -169,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> @@ -187,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> @@ -196,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> @@ -211,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> @@ -220,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> @@ -234,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> @@ -244,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> @@ -258,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> @@ -276,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> @@ -290,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> @@ -355,7 +372,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> @@ -391,6 +410,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> @@ -399,6 +419,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> @@ -408,6 +429,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>... @@ -416,6 +438,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> @@ -424,6 +447,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> @@ -433,6 +457,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>... @@ -443,11 +468,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> @@ -459,7 +485,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> @@ -471,11 +497,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> @@ -485,7 +514,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> @@ -500,6 +529,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> @@ -509,7 +539,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> @@ -616,6 +646,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 @@ -667,7 +698,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 @@ -683,7 +716,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 @@ -719,6 +754,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 @@ -759,6 +795,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 @@ -806,6 +843,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 @@ -845,6 +883,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 @@ -882,8 +921,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 @@ -922,8 +964,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 @@ -955,8 +999,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 @@ -973,8 +1019,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 @@ -1065,9 +1113,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 @@ -1116,6 +1166,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 @@ -1124,6 +1175,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 @@ -1133,6 +1185,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 @@ -1143,6 +1196,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 @@ -1151,6 +1205,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 @@ -1160,6 +1215,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 @@ -1172,6 +1228,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 @@ -1204,13 +1261,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 @@ -1250,6 +1310,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 @@ -3157,6 +3218,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 @@ -3202,6 +3288,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 @@ -4375,6 +4463,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 @@ -4401,6 +4514,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 @@ -4764,6 +4901,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 @@ -4790,6 +4952,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 @@ -5563,6 +5749,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 @@ -6031,6 +6243,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 @@ -6586,6 +6800,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 @@ -7054,6 +7294,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 @@ -7773,6 +8015,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 @@ -8241,6 +8509,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 @@ -8775,6 +9045,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 @@ -9243,6 +9539,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 @@ -9731,6 +10029,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 @@ -9777,6 +10117,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 @@ -10245,6 +10611,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 @@ -10755,6 +11123,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 @@ -10803,6 +11196,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 @@ -11495,6 +11912,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 @@ -11553,6 +11995,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 @@ -11912,6 +12378,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 @@ -11970,6 +12461,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 @@ -13968,6 +14483,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 @@ -14057,6 +14597,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 @@ -15162,6 +15726,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 @@ -15228,6 +15818,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 @@ -15252,6 +15845,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 @@ -15342,6 +15938,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 @@ -15408,6 +16030,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 @@ -15432,6 +16057,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 @@ -15542,6 +16170,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 @@ -15615,6 +16268,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 @@ -15638,6 +16294,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 @@ -15661,6 +16320,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 @@ -15774,6 +16436,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 @@ -15840,6 +16528,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 @@ -15864,6 +16555,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 @@ -15954,6 +16648,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 @@ -16020,6 +16740,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 @@ -16044,6 +16767,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 @@ -16154,6 +16880,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 @@ -16227,6 +16978,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 @@ -16250,6 +17004,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 @@ -16273,6 +17030,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 @@ -16427,6 +17187,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 @@ -16516,6 +17300,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 @@ -16540,6 +17326,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 @@ -16585,6 +17373,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 @@ -16930,6 +17720,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 @@ -17176,6 +17967,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 @@ -17228,6 +18045,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 @@ -17344,6 +18188,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 @@ -17812,6 +18682,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 @@ -18346,6 +19218,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 @@ -18814,6 +19712,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 @@ -19669,6 +20569,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 @@ -19720,7 +20621,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 @@ -19736,7 +20639,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 @@ -19772,6 +20677,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 @@ -19812,6 +20718,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 @@ -19859,6 +20766,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 @@ -19898,6 +20806,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 @@ -19935,8 +20844,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 @@ -19975,8 +20887,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 @@ -20008,8 +20922,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 @@ -20026,8 +20942,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 @@ -20118,9 +21036,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 @@ -20169,6 +21089,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 @@ -20177,6 +21098,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 @@ -20186,6 +21108,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 @@ -20196,6 +21119,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 @@ -20204,6 +21128,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 @@ -20213,6 +21138,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 @@ -20225,6 +21151,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 @@ -20257,13 +21184,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 @@ -20303,6 +21233,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 016bd3b07..4d83482f2 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -14,9 +14,11 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. **216 of 216 tracked in-scope endpoints.** 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`), modelled by +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`. Uploading and removing a project's +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)) @@ -211,17 +213,17 @@ 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 (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 | @@ -237,7 +239,7 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A | 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 and reports the new card once it lands | -| recordings (moves, copies) | 3 | `cards move --to-project`, `cards copy` | ✅ | 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. `--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. Moving or copying other recording types is not exposed yet | +| 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 | @@ -251,7 +253,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/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_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_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/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 0ec254276..b8b087af7 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -8,7 +8,6 @@ import ( "net/http" "strconv" "strings" - "time" "github.com/spf13/cobra" @@ -44,7 +43,7 @@ func NewCardsCmd() *cobra.Command { newCardsCreateCmd(&project, &cardTable), newCardsUpdateCmd(), newCardsMoveCmd(&project, &cardTable), - newCardsCopyCmd(&project, &cardTable), + newCardsCopyCmd(&project), newCardsDoneCmd(&project, &cardTable), newCardsColumnsCmd(&project, &cardTable), newCardsColumnCmd(&project, &cardTable), @@ -1364,7 +1363,7 @@ Basecamp to finish the move and shows the card where it landed.`, "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 means Basecamp is still moving it: basecamp cards show <old id> returns the new card once it lands.", + "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") @@ -1375,7 +1374,10 @@ Basecamp to finish the move and shows the card where it landed.`, 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 := validateFilingFlags(*cardTable, toProject, toCardTable); err != nil { + if err := refuseOwnCardTable(*cardTable, "card"); err != nil { + return err + } + if err := validateFilingFlags(toProject, toCardTable); err != nil { return err } } else if wormholeSet { @@ -1398,6 +1400,7 @@ Basecamp to finish the move and shows the card where it landed.`, } app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) if err := ensureAccount(cmd, app); err != nil { return err @@ -1408,7 +1411,7 @@ Basecamp to finish the move and shows the card where it landed.`, if err != nil { return err } - return moveCardToCardTable(cmd, app, card.id, *project, card.project, toProject, toCardTable) + return moveCardToCardTable(cmd, app, scope, card.id, *project, card.project, toProject, toCardTable) } cardIDStr, urlProjectID, err := extractWithProject(args[0]) @@ -1563,7 +1566,7 @@ Basecamp to finish the move and shows the card where it landed.`, 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 @@ -1613,7 +1616,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 := rejectCardProjectConflict(cmd, app, flagProject, urlProjectID, card.Bucket.ID); err != nil { + if err := rejectProjectConflict(cmd, app, "card", flagProject, urlProjectID, card.Bucket.ID); err != nil { return err } @@ -1666,7 +1669,7 @@ func moveCardThroughWormhole(cmd *cobra.Command, app *appctx.App, cardID int64, landed = card return card.ID != cardID, nil }); moved { - return filedCardOK(app, landed, cardID, "Teleported") + return filedOK(app, "card", "Teleported", cardID, filedCard(landed, cardID)) } result := map[string]any{ @@ -1706,42 +1709,6 @@ func wormholeMoveBreadcrumbs(sourceProjectID, cardTableID string) []output.Bread }} } -// rejectCardProjectConflict 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, before a wormhole move, a move into another card -// table, or a copy acts on the card. Config/interactive defaults are not -// checked — only explicit caller context can conflict. -func rejectCardProjectConflict(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 — the card's own project is always used", - ) - } - 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 move or copy. - 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 @@ -1833,7 +1800,7 @@ func parseColumnID(ref string) (int64, error) { return id, nil } -func newCardsCopyCmd(project, cardTable *string) *cobra.Command { +func newCardsCopyCmd(project *string) *cobra.Command { var toProject, toCardTable string var includeComments bool @@ -1858,17 +1825,18 @@ the new card.`, "--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.", + "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 || *cardTable != "" { - if err := validateFilingFlags(*cardTable, toProject, toCardTable); err != nil { + 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 } @@ -1892,7 +1860,7 @@ the new card.`, if err := requireFilableCard(card, ref.id); err != nil { return err } - if err := rejectCardProjectConflict(cmd, app, *project, ref.project, card.Bucket.ID); err != nil { + if err := rejectProjectConflict(cmd, app, "card", *project, ref.project, card.Bucket.ID); err != nil { return err } @@ -1902,36 +1870,14 @@ the new card.`, } } - return runCardFiling(cmd, app, cardFiling{ - kind: basecamp.FilingCopy, - card: card, - table: table, - includeComments: includeComments, - }) + 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 cmd -} - -// parseFilingRef reads the card or card table a cross-table 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) { - ref, err := parseFilesMoveRef(app, arg, []string{kind}) - 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 + return guardFilingFlags(cmd) } // addFilingDestinationFlags adds --to-project and --to-card-table, which name @@ -1941,16 +1887,23 @@ func addFilingDestinationFlags(cmd *cobra.Command, toProject, toCardTable *strin cmd.Flags().StringVar(toCardTable, "to-card-table", "", verb+" the card into this card table (ID, URL, or a name with --to-project)") } -// validateFilingFlags checks --to-project and --to-card-table before any -// request. The group's --card-table names the card's own table, so it is -// refused here rather than mistaken for the destination. -func validateFilingFlags(sourceCardTable, toProject, toCardTable string) error { - if sourceCardTable != "" { - return output.ErrUsageHint( - "--card-table names the card's own card table, not where it goes", - "Use --to-card-table for the destination card table", - ) +// 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") } @@ -1963,9 +1916,6 @@ func validateFilingFlags(sourceCardTable, toProject, toCardTable string) error { 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. // 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. @@ -1979,7 +1929,10 @@ func requireFilableCard(card *basecamp.Card, requestedID int64) error { return nil } -func moveCardToCardTable(cmd *cobra.Command, app *appctx.App, cardID int64, flagProject, urlProjectID, toProject, toCardTable string) error { +// 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 @@ -1991,7 +1944,7 @@ func moveCardToCardTable(cmd *cobra.Command, app *appctx.App, cardID int64, flag if err := requireFilableCard(card, cardID); err != nil { return err } - if err := rejectCardProjectConflict(cmd, app, flagProject, urlProjectID, card.Bucket.ID); err != nil { + 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) { @@ -2000,103 +1953,18 @@ func moveCardToCardTable(cmd *cobra.Command, app *appctx.App, cardID int64, flag 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 runCardFiling(cmd, app, cardFiling{kind: basecamp.FilingMove, card: card, table: table}) + return runFiling(cmd, app, scope, cardFiling(app, basecamp.FilingMove, card, table, false)) } -// resolveFilingTable finds the card table named by --to-project and -// --to-card-table: a card table ID or URL on its own, a project's only card -// table, or one of a project's card tables picked by ID or title. Only card -// tables 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. +// 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) { - var urlProjectID string - if urlarg.IsURL(toCardTable) { - ref, err := parseFilingRef(app, toCardTable, "card table", "card_tables", "…/card_tables/<id>") - if err != nil { - return nil, err - } - toCardTable, urlProjectID = strconv.FormatInt(ref.id, 10), ref.project - } - - if toProject == "" { - tableID, err := strconv.ParseInt(toCardTable, 10, 64) - if err != nil || tableID <= 0 { - return nil, output.ErrUsage("--to-card-table must be a positive card table ID") - } - table, err := getCardTable(cmd, app, tableID) - if err != nil { - return nil, err - } - if urlProjectID != "" && !sameNumericID(urlProjectID, strconv.FormatInt(cardTableProjectID(table), 10)) { - return nil, output.ErrUsage(fmt.Sprintf("The --to-card-table URL names project %s, but card table %d is in project %d", urlProjectID, table.ID, cardTableProjectID(table))) - } - return table, nil - } - - projectID, projectName, err := app.Names.ResolveProject(cmd.Context(), toProject) + target, err := resolveFilingTarget(cmd, app, cardTableContainer, toProject, toCardTable) if err != nil { return nil, err } - if urlProjectID != "" && !sameNumericID(urlProjectID, projectID) { - return nil, output.ErrUsageHint( - fmt.Sprintf("The --to-card-table URL is in project %s, but --to-project points at project %s", urlProjectID, projectID), - "Drop --to-project, since the card table URL names its own project", - ) - } - all, err := listProjectCardTables(cmd, app, projectID) - if err != nil { - return nil, err - } - var tables []projectCardTable - for _, ct := range all { - if ct.Enabled { - tables = append(tables, ct) - } - } - - if toCardTable == "" { - switch len(tables) { - case 0: - if projectName == "" { - projectName = projectID - if id, perr := strconv.ParseInt(projectID, 10, 64); perr == nil { - if name, nerr := app.Names.ProjectName(cmd.Context(), id); nerr == nil && name != "" { - projectName = name - } - } - } - return nil, &output.Error{ - Code: output.CodeNotFound, - Message: fmt.Sprintf("Project %s has no card table", projectName), - Hint: "Pick a project that has one, or add a card table there first", - } - case 1: - return getCardTable(cmd, app, tables[0].ID) - default: - return nil, ambiguousCardTablesErrorFor("--to-card-table", tables) - } - } - var named []projectCardTable - for _, ct := range tables { - if strconv.FormatInt(ct.ID, 10) == toCardTable { - return getCardTable(cmd, app, ct.ID) - } - if strings.EqualFold(ct.Title, toCardTable) { - named = append(named, ct) - } - } - switch len(named) { - case 1: - return getCardTable(cmd, app, named[0].ID) - case 0: - default: - return nil, ambiguousCardTablesErrorFor("--to-card-table", named) - } - return nil, output.ErrUsageHint( - fmt.Sprintf("Card table '%s' not found in project %s", toCardTable, projectID), - fmt.Sprintf("Available card tables: %s", formatCardTableIDs(tables)), - ) + return getCardTable(cmd, app, target.id) } func getCardTable(cmd *cobra.Command, app *appctx.App, tableID int64) (*basecamp.CardTable, error) { @@ -2114,126 +1982,27 @@ func cardTableProjectID(table *basecamp.CardTable) int64 { return table.Bucket.ID } -// cardFiling is one Move… or Copy… of a card into a card table, as the web's +// 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. Basecamp files it in the background, so runCardFiling waits. -type cardFiling struct { - kind basecamp.FilingType - card *basecamp.Card - table *basecamp.CardTable - includeComments bool -} - -func (f cardFiling) start(ctx context.Context, app *appctx.App) (*basecamp.Filing, error) { - recordings := app.Account().Recordings() - if f.kind == basecamp.FilingCopy { - return recordings.Copy(ctx, f.card.ID, &basecamp.CopyRecordingRequest{ - FilingDestinationID: f.table.ID, - IncludeComments: f.includeComments, - }) - } - return recordings.Move(ctx, f.card.ID, &basecamp.MoveRecordingRequest{FilingDestinationID: f.table.ID}) -} - -// 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 cardFiling, err error) error { - e := output.AsError(err) - refused := e.HTTPStatus >= 400 && e.HTTPStatus < 500 - if f.kind != basecamp.FilingCopy || refused || e.Code == output.CodeRateLimit { - return err - } - unsure := *e - unsure.Retryable = false - unsure.Hint = fmt.Sprintf("Basecamp may have started the copy before this failed, and copying again makes another copy. Check first: basecamp cards list --in %d --card-table %d", - cardTableProjectID(f.table), f.table.ID) - return &unsure -} - -// verbs returns the filing's verb as an infinitive, a progressive and a past. -func (f cardFiling) verbs() (string, string, string) { - if f.kind == basecamp.FilingCopy { - return "copy", "copying", "Copied" - } - return "move", "moving", "Moved" -} - -// runCardFiling starts the filing once, never retrying it (each copy request -// makes another copy), waits for Basecamp to finish it, and outputs the card it -// made. -func runCardFiling(cmd *cobra.Command, app *appctx.App, f cardFiling) error { - filing, err := f.start(cmd.Context(), app) - if err != nil { - return copyStartError(f, convertSDKError(err)) - } - - finished := func() bool { - return filing.Status == basecamp.FilingCompleted || filing.Status == basecamp.FilingFailed - } - _, waitErr := pollUntil(cmd.Context(), 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, progressive, past := f.verbs() - switch { - case filing.Status == basecamp.FilingFailed: - return &output.Error{ - Code: output.CodeAPI, - Message: fmt.Sprintf("Basecamp could not %s card #%d to card table %d (%s)", verb, f.card.ID, f.table.ID, f.table.Title), - Hint: fmt.Sprintf("Check the card with: basecamp cards show %d", f.card.ID), - } - case filing.Status != basecamp.FilingCompleted || filing.DestinationRecording == nil: - return stillFilingError(f, progressive, waitErr) - } - - filed, err := app.Account().Cards().Get(cmd.Context(), filing.DestinationRecording.ID) - if err != nil { - return &output.Error{ - Code: output.CodeAPI, - Message: fmt.Sprintf("%s card #%d as card #%d, but could not read the new card", past, f.card.ID, filing.DestinationRecording.ID), - Hint: fmt.Sprintf("Show it with: basecamp cards show %d", filing.DestinationRecording.ID), - Cause: textCause(err), - } - } - return filedCardOK(app, filed, f.card.ID, past) -} - -// stillFilingError reports a move or copy that Basecamp accepted but had not -// finished when the wait ended. -func stillFilingError(f cardFiling, progressive string, waitErr error) error { - hint := fmt.Sprintf("Once it lands, basecamp cards show %d returns the card at its new id", f.card.ID) - if f.kind == basecamp.FilingCopy { - hint = fmt.Sprintf("Do not copy it again, which makes another copy. The copy lands in that table's Triage, or Done if completed: basecamp cards list --in %d --card-table %d", - cardTableProjectID(f.table), f.table.ID) - } - return &output.Error{ - Code: output.CodeInProgress, - Message: fmt.Sprintf("Basecamp is still %s card #%d to card table %d (%s)", progressive, f.card.ID, f.table.ID, f.table.Title), - 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 +// 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 + }, } - return errors.New(err.Error()) } // filedCardResult is a card that a move, copy or teleport made, with the id of @@ -2243,66 +2012,28 @@ type filedCardResult struct { SourceID int64 `json:"source_id"` } -func filedCardOK(app *appctx.App, card *basecamp.Card, sourceID int64, past string) error { - place := []string{} - if card.Bucket != nil { - place = append(place, card.Bucket.Name) - } - if card.Parent != nil { - place = append(place, card.Parent.Title) - } - summary := fmt.Sprintf("%s card #%d to %s as card #%d", past, sourceID, strings.Join(place, " › "), card.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 app.OK(filedCardResult{Card: card, SourceID: sourceID}, - output.WithSummary(summary), - output.WithBreadcrumbs( - output.Breadcrumb{ + 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", }, - output.Breadcrumb{ + { Action: "move", Cmd: fmt.Sprintf("basecamp cards move %d --to <column>%s", card.ID, projectFlag), Description: "Move it to a column", }, - ), - ) -} - -// cardFilingWait bounds the wait for a card that Basecamp moves or copies in -// the background: checks start first apart, back off to max, and stop after -// total. Tests shorten it. -var cardFilingWait = struct{ first, max, total time.Duration }{ - first: 250 * time.Millisecond, - max: 2 * time.Second, - total: time.Minute, -} - -// pollUntil runs check, backing off between runs, until it reports done or -// cardFilingWait.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, cardFilingWait.total) - defer cancel() - - for delay := cardFilingWait.first; ; delay = min(2*delay, cardFilingWait.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): - } + }, } } @@ -2602,10 +2333,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), @@ -2616,14 +2348,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 { @@ -2633,8 +2365,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 } @@ -2643,45 +2375,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", }, ), @@ -2853,17 +2565,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 { @@ -2926,7 +2664,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{ @@ -2939,8 +2683,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 { 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 index 14ea5ff38..1b240da66 100644 --- a/internal/commands/cards_filing_test.go +++ b/internal/commands/cards_filing_test.go @@ -22,11 +22,11 @@ import ( func shortFilingWait(t *testing.T) { t.Helper() - saved := cardFilingWait - cardFilingWait.first = time.Millisecond - cardFilingWait.max = 2 * time.Millisecond - cardFilingWait.total = 50 * time.Millisecond - t.Cleanup(func() { cardFilingWait = saved }) + 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) { @@ -52,7 +52,7 @@ func TestPollUntilGivesUpAfterTotal(t *testing.T) { func TestPollUntilHonoursCancellation(t *testing.T) { shortFilingWait(t) - cardFilingWait.total = time.Minute + filingWait.total = time.Minute ctx, cancel := context.WithCancel(context.Background()) checks := 0 done, err := pollUntil(ctx, func(context.Context) (bool, error) { @@ -73,12 +73,15 @@ func TestPollUntilHonoursCancellation(t *testing.T) { // - 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 @@ -131,9 +134,11 @@ func (m *mockFilingTransport) RoundTrip(req *http.Request) (*http.Response, erro switch { case strings.HasSuffix(path, "/projects"): - return respond(200, `[{"id":123,"name":"Source"},{"id":999,"name":"Dest"},{"id":888,"name":"Bare"}]`) + 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"): @@ -144,11 +149,16 @@ func (m *mockFilingTransport) RoundTrip(req *http.Request) (*http.Response, erro 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"): @@ -234,14 +244,29 @@ func TestCardsMoveToProjectWithSeveralCardTablesIsAmbiguous(t *testing.T) { 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", "Bare") + 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 Bare has no card table", e.Message) - assert.NotEmpty(t, e.Hint) + 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) } @@ -264,7 +289,7 @@ func TestCardsCopyThatFailsUpstreamIsNotRetryable(t *testing.T) { 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 cards list --in 999 --card-table 900") + assert.Contains(t, e.Hint, "basecamp recordings list Kanban::Card --in 999 --status active --all") assert.Len(t, transport.posts, 1) } @@ -279,7 +304,7 @@ func TestCardsCopyWithNoReadableAnswerMayHaveStarted(t *testing.T) { 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 cards list --in 999 --card-table 900") + assert.Contains(t, e.Hint, "basecamp recordings list Kanban::Card --in 999 --status active --all") assert.Len(t, transport.posts, 1) }) } @@ -315,14 +340,13 @@ func TestCardsCopyThatIsRateLimitedStaysRetryable(t *testing.T) { } func TestAcceptedCopyStaysNotRetryableWhenTheWaitHitsTheRateLimitGate(t *testing.T) { - f := cardFiling{ - kind: basecamp.FilingCopy, - card: &basecamp.Card{ID: 456}, - table: &basecamp.CardTable{ID: 900, Title: "Ideas", Bucket: &basecamp.Bucket{ID: 999}}, - } + 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, "copying", gate)) + 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") @@ -362,6 +386,18 @@ func TestCardsMoveToExplicitCardTable(t *testing.T) { } } +// 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") @@ -406,7 +442,7 @@ func TestCardsMoveStillRunningWhenTheWaitRunsOut(t *testing.T) { assert.Equal(t, output.CodeInProgress, e.Code) assert.Contains(t, e.Message, "still moving card #456") - assert.Contains(t, e.Hint, "basecamp cards show 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) } @@ -441,20 +477,10 @@ func TestCardsCopyStillRunningSaysNotToCopyAgain(t *testing.T) { assert.Equal(t, output.CodeInProgress, e.Code) assert.Contains(t, e.Hint, "Do not copy it again") - assert.Contains(t, e.Hint, "basecamp cards list --in 999 --card-table 900") + assert.Contains(t, e.Hint, "basecamp filings show 70") assert.Len(t, transport.posts, 1) } -func TestCardsCopyRefusesTheSourceCardTableFlag(t *testing.T) { - transport := &mockFilingTransport{} - run := runFilingCommand(t, transport, "copy", "456", "--card-table", "555") - e := requireFilingError(t, run) - - assert.Equal(t, output.CodeUsage, e.Code) - assert.Contains(t, e.Hint, "--to-card-table") - assert.Empty(t, transport.posts) -} - func TestCardsMoveToAForeignColumnPointsAtCrossProjectMoves(t *testing.T) { transport := &mockFilingTransport{} run := runFilingCommand(t, transport, "move", "456", "--to", "8888", "--in", "123") diff --git a/internal/commands/cards_test.go b/internal/commands/cards_test.go index 2cf60f265..e7b6d59dc 100644 --- a/internal/commands/cards_test.go +++ b/internal/commands/cards_test.go @@ -665,23 +665,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 // ============================================================================= 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 99f78aa6e..034cb279a 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -35,20 +35,20 @@ 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: "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", "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", "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/schedule.go b/internal/commands/schedule.go index b95e04025..aae352119 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,29 @@ 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. It +// names the schedule whenever the event's parent is one, since a project can +// have several. A calendar, such as an employee's, has no dock to find its +// schedule on, so for a recurring event there, whose parent is the series, +// it looks up the calendar's schedule. +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 || entry.Bucket.Type != "Calendar" { + 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 +940,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..9f2059350 --- /dev/null +++ b/internal/commands/schedule_filing_test.go @@ -0,0 +1,218 @@ +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 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/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..0b6e7e8b6 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,480 @@ 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, convertSDKError(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, err := projectTodolists(cmd, app, projectID) + if err != nil { + return nil, err + } + 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) + } +} + +// 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. +func projectTodolists(cmd *cobra.Command, app *appctx.App, projectID string) ([]basecamp.Todolist, error) { + todosets, _, err := getDockTools(cmd.Context(), app, projectID, "todoset") + if err != nil { + return nil, err + } + var lists []basecamp.Todolist + for _, todoset := range todosets { + result, err := app.Account().Todolists().List(cmd.Context(), todoset.ID, nil) + if err != nil { + return nil, convertSDKError(err) + } + lists = append(lists, result.Todolists...) + } + return lists, 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 89d6eb449..12b5c001a 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"), @@ -2287,28 +2289,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 +2405,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 +2428,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 +2464,247 @@ 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 between the lists of its own project. +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 { + 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..bdf8b465c --- /dev/null +++ b/internal/commands/todos_filing_test.go @@ -0,0 +1,695 @@ +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 + 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 { + 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 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 TestTodosMoveToAProjectWithoutAListNamesTheProject(t *testing.T) { + transport := &mockTodoFilingTransport{} + run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", "456", "--to-project", "Bare") + e := requireUsageRefusal(t, run, transport) + + assert.Equal(t, output.CodeNotFound, e.Code) + assert.Equal(t, "Project Bare has no to-do list", e.Message) +} + +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 5219f550d..be5b89900 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/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/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 From 2f4c9a7218fb3adb01b72e5458fadcc3760034ff Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@zolkos.com> Date: Wed, 28 Oct 2026 00:34:01 -0500 Subject: [PATCH 22/25] feat(projects): set and remove a project's logo (#974) * Share the wallpaper's file check and project read-back with other project images The size cap and the noun in its refusal become arguments, and the helpers lose their wallpaper names, so a project logo can use them as they are. * Add projects logo set and remove bc3#14174 gives a project a logo, the image beside its name, through PUT and DELETE /projects/:id/logo.json, and SDK v0.30.0 wraps both. They follow projects wallpaper: the file is checked for size (5 MB) and image type before the upload, the project comes from --project, --in or the default, and each command answers with the project read back, which now carries .logo {url} when one is attached. * Say on a project's detail view whether it has a logo The logo's only field is a signed-in redirect URL, no use to read in a terminal, so the view says the logo is set and leaves the URL to JSON. * Document project logos in the skill and API coverage --- .surface | 140 +++++++++++++ API-COVERAGE.md | 11 +- e2e/projects.bats | 54 +++++ e2e/smoke/smoke_projects_write.bats | 28 +++ internal/commands/commands.go | 2 +- internal/commands/projects.go | 155 +++++++++++--- internal/commands/projects_logo_test.go | 262 ++++++++++++++++++++++++ internal/presenter/format.go | 11 + internal/presenter/presenter_test.go | 14 ++ internal/presenter/schemas/project.yaml | 6 +- skill-evals/cases/project-logo.yml | 9 + skills/basecamp/SKILL.md | 23 +++ 12 files changed, 686 insertions(+), 29 deletions(-) create mode 100644 internal/commands/projects_logo_test.go create mode 100644 skill-evals/cases/project-logo.yml diff --git a/.surface b/.surface index eccbb7daf..7931f86a4 100644 --- a/.surface +++ b/.surface @@ -349,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> @@ -356,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> @@ -1079,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 @@ -1090,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 @@ -13728,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 @@ -13970,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 @@ -21002,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 @@ -21013,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 diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 4d83482f2..c07aadd28 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -6,12 +6,16 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. | Status | Sections | Endpoints | |--------|----------|-----------| -| ✅ Implemented | 56 | 216 | +| ✅ Implemented | 57 | 218 | | ⚠️ Blocked | 0 | 0 | | ⏭️ Out of scope | 4 | 12 | -| **Total tracked** | **60** | **228** | +| **Total tracked** | **61** | **230** | -**216 of 216 tracked in-scope endpoints.** Moving and copying a recording +**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 @@ -212,6 +216,7 @@ 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, 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` | 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_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/internal/commands/commands.go b/internal/commands/commands.go index 034cb279a..3c4bc76ff 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -34,7 +34,7 @@ func CommandCategories() []CommandCategory { { Name: "Core Commands", Commands: []CommandInfo{ - {Name: "projects", Category: "core", Description: "Manage projects", Actions: []string{"list", "show", "create", "update", "delete", "wallpaper"}}, + {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", "copy", "trash", "archive", "restore"}}, 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/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/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/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 3495c9ad7..e3b9270f3 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 @@ -254,6 +255,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 From 044700e013a7cd24db2e96b260be6174ffe0e7b6 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Wed, 28 Oct 2026 01:38:59 -0400 Subject: [PATCH 23/25] Say a destination's To-dos is turned off, rather than that it has no list Moving or copying a to-do into a project whose To-dos tool is turned off said "has no to-do list" and hinted `todolists create`, which fails there too. It now gives the shared turned-off message and its `tools enable` hint, as every other family does. --- internal/commands/todolists.go | 18 +++++++++++------- internal/commands/todos_filing_test.go | 21 +++++++++++++++------ 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/internal/commands/todolists.go b/internal/commands/todolists.go index 0b6e7e8b6..73e4907fc 100644 --- a/internal/commands/todolists.go +++ b/internal/commands/todolists.go @@ -828,10 +828,13 @@ func resolveFilingTodolist(cmd *cobra.Command, app *appctx.App, toProject, toLis return list, requireFilingDestinationList(list) } - lists, err := projectTodolists(cmd, app, projectID) + 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: @@ -886,20 +889,21 @@ func requireFilingDestinationList(list *basecamp.Todolist) error { // projectTodolists lists the active to-do lists in the to-dos tools on a // project's dock. -func projectTodolists(cmd *cobra.Command, app *appctx.App, projectID string) ([]basecamp.Todolist, error) { - todosets, _, err := getDockTools(cmd.Context(), app, projectID, "todoset") +// 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, err + return nil, nil, nil, err } - var lists []basecamp.Todolist for _, todoset := range todosets { result, err := app.Account().Todolists().List(cmd.Context(), todoset.ID, nil) if err != nil { - return nil, convertSDKError(err) + return nil, nil, nil, convertSDKError(err) } lists = append(lists, result.Todolists...) } - return lists, nil + return lists, todosets, all, nil } func ambiguousTodolistsError(lists []basecamp.Todolist) error { diff --git a/internal/commands/todos_filing_test.go b/internal/commands/todos_filing_test.go index bdf8b465c..35bd9c26d 100644 --- a/internal/commands/todos_filing_test.go +++ b/internal/commands/todos_filing_test.go @@ -419,13 +419,22 @@ func TestTodosMoveToAListNameTwoListsShareIsAmbiguous(t *testing.T) { assert.NotContains(t, e.Hint, "902", "only the lists with that name are candidates") } -func TestTodosMoveToAProjectWithoutAListNamesTheProject(t *testing.T) { - transport := &mockTodoFilingTransport{} - run := runTodoFilingCommand(t, transport, NewTodosCmd(), "move", "456", "--to-project", "Bare") - e := requireUsageRefusal(t, run, transport) +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.Equal(t, "Project Bare has no to-do list", e.Message) + 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) { From dabb088b71079eb3e8c057234f69951d59cefd85 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Wed, 28 Oct 2026 01:57:41 -0400 Subject: [PATCH 24/25] Name the schedule in a recurring project event's entries follow-up A recurring event's parent is its series, not its schedule, so in a project the entries follow-up from schedule show (and show --date) left out --schedule. With several schedules, running it failed with "Multiple schedules found". It now looks the schedule up for project events as it already did for calendar events: one project read, plus a listing per schedule only when the project has several. Main resolved the project on every show, so this costs no more than main did, and only for recurring events. --- internal/commands/occurrence_url_test.go | 5 ++++- internal/commands/schedule.go | 11 +++++------ internal/commands/schedule_filing_test.go | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) 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/schedule.go b/internal/commands/schedule.go index aae352119..e43b19b0c 100644 --- a/internal/commands/schedule.go +++ b/internal/commands/schedule.go @@ -388,11 +388,10 @@ func runScheduleEntryShow(cmd *cobra.Command, app *appctx.App, entryID, occurren return app.OK(data, opts...) } -// scheduleEntriesCmd lists the entries of the schedule an event is on. It -// names the schedule whenever the event's parent is one, since a project can -// have several. A calendar, such as an employee's, has no dock to find its -// schedule on, so for a recurring event there, whose parent is the series, -// it looks up the calendar's schedule. +// 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 { @@ -400,7 +399,7 @@ func scheduleEntriesCmd(ctx context.Context, app *appctx.App, entry *basecamp.Sc } schedule := entry.Parent if schedule.Type != "Schedule" { - if entry.Bucket == nil || entry.Bucket.Type != "Calendar" { + if entry.Bucket == nil { return list } var err error diff --git a/internal/commands/schedule_filing_test.go b/internal/commands/schedule_filing_test.go index 9f2059350..0bd69adc7 100644 --- a/internal/commands/schedule_filing_test.go +++ b/internal/commands/schedule_filing_test.go @@ -146,6 +146,26 @@ func TestScheduleShowListsAProjectEventsEntriesByItsSchedule(t *testing.T) { "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") From 4aa127719b3a4cc658dc2f270baf73e99a826b91 Mon Sep 17 00:00:00 2001 From: Rob Zolkos <rob@37signals.com> Date: Wed, 28 Oct 2026 01:57:41 -0400 Subject: [PATCH 25/25] Let todos move --list take the project's To-dos again On main, todos move <id> --to <n> --list <To-dos id> passed the id to the reposition, which takes a project's To-dos and moves the to-do out of its list. The same-project check read the destination as a list, so it 404ed and refused that move. It now falls back to reading To-dos, keeps the same-project check for it, and refuses To-dos in another project with the command that does move a to-do there: into a list. --to-list names a list for Basecamp's generic move and copy, which file a to-do or group into a list and never onto To-dos. A To-dos id there used to read as "not found"; it is now refused as To-dos, with the command that lists the lists in it. --- internal/commands/todolists.go | 18 +++++++++++- internal/commands/todos.go | 16 ++++++++++- internal/commands/todos_filing_test.go | 40 ++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/internal/commands/todolists.go b/internal/commands/todolists.go index 73e4907fc..d28f98290 100644 --- a/internal/commands/todolists.go +++ b/internal/commands/todolists.go @@ -813,7 +813,7 @@ func resolveFilingTodolist(cmd *cobra.Command, app *appctx.App, toProject, toLis } list, err := app.Account().Todolists().Get(cmd.Context(), id) if err != nil { - return nil, convertSDKError(err) + return nil, notATodolistError(cmd, app, id, err) } listProject := strconv.FormatInt(todolistProjectID(list), 10) if urlProjectID != "" && !sameNumericID(urlProjectID, listProject) { @@ -868,6 +868,22 @@ func resolveFilingTodolist(cmd *cobra.Command, app *appctx.App, toProject, toLis } } +// 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. diff --git a/internal/commands/todos.go b/internal/commands/todos.go index b84c11de6..3bbfb4a35 100644 --- a/internal/commands/todos.go +++ b/internal/commands/todos.go @@ -2692,13 +2692,27 @@ func filedTodo(todo *basecamp.Todo, sourceID int64) filedRecording { } // requireListInTodosProject refuses a --list in another project before the -// reposition, which only moves a to-do between the lists of its own project. +// 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) } diff --git a/internal/commands/todos_filing_test.go b/internal/commands/todos_filing_test.go index 35bd9c26d..34a1b0d28 100644 --- a/internal/commands/todos_filing_test.go +++ b/internal/commands/todos_filing_test.go @@ -51,6 +51,7 @@ type mockTodoFilingTransport struct { requests []string posts []string bodies []map[string]any + puts []map[string]any filedTo map[int64]int64 } @@ -94,6 +95,12 @@ func (m *mockTodoFilingTransport) RoundTrip(req *http.Request) (*http.Response, } } 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, ``) } @@ -363,6 +370,39 @@ func TestTodosMoveToAnotherListInItsProjectStillRepositions(t *testing.T) { 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")