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
6952
.beads/issues.jsonl
6952
.beads/issues.jsonl
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
bd-rig-beads
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"database": "beads.db",
|
|
||||||
"jsonl_export": "issues.jsonl",
|
|
||||||
"last_bd_version": "0.27.2"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-0e3m",
|
|
||||||
"branch": "polecat/jade-1767138549967",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "jade-1767138549967",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: jade-1767138549967",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T15:55:27.93949-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-0kue",
|
|
||||||
"branch": "polecat/onyx-1767106262992",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "onyx-1767106262992",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: onyx-1767106262992",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T06:59:46.966142-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-1200",
|
|
||||||
"branch": "polecat/jasper-1767106250817",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "jasper-1767106250817",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: jasper-1767106250817",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T07:05:01.456893-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-13c2",
|
|
||||||
"branch": "polecat/garnet-1767138539231",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "garnet-1767138539231",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: garnet-1767138539231",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T16:00:34.709829-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-2c64",
|
|
||||||
"branch": "polecat/ruby-1767142029451",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "ruby-1767142029451",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: ruby-1767142029451",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T17:00:14.138585-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-2lkh",
|
|
||||||
"branch": "polecat/obsidian-1767138468820",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "obsidian-1767138468820",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: obsidian-1767138468820",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T16:14:15.964858-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-3rko",
|
|
||||||
"branch": "polecat/topaz-1767142022153",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "topaz-1767142022153",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: topaz-1767142022153",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T17:06:22.779045-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-5482",
|
|
||||||
"branch": "polecat/obsidian-1767083466920",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "obsidian-1767083466920",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: obsidian-1767083466920",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T00:36:06.075415-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-6vnu",
|
|
||||||
"branch": "polecat/opal-1767142018955",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "opal-1767142018955",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: opal-1767142018955",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T17:14:02.069377-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-71v3",
|
|
||||||
"branch": "polecat/topaz-1767083484329",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "topaz-1767083484329",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: topaz-1767083484329",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T00:39:43.097575-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-7wrh",
|
|
||||||
"branch": "polecat/jasper-1767083473604",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "jasper-1767083473604",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: jasper-1767083473604",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T00:39:04.849184-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-90fs",
|
|
||||||
"branch": "polecat/amber-1767138546434",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "amber-1767138546434",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: amber-1767138546434",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T15:57:38.683138-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-98b4",
|
|
||||||
"branch": "polecat/onyx-1767138516448",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "onyx-1767138516448",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: onyx-1767138516448",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T16:17:51.540906-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-a1t0",
|
|
||||||
"branch": "polecat/opal-1767138520240",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "opal-1767138520240",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: opal-1767138520240",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T15:59:09.907511-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-agye",
|
|
||||||
"branch": "polecat/quartz-1767083470444",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "quartz-1767083470444",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: quartz-1767083470444",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T00:36:39.739127-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-cfxq",
|
|
||||||
"branch": "polecat/topaz-1767106269860",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "topaz-1767106269860",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: topaz-1767106269860",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T07:18:49.116137-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-ehwp",
|
|
||||||
"branch": "polecat/ruby-1767138542815",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "ruby-1767138542815",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: ruby-1767138542815",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T15:59:42.842575-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-g1ai",
|
|
||||||
"branch": "polecat/opal-1767106266450",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "opal-1767106266450",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: opal-1767106266450",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T07:03:00.721165-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-ir5t",
|
|
||||||
"branch": "polecat/quartz-1767106247671",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "quartz-1767106247671",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: quartz-1767106247671",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T07:02:08.920599-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-jjzz",
|
|
||||||
"branch": "polecat/obsidian-1767106243818",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "obsidian-1767106243818",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: obsidian-1767106243818",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T07:00:26.330057-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-jxp6",
|
|
||||||
"branch": "polecat/jasper-1767142011373",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "jasper-1767142011373",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: jasper-1767142011373",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T16:57:20.35112-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-kvwf",
|
|
||||||
"branch": "polecat/quartz-1767142008267",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "quartz-1767142008267",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: quartz-1767142008267",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T16:50:35.729624-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-oajy",
|
|
||||||
"branch": "polecat/garnet-1767142025508",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "garnet-1767142025508",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: garnet-1767142025508",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T17:03:48.371414-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-qvpn",
|
|
||||||
"branch": "polecat/jasper-1767138512869",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "jasper-1767138512869",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: jasper-1767138512869",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T15:51:49.013034-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-s5kf",
|
|
||||||
"branch": "polecat/onyx-1767083477016",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "onyx-1767083477016",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: onyx-1767083477016",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T00:37:08.216603-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-tglv",
|
|
||||||
"branch": "polecat/onyx-1767142014545",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "onyx-1767142014545",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: onyx-1767142014545",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T17:05:30.190725-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-txds",
|
|
||||||
"branch": "polecat/obsidian-1767142004753",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "obsidian-1767142004753",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: obsidian-1767142004753",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T16:57:13.545352-08:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "bd-wx8t",
|
|
||||||
"branch": "polecat/topaz-1767138533986",
|
|
||||||
"target": "main",
|
|
||||||
"source_issue": "topaz-1767138533986",
|
|
||||||
"worker": "",
|
|
||||||
"rig": "beads",
|
|
||||||
"title": "Merge: topaz-1767138533986",
|
|
||||||
"priority": 2,
|
|
||||||
"created_at": "2025-12-30T16:00:41.077442-08:00"
|
|
||||||
}
|
|
||||||
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/steveyegge/beads/internal/configfile"
|
"github.com/steveyegge/beads/internal/configfile"
|
||||||
"github.com/steveyegge/beads/internal/storage"
|
"github.com/steveyegge/beads/internal/storage"
|
||||||
@@ -12,6 +15,32 @@ import (
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
RegisterBackend(configfile.BackendDolt, func(ctx context.Context, path string, opts Options) (storage.Storage, error) {
|
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})
|
return dolt.New(ctx, &dolt.Config{Path: path, ReadOnly: opts.ReadOnly})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user