Implement JSONL export/import and shift to text-first architecture
This is a fundamental architectural shift from binary SQLite to JSONL as the source of truth for git workflows. ## New Features - `bd export --format=jsonl` - Export issues to JSON Lines format - `bd import` - Import issues from JSONL (create new, update existing) - `--skip-existing` flag for import to only create new issues ## Architecture Change **Before:** Binary SQLite database committed to git **After:** JSONL text files as source of truth, SQLite as ephemeral cache Benefits: - Git-friendly text format with clean diffs - AI-resolvable merge conflicts (append-only is 95% conflict-free) - Human-readable issue tracking in git - No binary merge conflicts ## Documentation - Updated README with JSONL-first workflow and git hooks - Added TEXT_FORMATS.md analyzing JSONL vs CSV vs binary - Updated GIT_WORKFLOW.md with historical context - .gitignore now excludes *.db, includes .beads/*.jsonl ## Implementation Details - Export sorts issues by ID for consistent diffs - Import handles both creates and updates atomically - Proper handling of pointer fields (EstimatedMinutes) - All tests passing ## Breaking Changes - Database files (*.db) should now be gitignored - Use export/import workflow for git collaboration - Git hooks recommended for automation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
393
internal/storage/sqlite/sqlite_test.go
Normal file
393
internal/storage/sqlite/sqlite_test.go
Normal file
@@ -0,0 +1,393 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) (*SQLiteStorage, func()) {
|
||||
t.Helper()
|
||||
|
||||
// Create temporary directory
|
||||
tmpDir, err := os.MkdirTemp("", "beads-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
store, err := New(dbPath)
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
t.Fatalf("failed to create storage: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
store.Close()
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
return store, cleanup
|
||||
}
|
||||
|
||||
func TestCreateIssue(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
issue := &types.Issue{
|
||||
Title: "Test issue",
|
||||
Description: "Test description",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
|
||||
err := store.CreateIssue(ctx, issue, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if issue.ID == "" {
|
||||
t.Error("Issue ID should be set")
|
||||
}
|
||||
|
||||
if !issue.CreatedAt.After(time.Time{}) {
|
||||
t.Error("CreatedAt should be set")
|
||||
}
|
||||
|
||||
if !issue.UpdatedAt.After(time.Time{}) {
|
||||
t.Error("UpdatedAt should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIssueValidation(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
issue *types.Issue
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid issue",
|
||||
issue: &types.Issue{
|
||||
Title: "Valid",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing title",
|
||||
issue: &types.Issue{
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid priority",
|
||||
issue: &types.Issue{
|
||||
Title: "Test",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 10,
|
||||
IssueType: types.TypeTask,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid status",
|
||||
issue: &types.Issue{
|
||||
Title: "Test",
|
||||
Status: "invalid",
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := store.CreateIssue(ctx, tt.issue, "test-user")
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CreateIssue() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIssue(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
original := &types.Issue{
|
||||
Title: "Test issue",
|
||||
Description: "Description",
|
||||
Design: "Design notes",
|
||||
AcceptanceCriteria: "Acceptance",
|
||||
Notes: "Notes",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeFeature,
|
||||
Assignee: "alice",
|
||||
}
|
||||
|
||||
err := store.CreateIssue(ctx, original, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve the issue
|
||||
retrieved, err := store.GetIssue(ctx, original.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if retrieved == nil {
|
||||
t.Fatal("GetIssue returned nil")
|
||||
}
|
||||
|
||||
if retrieved.ID != original.ID {
|
||||
t.Errorf("ID mismatch: got %v, want %v", retrieved.ID, original.ID)
|
||||
}
|
||||
|
||||
if retrieved.Title != original.Title {
|
||||
t.Errorf("Title mismatch: got %v, want %v", retrieved.Title, original.Title)
|
||||
}
|
||||
|
||||
if retrieved.Description != original.Description {
|
||||
t.Errorf("Description mismatch: got %v, want %v", retrieved.Description, original.Description)
|
||||
}
|
||||
|
||||
if retrieved.Assignee != original.Assignee {
|
||||
t.Errorf("Assignee mismatch: got %v, want %v", retrieved.Assignee, original.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIssueNotFound(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
issue, err := store.GetIssue(ctx, "bd-999")
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if issue != nil {
|
||||
t.Errorf("Expected nil for non-existent issue, got %v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateIssue(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
issue := &types.Issue{
|
||||
Title: "Original",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
|
||||
err := store.CreateIssue(ctx, issue, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Update the issue
|
||||
updates := map[string]interface{}{
|
||||
"title": "Updated",
|
||||
"status": string(types.StatusInProgress),
|
||||
"priority": 1,
|
||||
"assignee": "bob",
|
||||
}
|
||||
|
||||
err = store.UpdateIssue(ctx, issue.ID, updates, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify updates
|
||||
updated, err := store.GetIssue(ctx, issue.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if updated.Title != "Updated" {
|
||||
t.Errorf("Title not updated: got %v, want Updated", updated.Title)
|
||||
}
|
||||
|
||||
if updated.Status != types.StatusInProgress {
|
||||
t.Errorf("Status not updated: got %v, want %v", updated.Status, types.StatusInProgress)
|
||||
}
|
||||
|
||||
if updated.Priority != 1 {
|
||||
t.Errorf("Priority not updated: got %v, want 1", updated.Priority)
|
||||
}
|
||||
|
||||
if updated.Assignee != "bob" {
|
||||
t.Errorf("Assignee not updated: got %v, want bob", updated.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseIssue(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
issue := &types.Issue{
|
||||
Title: "Test",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
|
||||
err := store.CreateIssue(ctx, issue, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("CloseIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify closure
|
||||
closed, err := store.GetIssue(ctx, issue.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if closed.Status != types.StatusClosed {
|
||||
t.Errorf("Status not closed: got %v, want %v", closed.Status, types.StatusClosed)
|
||||
}
|
||||
|
||||
if closed.ClosedAt == nil {
|
||||
t.Error("ClosedAt should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchIssues(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create test issues
|
||||
issues := []*types.Issue{
|
||||
{Title: "Bug in login", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeBug},
|
||||
{Title: "Feature request", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeFeature},
|
||||
{Title: "Another bug", Status: types.StatusClosed, Priority: 1, IssueType: types.TypeBug},
|
||||
}
|
||||
|
||||
for _, issue := range issues {
|
||||
err := store.CreateIssue(ctx, issue, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test query search
|
||||
results, err := store.SearchIssues(ctx, "bug", types.IssueFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchIssues failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 2 {
|
||||
t.Errorf("Expected 2 results, got %d", len(results))
|
||||
}
|
||||
|
||||
// Test status filter
|
||||
openStatus := types.StatusOpen
|
||||
results, err = store.SearchIssues(ctx, "", types.IssueFilter{Status: &openStatus})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchIssues failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 2 {
|
||||
t.Errorf("Expected 2 open issues, got %d", len(results))
|
||||
}
|
||||
|
||||
// Test type filter
|
||||
bugType := types.TypeBug
|
||||
results, err = store.SearchIssues(ctx, "", types.IssueFilter{IssueType: &bugType})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchIssues failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 2 {
|
||||
t.Errorf("Expected 2 bugs, got %d", len(results))
|
||||
}
|
||||
|
||||
// Test priority filter (P0)
|
||||
priority0 := 0
|
||||
results, err = store.SearchIssues(ctx, "", types.IssueFilter{Priority: &priority0})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchIssues failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Errorf("Expected 1 P0 issue, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentIDGeneration(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
const numIssues = 100
|
||||
|
||||
type result struct {
|
||||
id string
|
||||
err error
|
||||
}
|
||||
|
||||
results := make(chan result, numIssues)
|
||||
|
||||
// Create issues concurrently
|
||||
for i := 0; i < numIssues; i++ {
|
||||
go func(n int) {
|
||||
issue := &types.Issue{
|
||||
Title: "Concurrent test",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
err := store.CreateIssue(ctx, issue, "test-user")
|
||||
results <- result{id: issue.ID, err: err}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Collect results
|
||||
ids := make(map[string]bool)
|
||||
for i := 0; i < numIssues; i++ {
|
||||
res := <-results
|
||||
if res.err != nil {
|
||||
t.Errorf("CreateIssue failed: %v", res.err)
|
||||
continue
|
||||
}
|
||||
if ids[res.id] {
|
||||
t.Errorf("Duplicate ID generated: %s", res.id)
|
||||
}
|
||||
ids[res.id] = true
|
||||
}
|
||||
|
||||
if len(ids) != numIssues {
|
||||
t.Errorf("Expected %d unique IDs, got %d", numIssues, len(ids))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user