Add FindJSONLInDir helper that correctly prefers issues.jsonl over other .jsonl files. Previously, glob patterns could return deletions.jsonl or merge artifacts (beads.base.jsonl, etc.) first alphabetically, causing issue data to be written to the wrong file. This fixes the root cause of deletions.jsonl corruption where full issue objects were written instead of deletion records, leading to all issues being purged during sync. Changes: - Add FindJSONLInDir() in internal/autoimport with proper file selection - Update AutoImportIfNewer() to use FindJSONLInDir - Update CheckStaleness() to use FindJSONLInDir - Update triggerExport() in RPC server to use FindJSONLInDir - Add comprehensive tests for FindJSONLInDir 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
596 lines
14 KiB
Go
596 lines
14 KiB
Go
package autoimport
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/storage/memory"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// testNotifier captures notifications for assertions
|
|
type testNotifier struct {
|
|
debugs []string
|
|
infos []string
|
|
warns []string
|
|
errors []string
|
|
}
|
|
|
|
func (n *testNotifier) Debugf(format string, args ...interface{}) {
|
|
n.debugs = append(n.debugs, format)
|
|
}
|
|
|
|
func (n *testNotifier) Infof(format string, args ...interface{}) {
|
|
n.infos = append(n.infos, format)
|
|
}
|
|
|
|
func (n *testNotifier) Warnf(format string, args ...interface{}) {
|
|
n.warns = append(n.warns, format)
|
|
}
|
|
|
|
func (n *testNotifier) Errorf(format string, args ...interface{}) {
|
|
n.errors = append(n.errors, format)
|
|
}
|
|
|
|
func TestAutoImportIfNewer_NoJSONL(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-autoimport-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "bd.db")
|
|
store := memory.New("")
|
|
notify := &testNotifier{}
|
|
|
|
importCalled := false
|
|
importFunc := func(ctx context.Context, issues []*types.Issue) (int, int, map[string]string, error) {
|
|
importCalled = true
|
|
return 0, 0, nil, nil
|
|
}
|
|
|
|
err = AutoImportIfNewer(context.Background(), store, dbPath, notify, importFunc, nil)
|
|
if err != nil {
|
|
t.Errorf("Expected no error, got: %v", err)
|
|
}
|
|
|
|
if importCalled {
|
|
t.Error("Import should not be called when JSONL doesn't exist")
|
|
}
|
|
}
|
|
|
|
func TestAutoImportIfNewer_UnchangedHash(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-autoimport-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "bd.db")
|
|
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
|
|
|
// Create test JSONL
|
|
issue := &types.Issue{
|
|
ID: "test-1",
|
|
Title: "Test Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
f, err := os.Create(jsonlPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
json.NewEncoder(f).Encode(issue)
|
|
f.Close()
|
|
|
|
// Compute hash
|
|
data, _ := os.ReadFile(jsonlPath)
|
|
hasher := sha256.New()
|
|
hasher.Write(data)
|
|
hash := hex.EncodeToString(hasher.Sum(nil))
|
|
|
|
// Store hash in metadata
|
|
store := memory.New("")
|
|
ctx := context.Background()
|
|
store.SetMetadata(ctx, "last_import_hash", hash)
|
|
|
|
notify := &testNotifier{}
|
|
importCalled := false
|
|
importFunc := func(ctx context.Context, issues []*types.Issue) (int, int, map[string]string, error) {
|
|
importCalled = true
|
|
return 0, 0, nil, nil
|
|
}
|
|
|
|
err = AutoImportIfNewer(ctx, store, dbPath, notify, importFunc, nil)
|
|
if err != nil {
|
|
t.Errorf("Expected no error, got: %v", err)
|
|
}
|
|
|
|
if importCalled {
|
|
t.Error("Import should not be called when hash is unchanged")
|
|
}
|
|
}
|
|
|
|
func TestAutoImportIfNewer_ChangedHash(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-autoimport-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "bd.db")
|
|
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
|
|
|
// Create test JSONL
|
|
issue := &types.Issue{
|
|
ID: "test-1",
|
|
Title: "Test Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
f, err := os.Create(jsonlPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
json.NewEncoder(f).Encode(issue)
|
|
f.Close()
|
|
|
|
// Store different hash in metadata
|
|
store := memory.New("")
|
|
ctx := context.Background()
|
|
store.SetMetadata(ctx, "last_import_hash", "different-hash")
|
|
|
|
notify := &testNotifier{}
|
|
importCalled := false
|
|
var receivedIssues []*types.Issue
|
|
importFunc := func(ctx context.Context, issues []*types.Issue) (int, int, map[string]string, error) {
|
|
importCalled = true
|
|
receivedIssues = issues
|
|
return 1, 0, nil, nil
|
|
}
|
|
|
|
err = AutoImportIfNewer(ctx, store, dbPath, notify, importFunc, nil)
|
|
if err != nil {
|
|
t.Errorf("Expected no error, got: %v", err)
|
|
}
|
|
|
|
if !importCalled {
|
|
t.Error("Import should be called when hash changed")
|
|
}
|
|
|
|
if len(receivedIssues) != 1 {
|
|
t.Errorf("Expected 1 issue, got %d", len(receivedIssues))
|
|
}
|
|
|
|
if receivedIssues[0].ID != "test-1" {
|
|
t.Errorf("Expected issue ID 'test-1', got '%s'", receivedIssues[0].ID)
|
|
}
|
|
}
|
|
|
|
func TestAutoImportIfNewer_MergeConflict(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-autoimport-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "bd.db")
|
|
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
|
|
|
// Create JSONL with merge conflict markers
|
|
conflictData := `{"id":"test-1","title":"Issue 1"}
|
|
<<<<<<< HEAD
|
|
{"id":"test-2","title":"Local version"}
|
|
=======
|
|
{"id":"test-2","title":"Remote version"}
|
|
>>>>>>> main
|
|
{"id":"test-3","title":"Issue 3"}
|
|
`
|
|
os.WriteFile(jsonlPath, []byte(conflictData), 0644)
|
|
|
|
store := memory.New("")
|
|
ctx := context.Background()
|
|
notify := &testNotifier{}
|
|
|
|
importFunc := func(ctx context.Context, issues []*types.Issue) (int, int, map[string]string, error) {
|
|
t.Error("Import should not be called with merge conflict")
|
|
return 0, 0, nil, nil
|
|
}
|
|
|
|
err = AutoImportIfNewer(ctx, store, dbPath, notify, importFunc, nil)
|
|
if err == nil {
|
|
t.Error("Expected error for merge conflict")
|
|
}
|
|
|
|
if len(notify.errors) == 0 {
|
|
t.Error("Expected error notification")
|
|
}
|
|
}
|
|
|
|
func TestAutoImportIfNewer_WithRemapping(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-autoimport-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "bd.db")
|
|
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
|
|
|
// Create test JSONL
|
|
issue := &types.Issue{
|
|
ID: "test-1",
|
|
Title: "Test Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
f, err := os.Create(jsonlPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
json.NewEncoder(f).Encode(issue)
|
|
f.Close()
|
|
|
|
store := memory.New("")
|
|
ctx := context.Background()
|
|
notify := &testNotifier{}
|
|
|
|
idMapping := map[string]string{"test-1": "test-2"}
|
|
importFunc := func(ctx context.Context, issues []*types.Issue) (int, int, map[string]string, error) {
|
|
return 1, 0, idMapping, nil
|
|
}
|
|
|
|
onChangedCalled := false
|
|
var needsFullExport bool
|
|
onChanged := func(fullExport bool) {
|
|
onChangedCalled = true
|
|
needsFullExport = fullExport
|
|
}
|
|
|
|
err = AutoImportIfNewer(ctx, store, dbPath, notify, importFunc, onChanged)
|
|
if err != nil {
|
|
t.Errorf("Expected no error, got: %v", err)
|
|
}
|
|
|
|
if !onChangedCalled {
|
|
t.Error("onChanged should be called when issues are remapped")
|
|
}
|
|
|
|
if !needsFullExport {
|
|
t.Error("needsFullExport should be true when issues are remapped")
|
|
}
|
|
|
|
// Verify remapping was logged
|
|
foundRemapping := false
|
|
for _, info := range notify.infos {
|
|
if strings.Contains(info, "remapped") {
|
|
foundRemapping = true
|
|
break
|
|
}
|
|
}
|
|
if !foundRemapping {
|
|
t.Error("Expected remapping notification")
|
|
}
|
|
}
|
|
|
|
func TestCheckStaleness_NoMetadata(t *testing.T) {
|
|
store := memory.New("")
|
|
ctx := context.Background()
|
|
|
|
tmpDir, err := os.MkdirTemp("", "bd-stale-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "bd.db")
|
|
|
|
stale, err := CheckStaleness(ctx, store, dbPath)
|
|
if err != nil {
|
|
t.Errorf("Expected no error, got: %v", err)
|
|
}
|
|
|
|
if stale {
|
|
t.Error("Should not be stale with no metadata")
|
|
}
|
|
}
|
|
|
|
func TestCheckStaleness_NewerJSONL(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-stale-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "bd.db")
|
|
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
|
|
|
// Create old import time
|
|
oldTime := time.Now().Add(-1 * time.Hour)
|
|
store := memory.New("")
|
|
ctx := context.Background()
|
|
store.SetMetadata(ctx, "last_import_time", oldTime.Format(time.RFC3339))
|
|
|
|
// Create newer JSONL file
|
|
os.WriteFile(jsonlPath, []byte(`{"id":"test-1"}`), 0644)
|
|
|
|
stale, err := CheckStaleness(ctx, store, dbPath)
|
|
if err != nil {
|
|
t.Errorf("Expected no error, got: %v", err)
|
|
}
|
|
|
|
if !stale {
|
|
t.Error("Should be stale when JSONL is newer")
|
|
}
|
|
}
|
|
|
|
func TestCheckStaleness_CorruptedMetadata(t *testing.T) {
|
|
store := memory.New("")
|
|
ctx := context.Background()
|
|
|
|
tmpDir, err := os.MkdirTemp("", "bd-stale-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "bd.db")
|
|
|
|
// Set invalid timestamp format
|
|
store.SetMetadata(ctx, "last_import_time", "not-a-valid-timestamp")
|
|
|
|
_, err = CheckStaleness(ctx, store, dbPath)
|
|
if err == nil {
|
|
t.Error("Expected error for corrupted metadata, got nil")
|
|
}
|
|
if err != nil && !strings.Contains(err.Error(), "corrupted last_import_time") {
|
|
t.Errorf("Expected 'corrupted last_import_time' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCheckForMergeConflicts(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
data string
|
|
wantError bool
|
|
}{
|
|
{
|
|
name: "no conflict",
|
|
data: `{"id":"test-1"}`,
|
|
wantError: false,
|
|
},
|
|
{
|
|
name: "conflict with HEAD marker",
|
|
data: `<<<<<<< HEAD
|
|
{"id":"test-1"}`,
|
|
wantError: true,
|
|
},
|
|
{
|
|
name: "conflict with separator",
|
|
data: `{"id":"test-1"}
|
|
=======
|
|
{"id":"test-2"}`,
|
|
wantError: true,
|
|
},
|
|
{
|
|
name: "conflict with end marker",
|
|
data: `{"id":"test-1"}
|
|
>>>>>>> main`,
|
|
wantError: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := checkForMergeConflicts([]byte(tt.data), "test.jsonl")
|
|
if tt.wantError && err == nil {
|
|
t.Error("Expected error for merge conflict")
|
|
}
|
|
if !tt.wantError && err != nil {
|
|
t.Errorf("Unexpected error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseJSONL(t *testing.T) {
|
|
notify := &testNotifier{}
|
|
|
|
t.Run("valid jsonl", func(t *testing.T) {
|
|
data := `{"id":"test-1","title":"Issue 1","status":"open","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}
|
|
{"id":"test-2","title":"Issue 2","status":"open","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}`
|
|
|
|
issues, err := parseJSONL([]byte(data), notify)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got: %v", err)
|
|
}
|
|
|
|
if len(issues) != 2 {
|
|
t.Errorf("Expected 2 issues, got %d", len(issues))
|
|
}
|
|
})
|
|
|
|
t.Run("empty lines ignored", func(t *testing.T) {
|
|
data := `{"id":"test-1","title":"Issue 1","status":"open","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}
|
|
|
|
{"id":"test-2","title":"Issue 2","status":"open","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}`
|
|
|
|
issues, err := parseJSONL([]byte(data), notify)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got: %v", err)
|
|
}
|
|
|
|
if len(issues) != 2 {
|
|
t.Errorf("Expected 2 issues, got %d", len(issues))
|
|
}
|
|
})
|
|
|
|
t.Run("invalid json", func(t *testing.T) {
|
|
data := `{"id":"test-1","title":"Issue 1"}
|
|
not valid json`
|
|
|
|
_, err := parseJSONL([]byte(data), notify)
|
|
if err == nil {
|
|
t.Error("Expected error for invalid JSON")
|
|
}
|
|
})
|
|
|
|
t.Run("closed without closedAt", func(t *testing.T) {
|
|
data := `{"id":"test-1","title":"Closed Issue","status":"closed","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}`
|
|
|
|
issues, err := parseJSONL([]byte(data), notify)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got: %v", err)
|
|
}
|
|
|
|
if issues[0].ClosedAt == nil {
|
|
t.Error("Expected ClosedAt to be set for closed issue")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestShowRemapping(t *testing.T) {
|
|
notify := &testNotifier{}
|
|
|
|
allIssues := []*types.Issue{
|
|
{ID: "test-1", Title: "Issue 1"},
|
|
{ID: "test-2", Title: "Issue 2"},
|
|
}
|
|
|
|
idMapping := map[string]string{
|
|
"test-1": "test-3",
|
|
"test-2": "test-4",
|
|
}
|
|
|
|
showRemapping(allIssues, idMapping, notify)
|
|
|
|
if len(notify.infos) == 0 {
|
|
t.Error("Expected info messages for remapping")
|
|
}
|
|
|
|
foundRemappingHeader := false
|
|
for _, info := range notify.infos {
|
|
if strings.Contains(info, "remapped") && strings.Contains(info, "colliding") {
|
|
foundRemappingHeader = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !foundRemappingHeader {
|
|
t.Errorf("Expected remapping summary message, got infos: %v", notify.infos)
|
|
}
|
|
}
|
|
|
|
func TestStderrNotifier(t *testing.T) {
|
|
t.Run("debug enabled", func(t *testing.T) {
|
|
notify := NewStderrNotifier(true)
|
|
// Just verify it doesn't panic
|
|
notify.Debugf("test debug")
|
|
notify.Infof("test info")
|
|
notify.Warnf("test warn")
|
|
notify.Errorf("test error")
|
|
})
|
|
|
|
t.Run("debug disabled", func(t *testing.T) {
|
|
notify := NewStderrNotifier(false)
|
|
// Just verify it doesn't panic
|
|
notify.Debugf("test debug")
|
|
notify.Infof("test info")
|
|
})
|
|
}
|
|
|
|
// TestFindJSONLInDir tests that FindJSONLInDir correctly prefers issues.jsonl
|
|
// and avoids deletions.jsonl and merge artifacts (bd-tqo fix)
|
|
func TestFindJSONLInDir(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
files []string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "only issues.jsonl",
|
|
files: []string{"issues.jsonl"},
|
|
expected: "issues.jsonl",
|
|
},
|
|
{
|
|
name: "issues.jsonl and deletions.jsonl - prefers issues",
|
|
files: []string{"deletions.jsonl", "issues.jsonl"},
|
|
expected: "issues.jsonl",
|
|
},
|
|
{
|
|
name: "issues.jsonl with merge artifacts - prefers issues",
|
|
files: []string{"beads.base.jsonl", "beads.left.jsonl", "beads.right.jsonl", "issues.jsonl"},
|
|
expected: "issues.jsonl",
|
|
},
|
|
{
|
|
name: "beads.jsonl as legacy fallback",
|
|
files: []string{"beads.jsonl"},
|
|
expected: "beads.jsonl",
|
|
},
|
|
{
|
|
name: "issues.jsonl preferred over beads.jsonl",
|
|
files: []string{"beads.jsonl", "issues.jsonl"},
|
|
expected: "issues.jsonl",
|
|
},
|
|
{
|
|
name: "only deletions.jsonl - returns default issues.jsonl",
|
|
files: []string{"deletions.jsonl"},
|
|
expected: "issues.jsonl",
|
|
},
|
|
{
|
|
name: "only merge artifacts - returns default issues.jsonl",
|
|
files: []string{"beads.base.jsonl", "beads.left.jsonl", "beads.right.jsonl"},
|
|
expected: "issues.jsonl",
|
|
},
|
|
{
|
|
name: "no files - returns default issues.jsonl",
|
|
files: []string{},
|
|
expected: "issues.jsonl",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-findjsonl-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Create test files
|
|
for _, file := range tt.files {
|
|
path := filepath.Join(tmpDir, file)
|
|
if err := os.WriteFile(path, []byte("{}"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
result := FindJSONLInDir(tmpDir)
|
|
got := filepath.Base(result)
|
|
|
|
if got != tt.expected {
|
|
t.Errorf("FindJSONLInDir() = %q, want %q", got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|