Implement hierarchical child ID generation (bd-171)
- Add GetNextChildID to storage interface for generating child IDs - Implement in SQLiteStorage with atomic counter using child_counters table - Implement in MemoryStorage with in-memory counter - Add --parent flag to bd create command - Support hierarchical IDs (bd-a3f8e9.1, bd-a3f8e9.1.5) in CreateIssue - Validate parent exists when creating hierarchical issues - Enforce max depth of 3 levels - Update ID validation to accept hierarchical IDs with dots - Add comprehensive tests for child ID generation - Manual testing confirms: sequential children, nested hierarchies, depth enforcement
This commit is contained in:
203
internal/storage/sqlite/child_id_test.go
Normal file
203
internal/storage/sqlite/child_id_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func TestGetNextChildID(t *testing.T) {
|
||||
tmpFile := t.TempDir() + "/test.db"
|
||||
defer os.Remove(tmpFile)
|
||||
store := newTestStore(t, tmpFile)
|
||||
defer store.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a parent issue with hash ID
|
||||
parent := &types.Issue{
|
||||
ID: "bd-a3f8e9",
|
||||
Title: "Parent Epic",
|
||||
Description: "Parent issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeEpic,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, parent, "test"); err != nil {
|
||||
t.Fatalf("failed to create parent: %v", err)
|
||||
}
|
||||
|
||||
// Test: Generate first child ID
|
||||
childID1, err := store.GetNextChildID(ctx, parent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetNextChildID failed: %v", err)
|
||||
}
|
||||
expectedID1 := "bd-a3f8e9.1"
|
||||
if childID1 != expectedID1 {
|
||||
t.Errorf("expected %s, got %s", expectedID1, childID1)
|
||||
}
|
||||
|
||||
// Test: Generate second child ID (sequential)
|
||||
childID2, err := store.GetNextChildID(ctx, parent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetNextChildID failed: %v", err)
|
||||
}
|
||||
expectedID2 := "bd-a3f8e9.2"
|
||||
if childID2 != expectedID2 {
|
||||
t.Errorf("expected %s, got %s", expectedID2, childID2)
|
||||
}
|
||||
|
||||
// Create the first child and test nested hierarchy
|
||||
child1 := &types.Issue{
|
||||
ID: childID1,
|
||||
Title: "Child Task 1",
|
||||
Description: "First child",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, child1, "test"); err != nil {
|
||||
t.Fatalf("failed to create child: %v", err)
|
||||
}
|
||||
|
||||
// Test: Generate nested child (depth 2)
|
||||
nestedID1, err := store.GetNextChildID(ctx, childID1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetNextChildID failed for nested: %v", err)
|
||||
}
|
||||
expectedNested1 := "bd-a3f8e9.1.1"
|
||||
if nestedID1 != expectedNested1 {
|
||||
t.Errorf("expected %s, got %s", expectedNested1, nestedID1)
|
||||
}
|
||||
|
||||
// Create the nested child
|
||||
nested1 := &types.Issue{
|
||||
ID: nestedID1,
|
||||
Title: "Nested Task",
|
||||
Description: "Nested child",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, nested1, "test"); err != nil {
|
||||
t.Fatalf("failed to create nested child: %v", err)
|
||||
}
|
||||
|
||||
// Test: Generate third level (depth 3, maximum)
|
||||
deepID1, err := store.GetNextChildID(ctx, nestedID1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetNextChildID failed for depth 3: %v", err)
|
||||
}
|
||||
expectedDeep1 := "bd-a3f8e9.1.1.1"
|
||||
if deepID1 != expectedDeep1 {
|
||||
t.Errorf("expected %s, got %s", expectedDeep1, deepID1)
|
||||
}
|
||||
|
||||
// Create the deep child
|
||||
deep1 := &types.Issue{
|
||||
ID: deepID1,
|
||||
Title: "Deep Task",
|
||||
Description: "Third level",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, deep1, "test"); err != nil {
|
||||
t.Fatalf("failed to create deep child: %v", err)
|
||||
}
|
||||
|
||||
// Test: Attempt to create fourth level (should fail)
|
||||
_, err = store.GetNextChildID(ctx, deepID1)
|
||||
if err == nil {
|
||||
t.Errorf("expected error for depth 4, got nil")
|
||||
}
|
||||
if err != nil && err.Error() != "maximum hierarchy depth (3) exceeded for parent bd-a3f8e9.1.1.1" {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNextChildID_ParentNotExists(t *testing.T) {
|
||||
tmpFile := t.TempDir() + "/test.db"
|
||||
defer os.Remove(tmpFile)
|
||||
store := newTestStore(t, tmpFile)
|
||||
defer store.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
// Test: Attempt to get child ID for non-existent parent
|
||||
_, err := store.GetNextChildID(ctx, "bd-nonexistent")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for non-existent parent, got nil")
|
||||
}
|
||||
if err != nil && err.Error() != "parent issue bd-nonexistent does not exist" {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIssue_HierarchicalID(t *testing.T) {
|
||||
tmpFile := t.TempDir() + "/test.db"
|
||||
defer os.Remove(tmpFile)
|
||||
store := newTestStore(t, tmpFile)
|
||||
defer store.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create parent
|
||||
parent := &types.Issue{
|
||||
ID: "bd-parent1",
|
||||
Title: "Parent",
|
||||
Description: "Parent issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeEpic,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, parent, "test"); err != nil {
|
||||
t.Fatalf("failed to create parent: %v", err)
|
||||
}
|
||||
|
||||
// Test: Create child with explicit hierarchical ID
|
||||
child := &types.Issue{
|
||||
ID: "bd-parent1.1",
|
||||
Title: "Child",
|
||||
Description: "Child issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, child, "test"); err != nil {
|
||||
t.Fatalf("failed to create child: %v", err)
|
||||
}
|
||||
|
||||
// Verify child was created
|
||||
retrieved, err := store.GetIssue(ctx, child.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to retrieve child: %v", err)
|
||||
}
|
||||
if retrieved.ID != child.ID {
|
||||
t.Errorf("expected ID %s, got %s", child.ID, retrieved.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIssue_HierarchicalID_ParentNotExists(t *testing.T) {
|
||||
tmpFile := t.TempDir() + "/test.db"
|
||||
defer os.Remove(tmpFile)
|
||||
store := newTestStore(t, tmpFile)
|
||||
defer store.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
// Test: Attempt to create child without parent
|
||||
child := &types.Issue{
|
||||
ID: "bd-nonexistent.1",
|
||||
Title: "Child",
|
||||
Description: "Child issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
err := store.CreateIssue(ctx, child, "test")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for child without parent, got nil")
|
||||
}
|
||||
if err != nil && err.Error() != "parent issue bd-nonexistent does not exist" {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -665,6 +665,37 @@ func (s *SQLiteStorage) getNextChildNumber(ctx context.Context, parentID string)
|
||||
return nextChild, nil
|
||||
}
|
||||
|
||||
// GetNextChildID generates the next hierarchical child ID for a given parent
|
||||
// Returns formatted ID as parentID.{counter} (e.g., bd-a3f8e9.1 or bd-a3f8e9.1.5)
|
||||
// Works at any depth (max 3 levels)
|
||||
func (s *SQLiteStorage) GetNextChildID(ctx context.Context, parentID string) (string, error) {
|
||||
// Validate parent exists
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, parentID).Scan(&count)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to check parent existence: %w", err)
|
||||
}
|
||||
if count == 0 {
|
||||
return "", fmt.Errorf("parent issue %s does not exist", parentID)
|
||||
}
|
||||
|
||||
// Calculate current depth by counting dots
|
||||
depth := strings.Count(parentID, ".")
|
||||
if depth >= 3 {
|
||||
return "", fmt.Errorf("maximum hierarchy depth (3) exceeded for parent %s", parentID)
|
||||
}
|
||||
|
||||
// Get next child number atomically
|
||||
nextNum, err := s.getNextChildNumber(ctx, parentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Format as parentID.counter
|
||||
childID := fmt.Sprintf("%s.%d", parentID, nextNum)
|
||||
return childID, nil
|
||||
}
|
||||
|
||||
// SyncAllCounters synchronizes all ID counters based on existing issues in the database
|
||||
// This scans all issues and updates counters to prevent ID collisions with auto-generated IDs
|
||||
// Note: This unconditionally overwrites counter values, allowing them to decrease after deletions
|
||||
@@ -807,10 +838,27 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
|
||||
} else {
|
||||
// Validate that explicitly provided ID matches the configured prefix (bd-177)
|
||||
// This prevents wrong-prefix bugs when IDs are manually specified
|
||||
// Support both top-level (bd-a3f8e9) and hierarchical (bd-a3f8e9.1) IDs
|
||||
expectedPrefix := prefix + "-"
|
||||
if !strings.HasPrefix(issue.ID, expectedPrefix) {
|
||||
return fmt.Errorf("issue ID '%s' does not match configured prefix '%s'", issue.ID, prefix)
|
||||
}
|
||||
|
||||
// For hierarchical IDs (bd-a3f8e9.1), validate parent exists
|
||||
if strings.Contains(issue.ID, ".") {
|
||||
// Extract parent ID (everything before the last dot)
|
||||
lastDot := strings.LastIndex(issue.ID, ".")
|
||||
parentID := issue.ID[:lastDot]
|
||||
|
||||
var parentCount int
|
||||
err = conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, parentID).Scan(&parentCount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check parent existence: %w", err)
|
||||
}
|
||||
if parentCount == 0 {
|
||||
return fmt.Errorf("parent issue %s does not exist", parentID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert issue
|
||||
|
||||
Reference in New Issue
Block a user