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 3f5c5b847..bc7602263 100644 --- a/internal/wscli/milestone.go +++ b/internal/wscli/milestone.go @@ -6,6 +6,10 @@ import ( "github.com/spf13/cobra" ) +// 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. + var milestoneCmd = &cobra.Command{ Use: "milestone", Short: "Manage milestones", @@ -236,7 +240,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 +250,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) }