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
29 changes: 29 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ CMD basecamp todos show
CMD basecamp todos sweep
CMD basecamp todos trash
CMD basecamp todos uncomplete
CMD basecamp todos update
CMD basecamp todosets
CMD basecamp todosets list
CMD basecamp todosets show
Expand Down Expand Up @@ -6156,6 +6157,33 @@ FLAG basecamp todos uncomplete --stats type=bool
FLAG basecamp todos uncomplete --styled type=bool
FLAG basecamp todos uncomplete --todolist type=string
FLAG basecamp todos uncomplete --verbose type=count
FLAG basecamp todos update --account type=string
FLAG basecamp todos update --agent type=bool
FLAG basecamp todos update --assignee type=string
FLAG basecamp todos update --cache-dir type=string
FLAG basecamp todos update --count type=bool
FLAG basecamp todos update --description type=string
FLAG basecamp todos update --due type=string
FLAG basecamp todos update --hints type=bool
FLAG basecamp todos update --ids-only type=bool
FLAG basecamp todos update --in type=string
FLAG basecamp todos update --jq type=string
FLAG basecamp todos update --json type=bool
FLAG basecamp todos update --markdown type=bool
FLAG basecamp todos update --md type=bool
FLAG basecamp todos update --no-hints type=bool
FLAG basecamp todos update --no-stats type=bool
FLAG basecamp todos update --notify type=bool
FLAG basecamp todos update --profile type=string
FLAG basecamp todos update --project type=string
FLAG basecamp todos update --quiet type=bool
FLAG basecamp todos update --starts-on type=string
FLAG basecamp todos update --stats type=bool
FLAG basecamp todos update --styled type=bool
FLAG basecamp todos update --title type=string
FLAG basecamp todos update --to type=string
FLAG basecamp todos update --todolist type=string
FLAG basecamp todos update --verbose type=count
FLAG basecamp todosets --account type=string
FLAG basecamp todosets --agent type=bool
FLAG basecamp todosets --cache-dir type=string
Expand Down Expand Up @@ -7414,6 +7442,7 @@ SUB basecamp todos show
SUB basecamp todos sweep
SUB basecamp todos trash
SUB basecamp todos uncomplete
SUB basecamp todos update
SUB basecamp todosets
SUB basecamp todosets list
SUB basecamp todosets show
Expand Down
2 changes: 1 addition & 1 deletion API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Out-of-scope sections are excluded from parity totals and scripts: chatbots (dif
|---------|-----------|-------------|--------|----------|-------|
| **Core** |
| projects | 9 | `projects` | ✅ | - | list, show, create, update, delete |
| todos | 11 | `todos`, `todo`, `done`, `reopen` | ✅ | - | list, show, create, complete, uncomplete, position |
| todos | 11 | `todos`, `todo`, `done`, `reopen` | ✅ | - | list, show, create, update, complete, uncomplete, position |
| todolists | 8 | `todolists` | ✅ | - | list, show, create, update |
| todosets | 3 | `todosets` | ✅ | - | Container for todolists, accessed via project dock |
| todolist_groups | 8 | `todolistgroups` | ✅ | - | list, show, create, update, position |
Expand Down
11 changes: 11 additions & 0 deletions e2e/smoke/smoke_todos_write.bats
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ setup_file() {
assert_json_value '.ok' '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"
local todo_id
todo_id=$(<"$id_file")

run_smoke basecamp todos update "$todo_id" --title "Updated smoke todo $(date +%s)" -p "$QA_PROJECT" --json
assert_success
assert_json_value '.ok' 'true'
}

@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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.2
charm.land/lipgloss/v2 v2.0.2
github.com/basecamp/basecamp-sdk/go v0.6.1-0.20260318172136-4784bb2fda18
github.com/basecamp/basecamp-sdk/go v0.6.1-0.20260318221909-49475983b9c8
github.com/basecamp/cli v0.1.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/glamour v1.0.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/basecamp/basecamp-sdk/go v0.6.1-0.20260318172136-4784bb2fda18 h1:it4iTTmOKr4DFOdVGE7jVxNQ3lD9snwe8vYhVI8Soz4=
github.com/basecamp/basecamp-sdk/go v0.6.1-0.20260318172136-4784bb2fda18/go.mod h1:g05DM58QkUm4/mvBAvRiugPw+F4trliuGkRGg8y+Th4=
github.com/basecamp/basecamp-sdk/go v0.6.1-0.20260318221909-49475983b9c8 h1:WsLan1O+1GWBSQEn+dTd63roBunjojffjaoKnQ4g33U=
github.com/basecamp/basecamp-sdk/go v0.6.1-0.20260318221909-49475983b9c8/go.mod h1:g05DM58QkUm4/mvBAvRiugPw+F4trliuGkRGg8y+Th4=
github.com/basecamp/cli v0.1.1 h1:FAF3M09xo1m7gJJXf38glCkT50ZUuvz+31f+c3R3zcc=
github.com/basecamp/cli v0.1.1/go.mod h1:NTHe+keCTGI2qM5sMXdkUN0QgU3zGbwnBxcmg8vD5QU=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
Expand Down
8 changes: 4 additions & 4 deletions internal/commands/checkins.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,8 @@ Days format: comma-separated (0=Sun, 1=Mon, 2=Tue, 3=Wed, 4=Thu, 5=Fri, 6=Sat)`,
Schedule: &basecamp.QuestionSchedule{
Frequency: frequency,
Days: daysArray,
Hour: hour,
Minute: minute,
Hour: &hour,
Minute: &minute,
},
}

Expand Down Expand Up @@ -457,8 +457,8 @@ You can pass either a question ID or a Basecamp URL:
if err != nil {
return output.ErrUsage("Invalid time format: " + timeOfDay)
}
schedule.Hour = hour
schedule.Minute = minute
schedule.Hour = &hour
schedule.Minute = &minute
}
if days != "" {
dayParts := strings.Split(days, ",")
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func CommandCategories() []CommandCategory {
Name: "Core Commands",
Commands: []CommandInfo{
{Name: "projects", Category: "core", Description: "Manage projects", Actions: []string{"list", "show", "create", "update", "delete"}},
{Name: "todos", Category: "core", Description: "Manage to-dos", Actions: []string{"list", "show", "create", "complete", "uncomplete", "position", "trash", "archive", "restore"}},
{Name: "todos", Category: "core", Description: "Manage to-dos", Actions: []string{"list", "show", "create", "update", "complete", "uncomplete", "position", "trash", "archive", "restore"}},
{Name: "todolists", Category: "core", Description: "Manage to-do lists", Actions: []string{"list", "show", "create", "update", "trash", "archive", "restore"}},
{Name: "todosets", Category: "core", Description: "Manage to-do set containers", Actions: []string{"list", "show"}},
{Name: "todolistgroups", Category: "core", Description: "Manage to-do list groups", Actions: []string{"list", "show", "create", "update", "position"}},
Expand Down
4 changes: 2 additions & 2 deletions internal/commands/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ func runScheduleCreate(cmd *cobra.Command, app *appctx.App, project, scheduleID,
StartsAt: startsAt,
EndsAt: endsAt,
Description: description,
AllDay: allDay,
AllDay: &allDay,
Notify: notify,
Subscriptions: subs,
}
Expand Down Expand Up @@ -618,7 +618,7 @@ You can pass either an entry ID or a Basecamp URL:
hasChanges = true
}
if cmd.Flags().Changed("all-day") {
req.AllDay = allDay
req.AllDay = &allDay
hasChanges = true
}
if cmd.Flags().Changed("notify") {
Expand Down
137 changes: 137 additions & 0 deletions internal/commands/todos.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ func NewTodosCmd() *cobra.Command {
newTodosListCmd(),
newTodosShowCmd(),
newTodosCreateCmd(),
newTodosUpdateCmd(),
newTodosCompleteCmd(),
newTodosUncompleteCmd(),
newTodosSweepCmd(),
Expand Down Expand Up @@ -723,6 +724,11 @@ You can pass either a todo ID or a Basecamp URL:
return app.OK(todo,
output.WithEntity("todo"),
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "update",
Cmd: fmt.Sprintf("basecamp todos update %d --title <title>", todoID),
Description: "Update this todo",
},
output.Breadcrumb{
Action: "complete",
Cmd: fmt.Sprintf("basecamp done %d", todoID),
Expand Down Expand Up @@ -919,6 +925,137 @@ func newTodosCreateCmd() *cobra.Command {
return cmd
}

func newTodosUpdateCmd() *cobra.Command {
var title string
var description string
var assignee string
var due string
var startsOn string
var notify bool

cmd := &cobra.Command{
Use: "update <id|url> [title]",
Short: "Update a todo",
Long: `Update an existing todo.

You can pass either a todo ID or a Basecamp URL:
basecamp todos update 789 "New title"
basecamp todos update 789 --title "New title"
basecamp todos update 789 --due "next friday"
basecamp todos update https://3.basecamp.com/123/buckets/456/todos/789 --description "Details"`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return missingArg(cmd, "<id|url>")
}

// Positional title: args[1:] joined
positionalTitle := strings.Join(args[1:], " ")

// Effective title: positional takes precedence over --title flag
effectiveTitle := title
if strings.TrimSpace(positionalTitle) != "" {
effectiveTitle = positionalTitle
}

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

app := appctx.FromContext(cmd.Context())
if app == nil {
return fmt.Errorf("app not initialized")
}

if err := ensureAccount(cmd, app); err != nil {
return err
}

// Extract ID from URL if provided
todoIDStr := extractID(args[0])
todoID, err := strconv.ParseInt(todoIDStr, 10, 64)
if err != nil {
return output.ErrUsage("Invalid todo ID")
}

req := &basecamp.UpdateTodoRequest{}
if effectiveTitle != "" {
req.Content = effectiveTitle
}
if description != "" {
descHTML := richtext.MarkdownToHTML(description)
descHTML, err = resolveLocalImages(cmd, app, descHTML)
if err != nil {
return err
}
req.Description = descHTML
}
if strings.TrimSpace(due) != "" {
if parsed := dateparse.Parse(due); parsed != "" {
req.DueOn = parsed
}
}
if strings.TrimSpace(startsOn) != "" {
if parsed := dateparse.Parse(startsOn); parsed != "" {
req.StartsOn = parsed
}
}
if assigneeChanged {
assigneeIDs, err := resolveAssigneeIDs(cmd.Context(), app, assignee)
if err != nil {
return err
}
req.AssigneeIDs = assigneeIDs
}
if cmd.Flags().Changed("notify") && notify {
req.Notify = true
}

todo, err := app.Account().Todos().Update(cmd.Context(), todoID, req)
if err != nil {
return convertSDKError(err)
}

return app.OK(todo,
output.WithEntity("todo"),
output.WithSummary(fmt.Sprintf("Updated todo #%s", todoIDStr)),
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "show",
Cmd: fmt.Sprintf("basecamp todos show %s", todoIDStr),
Description: "View todo",
},
output.Breadcrumb{
Action: "complete",
Cmd: fmt.Sprintf("basecamp done %s", todoIDStr),
Description: "Complete todo",
},
),
)
},
}

cmd.Flags().StringVarP(&title, "title", "t", "", "Todo title (plain text)")
cmd.Flags().StringVar(&description, "description", "", "Extended description (Markdown)")
cmd.Flags().StringVar(&assignee, "assignee", "", "Assignees (names or IDs, comma-separated)")
cmd.Flags().StringVar(&assignee, "to", "", "Assignees (alias for --assignee)")
cmd.Flags().StringVarP(&due, "due", "d", "", "Due date (natural language or YYYY-MM-DD)")
cmd.Flags().StringVar(&startsOn, "starts-on", "", "Start date (natural language or YYYY-MM-DD)")
cmd.Flags().BoolVar(&notify, "notify", false, "Notify assignees")

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

return cmd
}

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