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
17 changes: 3 additions & 14 deletions pkg/cmd/status/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
Expand Down Expand Up @@ -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
}
Expand Down
12 changes: 10 additions & 2 deletions pkg/cmd/task/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
}
}

Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions pkg/cmd/task/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
58 changes: 57 additions & 1 deletion pkg/cmdutil/status.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmdutil

import (
"context"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
80 changes: 80 additions & 0 deletions pkg/cmdutil/status_test.go
Original file line number Diff line number Diff line change
@@ -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")
}