Fix auto-import collision detection and enforce status/closed_at invariant (bd-226)

Code review and fixes:
- Increased scanner buffer to 2MB for large JSON lines
- Added line numbers and snippets to parse error messages
- Made non-SQLite fallback conservative (skip import to prevent data loss)
- Improved collision warnings (concise, show first 10 IDs)
- Removed unused autoImportWithoutCollisionDetection function

Status/closed_at invariant enforcement:
- Auto-import now enforces invariant on all creates/updates
- Fixed CreateIssue to respect closed_at field (was ignoring it)
- Closed issues without closed_at get timestamp set automatically

Integration tests:
- TestAutoImportWithCollision: verifies local changes preserved
- TestAutoImportNoCollision: happy path with new issues
- TestAutoImportClosedAtInvariant: enforces invariant

Closes bd-226, bd-230, bd-231
This commit is contained in:
Steve Yegge
2025-10-15 13:47:46 -07:00
parent ff2ca503db
commit 619ce51250
4 changed files with 383 additions and 174 deletions

File diff suppressed because one or more lines are too long

View File

@@ -210,9 +210,12 @@ func autoImportIfNewer() {
// Content changed - parse all issues
scanner := bufio.NewScanner(strings.NewReader(string(jsonlData)))
scanner.Buffer(make([]byte, 0, 1024), 2*1024*1024) // 2MB buffer for large JSON lines
var allIssues []*types.Issue
lineNo := 0
for scanner.Scan() {
lineNo++
line := scanner.Text()
if line == "" {
continue
@@ -221,9 +224,11 @@ func autoImportIfNewer() {
var issue types.Issue
if err := json.Unmarshal([]byte(line), &issue); err != nil {
// Parse error, skip this import
if os.Getenv("BD_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "Debug: auto-import skipped, parse error: %v\n", err)
snippet := line
if len(snippet) > 80 {
snippet = snippet[:80] + "..."
}
fmt.Fprintf(os.Stderr, "Auto-import skipped: parse error at line %d: %v\nSnippet: %s\n", lineNo, err, snippet)
return
}
@@ -231,23 +236,23 @@ func autoImportIfNewer() {
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "Auto-import skipped: scanner error: %v\n", err)
return
}
// Detect collisions before importing (bd-228 fix)
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
if !ok {
// Not SQLite - fall back to simple import without collision detection
autoImportWithoutCollisionDetection(ctx, allIssues, currentHash)
// Not SQLite - skip auto-import to avoid silent data loss without collision detection
fmt.Fprintf(os.Stderr, "Auto-import disabled for non-SQLite backend (no collision detection).\n")
fmt.Fprintf(os.Stderr, "To import manually, run: bd import -i %s\n", jsonlPath)
return
}
collisionResult, err := sqlite.DetectCollisions(ctx, sqliteStore, allIssues)
if err != nil {
// Collision detection failed, skip import to be safe
if os.Getenv("BD_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "Debug: auto-import skipped, collision detection error: %v\n", err)
}
fmt.Fprintf(os.Stderr, "Auto-import skipped: collision detection error: %v\n", err)
return
}
@@ -256,15 +261,38 @@ func autoImportIfNewer() {
// If collisions detected, warn user and skip colliding issues
if len(collisionResult.Collisions) > 0 {
fmt.Fprintf(os.Stderr, "\nWarning: Auto-import detected %d issue collision(s)\n", len(collisionResult.Collisions))
fmt.Fprintf(os.Stderr, "Your local changes are preserved. The following issues were NOT updated:\n\n")
for _, collision := range collisionResult.Collisions {
fmt.Fprintf(os.Stderr, " %s: %s\n", collision.ID, collision.IncomingIssue.Title)
fmt.Fprintf(os.Stderr, " Conflicting fields: %v\n", collision.ConflictingFields)
collidingIDs[collision.ID] = true
}
fmt.Fprintf(os.Stderr, "\nTo merge these changes, run:\n")
fmt.Fprintf(os.Stderr, " bd import -i %s --resolve-collisions\n\n", jsonlPath)
// Concise warning: show first 10 collisions
maxShow := 10
if len(collisionResult.Collisions) < maxShow {
maxShow = len(collisionResult.Collisions)
}
fmt.Fprintf(os.Stderr, "\nAuto-import: skipped %d issue(s) due to local edits.\n", len(collisionResult.Collisions))
fmt.Fprintf(os.Stderr, "Conflicting issues (showing first %d): ", maxShow)
for i := 0; i < maxShow; i++ {
if i > 0 {
fmt.Fprintf(os.Stderr, ", ")
}
fmt.Fprintf(os.Stderr, "%s", collisionResult.Collisions[i].ID)
}
if len(collisionResult.Collisions) > maxShow {
fmt.Fprintf(os.Stderr, " ... and %d more", len(collisionResult.Collisions)-maxShow)
}
fmt.Fprintf(os.Stderr, "\n")
fmt.Fprintf(os.Stderr, "To merge these changes, run: bd import -i %s --resolve-collisions\n\n", jsonlPath)
// Full details under BD_DEBUG
if os.Getenv("BD_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "Debug: Full collision details:\n")
for _, collision := range collisionResult.Collisions {
fmt.Fprintf(os.Stderr, " %s: %s\n", collision.ID, collision.IncomingIssue.Title)
fmt.Fprintf(os.Stderr, " Conflicting fields: %v\n", collision.ConflictingFields)
}
}
// Remove colliding issues from the import list
safeIssues := make([]*types.Issue, 0)
@@ -302,9 +330,32 @@ func autoImportIfNewer() {
updates["external_ref"] = *issue.ExternalRef
}
// Enforce status/closed_at invariant (bd-226)
if issue.Status == "closed" {
// Issue is closed - ensure closed_at is set
if issue.ClosedAt != nil {
updates["closed_at"] = *issue.ClosedAt
} else if !issue.UpdatedAt.IsZero() {
updates["closed_at"] = issue.UpdatedAt
} else {
updates["closed_at"] = time.Now().UTC()
}
} else {
// Issue is not closed - ensure closed_at is null
updates["closed_at"] = nil
}
_ = store.UpdateIssue(ctx, issue.ID, updates, "auto-import")
} else {
// Create new issue
// Create new issue - enforce invariant before creation
if issue.Status == "closed" {
if issue.ClosedAt == nil {
now := time.Now().UTC()
issue.ClosedAt = &now
}
} else {
issue.ClosedAt = nil
}
_ = store.CreateIssue(ctx, issue, "auto-import")
}
}
@@ -346,69 +397,6 @@ func autoImportIfNewer() {
_ = store.SetMetadata(ctx, "last_import_hash", currentHash)
}
// autoImportWithoutCollisionDetection is a fallback for non-SQLite backends
// This preserves the old behavior for compatibility
func autoImportWithoutCollisionDetection(ctx context.Context, allIssues []*types.Issue, hash string) {
// Import issues without collision detection (old behavior)
for _, issue := range allIssues {
existing, err := store.GetIssue(ctx, issue.ID)
if err != nil {
continue
}
if existing != nil {
updates := make(map[string]interface{})
updates["title"] = issue.Title
updates["description"] = issue.Description
updates["design"] = issue.Design
updates["acceptance_criteria"] = issue.AcceptanceCriteria
updates["notes"] = issue.Notes
updates["status"] = issue.Status
updates["priority"] = issue.Priority
updates["issue_type"] = issue.IssueType
updates["assignee"] = issue.Assignee
if issue.EstimatedMinutes != nil {
updates["estimated_minutes"] = *issue.EstimatedMinutes
}
if issue.ExternalRef != nil {
updates["external_ref"] = *issue.ExternalRef
}
_ = store.UpdateIssue(ctx, issue.ID, updates, "auto-import")
} else {
_ = store.CreateIssue(ctx, issue, "auto-import")
}
}
// Import dependencies
for _, issue := range allIssues {
if len(issue.Dependencies) == 0 {
continue
}
existingDeps, err := store.GetDependencyRecords(ctx, issue.ID)
if err != nil {
continue
}
for _, dep := range issue.Dependencies {
exists := false
for _, existing := range existingDeps {
if existing.DependsOnID == dep.DependsOnID && existing.Type == dep.Type {
exists = true
break
}
}
if !exists {
_ = store.AddDependency(ctx, dep, "auto-import")
}
}
}
_ = store.SetMetadata(ctx, "last_import_hash", hash)
}
// checkVersionMismatch checks if the binary version matches the database version
// and warns the user if they're running an outdated binary
func checkVersionMismatch() {

View File

@@ -820,3 +820,224 @@ func TestAutoImportDisabled(t *testing.T) {
storeActive = false
storeMutex.Unlock()
}
// TestAutoImportWithCollision tests that auto-import detects collisions and preserves local changes
func TestAutoImportWithCollision(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-collision-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath = filepath.Join(tmpDir, "test.db")
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
testStore, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to create storage: %v", err)
}
defer testStore.Close()
store = testStore
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
defer func() {
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
}()
ctx := context.Background()
// Create issue in DB with status=closed
closedTime := time.Now().UTC()
dbIssue := &types.Issue{
ID: "test-col-1",
Title: "Local version",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ClosedAt: &closedTime,
}
if err := testStore.CreateIssue(ctx, dbIssue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Create JSONL with same ID but status=open (conflict)
jsonlIssue := &types.Issue{
ID: "test-col-1",
Title: "Remote version",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("Failed to create JSONL: %v", err)
}
json.NewEncoder(f).Encode(jsonlIssue)
f.Close()
// Run auto-import
autoImportIfNewer()
// Verify local changes preserved (status still closed)
result, err := testStore.GetIssue(ctx, "test-col-1")
if err != nil {
t.Fatalf("Failed to get issue: %v", err)
}
if result.Status != types.StatusClosed {
t.Errorf("Expected status=closed (local preserved), got %s", result.Status)
}
if result.Title != "Local version" {
t.Errorf("Expected title='Local version', got '%s'", result.Title)
}
}
// TestAutoImportNoCollision tests happy path with no conflicts
func TestAutoImportNoCollision(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-nocoll-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath = filepath.Join(tmpDir, "test.db")
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
testStore, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to create storage: %v", err)
}
defer testStore.Close()
store = testStore
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
defer func() {
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
}()
ctx := context.Background()
// Create issue in DB
dbIssue := &types.Issue{
ID: "test-noc-1",
Title: "Same version",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := testStore.CreateIssue(ctx, dbIssue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Create JSONL with exact match + new issue
newIssue := &types.Issue{
ID: "test-noc-2",
Title: "Brand new issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeBug,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("Failed to create JSONL: %v", err)
}
json.NewEncoder(f).Encode(dbIssue)
json.NewEncoder(f).Encode(newIssue)
f.Close()
// Run auto-import
autoImportIfNewer()
// Verify new issue imported
result, err := testStore.GetIssue(ctx, "test-noc-2")
if err != nil {
t.Fatalf("Failed to get issue: %v", err)
}
if result == nil {
t.Fatal("Expected new issue to be imported")
}
if result.Title != "Brand new issue" {
t.Errorf("Expected title='Brand new issue', got '%s'", result.Title)
}
}
// TestAutoImportClosedAtInvariant tests that auto-import enforces status/closed_at invariant
func TestAutoImportClosedAtInvariant(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-invariant-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath = filepath.Join(tmpDir, "test.db")
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
testStore, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to create storage: %v", err)
}
defer testStore.Close()
store = testStore
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
defer func() {
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
}()
ctx := context.Background()
// Create JSONL with closed issue but missing closed_at
closedIssue := &types.Issue{
ID: "test-inv-1",
Title: "Closed without timestamp",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ClosedAt: nil, // Missing!
}
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("Failed to create JSONL: %v", err)
}
json.NewEncoder(f).Encode(closedIssue)
f.Close()
// Run auto-import
autoImportIfNewer()
// Verify closed_at was set
result, err := testStore.GetIssue(ctx, "test-inv-1")
if err != nil {
t.Fatalf("Failed to get issue: %v", err)
}
if result == nil {
t.Fatal("Expected issue to be created")
}
if result.ClosedAt == nil {
t.Error("Expected closed_at to be set for closed issue")
}
}

View File

@@ -406,14 +406,14 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
INSERT INTO issues (
id, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, external_ref
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
created_at, updated_at, closed_at, external_ref
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
issue.ID, issue.Title, issue.Description, issue.Design,
issue.AcceptanceCriteria, issue.Notes, issue.Status,
issue.Priority, issue.IssueType, issue.Assignee,
issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt,
issue.ExternalRef,
issue.ClosedAt, issue.ExternalRef,
)
if err != nil {
return fmt.Errorf("failed to insert issue: %w", err)