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
13 changes: 10 additions & 3 deletions internal/commands/todos.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,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, pending)")
cmd.Flags().StringVarP(&flags.status, "status", "s", "", "Filter by status (completed, incomplete)")
Comment thread
jeremy marked this conversation as resolved.
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 @@ -511,7 +511,14 @@ func listTodosInList(cmd *cobra.Command, app *appctx.App, project, todolist, sta
sdkLimit = limit
}

todos, totalCount, err := fetchTodosIncludingGroups(cmd.Context(), app, todolistID, status, sdkLimit, true)
// 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)
if err != nil {
return convertSDKError(err)
}
Expand Down Expand Up @@ -608,7 +615,7 @@ func listAllTodos(cmd *cobra.Command, app *appctx.App, project, todosetFlag, ass
if status == "completed" && !todo.Completed {
continue
}
if status == "pending" && todo.Completed {
if (status == "incomplete" || status == "pending") && todo.Completed {
Comment thread
jeremy marked this conversation as resolved.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
continue
}
}
Expand Down
119 changes: 119 additions & 0 deletions internal/commands/todos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,125 @@ func TestTodosListInListLimitPreservedCrossList(t *testing.T) {
assert.Equal(t, 1, len(resp.Data))
}

// =============================================================================
// --status incomplete alias tests
// =============================================================================

// statusCapturingTransport serves a todolist with mixed-completion todos and
// captures the status query parameter sent to the todos.json endpoint.
type statusCapturingTransport struct {
capturedStatus string
}

func (s *statusCapturingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
header := make(http.Header)
header.Set("Content-Type", "application/json")

path := req.URL.Path
var body string

switch {
case strings.Contains(path, "/projects.json"):
body = `[{"id": 123, "name": "Test"}]`
case strings.Contains(path, "/projects/"):
body = `{"id": 123, "dock": [{"name": "todoset", "id": 900, "enabled": true}]}`
case strings.Contains(path, "/todosets/900/todolists"):
body = `[{"id": 500, "name": "Sprint"}]`
case strings.Contains(path, "/groups.json"):
body = `[]`
case strings.Contains(path, "/todos.json"):
s.capturedStatus = req.URL.Query().Get("status")
body = `[` +
`{"id": 1, "content": "Open task", "position": 1, "status": "active", "completed": false},` +
`{"id": 2, "content": "Done task", "position": 2, "status": "active", "completed": true}]`
default:
body = `{}`
}

return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: header,
}, nil
}

func setupStatusTestApp(t *testing.T, transport *statusCapturingTransport) (*appctx.App, *bytes.Buffer) {
t.Helper()
t.Setenv("BASECAMP_NO_KEYRING", "1")

buf := &bytes.Buffer{}
cfg := &config.Config{
AccountID: "99999",
ProjectID: "123",
}

authMgr := auth.NewManager(cfg, nil)
sdkClient := basecamp.NewClient(&basecamp.Config{}, &todosTestTokenProvider{},
basecamp.WithTransport(transport),
basecamp.WithMaxRetries(1),
Comment thread
jeremy marked this conversation as resolved.
)
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)

return &appctx.App{
Config: cfg,
Auth: authMgr,
SDK: sdkClient,
Names: nameResolver,
Output: output.New(output.Options{
Format: output.FormatJSON,
Writer: buf,
}),
}, buf
}

func TestTodosListStatusIncomplete_SingleList_NormalizesToPending(t *testing.T) {
transport := &statusCapturingTransport{}
app, _ := setupStatusTestApp(t, transport)

cmd := NewTodosCmd()
err := executeTodosCommand(cmd, app, "list", "--list", "500", "--status", "incomplete")
require.NoError(t, err)

assert.Equal(t, "pending", transport.capturedStatus,
"--status incomplete should be normalized to 'pending' before reaching the SDK")
}

func TestTodosListStatusPending_SingleList_PassedThrough(t *testing.T) {
transport := &statusCapturingTransport{}
app, _ := setupStatusTestApp(t, transport)

cmd := NewTodosCmd()
err := executeTodosCommand(cmd, app, "list", "--list", "500", "--status", "pending")
require.NoError(t, err)

assert.Equal(t, "pending", transport.capturedStatus,
"--status pending should pass through unchanged")
}

func TestTodosListStatusIncomplete_CrossList_FiltersClientSide(t *testing.T) {
transport := &statusCapturingTransport{}
app, buf := setupStatusTestApp(t, transport)

cmd := NewTodosCmd()
err := executeTodosCommand(cmd, app, "list", "--status", "incomplete")
require.NoError(t, err)

// Cross-list path fetches todos without a status filter and filters client-side.
assert.Empty(t, transport.capturedStatus,
"cross-list path should not send status to the API")

var resp struct {
Data []struct {
ID int64 `json:"id"`
Completed bool `json:"completed"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
require.Len(t, resp.Data, 1, "should filter out completed todos")
assert.Equal(t, int64(1), resp.Data[0].ID)
assert.False(t, resp.Data[0].Completed)
}

// =============================================================================
// Sweep Comment HTML Conversion Tests
// =============================================================================
Expand Down
23 changes: 13 additions & 10 deletions internal/presenter/presenter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,14 +191,14 @@ func TestDetectNoMatch(t *testing.T) {
func TestFormatFieldBoolean(t *testing.T) {
spec := FieldSpec{
Format: "boolean",
Labels: map[string]string{"true": "done", "false": "pending"},
Labels: map[string]string{"true": "done", "false": "incomplete"},
}

if got := FormatField(spec, "completed", true, enUS); got != "done" {
t.Errorf("FormatField(true) = %q, want %q", got, "done")
}
if got := FormatField(spec, "completed", false, enUS); got != "pending" {
t.Errorf("FormatField(false) = %q, want %q", got, "pending")
if got := FormatField(spec, "completed", false, enUS); got != "incomplete" {
t.Errorf("FormatField(false) = %q, want %q", got, "incomplete")
}
}

Expand Down Expand Up @@ -399,9 +399,9 @@ func TestRenderDetailTodo(t *testing.T) {
t.Errorf("Output should contain headline, got:\n%s", out)
}

// Should contain status fields
if !strings.Contains(out, "pending") {
t.Errorf("Output should contain 'pending', got:\n%s", out)
// Should contain status fields — incomplete todos omit the completed label
if strings.Contains(out, "incomplete") {
t.Errorf("Output should not contain 'incomplete' for default state, got:\n%s", out)
}
if !strings.Contains(out, "Jan 15, 2026") {
t.Errorf("Output should contain formatted due date, got:\n%s", out)
Expand Down Expand Up @@ -550,7 +550,10 @@ func TestRenderListTodosNoPaddingOnContent(t *testing.T) {
// Content (title role) should have a two-space column separator after the
// value, NOT be padded to the max content width (34 chars).
assert.Contains(t, lines[0], "Short ", "expected two-space separator after Short, got: %q", lines[0])
assert.NotContains(t, lines[0], "Short ", "content should not be padded beyond two-space separator, got: %q", lines[0])
// Content should NOT be padded to the max content width (34 chars).
// With "Short" (5) padded to 34, there would be 29+ extra spaces.
assert.NotContains(t, lines[0], "Short"+strings.Repeat(" ", 30),
"content should not be padded to max width, got: %q", lines[0])
}

// =============================================================================
Expand Down Expand Up @@ -925,9 +928,9 @@ func TestRenderDetailMarkdown(t *testing.T) {
t.Errorf("Markdown detail should have '#### Metadata' heading, got:\n%s", out)
}

// Fields should be Markdown list items with bold labels
if !strings.Contains(out, "- **Completed:** pending") {
t.Errorf("Markdown detail should have '- **Completed:** pending', got:\n%s", out)
// Incomplete todos omit the completed label (default state)
if strings.Contains(out, "- **Completed:**") {
t.Errorf("Markdown detail should omit Completed for default state, got:\n%s", out)
}
if !strings.Contains(out, "- **Due:** Jan 15, 2026") {
t.Errorf("Markdown detail should have '- **Due:** Jan 15, 2026', got:\n%s", out)
Expand Down
2 changes: 1 addition & 1 deletion internal/presenter/schemas/todo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ fields:
format: boolean
labels:
"true": done
"false": pending
"false": ""
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.

due_on:
role: detail
Expand Down
4 changes: 2 additions & 2 deletions internal/tui/empty/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ func NoTodos(context string) Message {
switch context {
case "completed":
msg.Body = "No completed todos."
case "pending":
msg.Body = "No pending todos. Everything is done!"
case "incomplete", "pending":
msg.Body = "No incomplete todos. Everything is done!"
case "overdue":
msg.Body = "No overdue todos. You're on track!"
default:
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/workspace/views/todos.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ func (v *Todos) ShortHelp() []key.Binding {
if v.focus == todosPaneLeft {
completedHint := key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "completed"))
if v.showCompleted {
completedHint = key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "pending"))
completedHint = key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "incomplete"))
}
return []key.Binding{
key.NewBinding(key.WithKeys("j/k"), key.WithHelp("j/k", "navigate")),
Expand Down
4 changes: 2 additions & 2 deletions internal/tui/workspace/views/todos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -773,15 +773,15 @@ func TestTodos_ShortHelp_LeftPane_ShowsCompletedHint(t *testing.T) {
assert.Equal(t, "completed", keys["c"])
}

func TestTodos_ShortHelp_LeftPane_ShowsPendingHintWhenCompleted(t *testing.T) {
func TestTodos_ShortHelp_LeftPane_ShowsIncompleteHintWhenCompleted(t *testing.T) {
v := testTodosView()
v.showCompleted = true
hints := v.ShortHelp()
keys := make(map[string]string)
for _, h := range hints {
keys[h.Help().Key] = h.Help().Desc
}
assert.Equal(t, "pending", keys["c"])
assert.Equal(t, "incomplete", keys["c"])
}

func TestTodos_ShortHelp_RightPaneCompleted_ShowsUncomplete(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion skills/basecamp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ basecamp todos position <id> --to 1 # Move to top
basecamp todos sweep --overdue --complete --comment "Done" --in <project>
```

**Flags:** `--assignee` (todos only - not available on cards/messages), `--status` (completed/pending), `--overdue`, `--list`, `--due`, `--limit`, `--all`
**Flags:** `--assignee` (todos only - not available on cards/messages), `--status` (completed/incomplete), `--overdue`, `--list`, `--due`, `--limit`, `--all`
Comment thread
jeremy marked this conversation as resolved.

### Todolists

Expand Down
Loading