diff --git a/internal/importer/importer.go b/internal/importer/importer.go index a27a51a6..9220c5f7 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -537,12 +537,28 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues } } - // Batch create all new issues + // Batch create all new issues with topological sorting + // Sort by hierarchy depth to ensure parents are created before children + // This prevents "parent does not exist" errors when importing hierarchical issues if len(newIssues) > 0 { - if err := sqliteStore.CreateIssues(ctx, newIssues, "import"); err != nil { - return fmt.Errorf("error creating issues: %w", err) + SortByDepth(newIssues) + + // Create issues in depth-order batches (max depth 3) + // This handles parent-child pairs in the same import batch + for depth := 0; depth <= 3; depth++ { + var batchForDepth []*types.Issue + for _, issue := range newIssues { + if GetHierarchyDepth(issue.ID) == depth { + batchForDepth = append(batchForDepth, issue) + } + } + if len(batchForDepth) > 0 { + if err := sqliteStore.CreateIssues(ctx, batchForDepth, "import"); err != nil { + return fmt.Errorf("error creating depth-%d issues: %w", depth, err) + } + result.Created += len(batchForDepth) + } } - result.Created += len(newIssues) } // REMOVED (bd-c7af): Counter sync after import - no longer needed with hash IDs diff --git a/internal/importer/sort.go b/internal/importer/sort.go new file mode 100644 index 00000000..23f3854a --- /dev/null +++ b/internal/importer/sort.go @@ -0,0 +1,44 @@ +package importer + +import ( + "sort" + "strings" + + "github.com/steveyegge/beads/internal/types" +) + +// GetHierarchyDepth returns the depth of a hierarchical issue ID. +// Depth is determined by the number of dots in the ID. +// Examples: +// - "bd-abc123" → 0 (top-level) +// - "bd-abc123.1" → 1 (one level deep) +// - "bd-abc123.1.2" → 2 (two levels deep) +func GetHierarchyDepth(id string) int { + return strings.Count(id, ".") +} + +// SortByDepth sorts issues by hierarchy depth (shallow to deep) with stable sorting. +// Issues at the same depth are sorted by ID for deterministic ordering. +// This ensures parent issues are processed before their children. +func SortByDepth(issues []*types.Issue) { + sort.SliceStable(issues, func(i, j int) bool { + depthI := GetHierarchyDepth(issues[i].ID) + depthJ := GetHierarchyDepth(issues[j].ID) + if depthI != depthJ { + return depthI < depthJ + } + return issues[i].ID < issues[j].ID + }) +} + +// GroupByDepth groups issues into buckets by hierarchy depth. +// Returns a map where keys are depth levels and values are slices of issues at that depth. +// Maximum supported depth is 3 (as per beads spec). +func GroupByDepth(issues []*types.Issue) map[int][]*types.Issue { + groups := make(map[int][]*types.Issue) + for _, issue := range issues { + depth := GetHierarchyDepth(issue.ID) + groups[depth] = append(groups[depth], issue) + } + return groups +} diff --git a/internal/importer/sort_test.go b/internal/importer/sort_test.go new file mode 100644 index 00000000..5f4f059c --- /dev/null +++ b/internal/importer/sort_test.go @@ -0,0 +1,117 @@ +package importer + +import ( + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +func TestGetHierarchyDepth(t *testing.T) { + tests := []struct { + name string + id string + expected int + }{ + {"top-level", "bd-abc123", 0}, + {"one level", "bd-abc123.1", 1}, + {"two levels", "bd-abc123.1.2", 2}, + {"three levels", "bd-abc123.1.2.3", 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetHierarchyDepth(tt.id) + if got != tt.expected { + t.Errorf("GetHierarchyDepth(%q) = %d, want %d", tt.id, got, tt.expected) + } + }) + } +} + +func TestSortByDepth(t *testing.T) { + tests := []struct { + name string + input []*types.Issue + expected []string + }{ + { + name: "already sorted", + input: []*types.Issue{ + {ID: "bd-abc"}, + {ID: "bd-abc.1"}, + {ID: "bd-abc.2"}, + }, + expected: []string{"bd-abc", "bd-abc.1", "bd-abc.2"}, + }, + { + name: "child before parent", + input: []*types.Issue{ + {ID: "bd-abc.1"}, + {ID: "bd-abc"}, + }, + expected: []string{"bd-abc", "bd-abc.1"}, + }, + { + name: "complex hierarchy", + input: []*types.Issue{ + {ID: "bd-abc.1.2"}, + {ID: "bd-xyz"}, + {ID: "bd-abc"}, + {ID: "bd-abc.1"}, + }, + expected: []string{"bd-abc", "bd-xyz", "bd-abc.1", "bd-abc.1.2"}, + }, + { + name: "stable sort same depth", + input: []*types.Issue{ + {ID: "bd-zzz.1"}, + {ID: "bd-aaa.1"}, + {ID: "bd-mmm.1"}, + }, + expected: []string{"bd-aaa.1", "bd-mmm.1", "bd-zzz.1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + SortByDepth(tt.input) + for i, issue := range tt.input { + if issue.ID != tt.expected[i] { + t.Errorf("Position %d: got %q, want %q", i, issue.ID, tt.expected[i]) + } + } + }) + } +} + +func TestGroupByDepth(t *testing.T) { + input := []*types.Issue{ + {ID: "bd-abc"}, + {ID: "bd-xyz"}, + {ID: "bd-abc.1"}, + {ID: "bd-abc.2"}, + {ID: "bd-abc.1.1"}, + } + + groups := GroupByDepth(input) + + if len(groups[0]) != 2 { + t.Errorf("Depth 0: got %d issues, want 2", len(groups[0])) + } + if len(groups[1]) != 2 { + t.Errorf("Depth 1: got %d issues, want 2", len(groups[1])) + } + if len(groups[2]) != 1 { + t.Errorf("Depth 2: got %d issues, want 1", len(groups[2])) + } + + if groups[0][0].ID != "bd-abc" && groups[0][1].ID != "bd-abc" { + t.Error("bd-abc not found in depth 0") + } + if groups[0][0].ID != "bd-xyz" && groups[0][1].ID != "bd-xyz" { + t.Error("bd-xyz not found in depth 0") + } + if groups[2][0].ID != "bd-abc.1.1" { + t.Errorf("Depth 2: got %q, want bd-abc.1.1", groups[2][0].ID) + } +}