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>
196 lines
6.7 KiB
Go
196 lines
6.7 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"strings"
|
|
|
|
"github.com/steveyegge/beads/internal/config"
|
|
)
|
|
|
|
// SetConfig sets a configuration value
|
|
func (s *SQLiteStorage) SetConfig(ctx context.Context, key, value string) error {
|
|
// Hold read lock during database operations to prevent reconnect() from
|
|
// closing the connection mid-query (GH#607 race condition fix)
|
|
s.reconnectMu.RLock()
|
|
defer s.reconnectMu.RUnlock()
|
|
|
|
_, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO config (key, value) VALUES (?, ?)
|
|
ON CONFLICT (key) DO UPDATE SET value = excluded.value
|
|
`, key, value)
|
|
return wrapDBError("set config", err)
|
|
}
|
|
|
|
// GetConfig gets a configuration value
|
|
func (s *SQLiteStorage) GetConfig(ctx context.Context, key string) (string, error) {
|
|
// Hold read lock during database operations to prevent reconnect() from
|
|
// closing the connection mid-query (GH#607 race condition fix)
|
|
s.reconnectMu.RLock()
|
|
defer s.reconnectMu.RUnlock()
|
|
|
|
var value string
|
|
err := s.db.QueryRowContext(ctx, `SELECT value FROM config WHERE key = ?`, key).Scan(&value)
|
|
if err == sql.ErrNoRows {
|
|
return "", nil
|
|
}
|
|
return value, wrapDBError("get config", err)
|
|
}
|
|
|
|
// GetAllConfig gets all configuration key-value pairs
|
|
func (s *SQLiteStorage) GetAllConfig(ctx context.Context) (map[string]string, error) {
|
|
// Hold read lock during database operations to prevent reconnect() from
|
|
// closing the connection mid-query (GH#607 race condition fix)
|
|
s.reconnectMu.RLock()
|
|
defer s.reconnectMu.RUnlock()
|
|
|
|
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM config ORDER BY key`)
|
|
if err != nil {
|
|
return nil, wrapDBError("query all config", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
config := make(map[string]string)
|
|
for rows.Next() {
|
|
var key, value string
|
|
if err := rows.Scan(&key, &value); err != nil {
|
|
return nil, wrapDBError("scan config row", err)
|
|
}
|
|
config[key] = value
|
|
}
|
|
return config, wrapDBError("iterate config rows", rows.Err())
|
|
}
|
|
|
|
// DeleteConfig deletes a configuration value
|
|
func (s *SQLiteStorage) DeleteConfig(ctx context.Context, key string) error {
|
|
// Hold read lock during database operations to prevent reconnect() from
|
|
// closing the connection mid-query (GH#607 race condition fix)
|
|
s.reconnectMu.RLock()
|
|
defer s.reconnectMu.RUnlock()
|
|
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM config WHERE key = ?`, key)
|
|
return wrapDBError("delete config", err)
|
|
}
|
|
|
|
// OrphanHandling defines how to handle orphan issues during import
|
|
type OrphanHandling string
|
|
|
|
const (
|
|
OrphanStrict OrphanHandling = "strict" // Reject imports with orphans
|
|
OrphanResurrect OrphanHandling = "resurrect" // Auto-resurrect parents from JSONL
|
|
OrphanSkip OrphanHandling = "skip" // Skip orphans silently
|
|
OrphanAllow OrphanHandling = "allow" // Allow orphans (default)
|
|
)
|
|
|
|
// GetOrphanHandling gets the import.orphan_handling config value
|
|
// Returns OrphanAllow (the default) if not set or if value is invalid
|
|
func (s *SQLiteStorage) GetOrphanHandling(ctx context.Context) OrphanHandling {
|
|
value, err := s.GetConfig(ctx, "import.orphan_handling")
|
|
if err != nil || value == "" {
|
|
return OrphanAllow // Default
|
|
}
|
|
|
|
switch OrphanHandling(value) {
|
|
case OrphanStrict, OrphanResurrect, OrphanSkip, OrphanAllow:
|
|
return OrphanHandling(value)
|
|
default:
|
|
return OrphanAllow // Invalid value, use default
|
|
}
|
|
}
|
|
|
|
// SetMetadata sets a metadata value (for internal state like import hashes)
|
|
func (s *SQLiteStorage) SetMetadata(ctx context.Context, key, value string) error {
|
|
// Hold read lock during database operations to prevent reconnect() from
|
|
// closing the connection mid-query (GH#607 race condition fix)
|
|
s.reconnectMu.RLock()
|
|
defer s.reconnectMu.RUnlock()
|
|
|
|
_, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO metadata (key, value) VALUES (?, ?)
|
|
ON CONFLICT (key) DO UPDATE SET value = excluded.value
|
|
`, key, value)
|
|
return wrapDBError("set metadata", err)
|
|
}
|
|
|
|
// GetMetadata gets a metadata value (for internal state like import hashes)
|
|
func (s *SQLiteStorage) GetMetadata(ctx context.Context, key string) (string, error) {
|
|
// Hold read lock during database operations to prevent reconnect() from
|
|
// closing the connection mid-query (GH#607 race condition fix)
|
|
s.reconnectMu.RLock()
|
|
defer s.reconnectMu.RUnlock()
|
|
|
|
var value string
|
|
err := s.db.QueryRowContext(ctx, `SELECT value FROM metadata WHERE key = ?`, key).Scan(&value)
|
|
if err == sql.ErrNoRows {
|
|
return "", nil
|
|
}
|
|
return value, wrapDBError("get metadata", err)
|
|
}
|
|
|
|
// CustomStatusConfigKey is the config key for custom status states
|
|
const CustomStatusConfigKey = "status.custom"
|
|
|
|
// CustomTypeConfigKey is the config key for custom issue types
|
|
const CustomTypeConfigKey = "types.custom"
|
|
|
|
// GetCustomStatuses retrieves the list of custom status states from config.
|
|
// Custom statuses are stored as comma-separated values in the "status.custom" config key.
|
|
// Returns an empty slice if no custom statuses are configured.
|
|
func (s *SQLiteStorage) GetCustomStatuses(ctx context.Context) ([]string, error) {
|
|
value, err := s.GetConfig(ctx, CustomStatusConfigKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if value == "" {
|
|
return nil, nil
|
|
}
|
|
return parseCustomStatuses(value), nil
|
|
}
|
|
|
|
// 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 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.
|
|
// Empty entries are filtered out.
|
|
func parseCommaSeparatedList(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
|
|
}
|
|
|
|
// parseCustomStatuses is an alias for parseCommaSeparatedList for backward compatibility.
|
|
func parseCustomStatuses(value string) []string {
|
|
return parseCommaSeparatedList(value)
|
|
}
|