Skip to content
Open
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
7 changes: 4 additions & 3 deletions docs/site/commands/cu_bulk_close.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ cu bulk close [task-ids...] [flags]
### Options

```
-h, --help help for close
-y, --yes Skip confirmation prompt
-h, --help help for close
-s, --status string Status to set (default: each list's done status)
-y, --yes Skip confirmation prompt
```

### Options inherited from parent commands
Expand All @@ -36,4 +37,4 @@ cu bulk close [task-ids...] [flags]

* [cu bulk](cu_bulk.md) - Perform bulk operations on tasks

###### Auto generated by spf13/cobra on 29-Jun-2025
###### Auto generated by spf13/cobra on 22-Aug-2026
5 changes: 3 additions & 2 deletions docs/site/commands/cu_task_close.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ cu task close [task-id] [flags]
### Options

```
-h, --help help for close
-h, --help help for close
-s, --status string Status to set (default: the list's done status)
```

### Options inherited from parent commands
Expand All @@ -28,4 +29,4 @@ cu task close [task-id] [flags]

* [cu task](cu_task.md) - Manage tasks

###### Auto generated by spf13/cobra on 29-Jun-2025
###### Auto generated by spf13/cobra on 22-Aug-2026
4 changes: 2 additions & 2 deletions docs/site/commands/cu_task_reopen.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ cu task reopen [task-id] [flags]

```
-h, --help help for reopen
-s, --status string Status to set when reopening (default: open)
-s, --status string Status to set (default: the list's first open status)
```

### Options inherited from parent commands
Expand All @@ -29,4 +29,4 @@ cu task reopen [task-id] [flags]

* [cu task](cu_task.md) - Manage tasks

###### Auto generated by spf13/cobra on 29-Jun-2025
###### Auto generated by spf13/cobra on 22-Aug-2026
137 changes: 137 additions & 0 deletions internal/api/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package api

import (
"context"
"fmt"
"sort"
"strings"

"github.com/raksul/go-clickup/clickup"
)

// GetList returns a single list, including its status set.
func (c *Client) GetList(ctx context.Context, listID string) (*clickup.List, error) {
if err := c.rateLimiter.Wait(ctx); err != nil {
return nil, err
}

list, _, err := c.client.Lists.GetList(ctx, listID)
if err != nil {
return nil, c.handleError(err)
}
return &list, nil
}

// statusName is one entry of a list's status set, flattened out of the SDK's
// anonymous struct so it can be passed around and sorted.
type statusName struct {
Name string
Type string
OrderIndex float64
}

func listStatuses(list *clickup.List) []statusName {
out := make([]statusName, 0, len(list.Statuses))
for _, s := range list.Statuses {
idx, _ := s.Orderindex.Float64()
out = append(out, statusName{Name: s.Status, Type: s.Type, OrderIndex: idx})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].OrderIndex < out[j].OrderIndex })
return out
}

func statusesOfType(list *clickup.List, types ...string) []statusName {
var out []statusName
for _, s := range listStatuses(list) {
for _, t := range types {
if strings.EqualFold(s.Type, t) {
out = append(out, s)
break
}
}
}
return out
}

func statusNames(list *clickup.List) string {
all := listStatuses(list)
names := make([]string, 0, len(all))
for _, s := range all {
names = append(names, fmt.Sprintf("%s (%s)", s.Name, s.Type))
}
return strings.Join(names, ", ")
}

// ClosedStatus returns the status a list uses to mean "done".
//
// ClickUp lists define their own status sets — "complete" only exists on lists
// that happen to use the default set, so it cannot be assumed. A list's done
// status is identified by its type ("done", or "closed"), not its name.
func ClosedStatus(list *clickup.List) (string, error) {
// Prefer "done"; "closed" covers sets that only mark a terminal state.
if s := statusesOfType(list, "done"); len(s) > 0 {
return s[0].Name, nil
}
if s := statusesOfType(list, "closed"); len(s) > 0 {
return s[0].Name, nil
}
return "", fmt.Errorf("list %q has no status of type done or closed (statuses: %s)", list.Name, statusNames(list))
}

// OpenStatus returns the status a list uses to mean "not started" — the first
// status of type "open" in board order.
func OpenStatus(list *clickup.List) (string, error) {
if s := statusesOfType(list, "open"); len(s) > 0 {
return s[0].Name, nil
}
return "", fmt.Errorf("list %q has no status of type open (statuses: %s)", list.Name, statusNames(list))
}

// StatusResolver resolves the closed/open status for a task's own list,
// caching per list so bulk operations across one list make a single call.
type StatusResolver struct {
client *Client
lists map[string]*clickup.List
}

func NewStatusResolver(client *Client) *StatusResolver {
return &StatusResolver{client: client, lists: map[string]*clickup.List{}}
}

func (r *StatusResolver) listForTask(ctx context.Context, taskID string) (*clickup.List, error) {
task, err := r.client.GetTask(ctx, taskID)
if err != nil {
return nil, err
}
if task.List.ID == "" {
return nil, fmt.Errorf("could not determine the list for task %s", taskID)
}
if l, ok := r.lists[task.List.ID]; ok {
return l, nil
}

list, err := r.client.GetList(ctx, task.List.ID)
if err != nil {
return nil, err
}
r.lists[task.List.ID] = list
return list, nil
}

// ClosedStatusForTask returns the done status of the list the task lives in.
func (r *StatusResolver) ClosedStatusForTask(ctx context.Context, taskID string) (string, error) {
list, err := r.listForTask(ctx, taskID)
if err != nil {
return "", err
}
return ClosedStatus(list)
}

// OpenStatusForTask returns the open status of the list the task lives in.
func (r *StatusResolver) OpenStatusForTask(ctx context.Context, taskID string) (string, error) {
list, err := r.listForTask(ctx, taskID)
if err != nil {
return "", err
}
return OpenStatus(list)
}
93 changes: 93 additions & 0 deletions internal/api/status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package api

import (
"encoding/json"
"testing"

"github.com/raksul/go-clickup/clickup"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// listWithStatuses builds a clickup.List from JSON because the SDK models
// Statuses as an anonymous struct slice, which cannot be written as a literal.
func listWithStatuses(t *testing.T, name, statusesJSON string) *clickup.List {
t.Helper()
var list clickup.List
require.NoError(t, json.Unmarshal([]byte(`{"name":"`+name+`","statuses":`+statusesJSON+`}`), &list))
return &list
}

func TestClosedStatus(t *testing.T) {
t.Run("custom status set resolves by type, not name", func(t *testing.T) {
// The DireLabs status set: no status is called "complete".
list := listWithStatuses(t, "R&D Projects", `[
{"status":"backlog","orderindex":0,"type":"open"},
{"status":"in development","orderindex":3,"type":"custom"},
{"status":"shipped","orderindex":7,"type":"done"},
{"status":"cancelled","orderindex":8,"type":"closed"}
]`)

got, err := ClosedStatus(list)
require.NoError(t, err)
assert.Equal(t, "shipped", got, "done must win over closed")
})

t.Run("default status set", func(t *testing.T) {
list := listWithStatuses(t, "Default", `[
{"status":"to do","orderindex":0,"type":"open"},
{"status":"complete","orderindex":1,"type":"closed"}
]`)

got, err := ClosedStatus(list)
require.NoError(t, err)
assert.Equal(t, "complete", got)
})

t.Run("lowest orderindex wins among several done statuses", func(t *testing.T) {
list := listWithStatuses(t, "Multi", `[
{"status":"open","orderindex":0,"type":"open"},
{"status":"released","orderindex":9,"type":"done"},
{"status":"shipped","orderindex":5,"type":"done"}
]`)

got, err := ClosedStatus(list)
require.NoError(t, err)
assert.Equal(t, "shipped", got)
})

t.Run("no done status is an error naming the available set", func(t *testing.T) {
list := listWithStatuses(t, "Odd", `[
{"status":"to do","orderindex":0,"type":"open"},
{"status":"doing","orderindex":1,"type":"custom"}
]`)

_, err := ClosedStatus(list)
require.Error(t, err)
assert.Contains(t, err.Error(), "to do")
assert.Contains(t, err.Error(), "doing")
})
}

func TestOpenStatus(t *testing.T) {
t.Run("first open status in board order", func(t *testing.T) {
list := listWithStatuses(t, "R&D Projects", `[
{"status":"triage","orderindex":2,"type":"open"},
{"status":"backlog","orderindex":0,"type":"open"},
{"status":"shipped","orderindex":7,"type":"done"}
]`)

got, err := OpenStatus(list)
require.NoError(t, err)
assert.Equal(t, "backlog", got)
})

t.Run("no open status is an error", func(t *testing.T) {
list := listWithStatuses(t, "Closed only", `[
{"status":"done","orderindex":0,"type":"done"}
]`)

_, err := OpenStatus(list)
assert.Error(t, err)
})
}
23 changes: 18 additions & 5 deletions internal/cmd/bulk.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,16 +204,28 @@ Examples:
os.Exit(1)
}

// Close tasks
updateOpts := &api.TaskUpdateOptions{
Status: "complete",
}
// Close tasks. The done status is resolved per task from its own list
// — a bulk close can span lists with different status sets — and the
// resolver caches per list so one list costs one lookup.
status, _ := cmd.Flags().GetString("status")
resolver := api.NewStatusResolver(client)

var successCount, errorCount int

fmt.Println("Closing tasks...")
for _, taskID := range taskIDs {
_, err := client.UpdateTask(ctx, taskID, updateOpts)
taskStatus := status
if taskStatus == "" {
resolved, err := resolver.ClosedStatusForTask(ctx, taskID)
if err != nil {
errorCount++
fmt.Printf(" ✗ %s: %v\n", taskID, err)
continue
}
taskStatus = resolved
}

_, err := client.UpdateTask(ctx, taskID, &api.TaskUpdateOptions{Status: taskStatus})
if err != nil {
errorCount++
fmt.Printf(" ✗ %s: %v\n", taskID, err)
Expand Down Expand Up @@ -342,6 +354,7 @@ func init() {

// Bulk close flags
bulkCloseCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
bulkCloseCmd.Flags().StringP("status", "s", "", "Status to set (default: each list's done status)")

// Bulk delete flags
bulkDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
Expand Down
29 changes: 22 additions & 7 deletions internal/cmd/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,11 +399,20 @@ var taskCloseCmd = &cobra.Command{
os.Exit(1)
}

// Find a closed status in the same list
// For now, we'll use "complete" as the closed status
// TODO: Query the list's statuses to find the actual closed status
// Resolve the done status from the task's own list. Status sets are
// per-list, so a hardcoded "complete" fails on any list with a custom
// set. --status overrides when a list has several done statuses.
status, _ := cmd.Flags().GetString("status")
if status == "" {
status, err = api.NewStatusResolver(client).ClosedStatusForTask(ctx, taskID)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to close task: %v\n", err)
os.Exit(1)
}
}

updateOpts := &api.TaskUpdateOptions{
Status: "complete",
Status: status,
}

// Update task
Expand Down Expand Up @@ -447,10 +456,15 @@ var taskReopenCmd = &cobra.Command{
os.Exit(1)
}

// Get the status flag or use default
// Get the status flag, or resolve the list's own open status — "open"
// is only a valid status name on lists using the default set.
status, _ := cmd.Flags().GetString("status")
if status == "" {
status = "open" // Default to "open"
status, err = api.NewStatusResolver(client).OpenStatusForTask(ctx, taskID)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to reopen task: %v\n", err)
os.Exit(1)
}
}

// Update task
Expand Down Expand Up @@ -705,7 +719,8 @@ func init() {
taskUpdateCmd.Flags().StringSlice("remove-assignee", []string{}, "Remove assignees (username or ID)")

// Reopen command flags
taskReopenCmd.Flags().StringP("status", "s", "", "Status to set when reopening (default: open)")
taskCloseCmd.Flags().StringP("status", "s", "", "Status to set (default: the list's done status)")
taskReopenCmd.Flags().StringP("status", "s", "", "Status to set (default: the list's first open status)")

// Search command flags
taskSearchCmd.Flags().StringP("space", "s", "", "Limit search to specific space")
Expand Down
Loading