diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 20cb203d..24903b4a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -162,7 +162,7 @@ {"id":"bd-244","title":"Implement SQLiteStorage.CreateIssues with atomic ID range reservation","description":"Core implementation of CreateIssues in internal/storage/sqlite/sqlite.go\n\nKey optimizations:\n- Single connection + transaction\n- Atomic ID range reservation (generate N IDs in one counter update)\n- Prepared statement for bulk inserts\n- All-or-nothing atomicity\n\nExpected 5-10x speedup for N\u003e10 issues.","design":"Implementation phases per ULTRATHINK_BD222.md:\n\n1. **Validation**: Pre-validate all issues (calls Issue.Validate() which enforces closed_at invariant from bd-224)\n2. **Connection \u0026 Transaction**: BEGIN IMMEDIATE (same as CreateIssue)\n3. **Batch ID Generation**: Reserve range [nextID, nextID+N) in single counter update\n4. **Bulk Insert**: Prepared statement loop (defer multi-VALUE INSERT optimization)\n5. **Bulk Events**: Record creation events for all issues\n6. **Bulk Dirty**: Mark all issues dirty for export\n7. **Commit**: All-or-nothing transaction commit\n\nSee ULTRATHINK_BD222.md lines 344-541 for full implementation details.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-15T14:21:53.433641-07:00","updated_at":"2025-10-15T16:27:22.000079-07:00","dependencies":[{"issue_id":"bd-244","depends_on_id":"bd-222","type":"parent-child","created_at":"2025-10-15T14:21:53.435109-07:00","created_by":"stevey"},{"issue_id":"bd-244","depends_on_id":"bd-240","type":"blocks","created_at":"2025-10-15T14:21:53.43563-07:00","created_by":"stevey"},{"issue_id":"bd-244","depends_on_id":"bd-241","type":"blocks","created_at":"2025-10-15T14:22:17.181984-07:00","created_by":"stevey"},{"issue_id":"bd-244","depends_on_id":"bd-242","type":"blocks","created_at":"2025-10-15T14:22:17.195635-07:00","created_by":"stevey"}]} {"id":"bd-245","title":"Add concurrency tests for CreateIssues","description":"Concurrent testing:\n- Multiple goroutines creating batches in parallel\n- Verify no ID collisions\n- Mix CreateIssue and CreateIssues calls\n- Verify all issues created correctly","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-15T14:21:58.802643-07:00","updated_at":"2025-10-15T16:27:22.000481-07:00","dependencies":[{"issue_id":"bd-245","depends_on_id":"bd-222","type":"parent-child","created_at":"2025-10-15T14:21:58.803494-07:00","created_by":"stevey"},{"issue_id":"bd-245","depends_on_id":"bd-244","type":"blocks","created_at":"2025-10-15T14:21:58.804094-07:00","created_by":"stevey"}]} {"id":"bd-247","title":"Add performance benchmarks for CreateIssues","description":"Benchmark suite comparing CreateIssue loop vs CreateIssues batch:\n- 10, 100, 1000 issues\n- Expected: 5-10x speedup for N\u003e10\n- Measure connection, transaction, and insert overhead\n\nTarget: 100 issues in \u003c130ms (vs 900ms sequential)","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-15T14:22:03.391873-07:00","updated_at":"2025-10-15T16:27:22.000882-07:00","dependencies":[{"issue_id":"bd-247","depends_on_id":"bd-222","type":"parent-child","created_at":"2025-10-15T14:22:03.392524-07:00","created_by":"stevey"},{"issue_id":"bd-247","depends_on_id":"bd-244","type":"blocks","created_at":"2025-10-15T14:22:03.392961-07:00","created_by":"stevey"}]} -{"id":"bd-248","title":"Test reopen command","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-15T16:28:44.246154-07:00","updated_at":"2025-10-15T16:28:44.246154-07:00"} +{"id":"bd-248","title":"Test reopen command","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-15T16:28:44.246154-07:00","updated_at":"2025-10-15T17:05:23.644788-07:00","closed_at":"2025-10-15T17:05:23.644788-07:00"} {"id":"bd-249","title":"Test reopen command","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-15T16:28:49.924381-07:00","updated_at":"2025-10-15T16:28:55.491141-07:00","closed_at":"2025-10-15T16:28:55.491141-07:00"} {"id":"bd-25","title":"Add transaction support to storage layer for atomic multi-operation workflows","description":"Currently each storage method (CreateIssue, UpdateIssue, etc.) starts its own transaction. This makes it impossible to perform atomic multi-step operations like collision resolution. Add support for passing *sql.Tx through the storage interface, or create transaction-aware versions of methods. This would make remapCollisions and other batch operations truly atomic.","status":"closed","priority":4,"issue_type":"feature","created_at":"2025-10-14T14:43:06.910892-07:00","updated_at":"2025-10-15T16:27:22.001363-07:00","closed_at":"2025-10-15T03:01:29.570206-07:00"} {"id":"bd-26","title":"Optimize reference updates to avoid loading all issues into memory","description":"In updateReferences(), we call SearchIssues with no filter to get ALL issues for updating references. For large databases (10k+ issues), this loads everything into memory. Options: 1) Use batched processing with LIMIT/OFFSET, 2) Use SQL UPDATE with REPLACE() directly, 3) Stream results instead of loading all at once. Located in collision.go:266","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-14T14:43:06.911497-07:00","updated_at":"2025-10-15T16:27:22.001829-07:00"} diff --git a/cmd/bd/reopen_test.go b/cmd/bd/reopen_test.go new file mode 100644 index 00000000..d18f329a --- /dev/null +++ b/cmd/bd/reopen_test.go @@ -0,0 +1,211 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" +) + +func TestReopenCommand(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "bd-test-reopen-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + testDB := filepath.Join(tmpDir, "test.db") + s, err := sqlite.New(testDB) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer s.Close() + + ctx := context.Background() + + t.Run("reopen closed issue", func(t *testing.T) { + issue := &types.Issue{ + Title: "Test Issue", + Description: "Test description", + Priority: 1, + IssueType: types.TypeBug, + Status: types.StatusOpen, + } + + if err := s.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("Failed to create issue: %v", err) + } + + if err := s.CloseIssue(ctx, issue.ID, "test-user", "Closing for test"); err != nil { + t.Fatalf("Failed to close issue: %v", err) + } + + closed, err := s.GetIssue(ctx, issue.ID) + if err != nil { + t.Fatalf("Failed to get closed issue: %v", err) + } + if closed.Status != types.StatusClosed { + t.Errorf("Expected status to be closed, got %s", closed.Status) + } + if closed.ClosedAt == nil { + t.Error("Expected ClosedAt to be set") + } + + updates := map[string]interface{}{ + "status": string(types.StatusOpen), + } + if err := s.UpdateIssue(ctx, issue.ID, updates, "test-user"); err != nil { + t.Fatalf("Failed to reopen issue: %v", err) + } + + reopened, err := s.GetIssue(ctx, issue.ID) + if err != nil { + t.Fatalf("Failed to get reopened issue: %v", err) + } + if reopened.Status != types.StatusOpen { + t.Errorf("Expected status to be open, got %s", reopened.Status) + } + if reopened.ClosedAt != nil { + t.Errorf("Expected ClosedAt to be nil, got %v", reopened.ClosedAt) + } + }) + + t.Run("reopen with reason adds comment", func(t *testing.T) { + issue := &types.Issue{ + Title: "Test Issue 2", + Description: "Test description", + Priority: 1, + IssueType: types.TypeTask, + Status: types.StatusOpen, + } + + if err := s.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("Failed to create issue: %v", err) + } + + if err := s.CloseIssue(ctx, issue.ID, "test-user", "Done"); err != nil { + t.Fatalf("Failed to close issue: %v", err) + } + + updates := map[string]interface{}{ + "status": string(types.StatusOpen), + } + if err := s.UpdateIssue(ctx, issue.ID, updates, "test-user"); err != nil { + t.Fatalf("Failed to reopen issue: %v", err) + } + + reason := "Found a regression" + if err := s.AddComment(ctx, issue.ID, "test-user", reason); err != nil { + t.Fatalf("Failed to add comment: %v", err) + } + + events, err := s.GetEvents(ctx, issue.ID, 100) + if err != nil { + t.Fatalf("Failed to get events: %v", err) + } + + found := false + for _, e := range events { + if e.EventType == types.EventCommented && e.Comment != nil && *e.Comment == reason { + found = true + break + } + } + if !found { + t.Errorf("Expected to find comment event with reason '%s'", reason) + } + }) + + t.Run("reopen multiple issues", func(t *testing.T) { + issue1 := &types.Issue{ + Title: "Multi Test 1", + Priority: 1, + IssueType: types.TypeBug, + Status: types.StatusOpen, + } + issue2 := &types.Issue{ + Title: "Multi Test 2", + Priority: 1, + IssueType: types.TypeBug, + Status: types.StatusOpen, + } + + if err := s.CreateIssue(ctx, issue1, "test-user"); err != nil { + t.Fatalf("Failed to create issue1: %v", err) + } + if err := s.CreateIssue(ctx, issue2, "test-user"); err != nil { + t.Fatalf("Failed to create issue2: %v", err) + } + + if err := s.CloseIssue(ctx, issue1.ID, "test-user", "Done"); err != nil { + t.Fatalf("Failed to close issue1: %v", err) + } + if err := s.CloseIssue(ctx, issue2.ID, "test-user", "Done"); err != nil { + t.Fatalf("Failed to close issue2: %v", err) + } + + updates1 := map[string]interface{}{ + "status": string(types.StatusOpen), + } + if err := s.UpdateIssue(ctx, issue1.ID, updates1, "test-user"); err != nil { + t.Fatalf("Failed to reopen issue1: %v", err) + } + + updates2 := map[string]interface{}{ + "status": string(types.StatusOpen), + } + if err := s.UpdateIssue(ctx, issue2.ID, updates2, "test-user"); err != nil { + t.Fatalf("Failed to reopen issue2: %v", err) + } + + reopened1, err := s.GetIssue(ctx, issue1.ID) + if err != nil { + t.Fatalf("Failed to get issue1: %v", err) + } + reopened2, err := s.GetIssue(ctx, issue2.ID) + if err != nil { + t.Fatalf("Failed to get issue2: %v", err) + } + + if reopened1.Status != types.StatusOpen { + t.Errorf("Expected issue1 status to be open, got %s", reopened1.Status) + } + if reopened2.Status != types.StatusOpen { + t.Errorf("Expected issue2 status to be open, got %s", reopened2.Status) + } + }) + + t.Run("reopen already open issue is no-op", func(t *testing.T) { + issue := &types.Issue{ + Title: "Already Open", + Priority: 1, + IssueType: types.TypeTask, + Status: types.StatusOpen, + } + + if err := s.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("Failed to create issue: %v", err) + } + + updates := map[string]interface{}{ + "status": string(types.StatusOpen), + } + if err := s.UpdateIssue(ctx, issue.ID, updates, "test-user"); err != nil { + t.Fatalf("Failed to update issue: %v", err) + } + + updated, err := s.GetIssue(ctx, issue.ID) + if err != nil { + t.Fatalf("Failed to get issue: %v", err) + } + if updated.Status != types.StatusOpen { + t.Errorf("Expected status to remain open, got %s", updated.Status) + } + if updated.ClosedAt != nil { + t.Errorf("Expected ClosedAt to remain nil, got %v", updated.ClosedAt) + } + }) +}