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
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.6
charm.land/lipgloss/v2 v2.0.3
github.com/basecamp/basecamp-sdk/go v0.7.3
github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260423230153-f54589f0924a
github.com/basecamp/cli v0.2.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/glamour v1.0.0
Expand Down Expand Up @@ -66,13 +66,13 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/oapi-codegen/runtime v1.3.1 // indirect
github.com/oapi-codegen/runtime v1.4.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/term v0.38.0 // indirect
)
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,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.7.3 h1:WKjlKUwAA0DGkPdsJdn1io/yXJKFvTYuX4N3V8cpmV8=
github.com/basecamp/basecamp-sdk/go v0.7.3/go.mod h1:Twc/C40kkhuShzp9BqmNkIk4Rcooc3XY+v+zSqEQrjE=
github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260423230153-f54589f0924a h1:TPVDkxRbdon4oxEYycnTV1Aslz2ZyMqgPiPUutNc+cg=
github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260423230153-f54589f0924a/go.mod h1:g53B/9z0VNYo217NrAf4zuEDc2yNolFBa09C3vSHbUI=
github.com/basecamp/cli v0.2.1 h1:8GyehPVtsTXla0oOPu4QgXRjwwzJ99prlByvyi+0HRQ=
github.com/basecamp/cli v0.2.1/go.mod h1:p8tt/DatJ2LAzWO6N6tNfV8x3gF5T3IxDTo+U8FfWPo=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
Expand Down Expand Up @@ -129,8 +129,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/oapi-codegen/runtime v1.3.1 h1:RgDY6J4OGQLbRXhG/Xpt3vSVqYpHQS7hN4m85+5xB9g=
github.com/oapi-codegen/runtime v1.3.1/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
github.com/oapi-codegen/runtime v1.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4=
github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJEY8HNT2WaPec=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
Expand Down Expand Up @@ -165,16 +165,16 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/cards_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func setupTestApp(t *testing.T) (*appctx.App, *bytes.Buffer) {
sdkCfg := &basecamp.Config{}
sdkClient := basecamp.NewClient(sdkCfg, &testTokenProvider{},
basecamp.WithTransport(noNetworkTransport{}),
basecamp.WithMaxRetries(0), // Disable retries for instant failure
basecamp.WithMaxRetries(1), // Disable retries for instant failure
)
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)

Expand Down
2 changes: 1 addition & 1 deletion internal/commands/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ func setupDoctorTestApp(t *testing.T, accountID string) (*appctx.App, *bytes.Buf

sdkCfg := &basecamp.Config{}
sdkClient := basecamp.NewClient(sdkCfg, nil,
basecamp.WithMaxRetries(0),
basecamp.WithMaxRetries(1),
)
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)

Expand Down
2 changes: 1 addition & 1 deletion internal/commands/messages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func setupMessagesTestApp(t *testing.T) (*appctx.App, *bytes.Buffer) {
sdkCfg := &basecamp.Config{}
sdkClient := basecamp.NewClient(sdkCfg, &messagesTestTokenProvider{},
basecamp.WithTransport(messagesNoNetworkTransport{}),
basecamp.WithMaxRetries(0), // Disable retries for instant failure
basecamp.WithMaxRetries(1), // Disable retries for instant failure
)
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)

Expand Down
8 changes: 4 additions & 4 deletions internal/commands/people_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func setupPeopleTestApp(t *testing.T) (*appctx.App, *bytes.Buffer) {
sdkCfg := &basecamp.Config{}
sdkClient := basecamp.NewClient(sdkCfg, &peopleTestTokenProvider{},
basecamp.WithTransport(peopleNoNetworkTransport{}),
basecamp.WithMaxRetries(0), // Disable retries for instant failure
basecamp.WithMaxRetries(1), // Disable retries for instant failure
)
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)

Expand Down Expand Up @@ -170,7 +170,7 @@ func setupAuthenticatedTestApp(t *testing.T, accountID string, launchpadResponse
sdkCfg := &basecamp.Config{}
// Use default transport to allow HTTP requests to the mock server
sdkClient := basecamp.NewClient(sdkCfg, &peopleTestTokenProvider{},
basecamp.WithMaxRetries(0),
basecamp.WithMaxRetries(1),
)
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)

Expand Down Expand Up @@ -362,7 +362,7 @@ func setupBC3TokenTestApp(t *testing.T, accountID string, bc3Response *basecamp.

sdkCfg := &basecamp.Config{BaseURL: server.URL}
sdkClient := basecamp.NewClient(sdkCfg, &peopleTestTokenProvider{},
basecamp.WithMaxRetries(0),
basecamp.WithMaxRetries(1),
)
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)

Expand Down Expand Up @@ -480,7 +480,7 @@ func TestMeWithBC3TokenOverridingStaleLaunchpadCreds(t *testing.T) {

sdkCfg := &basecamp.Config{BaseURL: server.URL}
sdkClient := basecamp.NewClient(sdkCfg, &peopleTestTokenProvider{},
basecamp.WithMaxRetries(0),
basecamp.WithMaxRetries(1),
)
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)

Expand Down
2 changes: 1 addition & 1 deletion internal/commands/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func setupProfileTestApp(t *testing.T, cfg *config.Config) (*appctx.App, *bytes.
authMgr := auth.NewManager(cfg, nil)

sdkCfg := &basecamp.Config{}
sdkClient := basecamp.NewClient(sdkCfg, nil, basecamp.WithMaxRetries(0))
sdkClient := basecamp.NewClient(sdkCfg, nil, basecamp.WithMaxRetries(1))
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)

app := &appctx.App{
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/quickstart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func setupQuickstartTestApp(t *testing.T, accountID, projectID string) (*appctx.
sdkCfg := &basecamp.Config{}
sdkClient := basecamp.NewClient(sdkCfg, &quickstartTestTokenProvider{},
basecamp.WithTransport(quickstartNoNetworkTransport{}),
basecamp.WithMaxRetries(0),
basecamp.WithMaxRetries(1),
)
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)

Expand Down
2 changes: 1 addition & 1 deletion internal/commands/recordings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func setupRecordingsTestApp(t *testing.T) (*appctx.App, *bytes.Buffer) {
sdkCfg := &basecamp.Config{}
sdkClient := basecamp.NewClient(sdkCfg, &todosTestTokenProvider{},
basecamp.WithTransport(todosNoNetworkTransport{}),
basecamp.WithMaxRetries(0),
basecamp.WithMaxRetries(1),
)
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)

Expand Down
2 changes: 1 addition & 1 deletion internal/commands/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func setupSearchTestApp(t *testing.T, transport http.RoundTripper) (*appctx.App,
authMgr := auth.NewManager(cfg, nil)
sdkClient := basecamp.NewClient(&basecamp.Config{}, &todosTestTokenProvider{},
basecamp.WithTransport(transport),
basecamp.WithMaxRetries(0),
basecamp.WithMaxRetries(1),
)
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)

Expand Down
68 changes: 43 additions & 25 deletions internal/commands/todos.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ func newTodosListCmd() *cobra.Command {
cmd.Flags().StringVarP(&flags.todolist, "list", "l", "", "Todolist ID")
cmd.Flags().StringVarP(&flags.todoset, "todoset", "t", "", "Todoset ID (for projects with multiple todosets)")
cmd.Flags().StringVar(&flags.assignee, "assignee", "", "Filter by assignee")
cmd.Flags().StringVarP(&flags.status, "status", "s", "", "Filter by status (completed, incomplete)")
cmd.Flags().StringVarP(&flags.status, "status", "s", "", "Filter by status (completed, incomplete, archived, trashed)")
cmd.Flags().BoolVar(&flags.completed, "completed", false, "Show completed todos (shorthand for --status completed)")
cmd.Flags().BoolVar(&flags.overdue, "overdue", false, "Filter overdue todos")
cmd.Flags().IntVarP(&flags.limit, "limit", "n", 0, "Maximum number of todos to fetch (0 = default 100)")
Expand Down Expand Up @@ -314,6 +314,11 @@ func runTodosList(cmd *cobra.Command, flags todosListFlags) error {
}
}

sdkStatus, sdkCompleted, err := resolveStatusFilter(flags.status)
if err != nil {
return err
}

// Resolve account (enables interactive prompt if needed)
if err := ensureAccount(cmd, app); err != nil {
return err
Expand Down Expand Up @@ -372,7 +377,7 @@ func runTodosList(cmd *cobra.Command, flags todosListFlags) error {

// If todolist is specified, list todos in that list
if todolist != "" {
return listTodosInList(cmd, app, project, todolist, flags.assignee, flags.status, flags.limit, flags.all, flags.sortField, flags.reverse)
return listTodosInList(cmd, app, project, todolist, flags.assignee, sdkStatus, sdkCompleted, flags.limit, flags.all, flags.sortField, flags.reverse)
}

// --page is not meaningful when aggregating across todolists
Expand All @@ -382,7 +387,26 @@ func runTodosList(cmd *cobra.Command, flags todosListFlags) error {
}

// Otherwise, get all todos from project's todoset
return listAllTodos(cmd, app, project, flags.todoset, flags.assignee, flags.status, flags.overdue, flags.limit, flags.all, flags.sortField, flags.reverse)
return listAllTodos(cmd, app, project, flags.todoset, flags.assignee, sdkStatus, sdkCompleted, flags.overdue, flags.limit, flags.all, flags.sortField, flags.reverse)
}

// resolveStatusFilter maps the user-facing --status value to the SDK's
// (Status, Completed) pair. Status is lifecycle-only ("archived", "trashed",
// or empty); Completed handles the completion filter. The empty/"incomplete"
// case lets the SDK apply its API default (incomplete todos only).
func resolveStatusFilter(status string) (sdkStatus string, completed bool, err error) {
switch status {
case "", "incomplete":
// API default: incomplete only.
case "completed":
completed = true
case "archived", "trashed":
sdkStatus = status
default:
return "", false, output.ErrUsage(
fmt.Sprintf("unknown --status value %q (expected completed, incomplete, archived, or trashed)", status))
}
return sdkStatus, completed, nil
}

// fetchTodosIncludingGroups fetches all todos from a todolist, including
Expand All @@ -403,7 +427,7 @@ func runTodosList(cmd *cobra.Command, flags todosListFlags) error {
// When failOnGroupError is true, any error fetching groups or their todos is
// fatal. When false, group errors are silently skipped (suitable for cross-list
// aggregation where partial results are acceptable).
func fetchTodosIncludingGroups(ctx context.Context, app *appctx.App, todolistID int64, status string, limit int, failOnGroupError bool) (todos []basecamp.Todo, totalCount int, err error) {
func fetchTodosIncludingGroups(ctx context.Context, app *appctx.App, todolistID int64, status string, completed bool, limit int, failOnGroupError bool) (todos []basecamp.Todo, totalCount int, err error) {
groupsResult, groupsErr := app.Account().TodolistGroups().List(ctx, todolistID, nil)
if groupsErr != nil {
if failOnGroupError {
Expand All @@ -421,6 +445,9 @@ func fetchTodosIncludingGroups(ctx context.Context, app *appctx.App, todolistID
if status != "" {
opts.Status = status
}
if completed {
opts.Completed = true
}
if limit != 0 {
opts.Limit = limit
}
Expand All @@ -437,6 +464,9 @@ func fetchTodosIncludingGroups(ctx context.Context, app *appctx.App, todolistID
if status != "" {
directOpts.Status = status
}
if completed {
directOpts.Completed = true
}
directResult, err := app.Account().Todos().List(ctx, todolistID, directOpts)
if err != nil {
return nil, 0, err
Expand All @@ -457,6 +487,9 @@ func fetchTodosIncludingGroups(ctx context.Context, app *appctx.App, todolistID
if status != "" {
groupOpts.Status = status
}
if completed {
groupOpts.Completed = true
}
for _, g := range groupsResult.Groups {
groupTodos, err := app.Account().Todos().List(ctx, g.ID, groupOpts)
if err != nil {
Expand Down Expand Up @@ -490,7 +523,7 @@ func fetchTodosIncludingGroups(ctx context.Context, app *appctx.App, todolistID
return result, totalCount, nil
}

func listTodosInList(cmd *cobra.Command, app *appctx.App, project, todolist, assignee, status string, limit int, all bool, sortField string, reverse bool) error {
func listTodosInList(cmd *cobra.Command, app *appctx.App, project, todolist, assignee, sdkStatus string, sdkCompleted bool, limit int, all bool, sortField string, reverse bool) error {
resolvedTodolist, _, err := app.Names.ResolveTodolist(cmd.Context(), todolist, project)
if err != nil {
return err
Expand All @@ -515,14 +548,7 @@ func listTodosInList(cmd *cobra.Command, app *appctx.App, project, todolist, ass
sdkLimit = limit
}

// Normalize "incomplete" to "pending" for the SDK, which documents
// "completed" and "pending" as valid Status values.
sdkStatus := status
if sdkStatus == "incomplete" {
sdkStatus = "pending"
}

todos, totalCount, err := fetchTodosIncludingGroups(cmd.Context(), app, todolistID, sdkStatus, sdkLimit, true)
todos, totalCount, err := fetchTodosIncludingGroups(cmd.Context(), app, todolistID, sdkStatus, sdkCompleted, sdkLimit, true)
if err != nil {
return convertSDKError(err)
}
Expand Down Expand Up @@ -584,7 +610,7 @@ func listTodosInList(cmd *cobra.Command, app *appctx.App, project, todolist, ass
return app.OK(todos, respOpts...)
}

func listAllTodos(cmd *cobra.Command, app *appctx.App, project, todosetFlag, assignee, status string, overdue bool, limit int, all bool, sortField string, reverse bool) error {
func listAllTodos(cmd *cobra.Command, app *appctx.App, project, todosetFlag, assignee, sdkStatus string, sdkCompleted bool, overdue bool, limit int, all bool, sortField string, reverse bool) error {
// Position is only meaningful within a single todolist — reject before
// the --all check so users get the right error message.
if sortField == "position" {
Expand Down Expand Up @@ -630,9 +656,11 @@ func listAllTodos(cmd *cobra.Command, app *appctx.App, project, todosetFlag, ass
}

// Aggregate todos from all todolists, including group-nested todos.
// The server applies the status/completed filter directly — no client-side
// status filter is needed (the API is the single source of truth).
var allTodos []basecamp.Todo
for _, tl := range todolistsResult.Todolists {
todos, _, err := fetchTodosIncludingGroups(cmd.Context(), app, tl.ID, "", sdkLimit, false)
todos, _, err := fetchTodosIncludingGroups(cmd.Context(), app, tl.ID, sdkStatus, sdkCompleted, sdkLimit, false)
if err != nil {
continue // Skip failed todolists
}
Expand All @@ -642,16 +670,6 @@ func listAllTodos(cmd *cobra.Command, app *appctx.App, project, todosetFlag, ass
// Apply filters
var result []basecamp.Todo
for _, todo := range allTodos {
// Filter by status
if status != "" {
if status == "completed" && !todo.Completed {
continue
}
if (status == "incomplete" || status == "pending") && todo.Completed {
continue
}
}

// Filter by assignee (using resolved ID)
if assigneeID != 0 {
found := false
Expand Down
Loading
Loading