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().
This commit is contained in:
committed by
GitHub
parent
6da965587b
commit
7b8c322e68
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user