From 2f78794e3d39985e49436677a40b13558956bc09 Mon Sep 17 00:00:00 2001 From: Tim Walsh Date: Sat, 22 Aug 2026 00:39:52 -0700 Subject: [PATCH] fix(task): resolve close/reopen statuses from the list instead of hardcoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `task close` sent status "complete" and `task reopen` sent "open". Those names only exist on lists using ClickUp's default status set; against any custom set the API rejects the update with "Status does not exist". Reproduced on a list whose done status is "shipped". Both now resolve the status from the task's own list by status *type* — done (falling back to closed) for close, the first open status in board order for reopen — and accept --status to override when a list has several. Bulk close resolves per task, since a bulk close can span lists with different sets, and caches per list so one list costs one lookup. Fixes #32 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014aqbmccWm1tqttmBUCR5rv ClickUp: 86dxbeqyt --- docs/site/commands/cu_bulk_close.md | 7 +- docs/site/commands/cu_task_close.md | 5 +- docs/site/commands/cu_task_reopen.md | 4 +- internal/api/status.go | 137 +++++++++++++++++++++++++++ internal/api/status_test.go | 93 ++++++++++++++++++ internal/cmd/bulk.go | 23 ++++- internal/cmd/task.go | 29 ++++-- 7 files changed, 279 insertions(+), 19 deletions(-) create mode 100644 internal/api/status.go create mode 100644 internal/api/status_test.go diff --git a/docs/site/commands/cu_bulk_close.md b/docs/site/commands/cu_bulk_close.md index 68e37ed..efa094b 100644 --- a/docs/site/commands/cu_bulk_close.md +++ b/docs/site/commands/cu_bulk_close.md @@ -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 @@ -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 diff --git a/docs/site/commands/cu_task_close.md b/docs/site/commands/cu_task_close.md index 5bc7339..611bb9c 100644 --- a/docs/site/commands/cu_task_close.md +++ b/docs/site/commands/cu_task_close.md @@ -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 @@ -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 diff --git a/docs/site/commands/cu_task_reopen.md b/docs/site/commands/cu_task_reopen.md index 6f0d79a..4b8df44 100644 --- a/docs/site/commands/cu_task_reopen.md +++ b/docs/site/commands/cu_task_reopen.md @@ -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 @@ -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 diff --git a/internal/api/status.go b/internal/api/status.go new file mode 100644 index 0000000..709282a --- /dev/null +++ b/internal/api/status.go @@ -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) +} diff --git a/internal/api/status_test.go b/internal/api/status_test.go new file mode 100644 index 0000000..56b0e95 --- /dev/null +++ b/internal/api/status_test.go @@ -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) + }) +} diff --git a/internal/cmd/bulk.go b/internal/cmd/bulk.go index df954c2..736d5f9 100644 --- a/internal/cmd/bulk.go +++ b/internal/cmd/bulk.go @@ -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) @@ -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") diff --git a/internal/cmd/task.go b/internal/cmd/task.go index e867f9c..4a0ea6f 100644 --- a/internal/cmd/task.go +++ b/internal/cmd/task.go @@ -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 @@ -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 @@ -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")