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
24 changes: 23 additions & 1 deletion .surface
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ CMD basecamp todos sweep
CMD basecamp todos trash
CMD basecamp todos uncomplete
CMD basecamp todosets
CMD basecamp todosets list
CMD basecamp todosets show
CMD basecamp tools
CMD basecamp tools create
Expand Down Expand Up @@ -5172,6 +5173,7 @@ FLAG basecamp todo --stats type=bool
FLAG basecamp todo --styled type=bool
FLAG basecamp todo --to type=string
FLAG basecamp todo --todolist type=string
FLAG basecamp todo --todoset type=string
FLAG basecamp todo --verbose type=count
FLAG basecamp todolistgroups --account type=string
FLAG basecamp todolistgroups --agent type=bool
Expand Down Expand Up @@ -5534,6 +5536,7 @@ FLAG basecamp todos create --stats type=bool
FLAG basecamp todos create --styled type=bool
FLAG basecamp todos create --to type=string
FLAG basecamp todos create --todolist type=string
FLAG basecamp todos create --todoset type=string
FLAG basecamp todos create --verbose type=count
FLAG basecamp todos list --account type=string
FLAG basecamp todos list --agent type=bool
Expand Down Expand Up @@ -5699,8 +5702,26 @@ FLAG basecamp todosets --quiet type=bool
FLAG basecamp todosets --stats type=bool
FLAG basecamp todosets --styled type=bool
FLAG basecamp todosets --todolist type=string
FLAG basecamp todosets --todoset type=string
FLAG basecamp todosets --verbose type=count
FLAG basecamp todosets list --account type=string
FLAG basecamp todosets list --agent type=bool
FLAG basecamp todosets list --cache-dir type=string
FLAG basecamp todosets list --count type=bool
FLAG basecamp todosets list --hints type=bool
FLAG basecamp todosets list --ids-only type=bool
FLAG basecamp todosets list --in type=string
FLAG basecamp todosets list --json type=bool
FLAG basecamp todosets list --markdown type=bool
FLAG basecamp todosets list --md type=bool
FLAG basecamp todosets list --no-hints type=bool
FLAG basecamp todosets list --no-stats type=bool
FLAG basecamp todosets list --profile type=string
FLAG basecamp todosets list --project type=string
FLAG basecamp todosets list --quiet type=bool
FLAG basecamp todosets list --stats type=bool
FLAG basecamp todosets list --styled type=bool
FLAG basecamp todosets list --todolist type=string
FLAG basecamp todosets list --verbose type=count
FLAG basecamp todosets show --account type=string
FLAG basecamp todosets show --agent type=bool
FLAG basecamp todosets show --cache-dir type=string
Expand Down Expand Up @@ -6869,6 +6890,7 @@ SUB basecamp todos sweep
SUB basecamp todos trash
SUB basecamp todos uncomplete
SUB basecamp todosets
SUB basecamp todosets list
SUB basecamp todosets show
SUB basecamp tools
SUB basecamp tools create
Expand Down
1 change: 1 addition & 0 deletions .surface-breaking
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ ARG basecamp assign 00 <todo_id>
ARG basecamp unassign 00 <todo_id>
FLAG basecamp checkin answer create --question type=string
FLAG basecamp checkins answer create --question type=string
FLAG basecamp todosets --todoset type=string
FLAG basecamp webhook create --url type=string
FLAG basecamp webhooks create --url type=string
ARG basecamp upload doc create 00 <title>
Expand Down
4 changes: 2 additions & 2 deletions e2e/todosets.bats
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ load test_helper
assert_output_contains "--project requires a value"
}

@test "todosets --todoset without value shows error" {
@test "todosets show --todoset without value shows error" {
create_credentials
create_global_config '{}'

run basecamp todosets --todoset
run basecamp todosets show --todoset
assert_failure
assert_output_contains "--todoset requires a value"
}
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func CommandCategories() []CommandCategory {
{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: "todolists", Category: "core", Description: "Manage to-do lists", Actions: []string{"list", "show", "create", "update", "trash", "archive", "restore"}},
{Name: "todosets", Category: "core", Description: "View to-do set containers", Actions: []string{"show"}},
{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"}},
{Name: "messages", Category: "core", Description: "Manage messages", Actions: []string{"list", "show", "create", "update", "pin", "unpin", "trash", "archive", "restore"}},
{Name: "campfire", Category: "core", Description: "Chat in Campfire rooms", Actions: []string{"list", "messages", "post", "upload", "line", "delete"}},
Expand Down
98 changes: 96 additions & 2 deletions internal/commands/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ func ensureTodoset(cmd *cobra.Command, app *appctx.App, projectID, explicitTodos
// ensureTodolist resolves the todolist ID if not already configured.
// This enables interactive prompts when --list flag and config are both missing.
// The project must be resolved first (call ensureProject before this).
func ensureTodolist(cmd *cobra.Command, app *appctx.App, projectID string) (string, error) {
func ensureTodolist(cmd *cobra.Command, app *appctx.App, projectID, explicitTodosetID string) (string, error) {
// Check if todolist is already set via flag or config
if app.Flags.Todolist != "" {
return app.Flags.Todolist, nil
Expand All @@ -247,13 +247,107 @@ func ensureTodolist(cmd *cobra.Command, app *appctx.App, projectID string) (stri
}

// Try interactive resolution
resolved, err := app.Resolve().Todolist(cmd.Context(), projectID)
resolved, err := app.Resolve().Todolist(cmd.Context(), projectID, explicitTodosetID)
if err != nil {
return "", err
}
return resolved.Value, nil
}

// resolveTodolistInTodoset resolves a todolist name or ID, scoped to a specific todoset
// when explicitTodosetID is non-empty. This ensures that --todoset actually constrains
// which todolists are visible for name resolution.
//
// When explicitTodosetID is empty, falls back to the project-wide name resolver.
func resolveTodolistInTodoset(cmd *cobra.Command, app *appctx.App, todolist, projectID, explicitTodosetID string) (string, error) {
// No todoset constraint — use the standard project-wide resolver
if explicitTodosetID == "" {
resolved, _, err := app.Names.ResolveTodolist(cmd.Context(), todolist, projectID)
return resolved, err
}

// Numeric ID passthrough — trust it regardless of todoset
if isNumeric(todolist) {
return todolist, nil
}

// Todoset-scoped name resolution: fetch todolists from only the specified
// todoset and resolve the name within that set.
todosetID, err := strconv.ParseInt(explicitTodosetID, 10, 64)
if err != nil {
return "", output.ErrUsage("Invalid todoset ID")
}

todolistsPath := fmt.Sprintf("/todosets/%d/todolists.json", todosetID)
pages, err := app.Account().GetAll(cmd.Context(), todolistsPath)
if err != nil {
return "", convertSDKError(err)
}

// Build a name-resolution list from the scoped todolists
type entry struct {
id int64
name string
}
var entries []entry
for _, raw := range pages {
var tl struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
if err := json.Unmarshal(raw, &tl); err != nil {
return "", fmt.Errorf("failed to parse todolist from todoset %s: %w", explicitTodosetID, err)
}
entries = append(entries, entry{id: tl.ID, name: tl.Name})
}

// Exact match
inputLower := strings.ToLower(todolist)
for _, e := range entries {
if e.name == todolist {
return strconv.FormatInt(e.id, 10), nil
}
}
// Case-insensitive match
var caseMatches []entry
for _, e := range entries {
if strings.ToLower(e.name) == inputLower {
caseMatches = append(caseMatches, e)
}
}
if len(caseMatches) == 1 {
return strconv.FormatInt(caseMatches[0].id, 10), nil
}
if len(caseMatches) > 1 {
matchNames := make([]string, len(caseMatches))
for i, m := range caseMatches {
matchNames[i] = m.name
}
return "", output.ErrAmbiguous("todolist", matchNames)
}
// Partial match
var partials []entry
for _, e := range entries {
if strings.Contains(strings.ToLower(e.name), inputLower) {
partials = append(partials, e)
}
}
if len(partials) == 1 {
return strconv.FormatInt(partials[0].id, 10), nil
}
if len(partials) > 1 {
matchNames := make([]string, len(partials))
for i, m := range partials {
matchNames[i] = m.name
}
return "", output.ErrAmbiguous("todolist", matchNames)
}

return "", output.ErrNotFoundHint("Todolist", todolist,
fmt.Sprintf("Not found in todoset %s. Use 'basecamp todolists --in %s --todoset %s' to see available lists",
explicitTodosetID, projectID, explicitTodosetID))
}

// ensurePersonInProject resolves a person ID interactively from project members.
// This is useful when you want to limit the selection to people who have
// access to a specific project.
Expand Down
20 changes: 12 additions & 8 deletions internal/commands/todos.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func NewReopenCmd() *cobra.Command {
func NewTodoCmd() *cobra.Command {
var project string
var todolist string
var todoset string
var assignee string
var due string
var description string
Expand Down Expand Up @@ -127,9 +128,9 @@ func NewTodoCmd() *cobra.Command {
if todolist == "" {
todolist = app.Config.TodolistID
}
// If still no todolist, try interactive selection
// If still no todolist, try interactive selection (todoset-scoped)
if todolist == "" {
selectedTodolist, err := ensureTodolist(cmd, app, project)
selectedTodolist, err := ensureTodolist(cmd, app, project, todoset)
if err != nil {
return err
}
Expand All @@ -140,8 +141,8 @@ func NewTodoCmd() *cobra.Command {
return output.ErrUsage("--list is required (no default todolist found)")
}

// Resolve todolist name to ID
resolvedTodolist, _, err := app.Names.ResolveTodolist(cmd.Context(), todolist, project)
// Resolve todolist name to ID, scoped to --todoset when provided
resolvedTodolist, err := resolveTodolistInTodoset(cmd, app, todolist, project, todoset)
if err != nil {
return err
}
Expand Down Expand Up @@ -228,6 +229,7 @@ func NewTodoCmd() *cobra.Command {
cmd.Flags().StringVarP(&project, "project", "p", "", "Project ID or name")
cmd.Flags().StringVar(&project, "in", "", "Project ID (alias for --project)")
cmd.Flags().StringVarP(&todolist, "list", "l", "", "Todolist ID")
cmd.Flags().StringVarP(&todoset, "todoset", "t", "", "Todoset ID (for projects with multiple todosets)")
cmd.Flags().StringVar(&assignee, "assignee", "", "Assignee ID or name")
cmd.Flags().StringVar(&assignee, "to", "", "Assignee (alias for --assignee)")
cmd.Flags().StringVarP(&due, "due", "d", "", "Due date")
Expand Down Expand Up @@ -605,6 +607,7 @@ You can pass either a todo ID or a Basecamp URL:
func newTodosCreateCmd() *cobra.Command {
var project string
var todolist string
var todoset string
var assignee string
var due string
var description string
Expand Down Expand Up @@ -661,9 +664,9 @@ func newTodosCreateCmd() *cobra.Command {
if todolist == "" {
todolist = app.Config.TodolistID
}
// If still no todolist, try interactive selection
// If still no todolist, try interactive selection (todoset-scoped)
if todolist == "" {
selectedTodolist, err := ensureTodolist(cmd, app, project)
selectedTodolist, err := ensureTodolist(cmd, app, project, todoset)
if err != nil {
return err
}
Expand All @@ -674,8 +677,8 @@ func newTodosCreateCmd() *cobra.Command {
return output.ErrUsage("--list is required (no default todolist found)")
}

// Resolve todolist name to ID
resolvedTodolist, _, err := app.Names.ResolveTodolist(cmd.Context(), todolist, project)
// Resolve todolist name to ID, scoped to --todoset when provided
resolvedTodolist, err := resolveTodolistInTodoset(cmd, app, todolist, project, todoset)
if err != nil {
return err
}
Expand Down Expand Up @@ -762,6 +765,7 @@ func newTodosCreateCmd() *cobra.Command {
cmd.Flags().StringVarP(&project, "project", "p", "", "Project ID or name")
cmd.Flags().StringVar(&project, "in", "", "Project ID (alias for --project)")
cmd.Flags().StringVarP(&todolist, "list", "l", "", "Todolist ID")
cmd.Flags().StringVarP(&todoset, "todoset", "t", "", "Todoset ID (for projects with multiple todosets)")
cmd.Flags().StringVar(&assignee, "assignee", "", "Assignee ID")
cmd.Flags().StringVar(&assignee, "to", "", "Assignee ID (alias for --assignee)")
cmd.Flags().StringVarP(&due, "due", "d", "", "Due date (YYYY-MM-DD)")
Expand Down
Loading
Loading