Phase 1: Add topological sorting to fix import ordering
- Add sort.go with depth-based utilities (GetHierarchyDepth, SortByDepth, GroupByDepth) - Sort issues by hierarchy depth before batch creation - Create in depth-order batches (0→1→2→3) - Fixes latent bug: parent-child pairs in same batch could fail if wrong order - Comprehensive tests for all sorting functions - Closes bd-37dd, bd-3433, bd-8b65 Part of bd-d19a (Fix import failure on missing parent issues) Amp-Thread-ID: https://ampcode.com/threads/T-44a36985-b59c-426f-834c-60a0faa0f9fb Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -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 len(newIssues) > 0 {
|
||||||
if err := sqliteStore.CreateIssues(ctx, newIssues, "import"); err != nil {
|
SortByDepth(newIssues)
|
||||||
return fmt.Errorf("error creating issues: %w", err)
|
|
||||||
|
// 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
|
// REMOVED (bd-c7af): Counter sync after import - no longer needed with hash IDs
|
||||||
|
|||||||
44
internal/importer/sort.go
Normal file
44
internal/importer/sort.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
117
internal/importer/sort_test.go
Normal file
117
internal/importer/sort_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user