From 90a39ce258ee8bb449818e6e2439044e2f68cb54 Mon Sep 17 00:00:00 2001 From: Yulian Diaz <5605867+spatialy@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:12:18 -0400 Subject: [PATCH 1/2] fix: accept "canceled" for ws milestone status flags The API only accepts "cancelled" for milestones (planning_validation.go), but `ws milestone ls --status` documented "canceled", so filtering by the documented value matched nothing. Show the persisted spelling in the --status help of ls, create and update, and map "canceled" to "cancelled" before sending, so both spellings work. --- .gitignore | 1 + internal/wscli/milestone.go | 28 +++++++++++++++++++------ internal/wscli/milestone_status_test.go | 19 +++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 internal/wscli/milestone_status_test.go diff --git a/.gitignore b/.gitignore index ad6e408c2..ce705a1c3 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ cx !/internal/scm/gitlab_test.go !/internal/services/planning_milestones_release_test.go !/internal/utils/repository_path_test.go +!/internal/wscli/milestone_status_test.go *_test.sh *.test.js *.test.ts diff --git a/internal/wscli/milestone.go b/internal/wscli/milestone.go index 3f5c5b847..2246a7860 100644 --- a/internal/wscli/milestone.go +++ b/internal/wscli/milestone.go @@ -2,10 +2,25 @@ package wscli import ( "fmt" + "strings" "github.com/spf13/cobra" ) +// milestoneCancelledStatus is the persisted milestone status; the API rejects +// the US spelling. +const milestoneCancelledStatus = "cancelled" //nolint:misspell // Persisted API status. + +// normalizeMilestoneStatus maps the US spelling "canceled" to +// milestoneCancelledStatus so `--status canceled` filters and updates as +// users expect. Other values pass through for the API to validate. +func normalizeMilestoneStatus(status string) string { + if strings.EqualFold(strings.TrimSpace(status), "canceled") { + return milestoneCancelledStatus + } + return status +} + var milestoneCmd = &cobra.Command{ Use: "milestone", Short: "Manage milestones", @@ -29,7 +44,7 @@ Examples: filters := make(map[string]string) if milestoneStatusFilter != "" { - filters["status"] = milestoneStatusFilter + filters["status"] = normalizeMilestoneStatus(milestoneStatusFilter) } // --global routes through the legacy global endpoint; everything else @@ -131,7 +146,7 @@ Examples: Name: milestoneCreateName, Description: milestoneCreateDesc, TargetDate: milestoneCreateTarget, - Status: milestoneCreateStatus, + Status: normalizeMilestoneStatus(milestoneCreateStatus), } milestone, err := client.CreateMilestoneInWorkspace(wsID, req) @@ -189,7 +204,8 @@ Examples: hasUpdate = true } if cmd.Flags().Changed("status") { - req.Status = &milestoneUpdateStatus + status := normalizeMilestoneStatus(milestoneUpdateStatus) + req.Status = &status hasUpdate = true } @@ -236,7 +252,7 @@ func init() { milestoneCmd.AddCommand(milestoneUpdateCmd) // List filters - milestoneListCmd.Flags().StringVarP(&milestoneStatusFilter, "status", "s", "", "filter by status (planning, in-progress, completed, canceled)") + milestoneListCmd.Flags().StringVarP(&milestoneStatusFilter, "status", "s", "", "filter by status (planning, in-progress, completed, "+milestoneCancelledStatus+")") milestoneListCmd.Flags().BoolVar(&milestoneGlobalOnly, "global", false, "show only global milestones") // Get flags @@ -246,11 +262,11 @@ func init() { milestoneCreateCmd.Flags().StringVarP(&milestoneCreateName, "name", "n", "", "milestone name (required)") milestoneCreateCmd.Flags().StringVarP(&milestoneCreateDesc, "description", "d", "", "milestone description") milestoneCreateCmd.Flags().StringVar(&milestoneCreateTarget, "target", "", "target date (YYYY-MM-DD)") - milestoneCreateCmd.Flags().StringVar(&milestoneCreateStatus, "status", "", "initial status (default: planning)") + milestoneCreateCmd.Flags().StringVar(&milestoneCreateStatus, "status", "", "initial status: planning (default), in-progress, completed, "+milestoneCancelledStatus) // Update flags milestoneUpdateCmd.Flags().StringVarP(&milestoneUpdateName, "name", "n", "", "new milestone name") milestoneUpdateCmd.Flags().StringVarP(&milestoneUpdateDesc, "description", "d", "", "new description") milestoneUpdateCmd.Flags().StringVar(&milestoneUpdateTarget, "target", "", "new target date (YYYY-MM-DD)") - milestoneUpdateCmd.Flags().StringVar(&milestoneUpdateStatus, "status", "", "new status") + milestoneUpdateCmd.Flags().StringVar(&milestoneUpdateStatus, "status", "", "new status: planning, in-progress, completed, "+milestoneCancelledStatus) } diff --git a/internal/wscli/milestone_status_test.go b/internal/wscli/milestone_status_test.go new file mode 100644 index 000000000..c8dca7e4f --- /dev/null +++ b/internal/wscli/milestone_status_test.go @@ -0,0 +1,19 @@ +package wscli + +import "testing" + +func TestNormalizeMilestoneStatus(t *testing.T) { + t.Parallel() + cases := map[string]string{ + "canceled": "cancelled", //nolint:misspell // Persisted API status. + "Canceled": "cancelled", //nolint:misspell // Persisted API status. + "cancelled": "cancelled", //nolint:misspell // Persisted API status. + "in-progress": "in-progress", + "": "", + } + for in, want := range cases { + if got := normalizeMilestoneStatus(in); got != want { + t.Errorf("normalizeMilestoneStatus(%q) = %q, want %q", in, got, want) + } + } +} From 581d016d726d763d8291f0ebb1e5263e7e3e75e3 Mon Sep 17 00:00:00 2001 From: Stefan Ernst <24554551+stefan-ernst@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:37:43 +0200 Subject: [PATCH 2/2] fix: accept both "canceled" and "cancelled" for planning statuses Normalize the US spelling at the planning service boundary so milestone and iteration create, update, list, and status-set calls accept either spelling. This covers the v1/v2 HTTP APIs, MCP tools, and the ws CLI. The ws CLI drops its own translation and documents the canonical value. --- .gitignore | 1 - internal/services/planning_iterations.go | 3 +++ internal/services/planning_milestones.go | 4 ++++ internal/services/planning_validation.go | 11 +++++++++++ internal/wscli/milestone.go | 22 +++++----------------- internal/wscli/milestone_status_test.go | 19 ------------------- 6 files changed, 23 insertions(+), 37 deletions(-) delete mode 100644 internal/wscli/milestone_status_test.go diff --git a/.gitignore b/.gitignore index ce705a1c3..ad6e408c2 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,6 @@ cx !/internal/scm/gitlab_test.go !/internal/services/planning_milestones_release_test.go !/internal/utils/repository_path_test.go -!/internal/wscli/milestone_status_test.go *_test.sh *.test.js *.test.ts diff --git a/internal/services/planning_iterations.go b/internal/services/planning_iterations.go index 260265843..6cb5b7290 100644 --- a/internal/services/planning_iterations.go +++ b/internal/services/planning_iterations.go @@ -140,6 +140,7 @@ func (s *PlanningService) FindIterationByName(workspaceID int, name string) (*It // ListIterations retrieves iterations with pagination and filtering. func (s *PlanningService) ListIterations(params IterationListParams) ([]IterationResult, int, error) { + params.Status = normalizePlanningStatus(params.Status) list := newPlanningListQuery(iterationSelectQuery+"\nWHERE 1=1", "SELECT COUNT(*) FROM iterations i WHERE 1=1") list.addWorkspaceScope("i.workspace_id", "i.is_global", params.WorkspaceID, params.WorkspaceIDs, params.IncludeGlobal) list.addNullableIDFilter("i.type_id", params.TypeID) @@ -245,6 +246,7 @@ func (s *PlanningService) CreateIteration(params CreateIterationParams) (*Iterat if params.Status == "" { params.Status = "planned" } + params.Status = normalizePlanningStatus(params.Status) if err := s.validateIterationMutation(params); err != nil { return nil, err } @@ -287,6 +289,7 @@ type UpdateIterationParams struct { // UpdateIteration updates an existing iteration within its declared scope. func (s *PlanningService) UpdateIteration(params UpdateIterationParams) (*IterationResult, error) { + params.Status = normalizePlanningStatus(params.Status) if err := s.validateIterationMutation(CreateIterationParams{ Name: params.Name, Description: params.Description, diff --git a/internal/services/planning_milestones.go b/internal/services/planning_milestones.go index bc724acf8..b5251a0db 100644 --- a/internal/services/planning_milestones.go +++ b/internal/services/planning_milestones.go @@ -269,6 +269,7 @@ type MilestoneListParams struct { // ListMilestones retrieves milestones with pagination and filtering. func (s *PlanningService) ListMilestones(params MilestoneListParams) ([]MilestoneResult, int, error) { + params.Status = normalizePlanningStatus(params.Status) list := newPlanningListQuery(milestoneSelectQuery+"\nWHERE 1=1", "SELECT COUNT(*) FROM milestones m WHERE 1=1") list.addWorkspaceScope("m.workspace_id", "m.is_global", params.WorkspaceID, params.WorkspaceIDs, params.IncludeGlobal) list.addNullableIDFilter("m.category_id", params.CategoryID) @@ -445,6 +446,7 @@ func (s *PlanningService) CreateMilestone(params CreateMilestoneParams) (*Milest if params.Status == "" { params.Status = "planning" } + params.Status = normalizePlanningStatus(params.Status) if err := s.validateMilestoneMutation(params); err != nil { return nil, err } @@ -522,6 +524,7 @@ func (s *PlanningService) FindMilestoneByName(workspaceID int, name string) (*Mi // milestone to "in-progress" or "completed" without disturbing the other // fields. Returns a "not found" error when no row matches the scope. func (s *PlanningService) SetMilestoneStatus(milestoneID, workspaceID int, status string) error { + status = normalizePlanningStatus(status) if !validMilestoneStatus(status) { return planningValidationError("status", milestoneStatusValidationMessage) } @@ -589,6 +592,7 @@ type UpdateMilestoneParams struct { // UpdateMilestone updates an existing milestone within its declared scope. // is_global / workspace_id cannot be changed via this method. func (s *PlanningService) UpdateMilestone(params UpdateMilestoneParams) (*MilestoneResult, error) { + params.Status = normalizePlanningStatus(params.Status) if err := s.validateMilestoneMutation(CreateMilestoneParams{ Name: params.Name, Description: params.Description, diff --git a/internal/services/planning_validation.go b/internal/services/planning_validation.go index 3fbccb450..336a237f1 100644 --- a/internal/services/planning_validation.go +++ b/internal/services/planning_validation.go @@ -43,6 +43,17 @@ func planningValidationErrorWithCause(field, message string, cause error) error const cancelledPlanningStatus = "cancelled" //nolint:misspell // Persisted API status. +// normalizePlanningStatus maps the US spelling "canceled" onto the canonical +// persisted planning status. Clients send either spelling; validation, +// filters, and storage all use the canonical one. Any other value passes +// through so validation can reject it. +func normalizePlanningStatus(status string) string { + if strings.EqualFold(strings.TrimSpace(status), "canceled") { + return cancelledPlanningStatus + } + return status +} + func validMilestoneStatus(status string) bool { switch status { case "planning", "in-progress", "completed", cancelledPlanningStatus: diff --git a/internal/wscli/milestone.go b/internal/wscli/milestone.go index 2246a7860..bc7602263 100644 --- a/internal/wscli/milestone.go +++ b/internal/wscli/milestone.go @@ -2,25 +2,14 @@ package wscli import ( "fmt" - "strings" "github.com/spf13/cobra" ) -// milestoneCancelledStatus is the persisted milestone status; the API rejects -// the US spelling. +// milestoneCancelledStatus is the canonical persisted milestone status. The +// API also accepts the US spelling "canceled" and normalizes it. const milestoneCancelledStatus = "cancelled" //nolint:misspell // Persisted API status. -// normalizeMilestoneStatus maps the US spelling "canceled" to -// milestoneCancelledStatus so `--status canceled` filters and updates as -// users expect. Other values pass through for the API to validate. -func normalizeMilestoneStatus(status string) string { - if strings.EqualFold(strings.TrimSpace(status), "canceled") { - return milestoneCancelledStatus - } - return status -} - var milestoneCmd = &cobra.Command{ Use: "milestone", Short: "Manage milestones", @@ -44,7 +33,7 @@ Examples: filters := make(map[string]string) if milestoneStatusFilter != "" { - filters["status"] = normalizeMilestoneStatus(milestoneStatusFilter) + filters["status"] = milestoneStatusFilter } // --global routes through the legacy global endpoint; everything else @@ -146,7 +135,7 @@ Examples: Name: milestoneCreateName, Description: milestoneCreateDesc, TargetDate: milestoneCreateTarget, - Status: normalizeMilestoneStatus(milestoneCreateStatus), + Status: milestoneCreateStatus, } milestone, err := client.CreateMilestoneInWorkspace(wsID, req) @@ -204,8 +193,7 @@ Examples: hasUpdate = true } if cmd.Flags().Changed("status") { - status := normalizeMilestoneStatus(milestoneUpdateStatus) - req.Status = &status + req.Status = &milestoneUpdateStatus hasUpdate = true } diff --git a/internal/wscli/milestone_status_test.go b/internal/wscli/milestone_status_test.go deleted file mode 100644 index c8dca7e4f..000000000 --- a/internal/wscli/milestone_status_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package wscli - -import "testing" - -func TestNormalizeMilestoneStatus(t *testing.T) { - t.Parallel() - cases := map[string]string{ - "canceled": "cancelled", //nolint:misspell // Persisted API status. - "Canceled": "cancelled", //nolint:misspell // Persisted API status. - "cancelled": "cancelled", //nolint:misspell // Persisted API status. - "in-progress": "in-progress", - "": "", - } - for in, want := range cases { - if got := normalizeMilestoneStatus(in); got != want { - t.Errorf("normalizeMilestoneStatus(%q) = %q, want %q", in, got, want) - } - } -}