fix(import): support custom issue types during import (#1322)
* fix(import): support custom issue types during import Fixes regression from7cf67153where custom issue types (agent, molecule, convoy, etc.) were rejected during import with "invalid issue type" error. - Add validateFieldUpdateWithCustom() for both custom statuses and types - Add validateIssueTypeWithCustom() for custom type validation - Update queries.go UpdateIssue() to fetch and validate custom types - Update transaction.go UpdateIssue() to fetch and validate custom types - Add 15 test cases covering custom type validation scenarios This aligns UpdateIssue() validation with the federation trust model used by ValidateForImport(). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(test): add metadata.json and chdir to temp dir in direct mode tests Fixes three test failures caused by commite82f5136which changed ensureStoreActive() to use factory.NewFromConfig() instead of respecting the global dbPath variable. Root cause: - Tests create issues in test.db and set dbPath = testDBPath - ensureStoreActive() calls factory.NewFromConfig() which reads metadata.json - Without metadata.json, it defaults to beads.db - Opens empty beads.db instead of test.db with the seeded issues - Additionally, FindBeadsDir() was finding the real .beads dir, not the test one Fixes applied: 1. TestFallbackToDirectModeEnablesFlush: Add metadata.json pointing to test.db and chdir to temp dir 2. TestImportFromJSONLInlineAfterDaemonDisconnect: Same fix 3. TestIsBeadsPluginInstalledProjectLevel: Set temp HOME to avoid detecting plugin from real ~/.claude/settings.json All three tests now pass. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -70,6 +70,22 @@ func TestFallbackToDirectModeEnablesFlush(t *testing.T) {
|
||||
}
|
||||
testDBPath := filepath.Join(beadsDir, "test.db")
|
||||
|
||||
// Create metadata.json so factory.NewFromConfig knows which DB to open (GH#e82f5136)
|
||||
metadataJSON := `{"database":"test.db","jsonl_export":"issues.jsonl"}`
|
||||
if err := os.WriteFile(filepath.Join(beadsDir, "metadata.json"), []byte(metadataJSON), 0644); err != nil {
|
||||
t.Fatalf("failed to create metadata.json: %v", err)
|
||||
}
|
||||
|
||||
// Change to temp directory so FindBeadsDir finds our test .beads directory
|
||||
originalWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get working directory: %v", err)
|
||||
}
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("failed to change to temp directory: %v", err)
|
||||
}
|
||||
defer os.Chdir(originalWd)
|
||||
|
||||
// Seed database with issues
|
||||
setupStore := newTestStore(t, testDBPath)
|
||||
|
||||
@@ -193,6 +209,22 @@ func TestImportFromJSONLInlineAfterDaemonDisconnect(t *testing.T) {
|
||||
testDBPath := filepath.Join(beadsDir, "beads.db")
|
||||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
|
||||
// Create metadata.json so factory.NewFromConfig knows which DB to open (GH#e82f5136)
|
||||
metadataJSON := `{"database":"beads.db","jsonl_export":"issues.jsonl"}`
|
||||
if err := os.WriteFile(filepath.Join(beadsDir, "metadata.json"), []byte(metadataJSON), 0644); err != nil {
|
||||
t.Fatalf("failed to create metadata.json: %v", err)
|
||||
}
|
||||
|
||||
// Change to temp directory so FindBeadsDir finds our test .beads directory
|
||||
originalWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get working directory: %v", err)
|
||||
}
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("failed to change to temp directory: %v", err)
|
||||
}
|
||||
defer os.Chdir(originalWd)
|
||||
|
||||
// Create and seed the database
|
||||
setupStore := newTestStore(t, testDBPath)
|
||||
issue := &types.Issue{
|
||||
|
||||
@@ -262,6 +262,9 @@ func TestIsBeadsPluginInstalledProjectLevel(t *testing.T) {
|
||||
t.Run("plugin disabled", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Chdir(tmpDir)
|
||||
// Set temp home to avoid detecting plugin from real ~/.claude/settings.json
|
||||
t.Setenv("HOME", tmpDir)
|
||||
t.Setenv("USERPROFILE", tmpDir)
|
||||
|
||||
if err := os.MkdirAll(".claude", 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -279,6 +282,9 @@ func TestIsBeadsPluginInstalledProjectLevel(t *testing.T) {
|
||||
t.Run("no plugin section", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Chdir(tmpDir)
|
||||
// Set temp home to avoid detecting plugin from real ~/.claude/settings.json
|
||||
t.Setenv("HOME", tmpDir)
|
||||
t.Setenv("USERPROFILE", tmpDir)
|
||||
|
||||
if err := os.MkdirAll(".claude", 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -887,12 +887,17 @@ func (s *SQLiteStorage) UpdateIssue(ctx context.Context, id string, updates map[
|
||||
return fmt.Errorf("issue %s not found", id)
|
||||
}
|
||||
|
||||
// Fetch custom statuses for validation
|
||||
// Fetch custom statuses and types for validation
|
||||
customStatuses, err := s.GetCustomStatuses(ctx)
|
||||
if err != nil {
|
||||
return wrapDBError("get custom statuses", err)
|
||||
}
|
||||
|
||||
customTypes, err := s.GetCustomTypes(ctx)
|
||||
if err != nil {
|
||||
return wrapDBError("get custom types", err)
|
||||
}
|
||||
|
||||
// Build update query with validated field names
|
||||
setClauses := []string{"updated_at = ?"}
|
||||
args := []interface{}{time.Now()}
|
||||
@@ -903,8 +908,8 @@ func (s *SQLiteStorage) UpdateIssue(ctx context.Context, id string, updates map[
|
||||
return fmt.Errorf("invalid field for update: %s", key)
|
||||
}
|
||||
|
||||
// Validate field values (with custom status support)
|
||||
if err := validateFieldUpdateWithCustomStatuses(key, value, customStatuses); err != nil {
|
||||
// Validate field values (with custom status and type support)
|
||||
if err := validateFieldUpdateWithCustom(key, value, customStatuses, customTypes); err != nil {
|
||||
return wrapDBError("validate field update", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -400,12 +400,17 @@ func (t *sqliteTxStorage) UpdateIssue(ctx context.Context, id string, updates ma
|
||||
return fmt.Errorf("issue %s not found", id)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Build update query with validated field names
|
||||
setClauses := []string{"updated_at = ?"}
|
||||
args := []interface{}{time.Now()}
|
||||
@@ -416,8 +421,8 @@ func (t *sqliteTxStorage) UpdateIssue(ctx context.Context, id string, updates ma
|
||||
return fmt.Errorf("invalid field for update: %s", key)
|
||||
}
|
||||
|
||||
// Validate field values (with custom status support)
|
||||
if err := validateFieldUpdateWithCustomStatuses(key, value, customStatuses); err != nil {
|
||||
// Validate field values (with custom status and type support)
|
||||
if err := validateFieldUpdateWithCustom(key, value, customStatuses, customTypes); err != nil {
|
||||
return fmt.Errorf("failed to validate field update: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -82,15 +82,37 @@ 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 {
|
||||
// validateFieldUpdateWithCustom validates a field update value,
|
||||
// allowing custom statuses and custom types for their respective field validations.
|
||||
func validateFieldUpdateWithCustom(key string, value interface{}, customStatuses, customTypes []string) error {
|
||||
// Special handling for status field to support custom statuses
|
||||
if key == "status" {
|
||||
return validateStatusWithCustom(value, customStatuses)
|
||||
}
|
||||
// Special handling for issue_type field to support custom types (federation trust model)
|
||||
if key == "issue_type" {
|
||||
return validateIssueTypeWithCustom(value, customTypes)
|
||||
}
|
||||
if validator, ok := fieldValidators[key]; ok {
|
||||
return validator(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFieldUpdateWithCustomStatuses validates a field update value,
|
||||
// allowing custom statuses for status field validation.
|
||||
func validateFieldUpdateWithCustomStatuses(key string, value interface{}, customStatuses []string) error {
|
||||
return validateFieldUpdateWithCustom(key, value, customStatuses, nil)
|
||||
}
|
||||
|
||||
// validateIssueTypeWithCustom validates an issue type value, allowing custom types.
|
||||
func validateIssueTypeWithCustom(value interface{}, customTypes []string) error {
|
||||
if issueType, ok := value.(string); ok {
|
||||
// Normalize first to support aliases like "enhancement" -> "feature"
|
||||
normalized := types.IssueType(issueType).Normalize()
|
||||
if !normalized.IsValidWithCustom(customTypes) {
|
||||
return fmt.Errorf("invalid issue type: %s", issueType)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -189,6 +189,50 @@ func TestValidateFieldUpdateWithCustomStatuses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFieldUpdateWithCustom(t *testing.T) {
|
||||
customStatuses := []string{"awaiting_review", "awaiting_testing"}
|
||||
customTypes := []string{"molecule", "agent", "convoy", "role", "event"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
value interface{}
|
||||
customStatuses []string
|
||||
customTypes []string
|
||||
wantErr bool
|
||||
}{
|
||||
// Custom type validation
|
||||
{"valid custom type agent", "issue_type", "agent", customStatuses, customTypes, false},
|
||||
{"valid custom type molecule", "issue_type", "molecule", customStatuses, customTypes, false},
|
||||
{"valid custom type convoy", "issue_type", "convoy", customStatuses, customTypes, false},
|
||||
{"valid built-in type task", "issue_type", "task", customStatuses, customTypes, false},
|
||||
{"valid built-in type bug", "issue_type", "bug", customStatuses, customTypes, false},
|
||||
{"invalid type", "issue_type", "invalid_type", customStatuses, customTypes, true},
|
||||
{"valid type without custom types", "issue_type", "task", customStatuses, nil, false},
|
||||
{"custom type without custom types configured", "issue_type", "agent", customStatuses, nil, true},
|
||||
|
||||
// Custom status validation
|
||||
{"valid custom status", "status", "awaiting_review", customStatuses, customTypes, false},
|
||||
{"valid built-in status", "status", "open", customStatuses, customTypes, false},
|
||||
{"invalid status", "status", "invalid_status", customStatuses, customTypes, true},
|
||||
|
||||
// Other field validation
|
||||
{"valid priority", "priority", 3, customStatuses, customTypes, false},
|
||||
{"invalid priority", "priority", 5, customStatuses, customTypes, true},
|
||||
{"valid title", "title", "Test title", customStatuses, customTypes, false},
|
||||
{"empty title", "title", "", customStatuses, customTypes, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateFieldUpdateWithCustom(tt.key, tt.value, tt.customStatuses, tt.customTypes)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateFieldUpdateWithCustom() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCustomStatuses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
Reference in New Issue
Block a user