Add automatic Dolt database bootstrapping when JSONL files exist but no Dolt database is present (cold-start scenario after git clone). Key features: - Lock/wait pattern prevents concurrent bootstrap races - Graceful degradation skips malformed JSONL lines with warnings - Multi-table ordering: issues → labels → dependencies - Prefix auto-detection from JSONL content New files: - internal/storage/dolt/bootstrap.go - Bootstrap logic - internal/storage/dolt/bootstrap_test.go - Comprehensive tests Modified: - internal/storage/factory/factory_dolt.go - Integration point Closes: hq-ew1mbr.10 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
350 lines
8.9 KiB
Go
350 lines
8.9 KiB
Go
//go:build cgo
|
|
|
|
package dolt
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
func TestBootstrapFromJSONL(t *testing.T) {
|
|
// Create temp directory structure
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
doltDir := filepath.Join(beadsDir, "dolt")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatalf("failed to create beads dir: %v", err)
|
|
}
|
|
|
|
// Create test JSONL file with issues
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
issues := []types.Issue{
|
|
{
|
|
ID: "test-001",
|
|
Title: "First issue",
|
|
Description: "Test description 1",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now().Add(-time.Hour),
|
|
UpdatedAt: time.Now(),
|
|
},
|
|
{
|
|
ID: "test-002",
|
|
Title: "Second issue",
|
|
Description: "Test description 2",
|
|
Status: types.StatusInProgress,
|
|
Priority: 1,
|
|
IssueType: types.TypeBug,
|
|
CreatedAt: time.Now().Add(-2 * time.Hour),
|
|
UpdatedAt: time.Now().Add(-30 * time.Minute),
|
|
Labels: []string{"urgent", "backend"},
|
|
},
|
|
{
|
|
ID: "test-003",
|
|
Title: "Closed issue",
|
|
Status: types.StatusClosed,
|
|
Priority: 3,
|
|
IssueType: types.TypeTask,
|
|
},
|
|
}
|
|
|
|
var jsonlContent strings.Builder
|
|
for _, issue := range issues {
|
|
data, err := json.Marshal(issue)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal issue: %v", err)
|
|
}
|
|
jsonlContent.Write(data)
|
|
jsonlContent.WriteString("\n")
|
|
}
|
|
|
|
if err := os.WriteFile(jsonlPath, []byte(jsonlContent.String()), 0644); err != nil {
|
|
t.Fatalf("failed to write JSONL: %v", err)
|
|
}
|
|
|
|
// Perform bootstrap
|
|
ctx := context.Background()
|
|
bootstrapped, result, err := Bootstrap(ctx, BootstrapConfig{
|
|
BeadsDir: beadsDir,
|
|
DoltPath: doltDir,
|
|
LockTimeout: 10 * time.Second,
|
|
})
|
|
|
|
if err != nil {
|
|
t.Fatalf("bootstrap failed: %v", err)
|
|
}
|
|
if !bootstrapped {
|
|
t.Fatal("expected bootstrap to be performed")
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
|
|
// Verify results
|
|
if result.IssuesImported != 3 {
|
|
t.Errorf("expected 3 issues imported, got %d", result.IssuesImported)
|
|
}
|
|
if result.PrefixDetected != "test" {
|
|
t.Errorf("expected prefix 'test', got '%s'", result.PrefixDetected)
|
|
}
|
|
|
|
// Open store and verify issues were imported
|
|
store, err := New(ctx, &Config{Path: doltDir})
|
|
if err != nil {
|
|
t.Fatalf("failed to open store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
// Check prefix was set
|
|
prefix, err := store.GetConfig(ctx, "issue_prefix")
|
|
if err != nil {
|
|
t.Fatalf("failed to get config: %v", err)
|
|
}
|
|
if prefix != "test" {
|
|
t.Errorf("expected prefix 'test', got '%s'", prefix)
|
|
}
|
|
|
|
// Check issues exist
|
|
issue1, err := store.GetIssue(ctx, "test-001")
|
|
if err != nil {
|
|
t.Fatalf("failed to get issue: %v", err)
|
|
}
|
|
if issue1 == nil {
|
|
t.Fatal("expected issue test-001 to exist")
|
|
}
|
|
if issue1.Title != "First issue" {
|
|
t.Errorf("expected title 'First issue', got '%s'", issue1.Title)
|
|
}
|
|
|
|
// Check labels were imported
|
|
issue2, err := store.GetIssue(ctx, "test-002")
|
|
if err != nil {
|
|
t.Fatalf("failed to get issue: %v", err)
|
|
}
|
|
if issue2 == nil {
|
|
t.Fatal("expected issue test-002 to exist")
|
|
}
|
|
if len(issue2.Labels) != 2 {
|
|
t.Errorf("expected 2 labels, got %d", len(issue2.Labels))
|
|
}
|
|
|
|
// Verify closed_at was set for closed issue
|
|
issue3, err := store.GetIssue(ctx, "test-003")
|
|
if err != nil {
|
|
t.Fatalf("failed to get issue: %v", err)
|
|
}
|
|
if issue3 == nil {
|
|
t.Fatal("expected issue test-003 to exist")
|
|
}
|
|
if issue3.ClosedAt == nil {
|
|
t.Error("expected closed_at to be set for closed issue")
|
|
}
|
|
}
|
|
|
|
func TestBootstrapNoOpWhenDoltExists(t *testing.T) {
|
|
// Create temp directory structure with existing Dolt DB
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
doltDir := filepath.Join(beadsDir, "dolt")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatalf("failed to create beads dir: %v", err)
|
|
}
|
|
|
|
// Create a Dolt store first
|
|
ctx := context.Background()
|
|
store, err := New(ctx, &Config{Path: doltDir})
|
|
if err != nil {
|
|
t.Fatalf("failed to create store: %v", err)
|
|
}
|
|
if err := store.SetConfig(ctx, "issue_prefix", "existing"); err != nil {
|
|
t.Fatalf("failed to set config: %v", err)
|
|
}
|
|
store.Close()
|
|
|
|
// Create JSONL file
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
issue := types.Issue{
|
|
ID: "new-001",
|
|
Title: "New issue",
|
|
Status: types.StatusOpen,
|
|
}
|
|
data, _ := json.Marshal(issue)
|
|
if err := os.WriteFile(jsonlPath, append(data, '\n'), 0644); err != nil {
|
|
t.Fatalf("failed to write JSONL: %v", err)
|
|
}
|
|
|
|
// Attempt bootstrap - should be no-op
|
|
bootstrapped, result, err := Bootstrap(ctx, BootstrapConfig{
|
|
BeadsDir: beadsDir,
|
|
DoltPath: doltDir,
|
|
LockTimeout: 10 * time.Second,
|
|
})
|
|
|
|
if err != nil {
|
|
t.Fatalf("bootstrap failed: %v", err)
|
|
}
|
|
if bootstrapped {
|
|
t.Error("expected no bootstrap when Dolt already exists")
|
|
}
|
|
if result != nil {
|
|
t.Error("expected nil result when no bootstrap performed")
|
|
}
|
|
|
|
// Verify original prefix preserved
|
|
store, err = New(ctx, &Config{Path: doltDir})
|
|
if err != nil {
|
|
t.Fatalf("failed to reopen store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
prefix, _ := store.GetConfig(ctx, "issue_prefix")
|
|
if prefix != "existing" {
|
|
t.Errorf("expected prefix 'existing', got '%s'", prefix)
|
|
}
|
|
}
|
|
|
|
func TestBootstrapNoOpWhenNoJSONL(t *testing.T) {
|
|
// Create temp directory structure without JSONL
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
doltDir := filepath.Join(beadsDir, "dolt")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatalf("failed to create beads dir: %v", err)
|
|
}
|
|
|
|
// Attempt bootstrap - should be no-op
|
|
ctx := context.Background()
|
|
bootstrapped, result, err := Bootstrap(ctx, BootstrapConfig{
|
|
BeadsDir: beadsDir,
|
|
DoltPath: doltDir,
|
|
LockTimeout: 10 * time.Second,
|
|
})
|
|
|
|
if err != nil {
|
|
t.Fatalf("bootstrap failed: %v", err)
|
|
}
|
|
if bootstrapped {
|
|
t.Error("expected no bootstrap when no JSONL exists")
|
|
}
|
|
if result != nil {
|
|
t.Error("expected nil result when no bootstrap performed")
|
|
}
|
|
}
|
|
|
|
func TestBootstrapGracefulDegradation(t *testing.T) {
|
|
// Create temp directory structure
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
doltDir := filepath.Join(beadsDir, "dolt")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatalf("failed to create beads dir: %v", err)
|
|
}
|
|
|
|
// Create JSONL with some malformed lines
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
goodIssue := types.Issue{
|
|
ID: "test-001",
|
|
Title: "Good issue",
|
|
Status: types.StatusOpen,
|
|
}
|
|
goodData, _ := json.Marshal(goodIssue)
|
|
|
|
content := string(goodData) + "\n" +
|
|
"{invalid json}\n" +
|
|
"<<<<<<< HEAD\n" + // Git conflict marker
|
|
string(goodData) + "\n" // Duplicate - will be skipped
|
|
|
|
if err := os.WriteFile(jsonlPath, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write JSONL: %v", err)
|
|
}
|
|
|
|
// Perform bootstrap
|
|
ctx := context.Background()
|
|
bootstrapped, result, err := Bootstrap(ctx, BootstrapConfig{
|
|
BeadsDir: beadsDir,
|
|
DoltPath: doltDir,
|
|
LockTimeout: 10 * time.Second,
|
|
})
|
|
|
|
if err != nil {
|
|
t.Fatalf("bootstrap failed: %v", err)
|
|
}
|
|
if !bootstrapped {
|
|
t.Fatal("expected bootstrap to be performed")
|
|
}
|
|
|
|
// Should have parse errors for malformed lines
|
|
if len(result.ParseErrors) != 2 {
|
|
t.Errorf("expected 2 parse errors, got %d", len(result.ParseErrors))
|
|
}
|
|
|
|
// Should have imported the good issue
|
|
if result.IssuesImported != 1 {
|
|
t.Errorf("expected 1 issue imported, got %d", result.IssuesImported)
|
|
}
|
|
|
|
// Duplicate should be skipped (not errored)
|
|
if result.IssuesSkipped != 1 {
|
|
t.Errorf("expected 1 issue skipped, got %d", result.IssuesSkipped)
|
|
}
|
|
}
|
|
|
|
func TestParseJSONLWithErrors(t *testing.T) {
|
|
// Create temp file with mixed content
|
|
tmpDir := t.TempDir()
|
|
jsonlPath := filepath.Join(tmpDir, "test.jsonl")
|
|
|
|
goodIssue := types.Issue{
|
|
ID: "test-001",
|
|
Title: "Good issue",
|
|
Status: types.StatusOpen,
|
|
}
|
|
goodData, _ := json.Marshal(goodIssue)
|
|
|
|
content := string(goodData) + "\n" +
|
|
"\n" + // Empty line - should be skipped
|
|
" \n" + // Whitespace line - should be skipped
|
|
"{broken\n" + // Invalid JSON
|
|
"<<<<<<< HEAD\n" + // Git conflict
|
|
"=======\n" + // Git conflict
|
|
">>>>>>> branch\n" + // Git conflict
|
|
string(goodData) + "\n" // Another good line
|
|
|
|
if err := os.WriteFile(jsonlPath, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write file: %v", err)
|
|
}
|
|
|
|
issues, errors := parseJSONLWithErrors(jsonlPath)
|
|
|
|
if len(issues) != 2 {
|
|
t.Errorf("expected 2 issues, got %d", len(issues))
|
|
}
|
|
|
|
// Should have 4 parse errors: 1 invalid JSON + 3 conflict markers
|
|
if len(errors) != 4 {
|
|
t.Errorf("expected 4 parse errors, got %d: %+v", len(errors), errors)
|
|
}
|
|
}
|
|
|
|
func TestDetectPrefixFromIssues(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{ID: "proj-001"},
|
|
{ID: "proj-002"},
|
|
{ID: "proj-003"},
|
|
{ID: "other-001"},
|
|
}
|
|
|
|
prefix := detectPrefixFromIssues(issues)
|
|
if prefix != "proj" {
|
|
t.Errorf("expected prefix 'proj', got '%s'", prefix)
|
|
}
|
|
}
|