diff --git a/cmd/bd/update.go b/cmd/bd/update.go index 53606559..955f8f93 100644 --- a/cmd/bd/update.go +++ b/cmd/bd/update.go @@ -4,9 +4,11 @@ import ( "encoding/json" "fmt" "os" + "strings" "time" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/hooks" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/timeparsing" @@ -106,8 +108,12 @@ create, update, show, or close operation).`, issueType, _ := cmd.Flags().GetString("type") // Normalize aliases (e.g., "enhancement" -> "feature") before validating issueType = util.NormalizeIssueType(issueType) - if !types.IssueType(issueType).IsValid() { - FatalErrorRespectJSON("invalid issue type %q. Valid types: bug, feature, task, epic, chore, merge-request, molecule, gate, agent, role, rig, convoy, event, slot", issueType) + // Get custom types from config to validate non-core types (GH#hq-8hif1z) + customTypes := getCustomTypesForValidation() + if !types.IssueType(issueType).IsValidWithCustom(customTypes) { + validTypes := []string{"bug", "feature", "task", "epic", "chore"} + validTypes = append(validTypes, customTypes...) + FatalErrorRespectJSON("invalid issue type %q. Valid types: %s", issueType, strings.Join(validTypes, ", ")) } updates["issue_type"] = issueType } @@ -602,6 +608,46 @@ create, update, show, or close operation).`, }, } +// getCustomTypesForValidation retrieves custom types for validation. +// Works in both daemon and direct modes, with fallback to config.yaml. +func getCustomTypesForValidation() []string { + ctx := rootCtx + + // Try daemon mode first + if daemonClient != nil { + resp, err := daemonClient.GetConfig(&rpc.GetConfigArgs{Key: "types.custom"}) + if err == nil && resp.Value != "" { + return parseCustomTypesList(resp.Value) + } + } + + // Direct mode - use store + if store != nil { + if ct, err := store.GetCustomTypes(ctx); err == nil && len(ct) > 0 { + return ct + } + } + + // Fallback to config.yaml + return config.GetCustomTypesFromYAML() +} + +// parseCustomTypesList splits a comma-separated string into custom types. +func parseCustomTypesList(value string) []string { + if value == "" { + return nil + } + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + func init() { updateCmd.Flags().StringP("status", "s", "", "New status") registerPriorityFlag(updateCmd, "") diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index a2ae1613..8fb8038c 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -886,11 +886,15 @@ func (s *SQLiteStorage) UpdateIssue(ctx context.Context, id string, updates map[ return fmt.Errorf("issue %s not found", id) } - // Fetch custom statuses for validation + // Fetch custom statuses and types for validation (GH#hq-8hif1z) customStatuses, err := s.GetCustomStatuses(ctx) if err != nil { return wrapDBError("get custom statuses", err) } + customTypes, err := s.GetCustomTypes(ctx) + if err != nil { + return wrapDBError("get custom types", err) + } // Build update query with validated field names setClauses := []string{"updated_at = ?"} @@ -902,8 +906,8 @@ func (s *SQLiteStorage) UpdateIssue(ctx context.Context, id string, updates map[ return fmt.Errorf("invalid field for update: %s", key) } - // Validate field values (with custom status support) - if err := validateFieldUpdateWithCustomStatuses(key, value, customStatuses); err != nil { + // Validate field values (with custom status and type support) + if err := validateFieldUpdateWithCustom(key, value, customStatuses, customTypes); err != nil { return wrapDBError("validate field update", err) } diff --git a/internal/storage/sqlite/transaction.go b/internal/storage/sqlite/transaction.go index 0a148021..6271f58c 100644 --- a/internal/storage/sqlite/transaction.go +++ b/internal/storage/sqlite/transaction.go @@ -400,11 +400,15 @@ func (t *sqliteTxStorage) UpdateIssue(ctx context.Context, id string, updates ma return fmt.Errorf("issue %s not found", id) } - // Fetch custom statuses for validation + // Fetch custom statuses and types for validation (GH#hq-8hif1z) customStatuses, err := t.GetCustomStatuses(ctx) if err != nil { return fmt.Errorf("failed to get custom statuses: %w", err) } + customTypes, err := t.GetCustomTypes(ctx) + if err != nil { + return fmt.Errorf("failed to get custom types: %w", err) + } // Build update query with validated field names setClauses := []string{"updated_at = ?"} @@ -416,8 +420,8 @@ func (t *sqliteTxStorage) UpdateIssue(ctx context.Context, id string, updates ma return fmt.Errorf("invalid field for update: %s", key) } - // Validate field values (with custom status support) - if err := validateFieldUpdateWithCustomStatuses(key, value, customStatuses); err != nil { + // Validate field values (with custom status and type support) + if err := validateFieldUpdateWithCustom(key, value, customStatuses, customTypes); err != nil { return fmt.Errorf("failed to validate field update: %w", err) } diff --git a/internal/storage/sqlite/validators.go b/internal/storage/sqlite/validators.go index 0bcfe100..bc3e53f3 100644 --- a/internal/storage/sqlite/validators.go +++ b/internal/storage/sqlite/validators.go @@ -37,11 +37,17 @@ func validateStatusWithCustom(value interface{}, customStatuses []string) error return nil } -// validateIssueType validates an issue type value +// validateIssueType validates an issue type value (built-in types only) func validateIssueType(value interface{}) error { + return validateIssueTypeWithCustom(value, nil) +} + +// validateIssueTypeWithCustom validates an issue type value, allowing custom types. +// Custom types are configured via bd config set types.custom "type1,type2,..." +func validateIssueTypeWithCustom(value interface{}, customTypes []string) error { if issueType, ok := value.(string); ok { // Normalize first to support aliases like "enhancement" -> "feature" - if !types.IssueType(issueType).Normalize().IsValid() { + if !types.IssueType(issueType).Normalize().IsValidWithCustom(customTypes) { return fmt.Errorf("invalid issue type: %s", issueType) } } @@ -77,18 +83,29 @@ var fieldValidators = map[string]func(interface{}) error{ "estimated_minutes": validateEstimatedMinutes, } -// validateFieldUpdate validates a field update value (built-in statuses only) +// validateFieldUpdate validates a field update value (built-in statuses and types only) func validateFieldUpdate(key string, value interface{}) error { - return validateFieldUpdateWithCustomStatuses(key, value, nil) + return validateFieldUpdateWithCustom(key, value, nil, nil) } // validateFieldUpdateWithCustomStatuses validates a field update value, // allowing custom statuses for status field validation. +// DEPRECATED: Use validateFieldUpdateWithCustom instead which also handles custom types. func validateFieldUpdateWithCustomStatuses(key string, value interface{}, customStatuses []string) error { + return validateFieldUpdateWithCustom(key, value, customStatuses, nil) +} + +// validateFieldUpdateWithCustom validates a field update value, +// allowing custom statuses and custom types for validation (GH#hq-8hif1z). +func validateFieldUpdateWithCustom(key string, value interface{}, customStatuses, customTypes []string) error { // Special handling for status field to support custom statuses if key == "status" { return validateStatusWithCustom(value, customStatuses) } + // Special handling for issue_type field to support custom types + if key == "issue_type" { + return validateIssueTypeWithCustom(value, customTypes) + } if validator, ok := fieldValidators[key]; ok { return validator(value) }