From 2d9194d3f4944ed503e039dc66607a679d61e99a Mon Sep 17 00:00:00 2001 From: Isaac Rowntree Date: Wed, 11 Mar 2026 18:28:13 +1100 Subject: [PATCH] fix: resolve statuses from list before falling back to space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lists with custom status overrides were ignored — status validation always used space-level statuses, causing valid list statuses like "in development" to fail with "no matching status found". Fixes #4 Co-Authored-By: Claude Opus 4.6 --- pkg/cmd/status/set.go | 17 ++------ pkg/cmd/task/create.go | 12 +++++- pkg/cmd/task/edit.go | 7 ++-- pkg/cmdutil/status.go | 58 ++++++++++++++++++++++++++- pkg/cmdutil/status_test.go | 80 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 20 deletions(-) create mode 100644 pkg/cmdutil/status_test.go diff --git a/pkg/cmd/status/set.go b/pkg/cmd/status/set.go index b1d0528..790cfbf 100644 --- a/pkg/cmd/status/set.go +++ b/pkg/cmd/status/set.go @@ -32,7 +32,7 @@ func NewCmdSet(f *cmdutil.Factory) *cobra.Command { Short: "Set the status of a task", Long: `Change a task's status using fuzzy matching. -The STATUS argument is matched against available statuses for the task's space. +The STATUS argument is matched against available statuses for the task's list (or space if the list has no custom statuses). Matching priority: exact match, then case-insensitive contains, then fuzzy match. If TASK is not provided, the task ID is auto-detected from the current git branch.`, @@ -101,19 +101,8 @@ func setRun(opts *setOptions) error { currentStatus := task.Status.Status - // Fetch statuses for the task's space. - spaceID := task.Space.ID - statusNames, err := cmdutil.FetchSpaceStatuses(client, spaceID) - if err != nil { - return err - } - - if len(statusNames) == 0 { - return fmt.Errorf("no statuses found for space %s", spaceID) - } - - // Find the best matching status. - matched, err := cmdutil.MatchStatus(opts.targetStatus, statusNames) + // Validate status against the task's list (with space fallback). + matched, err := cmdutil.ValidateStatusWithList(client, task.Space.ID, task.List.ID, opts.targetStatus, ios.ErrOut) if err != nil { return err } diff --git a/pkg/cmd/task/create.go b/pkg/cmd/task/create.go index 6e76f25..5a460c0 100644 --- a/pkg/cmd/task/create.go +++ b/pkg/cmd/task/create.go @@ -232,16 +232,20 @@ func runCreate(f *cmdutil.Factory, opts *createOptions) error { // Fetch the list once if we need it for status or tag validation. var spaceID string + var listStatuses []string if opts.status != "" || len(opts.tags) > 0 { list, _, listErr := client.Clickup.Lists.GetList(ctx, opts.listID) if listErr == nil && list.Space.ID != "" { spaceID = list.Space.ID + for _, s := range list.Statuses { + listStatuses = append(listStatuses, s.Status) + } } } if opts.status != "" { if spaceID != "" { - validated, valErr := cmdutil.ValidateStatus(client, spaceID, opts.status, ios.ErrOut) + validated, valErr := cmdutil.ValidateStatusFromLists(client, spaceID, listStatuses, opts.status, ios.ErrOut) if valErr != nil { return valErr } @@ -413,6 +417,7 @@ func runBulkCreate(f *cmdutil.Factory, opts *createOptions) error { // Fetch the list once for status/tag validation. var spaceID string + var listStatuses []string needsValidation := false for _, e := range entries { if e.Status != "" || len(e.Tags) > 0 { @@ -424,6 +429,9 @@ func runBulkCreate(f *cmdutil.Factory, opts *createOptions) error { list, _, listErr := client.Clickup.Lists.GetList(ctx, opts.listID) if listErr == nil && list.Space.ID != "" { spaceID = list.Space.ID + for _, s := range list.Statuses { + listStatuses = append(listStatuses, s.Status) + } } } @@ -466,7 +474,7 @@ func runBulkCreate(f *cmdutil.Factory, opts *createOptions) error { if entry.Status != "" { status := entry.Status if spaceID != "" { - validated, valErr := cmdutil.ValidateStatus(client, spaceID, status, ios.ErrOut) + validated, valErr := cmdutil.ValidateStatusFromLists(client, spaceID, listStatuses, status, ios.ErrOut) if valErr != nil { fmt.Fprintf(ios.ErrOut, "%s (%d/%d) %s: %v\n", cs.Yellow("!"), i+1, total, entry.Name, valErr) continue diff --git a/pkg/cmd/task/edit.go b/pkg/cmd/task/edit.go index a48afea..1a05023 100644 --- a/pkg/cmd/task/edit.go +++ b/pkg/cmd/task/edit.go @@ -176,8 +176,8 @@ func runEdit(f *cmdutil.Factory, opts *editOptions, cmd *cobra.Command) error { updateReq.Description = opts.description } - // Validate status and tags against the first task's space (shared across batch). - var spaceID string + // Validate status and tags against the first task's space/list (shared across batch). + var spaceID, listID string if cmd.Flags().Changed("status") || cmd.Flags().Changed("tags") || cmd.Flags().Changed("add-tags") { parsed := git.ParseTaskID(taskIDs[0]) var getOpts *clickup.GetTaskOptions @@ -187,12 +187,13 @@ func runEdit(f *cmdutil.Factory, opts *editOptions, cmd *cobra.Command) error { fetchTask, _, fetchErr := client.Clickup.Tasks.GetTask(context.Background(), parsed.ID, getOpts) if fetchErr == nil && fetchTask.Space.ID != "" { spaceID = fetchTask.Space.ID + listID = fetchTask.List.ID } } if cmd.Flags().Changed("status") { if spaceID != "" { - validated, valErr := cmdutil.ValidateStatus(client, spaceID, opts.status, ios.ErrOut) + validated, valErr := cmdutil.ValidateStatusWithList(client, spaceID, listID, opts.status, ios.ErrOut) if valErr != nil { return valErr } diff --git a/pkg/cmdutil/status.go b/pkg/cmdutil/status.go index 014500e..1bd3ff9 100644 --- a/pkg/cmdutil/status.go +++ b/pkg/cmdutil/status.go @@ -1,6 +1,7 @@ package cmdutil import ( + "context" "encoding/json" "fmt" "io" @@ -88,16 +89,52 @@ type spaceStatusResponse struct { } `json:"statuses"` } -// ValidateStatus validates a status string against the available statuses for a space. +// ValidateStatus validates a status string against the available statuses for a task's list, +// falling back to space-level statuses if the list has no custom overrides. // If the status fuzzy-matches, it returns the matched value and prints a warning to w. // If no match, it returns an error with available statuses. If validation cannot be // performed (e.g. network error), the original status is returned unchanged. func ValidateStatus(client *api.Client, spaceID, status string, w io.Writer) (string, error) { + return ValidateStatusWithList(client, spaceID, "", status, w) +} + +// ValidateStatusWithList validates a status string against the available statuses for a list, +// falling back to space-level statuses if the list has no custom status overrides. +func ValidateStatusWithList(client *api.Client, spaceID, listID, status string, w io.Writer) (string, error) { + // Try list-level statuses first (lists can override space statuses). + if listID != "" { + statusNames, err := FetchListStatuses(client, listID) + if err == nil && len(statusNames) > 0 { + return matchAndReport(status, statusNames, w) + } + } + + // Fall back to space-level statuses. + statusNames, err := FetchSpaceStatuses(client, spaceID) + if err != nil || len(statusNames) == 0 { + return status, nil // graceful fallback + } + + return matchAndReport(status, statusNames, w) +} + +// ValidateStatusFromLists validates a status string against pre-fetched list statuses, +// falling back to space-level statuses if no list statuses are provided. +func ValidateStatusFromLists(client *api.Client, spaceID string, listStatuses []string, status string, w io.Writer) (string, error) { + if len(listStatuses) > 0 { + return matchAndReport(status, listStatuses, w) + } + + // Fall back to space-level statuses. statusNames, err := FetchSpaceStatuses(client, spaceID) if err != nil || len(statusNames) == 0 { return status, nil // graceful fallback } + return matchAndReport(status, statusNames, w) +} + +func matchAndReport(status string, statusNames []string, w io.Writer) (string, error) { matched, err := MatchStatus(status, statusNames) if err != nil { return "", err @@ -139,3 +176,22 @@ func FetchSpaceStatuses(client *api.Client, spaceID string) ([]string, error) { } return statusNames, nil } + +// FetchListStatuses fetches the available status names for a ClickUp list. +// Returns nil if the list doesn't have custom status overrides. +func FetchListStatuses(client *api.Client, listID string) ([]string, error) { + list, _, err := client.Clickup.Lists.GetList(context.Background(), listID) + if err != nil { + return nil, fmt.Errorf("failed to fetch list statuses: %w", err) + } + + if len(list.Statuses) == 0 { + return nil, nil + } + + statusNames := make([]string, len(list.Statuses)) + for i, s := range list.Statuses { + statusNames[i] = s.Status + } + return statusNames, nil +} diff --git a/pkg/cmdutil/status_test.go b/pkg/cmdutil/status_test.go new file mode 100644 index 0000000..bb48d02 --- /dev/null +++ b/pkg/cmdutil/status_test.go @@ -0,0 +1,80 @@ +package cmdutil + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMatchStatus_ExactMatch(t *testing.T) { + matched, err := MatchStatus("in progress", []string{"backlog", "in progress", "done"}) + require.NoError(t, err) + assert.Equal(t, "in progress", matched) +} + +func TestMatchStatus_CaseInsensitive(t *testing.T) { + matched, err := MatchStatus("In Progress", []string{"backlog", "in progress", "done"}) + require.NoError(t, err) + assert.Equal(t, "in progress", matched) +} + +func TestMatchStatus_ContainsMatch(t *testing.T) { + matched, err := MatchStatus("prog", []string{"backlog", "in progress", "done"}) + require.NoError(t, err) + assert.Equal(t, "in progress", matched) +} + +func TestMatchStatus_FuzzyMatch(t *testing.T) { + matched, err := MatchStatus("progres", []string{"backlog", "in progress", "done"}) + require.NoError(t, err) + assert.Equal(t, "in progress", matched) +} + +func TestMatchStatus_NoMatch(t *testing.T) { + _, err := MatchStatus("nonexistent", []string{"backlog", "in progress", "done"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no matching status found") + assert.Contains(t, err.Error(), "Available statuses:") +} + +func TestMatchStatus_ContainsPicksShortest(t *testing.T) { + // "dev" matches both "in development" and "ready for development" + // Should pick shortest (most specific). + matched, err := MatchStatus("dev", []string{"in development", "ready for development", "done"}) + require.NoError(t, err) + assert.Equal(t, "in development", matched) +} + +func TestMatchAndReport_ExactNoWarning(t *testing.T) { + var buf bytes.Buffer + matched, err := matchAndReport("in progress", []string{"backlog", "in progress", "done"}, &buf) + require.NoError(t, err) + assert.Equal(t, "in progress", matched) + assert.Empty(t, buf.String()) +} + +func TestMatchAndReport_FuzzyPrintsWarning(t *testing.T) { + var buf bytes.Buffer + matched, err := matchAndReport("prog", []string{"backlog", "in progress", "done"}, &buf) + require.NoError(t, err) + assert.Equal(t, "in progress", matched) + assert.Contains(t, buf.String(), "matched to") +} + +func TestMatchStatus_ListCustomStatuses(t *testing.T) { + // Simulate list-level statuses (the bug scenario from issue #4). + listStatuses := []string{"in analysis", "in development", "ready for development", "verification+"} + matched, err := MatchStatus("in development", listStatuses) + require.NoError(t, err) + assert.Equal(t, "in development", matched) +} + +func TestMatchStatus_ListCustomStatuses_NotInSpaceStatuses(t *testing.T) { + // "in development" exists in list statuses but NOT in space statuses. + spaceStatuses := []string{"backlog", "task is ready", "in-progress", "complete"} + _, err := MatchStatus("in development", spaceStatuses) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no matching status found") +}