feat(dolt): implement automatic bootstrap from JSONL on first access
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>
This commit is contained in:
committed by
gastown/crew/dennis
parent
87f84c5fa6
commit
2cbffca4f3
434
internal/storage/dolt/bootstrap.go
Normal file
434
internal/storage/dolt/bootstrap.go
Normal file
@@ -0,0 +1,434 @@
|
||||
//go:build cgo
|
||||
|
||||
package dolt
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/lockfile"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/utils"
|
||||
)
|
||||
|
||||
// BootstrapResult contains statistics about the bootstrap operation
|
||||
type BootstrapResult struct {
|
||||
IssuesImported int
|
||||
IssuesSkipped int
|
||||
ParseErrors []ParseError
|
||||
PrefixDetected string
|
||||
}
|
||||
|
||||
// ParseError describes a JSONL parsing error
|
||||
type ParseError struct {
|
||||
Line int
|
||||
Message string
|
||||
Snippet string
|
||||
}
|
||||
|
||||
// BootstrapConfig controls bootstrap behavior
|
||||
type BootstrapConfig struct {
|
||||
BeadsDir string // Path to .beads directory
|
||||
DoltPath string // Path to dolt subdirectory
|
||||
LockTimeout time.Duration // Timeout waiting for bootstrap lock
|
||||
}
|
||||
|
||||
// Bootstrap checks if Dolt DB needs bootstrapping from JSONL and performs it if needed.
|
||||
// This is called during store creation to handle the cold-start scenario where
|
||||
// JSONL files exist (from git clone) but no Dolt database exists yet.
|
||||
//
|
||||
// Returns:
|
||||
// - true, result, nil: Bootstrap was performed successfully
|
||||
// - false, nil, nil: No bootstrap needed (Dolt already exists or no JSONL)
|
||||
// - false, nil, err: Bootstrap failed
|
||||
func Bootstrap(ctx context.Context, cfg BootstrapConfig) (bool, *BootstrapResult, error) {
|
||||
if cfg.LockTimeout == 0 {
|
||||
cfg.LockTimeout = 30 * time.Second
|
||||
}
|
||||
|
||||
// Check if Dolt database already exists and is ready
|
||||
if doltExists(cfg.DoltPath) && schemaReady(ctx, cfg.DoltPath) {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// Check if JSONL exists to bootstrap from
|
||||
jsonlPath := findJSONLPath(cfg.BeadsDir)
|
||||
if jsonlPath == "" {
|
||||
// No JSONL to bootstrap from - let normal init handle it
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// Acquire bootstrap lock to prevent concurrent bootstraps
|
||||
lockPath := cfg.DoltPath + ".bootstrap.lock"
|
||||
lockFile, err := acquireBootstrapLock(lockPath, cfg.LockTimeout)
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("bootstrap lock timeout: %w", err)
|
||||
}
|
||||
defer releaseBootstrapLock(lockFile, lockPath)
|
||||
|
||||
// Double-check after acquiring lock - another process may have bootstrapped
|
||||
if doltExists(cfg.DoltPath) && schemaReady(ctx, cfg.DoltPath) {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// Perform bootstrap
|
||||
result, err := performBootstrap(ctx, cfg, jsonlPath)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
return true, result, nil
|
||||
}
|
||||
|
||||
// doltExists checks if a Dolt database directory exists
|
||||
func doltExists(doltPath string) bool {
|
||||
// The embedded Dolt driver creates the database in a subdirectory
|
||||
// named after the database (default: "beads"), with .dolt inside that.
|
||||
// So we check for any subdirectory containing a .dolt directory.
|
||||
entries, err := os.ReadDir(doltPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
doltDir := filepath.Join(doltPath, entry.Name(), ".dolt")
|
||||
if info, err := os.Stat(doltDir); err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// schemaReady checks if the Dolt database has the required schema
|
||||
// This is a simple check based on the existence of expected files.
|
||||
// We avoid opening a connection here since the caller will do that.
|
||||
func schemaReady(_ context.Context, doltPath string) bool {
|
||||
// The embedded Dolt driver stores databases in subdirectories.
|
||||
// Check for the expected database name's config.json which indicates
|
||||
// the database was initialized.
|
||||
configPath := filepath.Join(doltPath, "beads", ".dolt", "config.json")
|
||||
_, err := os.Stat(configPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// findJSONLPath looks for JSONL files in the beads directory
|
||||
func findJSONLPath(beadsDir string) string {
|
||||
// Check in order of preference
|
||||
candidates := []string{
|
||||
filepath.Join(beadsDir, "issues.jsonl"),
|
||||
filepath.Join(beadsDir, "beads.jsonl"), // Legacy name
|
||||
}
|
||||
|
||||
for _, path := range candidates {
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// acquireBootstrapLock acquires an exclusive lock for bootstrap operations
|
||||
func acquireBootstrapLock(lockPath string, timeout time.Duration) (*os.File, error) {
|
||||
// Create lock file
|
||||
// #nosec G304 - controlled path
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create lock file: %w", err)
|
||||
}
|
||||
|
||||
// Try to acquire lock with timeout
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
err := lockfile.FlockExclusiveBlocking(f)
|
||||
if err == nil {
|
||||
// Lock acquired
|
||||
return f, nil
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("timeout waiting for bootstrap lock")
|
||||
}
|
||||
|
||||
// Wait briefly before retrying
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// releaseBootstrapLock releases the bootstrap lock and removes the lock file
|
||||
func releaseBootstrapLock(f *os.File, lockPath string) {
|
||||
if f != nil {
|
||||
_ = lockfile.FlockUnlock(f)
|
||||
_ = f.Close()
|
||||
}
|
||||
// Clean up lock file
|
||||
_ = os.Remove(lockPath)
|
||||
}
|
||||
|
||||
// performBootstrap performs the actual bootstrap from JSONL
|
||||
func performBootstrap(ctx context.Context, cfg BootstrapConfig, jsonlPath string) (*BootstrapResult, error) {
|
||||
result := &BootstrapResult{}
|
||||
|
||||
// Parse JSONL with graceful error handling
|
||||
issues, parseErrors := parseJSONLWithErrors(jsonlPath)
|
||||
result.ParseErrors = parseErrors
|
||||
|
||||
if len(parseErrors) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Bootstrap: Skipped %d malformed lines during bootstrap:\n", len(parseErrors))
|
||||
maxShow := 5
|
||||
if len(parseErrors) < maxShow {
|
||||
maxShow = len(parseErrors)
|
||||
}
|
||||
for i := 0; i < maxShow; i++ {
|
||||
e := parseErrors[i]
|
||||
fmt.Fprintf(os.Stderr, " Line %d: %s\n", e.Line, e.Message)
|
||||
}
|
||||
if len(parseErrors) > maxShow {
|
||||
fmt.Fprintf(os.Stderr, " ... and %d more\n", len(parseErrors)-maxShow)
|
||||
}
|
||||
}
|
||||
|
||||
if len(issues) == 0 {
|
||||
return nil, fmt.Errorf("no valid issues found in JSONL file %s", jsonlPath)
|
||||
}
|
||||
|
||||
// Detect prefix from issues
|
||||
result.PrefixDetected = detectPrefixFromIssues(issues)
|
||||
|
||||
// Create Dolt store (this initializes schema)
|
||||
store, err := New(ctx, &Config{Path: cfg.DoltPath})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Dolt store: %w", err)
|
||||
}
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
// Set issue prefix
|
||||
if result.PrefixDetected != "" {
|
||||
if err := store.SetConfig(ctx, "issue_prefix", result.PrefixDetected); err != nil {
|
||||
return nil, fmt.Errorf("failed to set issue_prefix: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Import issues in a transaction
|
||||
imported, skipped, err := importIssuesBootstrap(ctx, store, issues)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to import issues: %w", err)
|
||||
}
|
||||
|
||||
result.IssuesImported = imported
|
||||
result.IssuesSkipped = skipped
|
||||
|
||||
// Commit the bootstrap
|
||||
if err := store.Commit(ctx, "Bootstrap from JSONL"); err != nil {
|
||||
// Non-fatal - data is still in the database
|
||||
fmt.Fprintf(os.Stderr, "Bootstrap: warning: failed to create Dolt commit: %v\n", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseJSONLWithErrors parses JSONL, collecting errors instead of failing
|
||||
func parseJSONLWithErrors(jsonlPath string) ([]*types.Issue, []ParseError) {
|
||||
// #nosec G304 - controlled path
|
||||
f, err := os.Open(jsonlPath)
|
||||
if err != nil {
|
||||
return nil, []ParseError{{Line: 0, Message: fmt.Sprintf("failed to open file: %v", err)}}
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
var issues []*types.Issue
|
||||
var parseErrors []ParseError
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 1024), 2*1024*1024) // 2MB buffer for large lines
|
||||
lineNo := 0
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNo++
|
||||
line := scanner.Text()
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip Git merge conflict markers
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "<<<<<<< ") ||
|
||||
trimmed == "=======" ||
|
||||
strings.HasPrefix(trimmed, ">>>>>>> ") {
|
||||
parseErrors = append(parseErrors, ParseError{
|
||||
Line: lineNo,
|
||||
Message: "Git merge conflict marker",
|
||||
Snippet: truncateSnippet(line, 50),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
var issue types.Issue
|
||||
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
||||
parseErrors = append(parseErrors, ParseError{
|
||||
Line: lineNo,
|
||||
Message: err.Error(),
|
||||
Snippet: truncateSnippet(line, 50),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply defaults for omitted fields
|
||||
issue.SetDefaults()
|
||||
|
||||
// Fix closed_at invariant
|
||||
if issue.Status == types.StatusClosed && issue.ClosedAt == nil {
|
||||
now := time.Now()
|
||||
issue.ClosedAt = &now
|
||||
}
|
||||
|
||||
issues = append(issues, &issue)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
parseErrors = append(parseErrors, ParseError{
|
||||
Line: lineNo,
|
||||
Message: fmt.Sprintf("scanner error: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return issues, parseErrors
|
||||
}
|
||||
|
||||
// truncateSnippet truncates a string for display
|
||||
func truncateSnippet(s string, maxLen int) string {
|
||||
if len(s) > maxLen {
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// detectPrefixFromIssues detects the most common prefix from issues
|
||||
func detectPrefixFromIssues(issues []*types.Issue) string {
|
||||
prefixCounts := make(map[string]int)
|
||||
|
||||
for _, issue := range issues {
|
||||
if issue.ID == "" {
|
||||
continue
|
||||
}
|
||||
prefix := utils.ExtractIssuePrefix(issue.ID)
|
||||
if prefix != "" {
|
||||
prefixCounts[prefix]++
|
||||
}
|
||||
}
|
||||
|
||||
// Find most common prefix
|
||||
var maxPrefix string
|
||||
var maxCount int
|
||||
for prefix, count := range prefixCounts {
|
||||
if count > maxCount {
|
||||
maxPrefix = prefix
|
||||
maxCount = count
|
||||
}
|
||||
}
|
||||
|
||||
return maxPrefix
|
||||
}
|
||||
|
||||
// importIssuesBootstrap imports issues during bootstrap
|
||||
// Returns (imported, skipped, error)
|
||||
func importIssuesBootstrap(ctx context.Context, store *DoltStore, issues []*types.Issue) (int, int, error) {
|
||||
// Skip validation during bootstrap since we're importing existing data
|
||||
// The data was already validated when originally created
|
||||
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
imported := 0
|
||||
skipped := 0
|
||||
seenIDs := make(map[string]bool)
|
||||
|
||||
for _, issue := range issues {
|
||||
// Skip duplicates within batch
|
||||
if seenIDs[issue.ID] {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
seenIDs[issue.ID] = true
|
||||
|
||||
// Set timestamps if missing
|
||||
now := time.Now().UTC()
|
||||
if issue.CreatedAt.IsZero() {
|
||||
issue.CreatedAt = now
|
||||
}
|
||||
if issue.UpdatedAt.IsZero() {
|
||||
issue.UpdatedAt = now
|
||||
}
|
||||
|
||||
// Compute content hash if missing
|
||||
if issue.ContentHash == "" {
|
||||
issue.ContentHash = issue.ComputeContentHash()
|
||||
}
|
||||
|
||||
// Insert issue
|
||||
if err := insertIssue(ctx, tx, issue); err != nil {
|
||||
// Check for duplicate key (issue already exists)
|
||||
if strings.Contains(err.Error(), "Duplicate entry") ||
|
||||
strings.Contains(err.Error(), "UNIQUE constraint") {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
return imported, skipped, fmt.Errorf("failed to insert issue %s: %w", issue.ID, err)
|
||||
}
|
||||
|
||||
// Import labels
|
||||
for _, label := range issue.Labels {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO labels (issue_id, label)
|
||||
VALUES (?, ?)
|
||||
`, issue.ID, label)
|
||||
if err != nil && !strings.Contains(err.Error(), "Duplicate entry") {
|
||||
return imported, skipped, fmt.Errorf("failed to insert label for %s: %w", issue.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
imported++
|
||||
}
|
||||
|
||||
// Import dependencies in a second pass (after all issues exist)
|
||||
for _, issue := range issues {
|
||||
for _, dep := range issue.Dependencies {
|
||||
// Check if both issues exist
|
||||
var exists int
|
||||
err := tx.QueryRowContext(ctx, "SELECT 1 FROM issues WHERE id = ?", dep.DependsOnID).Scan(&exists)
|
||||
if err != nil {
|
||||
// Target doesn't exist, skip dependency
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE type = type
|
||||
`, dep.IssueID, dep.DependsOnID, dep.Type, "bootstrap", time.Now().UTC())
|
||||
if err != nil && !strings.Contains(err.Error(), "Duplicate entry") {
|
||||
// Non-fatal for dependencies
|
||||
fmt.Fprintf(os.Stderr, "Bootstrap: warning: failed to import dependency %s -> %s: %v\n",
|
||||
dep.IssueID, dep.DependsOnID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return imported, skipped, fmt.Errorf("failed to commit: %w", err)
|
||||
}
|
||||
|
||||
return imported, skipped, nil
|
||||
}
|
||||
349
internal/storage/dolt/bootstrap_test.go
Normal file
349
internal/storage/dolt/bootstrap_test.go
Normal file
@@ -0,0 +1,349 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ package factory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/steveyegge/beads/internal/configfile"
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
@@ -12,6 +15,32 @@ import (
|
||||
|
||||
func init() {
|
||||
RegisterBackend(configfile.BackendDolt, func(ctx context.Context, path string, opts Options) (storage.Storage, error) {
|
||||
// Check if bootstrap is needed (JSONL exists but Dolt doesn't)
|
||||
// Path is the dolt subdirectory, parent is .beads directory
|
||||
beadsDir := filepath.Dir(path)
|
||||
|
||||
bootstrapped, result, err := dolt.Bootstrap(ctx, dolt.BootstrapConfig{
|
||||
BeadsDir: beadsDir,
|
||||
DoltPath: path,
|
||||
LockTimeout: opts.LockTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bootstrap failed: %w", err)
|
||||
}
|
||||
|
||||
if bootstrapped && result != nil {
|
||||
// Report bootstrap results
|
||||
fmt.Fprintf(os.Stderr, "Bootstrapping Dolt from JSONL...\n")
|
||||
if len(result.ParseErrors) > 0 {
|
||||
fmt.Fprintf(os.Stderr, " Skipped %d malformed lines (see above for details)\n", len(result.ParseErrors))
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " Imported %d issues", result.IssuesImported)
|
||||
if result.IssuesSkipped > 0 {
|
||||
fmt.Fprintf(os.Stderr, ", skipped %d duplicates", result.IssuesSkipped)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n Dolt database ready\n")
|
||||
}
|
||||
|
||||
return dolt.New(ctx, &dolt.Config{Path: path, ReadOnly: opts.ReadOnly})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user