diff --git a/cmd/bd/update.go b/cmd/bd/update.go index 2942918f9a..3f8db2a119 100644 --- a/cmd/bd/update.go +++ b/cmd/bd/update.go @@ -9,7 +9,6 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/audit" - "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/timeparsing" "github.com/steveyegge/beads/internal/types" @@ -138,32 +137,10 @@ create, update, show, or close operation).`, } if cmd.Flags().Changed("type") { issueType, _ := cmd.Flags().GetString("type") - // Normalize aliases (e.g., "enhancement" -> "feature") before validating + // Normalize aliases (e.g., "enhancement" -> "feature") before validating. + // Type validation (including custom types) is handled by the storage + // layer inside the transaction, matching the create path. (GH#3030) issueType = utils.NormalizeIssueType(issueType) - var customTypes []string - if store != nil { - ct, err := store.GetCustomTypes(cmd.Context()) - if err != nil { - // Log DB error but continue with YAML fallback (GH#1499 bd-2ll) - if !jsonOutput { - fmt.Fprintf(os.Stderr, "%s Failed to get custom types from DB: %v (falling back to config.yaml)\n", - ui.RenderWarn("!"), err) - } - } else { - customTypes = ct - } - } - // Fallback to config.yaml when store returns no custom types. - if len(customTypes) == 0 { - customTypes = config.GetCustomTypesFromYAML() - } - if !types.IssueType(issueType).IsValidWithCustom(customTypes) { - validTypes := "bug, feature, task, epic, chore, decision" - if len(customTypes) > 0 { - validTypes += ", " + joinStrings(customTypes, ", ") - } - FatalErrorRespectJSON("invalid issue type %q. Valid types: %s", issueType, validTypes) - } updates["issue_type"] = issueType } if cmd.Flags().Changed("add-label") { diff --git a/cmd/bd/update_embedded_test.go b/cmd/bd/update_embedded_test.go index 4385bc6d75..61a48cdc94 100644 --- a/cmd/bd/update_embedded_test.go +++ b/cmd/bd/update_embedded_test.go @@ -185,6 +185,35 @@ func TestEmbeddedUpdate(t *testing.T) { } }) + t.Run("update_type_custom", func(t *testing.T) { + // Register "agent" as a custom type via bd config (GH#3030). + // This writes to Dolt only, NOT to .beads/config.yaml. + cfgCmd := exec.Command(bd, "config", "set", "types.custom", "agent,spike") + cfgCmd.Dir = dir + cfgCmd.Env = bdEnv(dir) + if out, err := cfgCmd.CombinedOutput(); err != nil { + t.Fatalf("bd config set types.custom failed: %v\n%s", err, out) + } + + issue := bdCreate(t, bd, dir, "Custom type update", "--type", "task") + // Before the fix (GH#3030), this would fail with "invalid issue type" + // because the CLI-level validation could not read custom types from Dolt. + bdUpdate(t, bd, dir, issue.ID, "--type", "agent") + got := bdShow(t, bd, dir, issue.ID) + if string(got.IssueType) != "agent" { + t.Errorf("expected type agent, got %s", got.IssueType) + } + }) + + t.Run("update_type_invalid_rejected", func(t *testing.T) { + // Verify that truly invalid types are still rejected by the storage layer. + issue := bdCreate(t, bd, dir, "Invalid type test", "--type", "task") + out := bdUpdateFail(t, bd, dir, issue.ID, "--type", "banana") + if !strings.Contains(out, "invalid issue type") { + t.Errorf("expected 'invalid issue type' error, got: %s", out) + } + }) + t.Run("update_design", func(t *testing.T) { issue := bdCreate(t, bd, dir, "Design test", "--type", "task") bdUpdate(t, bd, dir, issue.ID, "--design", "Design notes here") diff --git a/internal/storage/issueops/update.go b/internal/storage/issueops/update.go index 99c1312596..3ec63b9af9 100644 --- a/internal/storage/issueops/update.go +++ b/internal/storage/issueops/update.go @@ -107,6 +107,22 @@ func UpdateIssueInTx(ctx context.Context, tx *sql.Tx, id string, updates map[str return nil, fmt.Errorf("failed to get issue for update: %w", err) } + // Validate issue_type against built-in + custom types (GH#3030). + // This mirrors the create path (PrepareIssueForInsert → ValidateWithCustom) + // and reads custom types from the same transaction, so it works reliably + // even in subprocess contexts where the CLI-level store may be unavailable. + if rawType, ok := updates["issue_type"]; ok { + if issueType, ok := rawType.(string); ok { + customTypes, err := ResolveCustomTypesInTx(ctx, tx) + if err != nil { + return nil, fmt.Errorf("failed to get custom types for validation: %w", err) + } + if !types.IssueType(issueType).IsValidWithCustom(customTypes) { + return nil, fmt.Errorf("invalid issue type: %s", issueType) + } + } + } + // Build SET clauses. setClauses := []string{"updated_at = ?"} args := []interface{}{time.Now().UTC()}