diff --git a/cmd/bd/config.go b/cmd/bd/config.go index 9b11a20e..c5ac9156 100644 --- a/cmd/bd/config.go +++ b/cmd/bd/config.go @@ -18,14 +18,26 @@ var configCmd = &cobra.Command{ Configuration is stored per-project in .beads/*.db and is version-control-friendly. Common namespaces: - - jira.* Jira integration settings - - linear.* Linear integration settings - - github.* GitHub integration settings - - custom.* Custom integration settings + - jira.* Jira integration settings + - linear.* Linear integration settings + - github.* GitHub integration settings + - custom.* Custom integration settings + - status.* Issue status configuration + +Custom Status States: + You can define custom status states for multi-step pipelines using the + status.custom config key. Statuses should be comma-separated. + + Example: + bd config set status.custom "awaiting_review,awaiting_testing,awaiting_docs" + + This enables issues to use statuses like 'awaiting_review' in addition to + the built-in statuses (open, in_progress, blocked, closed). Examples: bd config set jira.url "https://company.atlassian.net" bd config set jira.project "PROJ" + bd config set status.custom "awaiting_review,awaiting_testing" bd config get jira.url bd config list bd config unset jira.url`, diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index 179e1a12..5c429a87 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -1087,6 +1087,34 @@ func (m *MemoryStorage) GetAllConfig(ctx context.Context) (map[string]string, er return result, nil } +// GetCustomStatuses retrieves the list of custom status states from config. +func (m *MemoryStorage) GetCustomStatuses(ctx context.Context) ([]string, error) { + value, err := m.GetConfig(ctx, "status.custom") + if err != nil { + return nil, err + } + if value == "" { + return nil, nil + } + return parseCustomStatuses(value), nil +} + +// parseCustomStatuses splits a comma-separated string into a slice of trimmed status names. +func parseCustomStatuses(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 +} + // 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 a9ed3c40..dcbc52e3 100644 --- a/internal/storage/sqlite/batch_ops.go +++ b/internal/storage/sqlite/batch_ops.go @@ -10,7 +10,14 @@ import ( ) // validateBatchIssues validates all issues in a batch and sets timestamps if not provided +// Uses built-in statuses only for backward compatibility. func validateBatchIssues(issues []*types.Issue) error { + return validateBatchIssuesWithCustomStatuses(issues, nil) +} + +// validateBatchIssuesWithCustomStatuses validates all issues in a batch, +// allowing custom statuses in addition to built-in ones (bd-1pj6). +func validateBatchIssuesWithCustomStatuses(issues []*types.Issue, customStatuses []string) error { now := time.Now() for i, issue := range issues { if issue == nil { @@ -25,7 +32,7 @@ func validateBatchIssues(issues []*types.Issue) error { issue.UpdatedAt = now } - if err := issue.Validate(); err != nil { + if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil { return fmt.Errorf("validation failed for issue %d: %w", i, err) } } @@ -146,8 +153,14 @@ func (s *SQLiteStorage) CreateIssuesWithFullOptions(ctx context.Context, issues return nil } - // Phase 1: Validate all issues first (fail-fast) - if err := validateBatchIssues(issues); err != nil { + // Fetch custom statuses for validation (bd-1pj6) + customStatuses, err := s.GetCustomStatuses(ctx) + if err != nil { + return fmt.Errorf("failed to get custom statuses: %w", err) + } + + // Phase 1: Validate all issues first (fail-fast, with custom status support) + if err := validateBatchIssuesWithCustomStatuses(issues, customStatuses); err != nil { return err } diff --git a/internal/storage/sqlite/config.go b/internal/storage/sqlite/config.go index 0f06abdc..a8935214 100644 --- a/internal/storage/sqlite/config.go +++ b/internal/storage/sqlite/config.go @@ -3,6 +3,7 @@ package sqlite import ( "context" "database/sql" + "strings" ) // SetConfig sets a configuration value @@ -93,3 +94,37 @@ func (s *SQLiteStorage) GetMetadata(ctx context.Context, key string) (string, er } return value, wrapDBError("get metadata", err) } + +// CustomStatusConfigKey is the config key for custom status states +const CustomStatusConfigKey = "status.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 +} + +// parseCustomStatuses splits a comma-separated string into a slice of trimmed status names. +// Empty entries are filtered out. +func parseCustomStatuses(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 +} diff --git a/internal/storage/sqlite/multirepo.go b/internal/storage/sqlite/multirepo.go index d054987d..80d09e32 100644 --- a/internal/storage/sqlite/multirepo.go +++ b/internal/storage/sqlite/multirepo.go @@ -124,6 +124,12 @@ func (s *SQLiteStorage) importJSONLFile(ctx context.Context, jsonlPath, sourceRe } defer file.Close() + // Fetch custom statuses for validation (bd-1pj6) + customStatuses, err := s.GetCustomStatuses(ctx) + if err != nil { + return 0, fmt.Errorf("failed to get custom statuses: %w", err) + } + scanner := bufio.NewScanner(file) // Increase buffer size for large issues buf := make([]byte, 0, 64*1024) @@ -161,8 +167,8 @@ func (s *SQLiteStorage) importJSONLFile(ctx context.Context, jsonlPath, sourceRe issue.ContentHash = issue.ComputeContentHash() } - // Insert or update issue - if err := s.upsertIssueInTx(ctx, tx, &issue); err != nil { + // Insert or update issue (with custom status support) + if err := s.upsertIssueInTx(ctx, tx, &issue, customStatuses); err != nil { return 0, fmt.Errorf("failed to import issue %s at line %d: %w", issue.ID, lineNum, err) } @@ -182,9 +188,9 @@ 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) error { - // Validate issue - if err := issue.Validate(); err != nil { +func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *types.Issue, customStatuses []string) error { + // Validate issue (with custom status support, bd-1pj6) + if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil { return fmt.Errorf("validation failed: %w", err) } diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index 07642de7..dbaf04d4 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -27,8 +27,14 @@ import ( // CreateIssue creates a new issue func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, actor string) error { - // Validate issue before creating - if err := issue.Validate(); err != nil { + // Fetch custom statuses for validation (bd-1pj6) + customStatuses, err := s.GetCustomStatuses(ctx) + if err != nil { + return fmt.Errorf("failed to get custom statuses: %w", err) + } + + // Validate issue before creating (with custom status support) + if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil { return fmt.Errorf("validation failed: %w", err) } @@ -471,6 +477,12 @@ func (s *SQLiteStorage) UpdateIssue(ctx context.Context, id string, updates map[ return fmt.Errorf("issue %s not found", id) } + // Fetch custom statuses for validation (bd-1pj6) + customStatuses, err := s.GetCustomStatuses(ctx) + if err != nil { + return wrapDBError("get custom statuses", err) + } + // Build update query with validated field names setClauses := []string{"updated_at = ?"} args := []interface{}{time.Now()} @@ -481,8 +493,8 @@ func (s *SQLiteStorage) UpdateIssue(ctx context.Context, id string, updates map[ return fmt.Errorf("invalid field for update: %s", key) } - // Validate field values - if err := validateFieldUpdate(key, value); err != nil { + // Validate field values (with custom status support) + if err := validateFieldUpdateWithCustomStatuses(key, value, customStatuses); err != nil { return wrapDBError("validate field update", err) } diff --git a/internal/storage/sqlite/transaction.go b/internal/storage/sqlite/transaction.go index f108f454..6782dd7b 100644 --- a/internal/storage/sqlite/transaction.go +++ b/internal/storage/sqlite/transaction.go @@ -91,8 +91,14 @@ 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 { - // Validate issue before creating - if err := issue.Validate(); err != nil { + // Fetch custom statuses for validation (bd-1pj6) + customStatuses, err := t.GetCustomStatuses(ctx) + if err != nil { + return fmt.Errorf("failed to get custom statuses: %w", err) + } + + // Validate issue before creating (with custom status support) + if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil { return fmt.Errorf("validation failed: %w", err) } @@ -108,7 +114,7 @@ func (t *sqliteTxStorage) CreateIssue(ctx context.Context, issue *types.Issue, a // Get prefix from config (needed for both ID generation and validation) var prefix string - err := t.conn.QueryRowContext(ctx, `SELECT value FROM config WHERE key = ?`, "issue_prefix").Scan(&prefix) + err = t.conn.QueryRowContext(ctx, `SELECT value FROM config WHERE key = ?`, "issue_prefix").Scan(&prefix) if err == sql.ErrNoRows || prefix == "" { // CRITICAL: Reject operation if issue_prefix config is missing (bd-166) return fmt.Errorf("database not initialized: issue_prefix config is missing (run 'bd init --prefix ' first)") @@ -170,10 +176,16 @@ func (t *sqliteTxStorage) CreateIssues(ctx context.Context, issues []*types.Issu return nil } - // Validate and prepare all issues first + // Fetch custom statuses for validation (bd-1pj6) + customStatuses, err := t.GetCustomStatuses(ctx) + if err != nil { + return fmt.Errorf("failed to get custom statuses: %w", err) + } + + // Validate and prepare all issues first (with custom status support) now := time.Now() for _, issue := range issues { - if err := issue.Validate(); err != nil { + if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil { return fmt.Errorf("validation failed for issue: %w", err) } issue.CreatedAt = now @@ -185,7 +197,7 @@ func (t *sqliteTxStorage) CreateIssues(ctx context.Context, issues []*types.Issu // Get prefix from config var prefix string - err := t.conn.QueryRowContext(ctx, `SELECT value FROM config WHERE key = ?`, "issue_prefix").Scan(&prefix) + err = t.conn.QueryRowContext(ctx, `SELECT value FROM config WHERE key = ?`, "issue_prefix").Scan(&prefix) if err == sql.ErrNoRows || prefix == "" { return fmt.Errorf("database not initialized: issue_prefix config is missing") } else if err != nil { @@ -297,6 +309,12 @@ func (t *sqliteTxStorage) UpdateIssue(ctx context.Context, id string, updates ma return fmt.Errorf("issue %s not found", id) } + // Fetch custom statuses for validation (bd-1pj6) + customStatuses, err := t.GetCustomStatuses(ctx) + if err != nil { + return fmt.Errorf("failed to get custom statuses: %w", err) + } + // Build update query with validated field names setClauses := []string{"updated_at = ?"} args := []interface{}{time.Now()} @@ -307,8 +325,8 @@ func (t *sqliteTxStorage) UpdateIssue(ctx context.Context, id string, updates ma return fmt.Errorf("invalid field for update: %s", key) } - // Validate field values - if err := validateFieldUpdate(key, value); err != nil { + // Validate field values (with custom status support) + if err := validateFieldUpdateWithCustomStatuses(key, value, customStatuses); err != nil { return fmt.Errorf("failed to validate field update: %w", err) } @@ -791,6 +809,18 @@ func (t *sqliteTxStorage) GetConfig(ctx context.Context, key string) (string, er return value, nil } +// GetCustomStatuses retrieves the list of custom status states from config within the transaction. +func (t *sqliteTxStorage) GetCustomStatuses(ctx context.Context) ([]string, error) { + value, err := t.GetConfig(ctx, CustomStatusConfigKey) + if err != nil { + return nil, err + } + if value == "" { + return nil, nil + } + return parseCustomStatuses(value), nil +} + // SetMetadata sets a metadata value within the transaction. func (t *sqliteTxStorage) SetMetadata(ctx context.Context, key, value string) error { _, err := t.conn.ExecContext(ctx, ` diff --git a/internal/storage/sqlite/validators.go b/internal/storage/sqlite/validators.go index a1ce52f7..bbcc1998 100644 --- a/internal/storage/sqlite/validators.go +++ b/internal/storage/sqlite/validators.go @@ -16,10 +16,15 @@ func validatePriority(value interface{}) error { return nil } -// validateStatus validates a status value +// validateStatus validates a status value (built-in statuses only) func validateStatus(value interface{}) error { + return validateStatusWithCustom(value, nil) +} + +// validateStatusWithCustom validates a status value, allowing custom statuses. +func validateStatusWithCustom(value interface{}, customStatuses []string) error { if status, ok := value.(string); ok { - if !types.Status(status).IsValid() { + if !types.Status(status).IsValidWithCustom(customStatuses) { return fmt.Errorf("invalid status: %s", status) } } @@ -65,8 +70,18 @@ var fieldValidators = map[string]func(interface{}) error{ "estimated_minutes": validateEstimatedMinutes, } -// validateFieldUpdate validates a field update value +// validateFieldUpdate validates a field update value (built-in statuses only) func validateFieldUpdate(key string, value interface{}) error { + return validateFieldUpdateWithCustomStatuses(key, value, nil) +} + +// validateFieldUpdateWithCustomStatuses validates a field update value, +// allowing custom statuses for status field validation. +func validateFieldUpdateWithCustomStatuses(key string, value interface{}, customStatuses []string) error { + // Special handling for status field to support custom statuses + if key == "status" { + return validateStatusWithCustom(value, customStatuses) + } if validator, ok := fieldValidators[key]; ok { return validator(value) } diff --git a/internal/storage/sqlite/validators_test.go b/internal/storage/sqlite/validators_test.go index 79e1f497..389a49ea 100644 --- a/internal/storage/sqlite/validators_test.go +++ b/internal/storage/sqlite/validators_test.go @@ -149,3 +149,72 @@ func TestValidateFieldUpdate(t *testing.T) { }) } } + +func TestValidateFieldUpdateWithCustomStatuses(t *testing.T) { + customStatuses := []string{"awaiting_review", "awaiting_testing"} + + tests := []struct { + name string + key string + value interface{} + customStatuses []string + wantErr bool + }{ + // Built-in statuses work with or without custom statuses + {"built-in status no custom", "status", string(types.StatusOpen), nil, false}, + {"built-in status with custom", "status", string(types.StatusOpen), customStatuses, false}, + {"built-in closed with custom", "status", string(types.StatusClosed), customStatuses, false}, + + // Custom statuses work when configured + {"custom status configured", "status", "awaiting_review", customStatuses, false}, + {"custom status awaiting_testing", "status", "awaiting_testing", customStatuses, false}, + + // Custom statuses fail without config + {"custom status not configured", "status", "awaiting_review", nil, true}, + {"custom status not in list", "status", "unknown_status", customStatuses, true}, + + // Non-status fields work as before + {"valid priority", "priority", 1, customStatuses, false}, + {"invalid priority", "priority", 5, customStatuses, true}, + {"unknown field", "unknown_field", "any value", customStatuses, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateFieldUpdateWithCustomStatuses(tt.key, tt.value, tt.customStatuses) + if (err != nil) != tt.wantErr { + t.Errorf("validateFieldUpdateWithCustomStatuses() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestParseCustomStatuses(t *testing.T) { + tests := []struct { + name string + value string + want []string + }{ + {"empty string", "", nil}, + {"single status", "awaiting_review", []string{"awaiting_review"}}, + {"multiple statuses", "awaiting_review,awaiting_testing", []string{"awaiting_review", "awaiting_testing"}}, + {"with spaces", "awaiting_review, awaiting_testing, awaiting_docs", []string{"awaiting_review", "awaiting_testing", "awaiting_docs"}}, + {"empty entries filtered", "awaiting_review,,awaiting_testing", []string{"awaiting_review", "awaiting_testing"}}, + {"whitespace only entries", "awaiting_review, , awaiting_testing", []string{"awaiting_review", "awaiting_testing"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseCustomStatuses(tt.value) + if len(got) != len(tt.want) { + t.Errorf("parseCustomStatuses() = %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]) + } + } + }) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index d88c1a8b..5df227c9 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -146,6 +146,7 @@ type Storage interface { GetConfig(ctx context.Context, key string) (string, error) 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 // Metadata (for internal state like import hashes) SetMetadata(ctx context.Context, key, value string) error diff --git a/internal/types/types.go b/internal/types/types.go index d2672afd..72090913 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -69,8 +69,14 @@ func (i *Issue) ComputeContentHash() string { return fmt.Sprintf("%x", h.Sum(nil)) } -// Validate checks if the issue has valid field values +// Validate checks if the issue has valid field values (built-in statuses only) func (i *Issue) Validate() error { + return i.ValidateWithCustomStatuses(nil) +} + +// ValidateWithCustomStatuses checks if the issue has valid field values, +// allowing custom statuses in addition to built-in ones. +func (i *Issue) ValidateWithCustomStatuses(customStatuses []string) error { if len(i.Title) == 0 { return fmt.Errorf("title is required") } @@ -80,7 +86,7 @@ func (i *Issue) Validate() error { if i.Priority < 0 || i.Priority > 4 { return fmt.Errorf("priority must be between 0 and 4 (got %d)", i.Priority) } - if !i.Status.IsValid() { + if !i.Status.IsValidWithCustom(customStatuses) { return fmt.Errorf("invalid status: %s", i.Status) } if !i.IssueType.IsValid() { @@ -110,7 +116,7 @@ const ( StatusClosed Status = "closed" ) -// IsValid checks if the status value is valid +// IsValid checks if the status value is valid (built-in statuses only) func (s Status) IsValid() bool { switch s { case StatusOpen, StatusInProgress, StatusBlocked, StatusClosed: @@ -119,6 +125,22 @@ func (s Status) IsValid() bool { return false } +// IsValidWithCustom checks if the status is valid, including custom statuses. +// Custom statuses are user-defined via bd config set status.custom "status1,status2,..." +func (s Status) IsValidWithCustom(customStatuses []string) bool { + // First check built-in statuses + if s.IsValid() { + return true + } + // Then check custom statuses + for _, custom := range customStatuses { + if string(s) == custom { + return true + } + } + return false +} + // IssueType categorizes the kind of work type IssueType string diff --git a/internal/types/types_test.go b/internal/types/types_test.go index a28adc5f..027dbe74 100644 --- a/internal/types/types_test.go +++ b/internal/types/types_test.go @@ -215,6 +215,108 @@ func TestStatusIsValid(t *testing.T) { } } +func TestStatusIsValidWithCustom(t *testing.T) { + customStatuses := []string{"awaiting_review", "awaiting_testing", "awaiting_docs"} + + tests := []struct { + name string + status Status + customStatuses []string + valid bool + }{ + // Built-in statuses should always be valid + {"built-in open", StatusOpen, nil, true}, + {"built-in open with custom", StatusOpen, customStatuses, true}, + {"built-in closed", StatusClosed, customStatuses, true}, + + // Custom statuses with config + {"custom awaiting_review", Status("awaiting_review"), customStatuses, true}, + {"custom awaiting_testing", Status("awaiting_testing"), customStatuses, true}, + {"custom awaiting_docs", Status("awaiting_docs"), customStatuses, true}, + + // Custom statuses without config (should fail) + {"custom without config", Status("awaiting_review"), nil, false}, + {"custom without config empty", Status("awaiting_review"), []string{}, false}, + + // Invalid statuses + {"invalid status", Status("not_a_status"), customStatuses, false}, + {"empty status", Status(""), customStatuses, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.status.IsValidWithCustom(tt.customStatuses); got != tt.valid { + t.Errorf("Status(%q).IsValidWithCustom(%v) = %v, want %v", tt.status, tt.customStatuses, got, tt.valid) + } + }) + } +} + +func TestValidateWithCustomStatuses(t *testing.T) { + customStatuses := []string{"awaiting_review", "awaiting_testing"} + + tests := []struct { + name string + issue Issue + customStatuses []string + wantErr bool + }{ + { + name: "valid issue with built-in status", + issue: Issue{ + Title: "Test Issue", + Status: StatusOpen, + Priority: 1, + IssueType: TypeTask, + }, + customStatuses: nil, + wantErr: false, + }, + { + name: "valid issue with custom status", + issue: Issue{ + Title: "Test Issue", + Status: Status("awaiting_review"), + Priority: 1, + IssueType: TypeTask, + }, + customStatuses: customStatuses, + wantErr: false, + }, + { + name: "invalid custom status without config", + issue: Issue{ + Title: "Test Issue", + Status: Status("awaiting_review"), + Priority: 1, + IssueType: TypeTask, + }, + customStatuses: nil, + wantErr: true, + }, + { + name: "invalid custom status not in config", + issue: Issue{ + Title: "Test Issue", + Status: Status("unknown_status"), + Priority: 1, + IssueType: TypeTask, + }, + customStatuses: customStatuses, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.issue.ValidateWithCustomStatuses(tt.customStatuses) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateWithCustomStatuses() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + func TestIssueTypeIsValid(t *testing.T) { tests := []struct { issueType IssueType