Skip to content
Open
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
29 changes: 3 additions & 26 deletions cmd/bd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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") {
Expand Down
29 changes: 29 additions & 0 deletions cmd/bd/update_embedded_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
16 changes: 16 additions & 0 deletions internal/storage/issueops/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
Expand Down