diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index f52de112..51a76027 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -1648,8 +1648,8 @@ func (m *MemoryStorage) GetCustomStatuses(ctx context.Context) ([]string, error) return parseCustomStatuses(value), nil } -// parseCustomStatuses splits a comma-separated string into a slice of trimmed status names. -func parseCustomStatuses(value string) []string { +// parseCommaSeparated splits a comma-separated string into a slice of trimmed values. +func parseCommaSeparated(value string) []string { if value == "" { return nil } @@ -1664,6 +1664,23 @@ func parseCustomStatuses(value string) []string { return result } +// Alias for backwards compatibility in tests +func parseCustomStatuses(value string) []string { + return parseCommaSeparated(value) +} + +// GetCustomTypes retrieves the list of custom issue types from config. +func (m *MemoryStorage) GetCustomTypes(ctx context.Context) ([]string, error) { + value, err := m.GetConfig(ctx, "types.custom") + if err != nil { + return nil, err + } + if value == "" { + return nil, nil + } + return parseCommaSeparated(value), nil +} + // Metadata func (m *MemoryStorage) SetMetadata(ctx context.Context, key, value string) error { m.mu.Lock() diff --git a/internal/storage/sqlite/batch_ops.go b/internal/storage/sqlite/batch_ops.go index 20a2d525..69e31ee5 100644 --- a/internal/storage/sqlite/batch_ops.go +++ b/internal/storage/sqlite/batch_ops.go @@ -11,14 +11,21 @@ import ( ) // validateBatchIssues validates all issues in a batch and sets timestamps if not provided -// Uses built-in statuses only for backward compatibility. +// Uses built-in statuses and types only for backward compatibility. func validateBatchIssues(issues []*types.Issue) error { - return validateBatchIssuesWithCustomStatuses(issues, nil) + return validateBatchIssuesWithCustom(issues, nil, nil) } // validateBatchIssuesWithCustomStatuses validates all issues in a batch, // allowing custom statuses in addition to built-in ones. +// Deprecated: Use validateBatchIssuesWithCustom instead. func validateBatchIssuesWithCustomStatuses(issues []*types.Issue, customStatuses []string) error { + return validateBatchIssuesWithCustom(issues, customStatuses, nil) +} + +// validateBatchIssuesWithCustom validates all issues in a batch, +// allowing custom statuses and types in addition to built-in ones. +func validateBatchIssuesWithCustom(issues []*types.Issue, customStatuses, customTypes []string) error { now := time.Now() for i, issue := range issues { if issue == nil { @@ -54,7 +61,7 @@ func validateBatchIssuesWithCustomStatuses(issues []*types.Issue, customStatuses issue.DeletedAt = &deletedAt } - if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil { + if err := issue.ValidateWithCustom(customStatuses, customTypes); err != nil { return fmt.Errorf("validation failed for issue %d: %w", i, err) } } diff --git a/internal/storage/sqlite/config.go b/internal/storage/sqlite/config.go index a8935214..73eecd47 100644 --- a/internal/storage/sqlite/config.go +++ b/internal/storage/sqlite/config.go @@ -98,6 +98,9 @@ func (s *SQLiteStorage) GetMetadata(ctx context.Context, key string) (string, er // 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. @@ -109,12 +112,12 @@ func (s *SQLiteStorage) GetCustomStatuses(ctx context.Context) ([]string, error) if value == "" { return nil, nil } - return parseCustomStatuses(value), nil + return parseCommaSeparated(value), nil } -// parseCustomStatuses splits a comma-separated string into a slice of trimmed status names. +// parseCommaSeparated splits a comma-separated string into a slice of trimmed values. // Empty entries are filtered out. -func parseCustomStatuses(value string) []string { +func parseCommaSeparated(value string) []string { if value == "" { return nil } @@ -128,3 +131,17 @@ func parseCustomStatuses(value string) []string { } return result } + +// GetCustomTypes retrieves the list of custom issue types from config. +// Custom types are stored as comma-separated values in the "types.custom" config key. +// 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 + } + return parseCommaSeparated(value), nil +} diff --git a/internal/storage/sqlite/multirepo.go b/internal/storage/sqlite/multirepo.go index c2509ff7..71c820c9 100644 --- a/internal/storage/sqlite/multirepo.go +++ b/internal/storage/sqlite/multirepo.go @@ -126,11 +126,15 @@ func (s *SQLiteStorage) importJSONLFile(ctx context.Context, jsonlPath, sourceRe } defer file.Close() - // Fetch custom statuses for validation (bd-1pj6) + // Fetch custom statuses and types for validation (bd-1pj6) customStatuses, err := s.GetCustomStatuses(ctx) if err != nil { return 0, fmt.Errorf("failed to get custom statuses: %w", err) } + customTypes, err := s.GetCustomTypes(ctx) + if err != nil { + return 0, fmt.Errorf("failed to get custom types: %w", err) + } scanner := bufio.NewScanner(file) // Increase buffer size for large issues @@ -183,8 +187,8 @@ func (s *SQLiteStorage) importJSONLFile(ctx context.Context, jsonlPath, sourceRe issue.ContentHash = issue.ComputeContentHash() } - // Insert or update issue (with custom status support) - if err := s.upsertIssueInTx(ctx, tx, &issue, customStatuses); err != nil { + // Insert or update issue (with custom status and type support) + if err := s.upsertIssueInTx(ctx, tx, &issue, customStatuses, customTypes); err != nil { return 0, fmt.Errorf("failed to import issue %s at line %d: %w", issue.ID, lineNum, err) } @@ -250,7 +254,7 @@ func (s *SQLiteStorage) importJSONLFile(ctx context.Context, jsonlPath, sourceRe // upsertIssueInTx inserts or updates an issue within a transaction. // Uses INSERT OR REPLACE to handle both new and existing issues. -func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *types.Issue, customStatuses []string) error { +func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *types.Issue, customStatuses, customTypes []string) error { // Defensive fix for closed_at invariant (GH#523): older versions of bd could // close issues without setting closed_at. Fix by using max(created_at, updated_at) + 1s. if issue.Status == types.StatusClosed && issue.ClosedAt == nil { @@ -272,8 +276,8 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * issue.DeletedAt = &deletedAt } - // Validate issue (with custom status support, bd-1pj6) - if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil { + // Validate issue (with custom status and type support, bd-1pj6) + if err := issue.ValidateWithCustom(customStatuses, customTypes); err != nil { return fmt.Errorf("validation failed: %w", err) } diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index 13fcaef9..42014ba3 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -75,11 +75,15 @@ func formatJSONStringArray(arr []string) string { // CreateIssue creates a new issue func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, actor string) error { - // Fetch custom statuses for validation + // Fetch custom statuses and types for validation customStatuses, err := s.GetCustomStatuses(ctx) if err != nil { return fmt.Errorf("failed to get custom statuses: %w", err) } + customTypes, err := s.GetCustomTypes(ctx) + if err != nil { + return fmt.Errorf("failed to get custom types: %w", err) + } // Set timestamps first so defensive fixes can use them now := time.Now() @@ -111,8 +115,8 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act issue.DeletedAt = &deletedAt } - // Validate issue before creating (with custom status support) - if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil { + // Validate issue before creating (with custom status and type support) + if err := issue.ValidateWithCustom(customStatuses, customTypes); err != nil { return fmt.Errorf("validation failed: %w", err) } diff --git a/internal/storage/sqlite/transaction.go b/internal/storage/sqlite/transaction.go index 9f2a6ab5..17b2bb74 100644 --- a/internal/storage/sqlite/transaction.go +++ b/internal/storage/sqlite/transaction.go @@ -91,11 +91,15 @@ func (s *SQLiteStorage) RunInTransaction(ctx context.Context, fn func(tx storage // CreateIssue creates a new issue within the transaction. func (t *sqliteTxStorage) CreateIssue(ctx context.Context, issue *types.Issue, actor string) error { - // Fetch custom statuses for validation + // Fetch custom statuses and types for validation 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) + } // Set timestamps first so defensive fixes can use them now := time.Now() @@ -127,8 +131,8 @@ func (t *sqliteTxStorage) CreateIssue(ctx context.Context, issue *types.Issue, a issue.DeletedAt = &deletedAt } - // Validate issue before creating (with custom status support) - if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil { + // Validate issue before creating (with custom status and type support) + if err := issue.ValidateWithCustom(customStatuses, customTypes); err != nil { return fmt.Errorf("validation failed: %w", err) } @@ -207,13 +211,17 @@ func (t *sqliteTxStorage) CreateIssues(ctx context.Context, issues []*types.Issu return nil } - // Fetch custom statuses for validation + // Fetch custom statuses and types for validation 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) + } - // Validate and prepare all issues first (with custom status support) + // Validate and prepare all issues first (with custom status and type support) now := time.Now() for _, issue := range issues { // Set timestamps first so defensive fixes can use them @@ -245,7 +253,7 @@ func (t *sqliteTxStorage) CreateIssues(ctx context.Context, issues []*types.Issu issue.DeletedAt = &deletedAt } - if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil { + if err := issue.ValidateWithCustom(customStatuses, customTypes); err != nil { return fmt.Errorf("validation failed for issue: %w", err) } if issue.ContentHash == "" { @@ -965,7 +973,19 @@ func (t *sqliteTxStorage) GetCustomStatuses(ctx context.Context) ([]string, erro if value == "" { return nil, nil } - return parseCustomStatuses(value), nil + return parseCommaSeparated(value), nil +} + +// GetCustomTypes retrieves the list of custom issue types from config within the transaction. +func (t *sqliteTxStorage) GetCustomTypes(ctx context.Context) ([]string, error) { + value, err := t.GetConfig(ctx, CustomTypeConfigKey) + if err != nil { + return nil, err + } + if value == "" { + return nil, nil + } + return parseCommaSeparated(value), nil } // SetMetadata sets a metadata value within the transaction. diff --git a/internal/storage/sqlite/validators_test.go b/internal/storage/sqlite/validators_test.go index 389a49ea..7d092f8f 100644 --- a/internal/storage/sqlite/validators_test.go +++ b/internal/storage/sqlite/validators_test.go @@ -205,14 +205,14 @@ func TestParseCustomStatuses(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := parseCustomStatuses(tt.value) + got := parseCommaSeparated(tt.value) if len(got) != len(tt.want) { - t.Errorf("parseCustomStatuses() = %v, want %v", got, tt.want) + t.Errorf("parseCommaSeparated() = %v, want %v", got, tt.want) return } for i := range got { if got[i] != tt.want[i] { - t.Errorf("parseCustomStatuses()[%d] = %v, want %v", i, got[i], tt.want[i]) + t.Errorf("parseCommaSeparated()[%d] = %v, want %v", i, got[i], tt.want[i]) } } }) diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 0635219e..9fc94a4b 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -152,6 +152,7 @@ type Storage interface { GetAllConfig(ctx context.Context) (map[string]string, error) DeleteConfig(ctx context.Context, key string) error GetCustomStatuses(ctx context.Context) ([]string, error) // Custom status states from status.custom config + GetCustomTypes(ctx context.Context) ([]string, error) // Custom issue types from types.custom config // Metadata (for internal state like import hashes) SetMetadata(ctx context.Context, key, value string) error diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index a509a3ad..6d840f0c 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -170,6 +170,9 @@ func (m *mockStorage) DeleteConfig(ctx context.Context, key string) error { func (m *mockStorage) GetCustomStatuses(ctx context.Context) ([]string, error) { return nil, nil } +func (m *mockStorage) GetCustomTypes(ctx context.Context) ([]string, error) { + return nil, nil +} func (m *mockStorage) SetMetadata(ctx context.Context, key, value string) error { return nil } @@ -358,6 +361,7 @@ func TestInterfaceDocumentation(t *testing.T) { _ = s.GetAllConfig _ = s.DeleteConfig _ = s.GetCustomStatuses + _ = s.GetCustomTypes // Verify metadata operations _ = s.SetMetadata diff --git a/internal/types/types.go b/internal/types/types.go index e958b985..37d861e5 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -292,14 +292,21 @@ func (i *Issue) IsExpired(ttl time.Duration) bool { return time.Now().After(expirationTime) } -// Validate checks if the issue has valid field values (built-in statuses only) +// Validate checks if the issue has valid field values (built-in statuses and types only) func (i *Issue) Validate() error { - return i.ValidateWithCustomStatuses(nil) + return i.ValidateWithCustom(nil, nil) } // ValidateWithCustomStatuses checks if the issue has valid field values, // allowing custom statuses in addition to built-in ones. +// Deprecated: Use ValidateWithCustom instead. func (i *Issue) ValidateWithCustomStatuses(customStatuses []string) error { + return i.ValidateWithCustom(customStatuses, nil) +} + +// ValidateWithCustom checks if the issue has valid field values, +// allowing custom statuses and types in addition to built-in ones. +func (i *Issue) ValidateWithCustom(customStatuses, customTypes []string) error { if len(i.Title) == 0 { return fmt.Errorf("title is required") } @@ -312,7 +319,7 @@ func (i *Issue) ValidateWithCustomStatuses(customStatuses []string) error { if !i.Status.IsValidWithCustom(customStatuses) { return fmt.Errorf("invalid status: %s", i.Status) } - if !i.IssueType.IsValid() { + if !i.IssueType.IsValidWithCustom(customTypes) { return fmt.Errorf("invalid issue type: %s", i.IssueType) } if i.EstimatedMinutes != nil && *i.EstimatedMinutes < 0 { @@ -423,7 +430,7 @@ const ( TypeSlot IssueType = "slot" // Exclusive access slot (merge-slot gate) ) -// IsValid checks if the issue type value is valid +// IsValid checks if the issue type value is valid (built-in types only) func (t IssueType) IsValid() bool { switch t { case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore, TypeMessage, TypeMergeRequest, TypeMolecule, TypeGate, TypeAgent, TypeRole, TypeRig, TypeConvoy, TypeEvent, TypeSlot: @@ -432,6 +439,22 @@ func (t IssueType) IsValid() bool { return false } +// IsValidWithCustom checks if the issue type is valid, including custom types. +// Custom types are user-defined via bd config set types.custom "type1,type2,..." +func (t IssueType) IsValidWithCustom(customTypes []string) bool { + // First check built-in types + if t.IsValid() { + return true + } + // Then check custom types + for _, custom := range customTypes { + if string(t) == custom { + return true + } + } + return false +} + // RequiredSection describes a recommended section for an issue type. // Used by bd lint and bd create --validate for template validation. type RequiredSection struct {