feat(types): add custom type support to beads config (bd-649s)
Add types.custom config key mirroring the status.custom pattern: - Add CustomTypeConfigKey constant and GetCustomTypes() to storage interface - Add IssueType.IsValidWithCustom() method for validation - Add ValidateWithCustom() to Issue for combined status/type validation - Update all validation call sites to use GetCustomTypes() - Rename parseCustomStatuses to parseCommaSeparated for reuse This enables Gas Town to register custom types like agent/role/rig/convoy without hardcoding them in beads core, supporting the type extraction epic. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Executed-By: beads/crew/dave Rig: beads Role: crew
This commit is contained in:
committed by
Steve Yegge
parent
3287340678
commit
b7358f17bf
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user