Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -13091,6 +13091,7 @@ FLAG basecamp todos create --markdown type=bool
FLAG basecamp todos create --md type=bool
FLAG basecamp todos create --no-hints type=bool
FLAG basecamp todos create --no-stats type=bool
FLAG basecamp todos create --notify-on-completion type=string
FLAG basecamp todos create --profile type=string
FLAG basecamp todos create --project type=string
FLAG basecamp todos create --quiet type=bool
Expand Down Expand Up @@ -13359,9 +13360,11 @@ FLAG basecamp todos update --md type=bool
FLAG basecamp todos update --no-description type=bool
FLAG basecamp todos update --no-due type=bool
FLAG basecamp todos update --no-hints type=bool
FLAG basecamp todos update --no-notify-on-completion type=bool
FLAG basecamp todos update --no-starts-on type=bool
FLAG basecamp todos update --no-stats type=bool
FLAG basecamp todos update --notify type=bool
FLAG basecamp todos update --notify-on-completion type=string
FLAG basecamp todos update --profile type=string
FLAG basecamp todos update --project type=string
FLAG basecamp todos update --quiet type=bool
Expand Down
49 changes: 49 additions & 0 deletions e2e/smoke/smoke_todos_write.bats
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,55 @@ setup_file() {
run_smoke basecamp todos trash "$todo_id" -p "$QA_PROJECT" --json
}

@test "todos update preserves completion subscribers" {
# Own person ID for use as a completion subscriber
run_smoke basecamp api get /my/profile.json --json
assert_success
local person_id
person_id=$(echo "$output" | jq -r '.data.id')
[[ -n "$person_id" && "$person_id" != "null" ]] || mark_unverifiable "Cannot resolve own person ID"

# Create with a due date and a completion subscriber
run_smoke basecamp todos create "Subscriber preservation test $(date +%s)" \
--list "$QA_TODOLIST" -p "$QA_PROJECT" \
--due "2026-08-15" \
--notify-on-completion "$person_id" --json
assert_success
local todo_id
todo_id=$(echo "$output" | jq -r '.data.id')

# Title-only update (typed merge branch) must preserve the subscriber
run_smoke basecamp todos update "$todo_id" --title "Renamed, subscriber kept" --json
assert_success

run_smoke basecamp api get "/todos/$todo_id.json" --json
assert_success
local subs
subs=$(echo "$output" | jq -r '[.data.completion_subscribers[].id] | join(",")')
[[ "$subs" == "$person_id" ]] || { echo "subscriber lost after title update: [$subs]"; return 1; }

# --no-due (raw clear branch) must also preserve the subscriber
run_smoke basecamp todos update "$todo_id" --no-due --json
assert_success

run_smoke basecamp api get "/todos/$todo_id.json" --json
assert_success
subs=$(echo "$output" | jq -r '[.data.completion_subscribers[].id] | join(",")')
[[ "$subs" == "$person_id" ]] || { echo "subscriber lost after --no-due: [$subs]"; return 1; }

# --no-notify-on-completion clears the subscribers
run_smoke basecamp todos update "$todo_id" --no-notify-on-completion --json
assert_success

run_smoke basecamp api get "/todos/$todo_id.json" --json
assert_success
subs=$(echo "$output" | jq -r '.data.completion_subscribers | length')
[[ "$subs" == "0" ]] || { echo "subscribers not cleared: $subs remain"; return 1; }

# Clean up
run_smoke basecamp todos trash "$todo_id" -p "$QA_PROJECT" --json
}

@test "todos trash trashes a todo" {
local id_file="$BATS_FILE_TMPDIR/direct_todo_id"
[[ -f "$id_file" ]] || mark_unverifiable "No direct todo created in prior test"
Expand Down
25 changes: 19 additions & 6 deletions internal/commands/cards.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,27 +347,34 @@ You can pass either a card ID or a Basecamp URL:
}

func resolveAssigneeID(ctx context.Context, app *appctx.App, input string) (int64, error) {
return resolvePersonRoleID(ctx, app, input, "Assignee")
}

// resolvePersonRoleID resolves a single person name or ID. The role label
// ("Assignee", "Completion subscriber", …) names the person's role in error
// messages so callers surface the flag the user actually passed.
func resolvePersonRoleID(ctx context.Context, app *appctx.App, input, role string) (int64, error) {
input = strings.TrimSpace(input)
if input == "" {
return 0, output.ErrUsage("Assignee cannot be empty")
return 0, output.ErrUsage(fmt.Sprintf("%s cannot be empty", role))
}

if id, err := strconv.ParseInt(input, 10, 64); err == nil {
if id <= 0 {
return 0, output.ErrUsage("Assignee ID must be a positive number")
return 0, output.ErrUsage(fmt.Sprintf("%s ID must be a positive number", role))
}
return id, nil
}
resolvedID, _, err := app.Names.ResolvePerson(ctx, input)
if err != nil {
return 0, fmt.Errorf("failed to resolve assignee '%s': %w", input, err)
return 0, fmt.Errorf("failed to resolve %s '%s': %w", strings.ToLower(role), input, err)
}
id, err := strconv.ParseInt(resolvedID, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid resolved ID '%s': %w", resolvedID, err)
}
if id <= 0 {
return 0, fmt.Errorf("resolved assignee ID for '%s' is not valid: %d", input, id)
return 0, fmt.Errorf("resolved %s ID for '%s' is not valid: %d", strings.ToLower(role), input, id)
}
return id, nil
}
Expand Down Expand Up @@ -2460,6 +2467,12 @@ func resolveColumn(columns []basecamp.CardColumn, identifier string) int64 {
}

func resolveAssigneeIDs(ctx context.Context, app *appctx.App, input string) ([]int64, error) {
return resolvePersonRoleIDs(ctx, app, input, "Assignee")
}

// resolvePersonRoleIDs resolves a comma-separated list of person names or IDs,
// labeling errors with the given role (see resolvePersonRoleID).
func resolvePersonRoleIDs(ctx context.Context, app *appctx.App, input, role string) ([]int64, error) {
parts := strings.Split(input, ",")
ids := make([]int64, 0, len(parts))

Expand All @@ -2469,15 +2482,15 @@ func resolveAssigneeIDs(ctx context.Context, app *appctx.App, input string) ([]i
continue
}

id, err := resolveAssigneeID(ctx, app, part)
id, err := resolvePersonRoleID(ctx, app, part, role)
if err != nil {
return nil, err
}
ids = append(ids, id)
}

if len(ids) == 0 {
return nil, output.ErrUsage("No valid assignees provided")
return nil, output.ErrUsage(fmt.Sprintf("No valid %ss provided", strings.ToLower(role)))
}

return ids, nil
Expand Down
116 changes: 112 additions & 4 deletions internal/commands/todos.go
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,7 @@ func newTodosCreateCmd() *cobra.Command {
var due string
var description string
var attachFiles []string
var notifyOnCompletion string

cmd := &cobra.Command{
Use: "create <content>",
Expand Down Expand Up @@ -881,6 +882,13 @@ func newTodosCreateCmd() *cobra.Command {
assigneeIDInt, _ := strconv.ParseInt(assigneeID, 10, 64)
req.AssigneeIDs = []int64{assigneeIDInt}
}
if strings.TrimSpace(notifyOnCompletion) != "" {
subscriberIDs, err := resolveCompletionSubscriberIDs(cmd.Context(), app, notifyOnCompletion)
if err != nil {
return err
}
req.CompletionSubscriberIDs = subscriberIDs
Comment thread
jeremy marked this conversation as resolved.
}

todolistID, err := strconv.ParseInt(resolvedTodolist, 10, 64)
if err != nil {
Expand Down Expand Up @@ -925,13 +933,15 @@ func newTodosCreateCmd() *cobra.Command {
cmd.Flags().StringVarP(&due, "due", "d", "", "Due date (YYYY-MM-DD)")
cmd.Flags().StringVar(&description, "description", "", "Extended description (Markdown)")
cmd.Flags().StringArrayVar(&attachFiles, "attach", nil, "Attach file (repeatable)")
cmd.Flags().StringVar(&notifyOnCompletion, "notify-on-completion", "", "People to notify when done (names or IDs, comma-separated)")

// Register tab completion for flags
completer := completion.NewCompleter(nil)
_ = cmd.RegisterFlagCompletionFunc("project", completer.ProjectNameCompletion())
_ = cmd.RegisterFlagCompletionFunc("in", completer.ProjectNameCompletion())
_ = cmd.RegisterFlagCompletionFunc("assignee", completer.PeopleNameCompletion())
_ = cmd.RegisterFlagCompletionFunc("to", completer.PeopleNameCompletion())
_ = cmd.RegisterFlagCompletionFunc("notify-on-completion", completer.PeopleNameCompletion())

return cmd
}
Expand All @@ -946,6 +956,8 @@ func newTodosUpdateCmd() *cobra.Command {
var noDue bool
var noStartsOn bool
var noDescription bool
var notifyOnCompletion string
var noNotifyOnCompletion bool

cmd := &cobra.Command{
Use: "update <id|url> [title]",
Expand All @@ -961,7 +973,11 @@ You can pass either a todo ID or a Basecamp URL:
Clear a field by passing its --no- flag or an empty value:
basecamp todos update 789 --no-due
basecamp todos update 789 --due ""
basecamp todos update 789 --no-description`,
basecamp todos update 789 --no-description

Set or clear the people notified when the todo is completed:
basecamp todos update 789 --notify-on-completion "Jane Smith,Bob"
basecamp todos update 789 --no-notify-on-completion`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return missingArg(cmd, "<id|url>")
Expand All @@ -977,11 +993,17 @@ Clear a field by passing its --no- flag or an empty value:
if noDescription && strings.TrimSpace(description) != "" {
return output.ErrUsage("--no-description and --description cannot be used together")
}
if noNotifyOnCompletion && strings.TrimSpace(notifyOnCompletion) != "" {
return output.ErrUsage("--no-notify-on-completion and --notify-on-completion cannot be used together")
}
// Detect clear intent: explicit --no-X flag or empty value via --X ""
clearDue := noDue || (cmd.Flags().Changed("due") && strings.TrimSpace(due) == "")
clearStarts := noStartsOn || (cmd.Flags().Changed("starts-on") && strings.TrimSpace(startsOn) == "")
clearDescription := noDescription || (cmd.Flags().Changed("description") && strings.TrimSpace(description) == "")
needsClear := clearDue || clearStarts || clearDescription
// Subscriber clearing works by omission in both branches, so it
// doesn't force the raw-PUT clear branch.
clearSubscribers := noNotifyOnCompletion || (cmd.Flags().Changed("notify-on-completion") && strings.TrimSpace(notifyOnCompletion) == "")

// Clearing due while setting starts is contradictory (Basecamp enforces starts <= due)
if clearDue && strings.TrimSpace(startsOn) != "" {
Expand All @@ -999,12 +1021,13 @@ Clear a field by passing its --no- flag or an empty value:

// No-op guard: at least one effective field required
assigneeChanged := (cmd.Flags().Changed("assignee") || cmd.Flags().Changed("to")) && strings.TrimSpace(assignee) != ""
subscribersChanged := cmd.Flags().Changed("notify-on-completion") && strings.TrimSpace(notifyOnCompletion) != ""
if strings.TrimSpace(effectiveTitle) == "" &&
strings.TrimSpace(description) == "" &&
strings.TrimSpace(due) == "" && strings.TrimSpace(startsOn) == "" &&
!assigneeChanged &&
!assigneeChanged && !subscribersChanged &&
(!cmd.Flags().Changed("notify") || !notify) &&
!needsClear {
!needsClear && !clearSubscribers {
return noChanges(cmd)
}

Expand Down Expand Up @@ -1100,6 +1123,26 @@ Clear a field by passing its --no- flag or an empty value:
body["assignee_ids"] = ids
}

// Completion subscribers: the SDK's Todo model drops them, so
// existingTodo can't supply them — set explicitly, clear by
// omission, or preserve via a raw read that must succeed
// before any PUT (fail closed).
if subscribersChanged {
subscriberIDs, err := resolveCompletionSubscriberIDs(cmd.Context(), app, notifyOnCompletion)
if err != nil {
return err
}
body["completion_subscriber_ids"] = subscriberIDs
} else if !clearSubscribers {
Comment thread
jeremy marked this conversation as resolved.
subscriberIDs, err := completionSubscriberIDs(cmd.Context(), app, todoID)
if err != nil {
return err
}
if len(subscriberIDs) > 0 {
body["completion_subscriber_ids"] = subscriberIDs
}
}

if cmd.Flags().Changed("notify") && notify {
body["notify"] = true
}
Expand Down Expand Up @@ -1166,6 +1209,25 @@ Clear a field by passing its --no- flag or an empty value:
}
req.AssigneeIDs = assigneeIDs
}
// Completion subscribers: the SDK's Todo model drops them, so
// existingTodo can't supply them — set explicitly, clear by
// omission (nil), or preserve via a raw read that must succeed
// before any PUT (fail closed).
if subscribersChanged {
subscriberIDs, err := resolveCompletionSubscriberIDs(cmd.Context(), app, notifyOnCompletion)
if err != nil {
return err
}
req.CompletionSubscriberIDs = subscriberIDs
} else if !clearSubscribers {
Comment thread
jeremy marked this conversation as resolved.
subscriberIDs, err := completionSubscriberIDs(cmd.Context(), app, todoID)
if err != nil {
return err
}
if len(subscriberIDs) > 0 {
req.CompletionSubscriberIDs = subscriberIDs
}
}
if cmd.Flags().Changed("notify") && notify {
req.Notify = true
}
Expand Down Expand Up @@ -1205,15 +1267,61 @@ Clear a field by passing its --no- flag or an empty value:
cmd.Flags().BoolVar(&noDue, "no-due", false, "Clear the due date")
cmd.Flags().BoolVar(&noStartsOn, "no-starts-on", false, "Clear the start date")
cmd.Flags().BoolVar(&noDescription, "no-description", false, "Clear the description")
cmd.Flags().StringVar(&notifyOnCompletion, "notify-on-completion", "", "People to notify when done (names or IDs, comma-separated)")
cmd.Flags().BoolVar(&noNotifyOnCompletion, "no-notify-on-completion", false, "Clear the people notified when done")

// Register tab completion for assignee flags
// Register tab completion for people flags
completer := completion.NewCompleter(nil)
_ = cmd.RegisterFlagCompletionFunc("assignee", completer.PeopleNameCompletion())
_ = cmd.RegisterFlagCompletionFunc("to", completer.PeopleNameCompletion())
_ = cmd.RegisterFlagCompletionFunc("notify-on-completion", completer.PeopleNameCompletion())

return cmd
}

// resolveCompletionSubscriberIDs resolves --notify-on-completion values
// (comma-separated names or IDs) with completion-subscriber wording in errors.
func resolveCompletionSubscriberIDs(ctx context.Context, app *appctx.App, input string) ([]int64, error) {
return resolvePersonRoleIDs(ctx, app, input, "Completion subscriber")
}

// completionSubscriberIDs reads the current completion subscriber ids via a raw
// GET of /todos/{id}.json. The SDK's Todo model drops completion_subscribers in
// todoFromGenerated (basecamp/basecamp-sdk#355), so a raw read is the only way
// to preserve them across the replace-semantics PUT (#538). Delete once the SDK
// round-trips the field.
//
// Fails closed: a missing or null completion_subscribers key, a malformed
// response, or a failed GET all return an error — proceeding without knowing
// the current subscribers would silently clear them.
func completionSubscriberIDs(ctx context.Context, app *appctx.App, todoID int64) ([]int64, error) {
resp, err := app.Account().Get(ctx, fmt.Sprintf("/todos/%d.json", todoID))
if err != nil {
return nil, fmt.Errorf("failed to read current completion subscribers for todo %d: %w", todoID, convertSDKError(err))
}
Comment thread
jeremy marked this conversation as resolved.

var raw struct {
CompletionSubscribers *[]struct {
ID int64 `json:"id"`
} `json:"completion_subscribers"`
}
if err := resp.UnmarshalData(&raw); err != nil {
return nil, fmt.Errorf("failed to parse completion subscribers for todo %d: %w", todoID, err)
}
if raw.CompletionSubscribers == nil {
return nil, fmt.Errorf("cannot verify current completion subscribers for todo %d: response missing completion_subscribers", todoID)
}

ids := make([]int64, len(*raw.CompletionSubscribers))
for i, s := range *raw.CompletionSubscribers {
if s.ID <= 0 {
return nil, fmt.Errorf("cannot verify current completion subscribers for todo %d: subscriber with missing or invalid id", todoID)
}
ids[i] = s.ID
}
return ids, nil
}

func newTodosCompleteCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "complete <id|url>...",
Expand Down
Loading
Loading