From 666c3e8b7e8165ccf2a33164d90a8fe17247f66a Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 8 Apr 2026 18:13:36 -0400 Subject: [PATCH] Fix todo update dropping untouched fields (#412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-clear update path sent only user-provided fields in the PUT body. Because the BC3 API clears fields by omission, this caused partial updates (e.g. --due alone) to silently wipe content, description, dates, and assignees. Fetch the existing todo first and seed the request with its current values before applying overrides — matching what the clear path already does. --- internal/commands/todos.go | 24 ++++++++++++++++- internal/commands/todos_test.go | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/internal/commands/todos.go b/internal/commands/todos.go index c8808a71..86f61e65 100644 --- a/internal/commands/todos.go +++ b/internal/commands/todos.go @@ -1164,7 +1164,29 @@ Clear a field by passing its --no- flag or an empty value: return convertSDKError(err) } } else { - req := &basecamp.UpdateTodoRequest{} + // Fetch existing todo so we can preserve fields the user + // didn't change. The BC3 API clears fields by omission, + // so a partial PUT would wipe untouched fields. + existingTodo, err := app.Account().Todos().Get(cmd.Context(), todoID) + if err != nil { + return convertSDKError(err) + } + + req := &basecamp.UpdateTodoRequest{ + Content: existingTodo.Content, + Description: existingTodo.Description, + DueOn: existingTodo.DueOn, + StartsOn: existingTodo.StartsOn, + } + if len(existingTodo.Assignees) > 0 { + ids := make([]int64, len(existingTodo.Assignees)) + for i, a := range existingTodo.Assignees { + ids[i] = a.ID + } + req.AssigneeIDs = ids + } + + // Override with user-provided values. if effectiveTitle != "" { req.Content = effectiveTitle } diff --git a/internal/commands/todos_test.go b/internal/commands/todos_test.go index 0f343c93..97d7823b 100644 --- a/internal/commands/todos_test.go +++ b/internal/commands/todos_test.go @@ -1810,6 +1810,53 @@ func TestTodosUpdateClearPreservesAssignees(t *testing.T) { assert.Equal(t, float64(42), ids[0]) } +func TestTodosUpdateDueDatePreservesExistingFields(t *testing.T) { + transport := &mockTodoUpdateTransport{} + app := setupTodoUpdateApp(t, transport) + + cmd := NewTodosCmd() + err := executeTodosCommand(cmd, app, "update", "999", "--due", "2026-04-12") + require.NoError(t, err) + require.NotEmpty(t, transport.capturedBody, "expected PUT body but got none") + + var body map[string]any + err = json.Unmarshal(transport.capturedBody, &body) + require.NoError(t, err) + + // The new due date is applied. + assert.Equal(t, "2026-04-12", body["due_on"]) + + // BC3 API clears fields by omission, so untouched fields from the + // existing todo must be preserved in the request body. + assert.Equal(t, "Test todo", body["content"]) + assert.Equal(t, "Existing desc", body["description"]) + assert.Equal(t, "2026-03-25", body["starts_on"]) + + ids, ok := body["assignee_ids"].([]any) + require.True(t, ok, "assignee_ids must be preserved") + require.Len(t, ids, 1) + assert.Equal(t, float64(42), ids[0]) +} + +func TestTodosUpdateTitlePreservesExistingFields(t *testing.T) { + transport := &mockTodoUpdateTransport{} + app := setupTodoUpdateApp(t, transport) + + cmd := NewTodosCmd() + err := executeTodosCommand(cmd, app, "update", "999", "New title") + require.NoError(t, err) + require.NotEmpty(t, transport.capturedBody) + + var body map[string]any + err = json.Unmarshal(transport.capturedBody, &body) + require.NoError(t, err) + + assert.Equal(t, "New title", body["content"]) + assert.Equal(t, "Existing desc", body["description"]) + assert.Equal(t, "2026-04-01", body["due_on"]) + assert.Equal(t, "2026-03-25", body["starts_on"]) +} + // ============================================================================= // --assignee filtering tests (single-list path) // =============================================================================