From 7b8c322e68940ad3bc32a06f1b10ac06ada0e443 Mon Sep 17 00:00:00 2001 From: Eugene Sukhodolin Date: Mon, 12 Jan 2026 16:39:06 -0800 Subject: [PATCH] fix(autoflush): preserve comments during full re-export triggered by hash mismatch (#1039) Problem: When a JSONL file hash mismatch was detected (e.g., after git operations that modify the JSONL without updating export_hashes), the autoflush system would trigger a full re-export. During this re-export, all issue comments were silently dropped from the exported JSONL file. Steps to reproduce: 1. Have a beads database with issues containing comments 2. Create a situation where JSONL hash doesn't match stored hash (e.g., clone a repo, or manual JSONL edits) 3. Run any bd command that triggers autoflush (e.g., `bd create foo`) 4. Observe warning: "JSONL file hash mismatch detected" 5. Check .beads/issues.jsonl - all comments are now missing Root cause: Two different export code paths existed: - exportToJSONLWithStore (daemon_sync.go) - correctly populated comments - fetchAndMergeIssues (autoflush.go) - only fetched dependencies, NOT comments When hash mismatch triggered a full re-export via the autoflush path, fetchAndMergeIssues was called but it never populated issue.Comments, resulting in all comments being lost. Fix: Add GetIssueComments call in fetchAndMergeIssues to populate comments for each issue before export, matching the behavior of exportToJSONLWithStore. Note: Labels were not affected because GetIssue() already populates them internally. Comments are stored in a separate table and require explicit fetching via GetIssueComments(). --- cmd/bd/autoflush.go | 7 +++ cmd/bd/autoflush_test.go | 129 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/cmd/bd/autoflush.go b/cmd/bd/autoflush.go index c3024275..57257a3e 100644 --- a/cmd/bd/autoflush.go +++ b/cmd/bd/autoflush.go @@ -626,6 +626,13 @@ func fetchAndMergeIssues(ctx context.Context, s storage.Storage, dirtyIDs []stri } issue.Dependencies = deps + // Get comments for this issue + comments, err := s.GetIssueComments(ctx, issueID) + if err != nil { + return fmt.Errorf("failed to get comments for %s: %w", issueID, err) + } + issue.Comments = comments + // Update map issueMap[issueID] = issue } diff --git a/cmd/bd/autoflush_test.go b/cmd/bd/autoflush_test.go index 21f176b7..7804abd4 100644 --- a/cmd/bd/autoflush_test.go +++ b/cmd/bd/autoflush_test.go @@ -1,11 +1,15 @@ package main import ( + "context" "os" "path/filepath" "testing" + "time" "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" ) // TestFindJSONLPath_RelativeDbPath tests that findJSONLPath() returns an absolute @@ -152,3 +156,128 @@ func TestFindJSONLPath_EmptyDbPath(t *testing.T) { result) } } + +// TestFetchAndMergeIssues_IncludesComments verifies that fetchAndMergeIssues +// populates comments on issues fetched from the database. +// Bug: fetchAndMergeIssues was only fetching dependencies, not comments or labels. +// This caused comments to be lost during autoflush full-export triggered by hash mismatch. +func TestFetchAndMergeIssues_IncludesComments(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, ".beads", "beads.db") + + // Create storage + store, err := sqlite.New(context.Background(), dbPath) + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer store.Close() + + ctx := context.Background() + + // Set issue_prefix to prevent "database not initialized" errors + if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("failed to set issue_prefix: %v", err) + } + + // Create test issue + issue := &types.Issue{ + ID: "test-1", + Title: "Test Issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := store.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + // Add a comment to the issue (use AddIssueComment which writes to comments table) + if _, err := store.AddIssueComment(ctx, "test-1", "tester", "This is a test comment"); err != nil { + t.Fatalf("failed to add comment: %v", err) + } + + // Call fetchAndMergeIssues + issueMap := make(map[string]*types.Issue) + dirtyIDs := []string{"test-1"} + if err := fetchAndMergeIssues(ctx, store, dirtyIDs, issueMap); err != nil { + t.Fatalf("fetchAndMergeIssues failed: %v", err) + } + + // Verify issue was fetched + fetchedIssue, ok := issueMap["test-1"] + if !ok { + t.Fatal("issue test-1 not found in issueMap") + } + + // Verify comments were populated + if len(fetchedIssue.Comments) == 0 { + t.Errorf("fetchAndMergeIssues did not populate comments: expected 1 comment, got 0") + } else if len(fetchedIssue.Comments) != 1 { + t.Errorf("fetchAndMergeIssues: expected 1 comment, got %d", len(fetchedIssue.Comments)) + } else if fetchedIssue.Comments[0].Text != "This is a test comment" { + t.Errorf("comment text mismatch: expected 'This is a test comment', got %q", fetchedIssue.Comments[0].Text) + } +} + +// TestFetchAndMergeIssues_IncludesMultipleComments verifies that fetchAndMergeIssues +// populates all comments on issues, not just one. +func TestFetchAndMergeIssues_IncludesMultipleComments(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, ".beads", "beads.db") + + // Create storage + store, err := sqlite.New(context.Background(), dbPath) + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer store.Close() + + ctx := context.Background() + + // Set issue_prefix + if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("failed to set issue_prefix: %v", err) + } + + // Create test issue + issue := &types.Issue{ + ID: "test-1", + Title: "Test Issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := store.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + // Add multiple comments (use AddIssueComment which writes to comments table) + if _, err := store.AddIssueComment(ctx, "test-1", "alice", "First comment"); err != nil { + t.Fatalf("failed to add first comment: %v", err) + } + if _, err := store.AddIssueComment(ctx, "test-1", "bob", "Second comment"); err != nil { + t.Fatalf("failed to add second comment: %v", err) + } + + // Call fetchAndMergeIssues + issueMap := make(map[string]*types.Issue) + dirtyIDs := []string{"test-1"} + if err := fetchAndMergeIssues(ctx, store, dirtyIDs, issueMap); err != nil { + t.Fatalf("fetchAndMergeIssues failed: %v", err) + } + + // Verify issue was fetched + fetchedIssue, ok := issueMap["test-1"] + if !ok { + t.Fatal("issue test-1 not found in issueMap") + } + + // Verify all comments were populated + if len(fetchedIssue.Comments) != 2 { + t.Errorf("fetchAndMergeIssues: expected 2 comments, got %d", len(fetchedIssue.Comments)) + } +}