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:
@@ -7,11 +7,16 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyackey/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// AddDependency adds a dependency between issues with cycle prevention
|
||||
func (s *SQLiteStorage) AddDependency(ctx context.Context, dep *types.Dependency, actor string) error {
|
||||
// Validate dependency type
|
||||
if !dep.Type.IsValid() {
|
||||
return fmt.Errorf("invalid dependency type: %s (must be blocks, related, parent-child, or discovered-from)", dep.Type)
|
||||
}
|
||||
|
||||
// Validate that both issues exist
|
||||
issueExists, err := s.GetIssue(ctx, dep.IssueID)
|
||||
if err != nil {
|
||||
|
||||
280
internal/storage/sqlite/dependencies_test.go
Normal file
280
internal/storage/sqlite/dependencies_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func TestAddDependency(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create two issues
|
||||
issue1 := &types.Issue{Title: "First", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issue2 := &types.Issue{Title: "Second", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
|
||||
store.CreateIssue(ctx, issue1, "test-user")
|
||||
store.CreateIssue(ctx, issue2, "test-user")
|
||||
|
||||
// Add dependency (issue2 depends on issue1)
|
||||
dep := &types.Dependency{
|
||||
IssueID: issue2.ID,
|
||||
DependsOnID: issue1.ID,
|
||||
Type: types.DepBlocks,
|
||||
}
|
||||
|
||||
err := store.AddDependency(ctx, dep, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDependency failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify dependency was added
|
||||
deps, err := store.GetDependencies(ctx, issue2.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDependencies failed: %v", err)
|
||||
}
|
||||
|
||||
if len(deps) != 1 {
|
||||
t.Fatalf("Expected 1 dependency, got %d", len(deps))
|
||||
}
|
||||
|
||||
if deps[0].ID != issue1.ID {
|
||||
t.Errorf("Expected dependency on %s, got %s", issue1.ID, deps[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDependencyDiscoveredFrom(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create two issues
|
||||
parent := &types.Issue{Title: "Parent task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
discovered := &types.Issue{Title: "Bug found during work", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeBug}
|
||||
|
||||
store.CreateIssue(ctx, parent, "test-user")
|
||||
store.CreateIssue(ctx, discovered, "test-user")
|
||||
|
||||
// Add discovered-from dependency
|
||||
dep := &types.Dependency{
|
||||
IssueID: discovered.ID,
|
||||
DependsOnID: parent.ID,
|
||||
Type: types.DepDiscoveredFrom,
|
||||
}
|
||||
|
||||
err := store.AddDependency(ctx, dep, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDependency with discovered-from failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify dependency was added
|
||||
deps, err := store.GetDependencies(ctx, discovered.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDependencies failed: %v", err)
|
||||
}
|
||||
|
||||
if len(deps) != 1 {
|
||||
t.Fatalf("Expected 1 dependency, got %d", len(deps))
|
||||
}
|
||||
|
||||
if deps[0].ID != parent.ID {
|
||||
t.Errorf("Expected dependency on %s, got %s", parent.ID, deps[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveDependency(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create and link issues
|
||||
issue1 := &types.Issue{Title: "First", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issue2 := &types.Issue{Title: "Second", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
|
||||
store.CreateIssue(ctx, issue1, "test-user")
|
||||
store.CreateIssue(ctx, issue2, "test-user")
|
||||
|
||||
dep := &types.Dependency{
|
||||
IssueID: issue2.ID,
|
||||
DependsOnID: issue1.ID,
|
||||
Type: types.DepBlocks,
|
||||
}
|
||||
store.AddDependency(ctx, dep, "test-user")
|
||||
|
||||
// Remove the dependency
|
||||
err := store.RemoveDependency(ctx, issue2.ID, issue1.ID, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveDependency failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify dependency was removed
|
||||
deps, err := store.GetDependencies(ctx, issue2.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDependencies failed: %v", err)
|
||||
}
|
||||
|
||||
if len(deps) != 0 {
|
||||
t.Errorf("Expected 0 dependencies after removal, got %d", len(deps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDependents(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create issues: bd-2 and bd-3 both depend on bd-1
|
||||
issue1 := &types.Issue{Title: "Foundation", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issue2 := &types.Issue{Title: "Feature A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issue3 := &types.Issue{Title: "Feature B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
|
||||
store.CreateIssue(ctx, issue1, "test-user")
|
||||
store.CreateIssue(ctx, issue2, "test-user")
|
||||
store.CreateIssue(ctx, issue3, "test-user")
|
||||
|
||||
store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user")
|
||||
store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user")
|
||||
|
||||
// Get dependents of issue1
|
||||
dependents, err := store.GetDependents(ctx, issue1.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDependents failed: %v", err)
|
||||
}
|
||||
|
||||
if len(dependents) != 2 {
|
||||
t.Fatalf("Expected 2 dependents, got %d", len(dependents))
|
||||
}
|
||||
|
||||
// Verify both dependents are present
|
||||
foundIDs := make(map[string]bool)
|
||||
for _, dep := range dependents {
|
||||
foundIDs[dep.ID] = true
|
||||
}
|
||||
|
||||
if !foundIDs[issue2.ID] || !foundIDs[issue3.ID] {
|
||||
t.Errorf("Expected dependents %s and %s", issue2.ID, issue3.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDependencyTree(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a chain: bd-3 → bd-2 → bd-1
|
||||
issue1 := &types.Issue{Title: "Level 0", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issue2 := &types.Issue{Title: "Level 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issue3 := &types.Issue{Title: "Level 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
|
||||
store.CreateIssue(ctx, issue1, "test-user")
|
||||
store.CreateIssue(ctx, issue2, "test-user")
|
||||
store.CreateIssue(ctx, issue3, "test-user")
|
||||
|
||||
store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user")
|
||||
store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue2.ID, Type: types.DepBlocks}, "test-user")
|
||||
|
||||
// Get tree starting from issue3
|
||||
tree, err := store.GetDependencyTree(ctx, issue3.ID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDependencyTree failed: %v", err)
|
||||
}
|
||||
|
||||
if len(tree) != 3 {
|
||||
t.Fatalf("Expected 3 nodes in tree, got %d", len(tree))
|
||||
}
|
||||
|
||||
// Verify depths
|
||||
depthMap := make(map[string]int)
|
||||
for _, node := range tree {
|
||||
depthMap[node.ID] = node.Depth
|
||||
}
|
||||
|
||||
if depthMap[issue3.ID] != 0 {
|
||||
t.Errorf("Expected depth 0 for %s, got %d", issue3.ID, depthMap[issue3.ID])
|
||||
}
|
||||
|
||||
if depthMap[issue2.ID] != 1 {
|
||||
t.Errorf("Expected depth 1 for %s, got %d", issue2.ID, depthMap[issue2.ID])
|
||||
}
|
||||
|
||||
if depthMap[issue1.ID] != 2 {
|
||||
t.Errorf("Expected depth 2 for %s, got %d", issue1.ID, depthMap[issue1.ID])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectCycles(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Try to create a cycle: bd-1 → bd-2 → bd-3 → bd-1
|
||||
// This should be prevented by AddDependency
|
||||
issue1 := &types.Issue{Title: "First", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issue2 := &types.Issue{Title: "Second", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issue3 := &types.Issue{Title: "Third", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
|
||||
store.CreateIssue(ctx, issue1, "test-user")
|
||||
store.CreateIssue(ctx, issue2, "test-user")
|
||||
store.CreateIssue(ctx, issue3, "test-user")
|
||||
|
||||
// Add first two dependencies successfully
|
||||
err := store.AddDependency(ctx, &types.Dependency{IssueID: issue1.ID, DependsOnID: issue2.ID, Type: types.DepBlocks}, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("First dependency failed: %v", err)
|
||||
}
|
||||
|
||||
err = store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue3.ID, Type: types.DepBlocks}, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("Second dependency failed: %v", err)
|
||||
}
|
||||
|
||||
// The third dependency should fail because it would create a cycle
|
||||
err = store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when creating cycle, but got none")
|
||||
}
|
||||
|
||||
// Verify no cycles exist
|
||||
cycles, err := store.DetectCycles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectCycles failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cycles) != 0 {
|
||||
t.Errorf("Expected no cycles after prevention, but found %d", len(cycles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoCyclesDetected(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a valid chain with no cycles
|
||||
issue1 := &types.Issue{Title: "First", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issue2 := &types.Issue{Title: "Second", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
|
||||
store.CreateIssue(ctx, issue1, "test-user")
|
||||
store.CreateIssue(ctx, issue2, "test-user")
|
||||
|
||||
store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user")
|
||||
|
||||
cycles, err := store.DetectCycles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectCycles failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cycles) != 0 {
|
||||
t.Errorf("Expected no cycles, but found %d", len(cycles))
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/steveyackey/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// AddComment adds a comment to an issue
|
||||
@@ -31,9 +31,11 @@ func (s *SQLiteStorage) AddComment(ctx context.Context, issueID, actor, comment
|
||||
|
||||
// GetEvents returns the event history for an issue
|
||||
func (s *SQLiteStorage) GetEvents(ctx context.Context, issueID string, limit int) ([]*types.Event, error) {
|
||||
args := []interface{}{issueID}
|
||||
limitSQL := ""
|
||||
if limit > 0 {
|
||||
limitSQL = fmt.Sprintf(" LIMIT %d", limit)
|
||||
limitSQL = " LIMIT ?"
|
||||
args = append(args, limit)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
@@ -44,7 +46,7 @@ func (s *SQLiteStorage) GetEvents(ctx context.Context, issueID string, limit int
|
||||
%s
|
||||
`, limitSQL)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, issueID)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get events: %w", err)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/steveyackey/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// AddLabel adds a label to an issue
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyackey/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// GetReadyWork returns issues with no open blockers
|
||||
|
||||
274
internal/storage/sqlite/ready_test.go
Normal file
274
internal/storage/sqlite/ready_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func TestGetReadyWork(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create issues:
|
||||
// bd-1: open, no dependencies → READY
|
||||
// bd-2: open, depends on bd-1 (open) → BLOCKED
|
||||
// bd-3: open, no dependencies → READY
|
||||
// bd-4: closed, no dependencies → NOT READY (closed)
|
||||
// bd-5: open, depends on bd-4 (closed) → READY (blocker is closed)
|
||||
|
||||
issue1 := &types.Issue{Title: "Ready 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issue2 := &types.Issue{Title: "Blocked", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issue3 := &types.Issue{Title: "Ready 2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
||||
issue4 := &types.Issue{Title: "Closed", Status: types.StatusClosed, Priority: 1, IssueType: types.TypeTask}
|
||||
issue5 := &types.Issue{Title: "Ready 3", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask}
|
||||
|
||||
store.CreateIssue(ctx, issue1, "test-user")
|
||||
store.CreateIssue(ctx, issue2, "test-user")
|
||||
store.CreateIssue(ctx, issue3, "test-user")
|
||||
store.CreateIssue(ctx, issue4, "test-user")
|
||||
store.CloseIssue(ctx, issue4.ID, "Done", "test-user")
|
||||
store.CreateIssue(ctx, issue5, "test-user")
|
||||
|
||||
// Add dependencies
|
||||
store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user")
|
||||
store.AddDependency(ctx, &types.Dependency{IssueID: issue5.ID, DependsOnID: issue4.ID, Type: types.DepBlocks}, "test-user")
|
||||
|
||||
// Get ready work
|
||||
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReadyWork failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have 3 ready issues: bd-1, bd-3, bd-5
|
||||
if len(ready) != 3 {
|
||||
t.Fatalf("Expected 3 ready issues, got %d", len(ready))
|
||||
}
|
||||
|
||||
// Verify ready issues
|
||||
readyIDs := make(map[string]bool)
|
||||
for _, issue := range ready {
|
||||
readyIDs[issue.ID] = true
|
||||
}
|
||||
|
||||
if !readyIDs[issue1.ID] {
|
||||
t.Errorf("Expected %s to be ready", issue1.ID)
|
||||
}
|
||||
if !readyIDs[issue3.ID] {
|
||||
t.Errorf("Expected %s to be ready", issue3.ID)
|
||||
}
|
||||
if !readyIDs[issue5.ID] {
|
||||
t.Errorf("Expected %s to be ready", issue5.ID)
|
||||
}
|
||||
if readyIDs[issue2.ID] {
|
||||
t.Errorf("Expected %s to be blocked, but it was ready", issue2.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetReadyWorkPriorityOrder(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create issues with different priorities
|
||||
issueP0 := &types.Issue{Title: "Highest", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask}
|
||||
issueP2 := &types.Issue{Title: "Medium", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
||||
issueP1 := &types.Issue{Title: "High", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
|
||||
store.CreateIssue(ctx, issueP2, "test-user")
|
||||
store.CreateIssue(ctx, issueP0, "test-user")
|
||||
store.CreateIssue(ctx, issueP1, "test-user")
|
||||
|
||||
// Get ready work
|
||||
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReadyWork failed: %v", err)
|
||||
}
|
||||
|
||||
if len(ready) != 3 {
|
||||
t.Fatalf("Expected 3 ready issues, got %d", len(ready))
|
||||
}
|
||||
|
||||
// Verify priority ordering (P0 first, then P1, then P2)
|
||||
if ready[0].Priority != 0 {
|
||||
t.Errorf("Expected first issue to be P0, got P%d", ready[0].Priority)
|
||||
}
|
||||
if ready[1].Priority != 1 {
|
||||
t.Errorf("Expected second issue to be P1, got P%d", ready[1].Priority)
|
||||
}
|
||||
if ready[2].Priority != 2 {
|
||||
t.Errorf("Expected third issue to be P2, got P%d", ready[2].Priority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetReadyWorkWithPriorityFilter(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create issues with different priorities
|
||||
issueP0 := &types.Issue{Title: "P0", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask}
|
||||
issueP1 := &types.Issue{Title: "P1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issueP2 := &types.Issue{Title: "P2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
||||
|
||||
store.CreateIssue(ctx, issueP0, "test-user")
|
||||
store.CreateIssue(ctx, issueP1, "test-user")
|
||||
store.CreateIssue(ctx, issueP2, "test-user")
|
||||
|
||||
// Filter for P0 only
|
||||
priority0 := 0
|
||||
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Priority: &priority0})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReadyWork failed: %v", err)
|
||||
}
|
||||
|
||||
if len(ready) != 1 {
|
||||
t.Fatalf("Expected 1 P0 issue, got %d", len(ready))
|
||||
}
|
||||
|
||||
if ready[0].Priority != 0 {
|
||||
t.Errorf("Expected P0 issue, got P%d", ready[0].Priority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetReadyWorkWithAssigneeFilter(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create issues with different assignees
|
||||
issueAlice := &types.Issue{Title: "Alice's task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, Assignee: "alice"}
|
||||
issueBob := &types.Issue{Title: "Bob's task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, Assignee: "bob"}
|
||||
issueUnassigned := &types.Issue{Title: "Unassigned", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
|
||||
store.CreateIssue(ctx, issueAlice, "test-user")
|
||||
store.CreateIssue(ctx, issueBob, "test-user")
|
||||
store.CreateIssue(ctx, issueUnassigned, "test-user")
|
||||
|
||||
// Filter for alice
|
||||
assignee := "alice"
|
||||
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Assignee: &assignee})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReadyWork failed: %v", err)
|
||||
}
|
||||
|
||||
if len(ready) != 1 {
|
||||
t.Fatalf("Expected 1 issue for alice, got %d", len(ready))
|
||||
}
|
||||
|
||||
if ready[0].Assignee != "alice" {
|
||||
t.Errorf("Expected alice's issue, got %s", ready[0].Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetReadyWorkWithLimit(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create 5 ready issues
|
||||
for i := 0; i < 5; i++ {
|
||||
issue := &types.Issue{Title: "Task", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
||||
store.CreateIssue(ctx, issue, "test-user")
|
||||
}
|
||||
|
||||
// Limit to 3
|
||||
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Limit: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReadyWork failed: %v", err)
|
||||
}
|
||||
|
||||
if len(ready) != 3 {
|
||||
t.Errorf("Expected 3 issues (limit), got %d", len(ready))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetReadyWorkIgnoresRelatedDeps(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create two issues with "related" dependency (should not block)
|
||||
issue1 := &types.Issue{Title: "First", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issue2 := &types.Issue{Title: "Second", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
|
||||
store.CreateIssue(ctx, issue1, "test-user")
|
||||
store.CreateIssue(ctx, issue2, "test-user")
|
||||
|
||||
// Add "related" dependency (not blocking)
|
||||
store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepRelated}, "test-user")
|
||||
|
||||
// Both should be ready (related deps don't block)
|
||||
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReadyWork failed: %v", err)
|
||||
}
|
||||
|
||||
if len(ready) != 2 {
|
||||
t.Fatalf("Expected 2 ready issues (related deps don't block), got %d", len(ready))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBlockedIssues(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create issues:
|
||||
// bd-1: open, no dependencies → not blocked
|
||||
// bd-2: open, depends on bd-1 (open) → blocked by bd-1
|
||||
// bd-3: open, depends on bd-1 and bd-2 (both open) → blocked by 2 issues
|
||||
|
||||
issue1 := &types.Issue{Title: "Foundation", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issue2 := &types.Issue{Title: "Blocked by 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issue3 := &types.Issue{Title: "Blocked by 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
|
||||
store.CreateIssue(ctx, issue1, "test-user")
|
||||
store.CreateIssue(ctx, issue2, "test-user")
|
||||
store.CreateIssue(ctx, issue3, "test-user")
|
||||
|
||||
store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user")
|
||||
store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user")
|
||||
store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue2.ID, Type: types.DepBlocks}, "test-user")
|
||||
|
||||
// Get blocked issues
|
||||
blocked, err := store.GetBlockedIssues(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBlockedIssues failed: %v", err)
|
||||
}
|
||||
|
||||
if len(blocked) != 2 {
|
||||
t.Fatalf("Expected 2 blocked issues, got %d", len(blocked))
|
||||
}
|
||||
|
||||
// Find issue3 in blocked list
|
||||
var issue3Blocked *types.BlockedIssue
|
||||
for i := range blocked {
|
||||
if blocked[i].ID == issue3.ID {
|
||||
issue3Blocked = blocked[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if issue3Blocked == nil {
|
||||
t.Fatal("Expected issue3 to be in blocked list")
|
||||
}
|
||||
|
||||
if issue3Blocked.BlockedByCount != 2 {
|
||||
t.Errorf("Expected issue3 to be blocked by 2 issues, got %d", issue3Blocked.BlockedByCount)
|
||||
}
|
||||
|
||||
// Verify the blockers are correct
|
||||
if len(issue3Blocked.BlockedBy) != 2 {
|
||||
t.Errorf("Expected 2 blocker IDs, got %d", len(issue3Blocked.BlockedBy))
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,12 @@ CREATE TABLE IF NOT EXISTS events (
|
||||
CREATE INDEX IF NOT EXISTS idx_events_issue ON events(issue_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
|
||||
|
||||
-- Config table (for storing settings like issue prefix)
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Ready work view
|
||||
CREATE VIEW IF NOT EXISTS ready_issues AS
|
||||
SELECT i.*
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/steveyackey/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// SQLiteStorage implements the Storage interface using SQLite
|
||||
@@ -94,7 +94,14 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
|
||||
// Generate ID if not set (thread-safe)
|
||||
if issue.ID == "" {
|
||||
s.idMu.Lock()
|
||||
issue.ID = fmt.Sprintf("bd-%d", s.nextID)
|
||||
|
||||
// Get prefix from config, default to "bd"
|
||||
prefix, err := s.GetConfig(ctx, "issue_prefix")
|
||||
if err != nil || prefix == "" {
|
||||
prefix = "bd"
|
||||
}
|
||||
|
||||
issue.ID = fmt.Sprintf("%s-%d", prefix, s.nextID)
|
||||
s.nextID++
|
||||
s.idMu.Unlock()
|
||||
}
|
||||
@@ -129,7 +136,11 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
|
||||
}
|
||||
|
||||
// Record creation event
|
||||
eventData, _ := json.Marshal(issue)
|
||||
eventData, err := json.Marshal(issue)
|
||||
if err != nil {
|
||||
// Fall back to minimal description if marshaling fails
|
||||
eventData = []byte(fmt.Sprintf(`{"id":"%s","title":"%s"}`, issue.ID, issue.Title))
|
||||
}
|
||||
eventDataStr := string(eventData)
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO events (issue_id, event_type, actor, new_value)
|
||||
@@ -272,8 +283,16 @@ func (s *SQLiteStorage) UpdateIssue(ctx context.Context, id string, updates map[
|
||||
}
|
||||
|
||||
// Record event
|
||||
oldData, _ := json.Marshal(oldIssue)
|
||||
newData, _ := json.Marshal(updates)
|
||||
oldData, err := json.Marshal(oldIssue)
|
||||
if err != nil {
|
||||
// Fall back to minimal description if marshaling fails
|
||||
oldData = []byte(fmt.Sprintf(`{"id":"%s"}`, id))
|
||||
}
|
||||
newData, err := json.Marshal(updates)
|
||||
if err != nil {
|
||||
// Fall back to minimal description if marshaling fails
|
||||
newData = []byte(`{}`)
|
||||
}
|
||||
oldDataStr := string(oldData)
|
||||
newDataStr := string(newData)
|
||||
|
||||
@@ -365,7 +384,8 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
|
||||
|
||||
limitSQL := ""
|
||||
if filter.Limit > 0 {
|
||||
limitSQL = fmt.Sprintf(" LIMIT %d", filter.Limit)
|
||||
limitSQL = " LIMIT ?"
|
||||
args = append(args, filter.Limit)
|
||||
}
|
||||
|
||||
querySQL := fmt.Sprintf(`
|
||||
@@ -418,6 +438,25 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// SetConfig sets a configuration value
|
||||
func (s *SQLiteStorage) SetConfig(ctx context.Context, key, value string) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO config (key, value) VALUES (?, ?)
|
||||
ON CONFLICT (key) DO UPDATE SET value = excluded.value
|
||||
`, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetConfig gets a configuration value
|
||||
func (s *SQLiteStorage) GetConfig(ctx context.Context, key string) (string, error) {
|
||||
var value string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM config WHERE key = ?`, key).Scan(&value)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return value, err
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (s *SQLiteStorage) Close() error {
|
||||
return s.db.Close()
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package storage
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/steveyackey/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// Storage defines the interface for issue storage backends
|
||||
|
||||
@@ -98,15 +98,16 @@ type Dependency struct {
|
||||
type DependencyType string
|
||||
|
||||
const (
|
||||
DepBlocks DependencyType = "blocks"
|
||||
DepRelated DependencyType = "related"
|
||||
DepParentChild DependencyType = "parent-child"
|
||||
DepBlocks DependencyType = "blocks"
|
||||
DepRelated DependencyType = "related"
|
||||
DepParentChild DependencyType = "parent-child"
|
||||
DepDiscoveredFrom DependencyType = "discovered-from"
|
||||
)
|
||||
|
||||
// IsValid checks if the dependency type value is valid
|
||||
func (d DependencyType) IsValid() bool {
|
||||
switch d {
|
||||
case DepBlocks, DepRelated, DepParentChild:
|
||||
case DepBlocks, DepRelated, DepParentChild, DepDiscoveredFrom:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user