fix: load types.custom from config.yaml during init auto-import (GH#1225) (#1226)

During bd init, auto-import fails with "invalid issue type" errors even
when types.custom is defined in config.yaml. This happens because custom
types are read from the database, but the database is being created
during init and doesn't have the config set yet.

Changes:
- Add GetCustomTypesFromYAML() to internal/config/config.go to read
  types.custom from config.yaml via viper
- Modify GetCustomTypes() in sqlite/config.go to fallback to config.yaml
  when the database doesn't have types.custom configured
- Add tests for GetCustomTypesFromYAML()

This allows fresh clones with custom types defined in config.yaml (e.g.,
Gas Town types like molecule, gate, convoy, agent, event) to successfully
auto-import their JSONL during bd init.

Fixes GH#1225

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Brown
2026-01-21 01:34:22 -05:00
committed by GitHub
parent 63c2e50158
commit b7242a67d1
3 changed files with 144 additions and 3 deletions

View File

@@ -638,3 +638,30 @@ func NeedsJSONL() bool {
mode := GetSyncMode()
return mode == SyncModeGitPortable || mode == SyncModeRealtime || mode == SyncModeBeltAndSuspenders
}
// GetCustomTypesFromYAML retrieves custom issue types from config.yaml.
// This is used as a fallback when the database doesn't have types.custom set yet
// (e.g., during bd init auto-import before the database is fully configured).
// Returns nil if no custom types are configured in config.yaml.
func GetCustomTypesFromYAML() []string {
if v == nil {
return nil
}
// Try to get types.custom from viper (config.yaml or env var)
value := v.GetString("types.custom")
if value == "" {
return nil
}
// Parse comma-separated list
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
}

View File

@@ -1442,3 +1442,104 @@ func TestGetSovereigntyInvalid(t *testing.T) {
t.Errorf("GetSovereignty() with invalid tier = %q, want %q (fallback)", got, SovereigntyT1)
}
}
func TestGetCustomTypesFromYAML(t *testing.T) {
// Isolate from environment variables
restore := envSnapshot(t)
defer restore()
// Create a temporary directory with a .beads/config.yaml
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads directory: %v", err)
}
// Write a config file with types.custom set
configContent := `
types:
custom: "molecule,gate,convoy,agent,event"
`
configPath := filepath.Join(beadsDir, "config.yaml")
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Change to tmp directory so config is found
t.Chdir(tmpDir)
// Reset and initialize viper
ResetForTesting()
if err := Initialize(); err != nil {
t.Fatalf("Initialize() returned error: %v", err)
}
// Test GetCustomTypesFromYAML returns the expected types
got := GetCustomTypesFromYAML()
if got == nil {
t.Fatal("GetCustomTypesFromYAML() returned nil, want custom types")
}
expected := []string{"molecule", "gate", "convoy", "agent", "event"}
if len(got) != len(expected) {
t.Errorf("GetCustomTypesFromYAML() returned %d types, want %d", len(got), len(expected))
}
for i, typ := range expected {
if i >= len(got) || got[i] != typ {
t.Errorf("GetCustomTypesFromYAML()[%d] = %q, want %q", i, got[i], typ)
}
}
}
func TestGetCustomTypesFromYAML_NotSet(t *testing.T) {
// Isolate from environment variables
restore := envSnapshot(t)
defer restore()
// Create a temporary directory with a .beads/config.yaml WITHOUT types.custom
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads directory: %v", err)
}
// Write a config file without types.custom
configContent := `
issue-prefix: "test"
`
configPath := filepath.Join(beadsDir, "config.yaml")
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Change to tmp directory
t.Chdir(tmpDir)
// Reset and initialize viper
ResetForTesting()
if err := Initialize(); err != nil {
t.Fatalf("Initialize() returned error: %v", err)
}
// Test GetCustomTypesFromYAML returns nil when not set
got := GetCustomTypesFromYAML()
if got != nil {
t.Errorf("GetCustomTypesFromYAML() = %v, want nil when types.custom not set", got)
}
}
func TestGetCustomTypesFromYAML_NilViper(t *testing.T) {
// Save the current viper instance
savedV := v
// Set viper to nil to test nil-safety
v = nil
defer func() { v = savedV }()
// Should return nil without panicking
got := GetCustomTypesFromYAML()
if got != nil {
t.Errorf("GetCustomTypesFromYAML() with nil viper = %v, want nil", got)
}
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"strings"
"github.com/steveyegge/beads/internal/config"
)
// SetConfig sets a configuration value
@@ -147,16 +149,27 @@ func (s *SQLiteStorage) GetCustomStatuses(ctx context.Context) ([]string, error)
// GetCustomTypes retrieves the list of custom issue types from config.
// Custom types are stored as comma-separated values in the "types.custom" config key.
// If the database doesn't have custom types configured, falls back to config.yaml.
// This fallback is essential during bd init when the database is being created
// but auto-import needs to validate issues with custom types (GH#1225).
// Returns an empty slice if no custom types are configured.
func (s *SQLiteStorage) GetCustomTypes(ctx context.Context) ([]string, error) {
value, err := s.GetConfig(ctx, CustomTypeConfigKey)
if err != nil {
return nil, err
}
if value == "" {
return nil, nil
}
if value != "" {
return parseCommaSeparatedList(value), nil
}
// Fallback to config.yaml when database doesn't have types.custom set.
// This allows auto-import during bd init to work with custom types
// defined in config.yaml before they're persisted to the database.
if yamlTypes := config.GetCustomTypesFromYAML(); len(yamlTypes) > 0 {
return yamlTypes, nil
}
return nil, nil
}
// parseCommaSeparatedList splits a comma-separated string into a slice of trimmed entries.