diff --git a/.beads/beads.jsonl b/.beads/beads.jsonl index 14b9675b..6933e103 100644 --- a/.beads/beads.jsonl +++ b/.beads/beads.jsonl @@ -1,7 +1,7 @@ {"id":"bd-0134cc5a","content_hash":"d45c0e44c01c5855f14f07693bd800f4bfeac3084e10ceb17970ff54c58f6a40","title":"Fix auto-import creating duplicates instead of updating issues","description":"ROOT CAUSE: server_export_import_auto.go line 221 uses ResolveCollisions: true for ALL auto-imports. This is wrong.\n\nProblem:\n- ResolveCollisions is for branch merges (different issues with same ID)\n- Auto-import should UPDATE existing issues, not create duplicates\n- Every git pull creates NEW duplicate issues with different IDs\n- Two agents ping-pong creating endless duplicates\n\nEvidence:\n- 31 duplicate groups found (bd duplicates)\n- bd-236-246 are duplicates of bd-224-235\n- Both agents keep pulling and creating more duplicates\n- JSONL file grows endlessly with duplicates\n\nThe Fix:\nChange checkAndAutoImportIfStale in server_export_import_auto.go:\n- Remove ResolveCollisions: true (line 221)\n- Use normal import logic that updates existing issues by ID\n- Only use ResolveCollisions for explicit bd import --resolve-collisions\n\nImpact: Critical - makes beads unusable for multi-agent workflows","acceptance_criteria":"- Auto-import does NOT create duplicates when pulling git changes\n- Existing issues are updated in-place by ID match\n- No ping-pong commits between agents\n- Test: two agents updating same issue should NOT create duplicates\n- bd duplicates shows 0 groups after fix","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-10-27T21:48:57.733846-07:00","updated_at":"2025-10-30T17:12:58.21084-07:00","closed_at":"2025-10-27T22:26:40.627239-07:00"} {"id":"bd-0447029c","content_hash":"f32f7d8f0b07aaaeb9d07d8a1d000eef8fc79cf864e8aa20ebb899f6e359ebda","title":"bd find-duplicates - AI-powered duplicate detection","description":"Find semantically duplicate issues.\n\nApproaches:\n1. Mechanical: Exact title/description matching\n2. Embeddings: Cosine similarity (cheap, scalable)\n3. AI: LLM-based semantic comparison (expensive, accurate)\n\nUses embeddings by default for \u003e100 issues.\n\nFiles: cmd/bd/find_duplicates.go (new)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-29T16:43:28.182327-07:00","updated_at":"2025-10-30T17:12:58.188016-07:00","closed_at":"2025-10-29T16:15:10.64719-07:00"} -{"id":"bd-0458","content_hash":"7503fa7f4b0b10c9b22e20ee8e0b8d691397979e89275d8e2efd3c0c0f7cbcb6","title":"Consolidate export/import/commit/push into sync.go","description":"Create internal/daemonrunner/sync.go with Syncer type. Add ExportOnce, ImportOnce, CommitAndMaybePush methods. Replace createExportFunc/createAutoImportFunc with thin closures calling Syncer.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-01T11:41:14.874539-07:00","updated_at":"2025-11-01T22:56:08.364335-07:00","closed_at":"2025-11-01T22:56:08.364335-07:00"} -{"id":"bd-05a1","content_hash":"ffa9764c3c65d13af6d9c54b691b57541a66bf3d266b1e11c5172cd09a32e1f5","title":"Isolate RPC server startup into rpc_server.go","description":"Create internal/daemonrunner/rpc_server.go with StartRPC function. Move startRPCServer logic here and return typed handle.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-01T11:41:14.876839-07:00","updated_at":"2025-11-01T22:56:08.362581-07:00","closed_at":"2025-11-01T22:56:08.362581-07:00"} +{"id":"bd-0458","content_hash":"7503fa7f4b0b10c9b22e20ee8e0b8d691397979e89275d8e2efd3c0c0f7cbcb6","title":"Consolidate export/import/commit/push into sync.go","description":"Create internal/daemonrunner/sync.go with Syncer type. Add ExportOnce, ImportOnce, CommitAndMaybePush methods. Replace createExportFunc/createAutoImportFunc with thin closures calling Syncer.","status":"open","priority":1,"issue_type":"task","created_at":"2025-11-01T11:41:14.874539-07:00","updated_at":"2025-11-01T11:41:14.874539-07:00"} +{"id":"bd-05a1","content_hash":"ffa9764c3c65d13af6d9c54b691b57541a66bf3d266b1e11c5172cd09a32e1f5","title":"Isolate RPC server startup into rpc_server.go","description":"Create internal/daemonrunner/rpc_server.go with StartRPC function. Move startRPCServer logic here and return typed handle.","status":"open","priority":1,"issue_type":"task","created_at":"2025-11-01T11:41:14.876839-07:00","updated_at":"2025-11-01T11:41:14.876839-07:00"} {"id":"bd-0650a73b","content_hash":"a596aa8d6114d4938471e181ebc30da5d0315f74fd711a92dbbb83f5d0e7af88","title":"Create cmd/bd/daemon_debouncer.go (~60 LOC)","description":"Implement Debouncer to batch rapid events into single action. Default 500ms, configurable via BEADS_DEBOUNCE_MS. Thread-safe with mutex.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T16:20:02.431118-07:00","updated_at":"2025-10-30T17:12:58.221711-07:00","closed_at":"2025-10-28T12:03:35.614191-07:00"} {"id":"bd-06aec0c3","content_hash":"330e69cf6ca40209948559b453ed5242c15a71b5c949a858ad6854488b12dca2","title":"Integration Testing","description":"Verify cache removal doesn't break any workflows","acceptance_criteria":"- All test cases pass\n- No stale data observed\n- Performance is same or better\n- MCP works as before\n\nTest cases:\n1. Basic daemon operations (bd daemon --stop, bd daemon, bd list, bd create, bd show)\n2. Auto-import/export cycle (edit beads.jsonl externally, bd list auto-imports)\n3. Git workflow (git pull updates beads.jsonl, bd list shows pulled issues)\n4. Concurrent operations (multiple bd commands simultaneously)\n5. Daemon health (bd daemon --health, bd daemon --metrics)\n6. MCP operations (test MCP server with multiple repos, verify project switching)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T10:50:15.126668-07:00","updated_at":"2025-10-30T17:12:58.217214-07:00","closed_at":"2025-10-28T10:49:20.471129-07:00"} {"id":"bd-0702","content_hash":"7d338a7ecc544ac818bb49f32654bba946e34383a8e27e2cb8181be0fcf93282","title":"Consolidate ID generation and validation into ids.go","description":"Extract ID logic into ids.go: ValidateIssueIDPrefix, GenerateIssueID, EnsureIDs. Move GetAdaptiveIDLength here. Unify single and bulk ID generation flows.","status":"open","priority":1,"issue_type":"task","created_at":"2025-11-01T11:41:14.877886-07:00","updated_at":"2025-11-01T11:41:14.877886-07:00"} @@ -36,6 +36,7 @@ {"id":"bd-2b34.6","content_hash":"35f5cd1a2cd942610511e4161b7bc1c55e32a6786cf2ae3e36fd094dac026d27","title":"Add tests for daemon lifecycle module","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-31T22:28:42.359587-07:00","updated_at":"2025-11-01T21:22:39.009259-07:00","closed_at":"2025-11-01T21:22:39.009259-07:00"} {"id":"bd-2b34.7","content_hash":"62bfa4e0024d5b68061e3ca64645449e5abfc5849d611f6797cd378e5ea0af93","title":"Add tests for daemon config module","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-31T22:28:42.373684-07:00","updated_at":"2025-11-01T21:21:42.431252-07:00","closed_at":"2025-11-01T21:21:42.431252-07:00"} {"id":"bd-2b34.8","content_hash":"449c5ba132b35ebfbd8ed8dc31f7d96c03311c0dc5eda61d88af3a071e365338","title":"Extract daemon lifecycle functions to daemon_lifecycle.go","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-31T22:28:42.382892-07:00","updated_at":"2025-11-01T21:02:58.350055-07:00","closed_at":"2025-11-01T21:02:58.350055-07:00"} +{"id":"bd-2e80","content_hash":"3dff3144a6e7e91a7a448621f63d5ca40ffe1d8a3a654daf44484fdd629a1678","title":"Document shared memory test isolation pattern in test_helpers.go","description":"Tests were failing because :memory: creates a shared database across all tests. The fix is to use \"file::memory:?mode=memory\u0026cache=private\" for test isolation.\n\nShould document this pattern in test_helpers.go and potentially update newTestStore to use private memory by default.","status":"open","priority":3,"issue_type":"chore","created_at":"2025-11-01T22:40:58.993496-07:00","updated_at":"2025-11-01T22:40:58.993496-07:00"} {"id":"bd-2f388ca7","content_hash":"27498c808874010ee62da58e12434a6ae7c73f4659b2233aaf8dcd59566a907d","title":"Fix TestTwoCloneCollision timeout","description":"","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-28T14:11:25.219607-07:00","updated_at":"2025-10-30T17:12:58.217635-07:00","closed_at":"2025-10-28T16:12:26.286611-07:00"} {"id":"bd-317ddbbf","content_hash":"81a74ccf29037e5a780b12540a4059bab98b9a790a5a043a68118fc00a083cda","title":"Add BEADS_DAEMON_MODE flag handling","description":"Add environment variable BEADS_DAEMON_MODE (values: poll, events). Default to 'poll' for Phase 1. Wire into daemon startup to select runEventLoop vs runEventDrivenLoop.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T16:20:02.433638-07:00","updated_at":"2025-10-30T17:12:58.224373-07:00","closed_at":"2025-10-28T12:31:47.819136-07:00"} {"id":"bd-31aab707","content_hash":"8f64a8dbcc5ed63bc73b7d91fca624527033265dc1c89a7775eb2f45b378f382","title":"Unit tests for FileWatcher","description":"Test watcher detects JSONL changes. Test git ref changes trigger import. Test debounce integration. Test watcher recovery from file removal/rename.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-29T11:30:59.842317-07:00","updated_at":"2025-10-31T12:00:43.189591-07:00","closed_at":"2025-10-31T12:00:43.189591-07:00"} @@ -66,9 +67,10 @@ {"id":"bd-589c7c1e","content_hash":"efbc1fe1379d414d2af33f5aff9787e4f8a3234922199bdc9abce25dba99aef0","title":"Fix revive style issues (78 issues)","description":"Style violations: unused parameters (many cmd/args in cobra commands), missing exported comments, stuttering names (SQLiteStorage), indent-error-flow issues.","design":"Rename unused params to _, add godoc comments to exported types, fix stuttering names, simplify control flow.","notes":"Fixed 19 revive issues:\n- 14 unused-parameter (renamed to _)\n- 2 redefines-builtin-id (max→maxCount, min→minInt)\n- 3 indent-error-flow (gofmt fixed 2, skipped 1 complex nested one)\n\nRemaining issues are acceptable: 11 unused-params in deeper code, 2 empty-blocks with comments, 1 complex indent case, 1 superfluous-else in test.","status":"closed","priority":3,"issue_type":"task","created_at":"2025-10-27T23:20:10.391821-07:00","updated_at":"2025-10-30T17:12:58.215077-07:00","closed_at":"2025-10-27T23:02:41.30653-07:00"} {"id":"bd-5aad5a9c","content_hash":"03aa8900fd0e6f4bd911364bb69d4446f239c6a5e9688f0fc4928f3de1259242","title":"Add TestNWayCollision for 5+ clones","description":"## Overview\nAdd comprehensive tests for N-way (5+) collision resolution to verify the solution scales beyond 3 clones.\n\n## Purpose\nWhile TestThreeCloneCollision validates the basic N-way case, we need to verify:\n1. Solution scales to arbitrary N\n2. Performance is acceptable with more clones\n3. Convergence time is bounded\n4. No edge cases in larger collision groups\n\n## Implementation Tasks\n\n### 1. Create TestFiveCloneCollision\nFile: beads_twoclone_test.go (or new beads_nway_test.go)\n\n```go\nfunc TestFiveCloneCollision(t *testing.T) {\n // Test with 5 clones creating same ID with different content\n // Verify all 5 clones converge after sync rounds\n \n t.Run(\"SequentialSync\", func(t *testing.T) {\n testNCloneCollision(t, 5, \"A\", \"B\", \"C\", \"D\", \"E\")\n })\n \n t.Run(\"ReverseSync\", func(t *testing.T) {\n testNCloneCollision(t, 5, \"E\", \"D\", \"C\", \"B\", \"A\")\n })\n \n t.Run(\"RandomSync\", func(t *testing.T) {\n testNCloneCollision(t, 5, \"C\", \"A\", \"E\", \"B\", \"D\")\n })\n}\n```\n\n### 2. Implement generalized testNCloneCollision\nGeneralize the 3-clone test to handle arbitrary N:\n\n```go\nfunc testNCloneCollision(t *testing.T, numClones int, syncOrder ...string) {\n t.Helper()\n \n if len(syncOrder) != numClones {\n t.Fatalf(\"syncOrder length (%d) must match numClones (%d)\", \n len(syncOrder), numClones)\n }\n \n tmpDir := t.TempDir()\n \n // Setup remote and N clones\n remoteDir := setupBareRepo(t, tmpDir)\n cloneDirs := make(map[string]string)\n \n for i := 0; i \u003c numClones; i++ {\n name := string(rune('A' + i))\n cloneDirs[name] = setupClone(t, tmpDir, remoteDir, name)\n }\n \n // Each clone creates issue with same ID but different content\n for name, dir := range cloneDirs {\n createIssue(t, dir, fmt.Sprintf(\"Issue from clone %s\", name))\n }\n \n // Sync in specified order\n for _, name := range syncOrder {\n syncClone(t, cloneDirs[name], name)\n }\n \n // Final pull for convergence\n for name, dir := range cloneDirs {\n finalPull(t, dir, name)\n }\n \n // Verify all clones have all N issues\n expectedTitles := make(map[string]bool)\n for i := 0; i \u003c numClones; i++ {\n name := string(rune('A' + i))\n expectedTitles[fmt.Sprintf(\"Issue from clone %s\", name)] = true\n }\n \n for name, dir := range cloneDirs {\n titles := getTitles(t, dir)\n if !compareTitleSets(titles, expectedTitles) {\n t.Errorf(\"Clone %s missing issues: expected %v, got %v\", \n name, expectedTitles, titles)\n }\n }\n \n t.Log(\"✓ All\", numClones, \"clones converged successfully\")\n}\n```\n\n### 3. Add performance benchmarks\nTest convergence time and memory usage:\n\n```go\nfunc BenchmarkNWayCollision(b *testing.B) {\n for _, n := range []int{3, 5, 10, 20} {\n b.Run(fmt.Sprintf(\"N=%d\", n), func(b *testing.B) {\n for i := 0; i \u003c b.N; i++ {\n // Run N-way collision and measure time\n testNCloneCollisionBench(b, n)\n }\n })\n }\n}\n```\n\n### 4. Add convergence time tests\nVerify bounded convergence:\n\n```go\nfunc TestConvergenceTime(t *testing.T) {\n // Test that convergence happens within expected rounds\n // For N clones, should converge in at most N-1 sync rounds\n \n for n := 3; n \u003c= 10; n++ {\n t.Run(fmt.Sprintf(\"N=%d\", n), func(t *testing.T) {\n rounds := measureConvergenceRounds(t, n)\n maxExpected := n - 1\n if rounds \u003e maxExpected {\n t.Errorf(\"Convergence took %d rounds, expected ≤ %d\", \n rounds, maxExpected)\n }\n })\n }\n}\n```\n\n### 5. Add edge case tests\nTest boundary conditions:\n- All N clones have identical content (dedup works)\n- N-1 clones have same content, 1 differs\n- All N clones have unique content\n- Mix of collisions and non-collisions\n\n## Acceptance Criteria\n- TestFiveCloneCollision passes with all sync orders\n- All 5 clones converge to identical content\n- Performance is acceptable (\u003c 5 seconds for 5 clones)\n- Convergence time is bounded (≤ N-1 rounds)\n- Edge cases handled correctly\n- Benchmarks show scalability to 10+ clones\n\n## Files to Create/Modify\n- beads_twoclone_test.go or beads_nway_test.go\n- Add helper functions for N-clone setup\n\n## Testing Strategy\n\n### Test Matrix\n| N Clones | Sync Orders | Expected Result |\n|----------|-------------|-----------------|\n| 3 | A→B→C | Pass |\n| 3 | C→B→A | Pass |\n| 5 | A→B→C→D→E | Pass |\n| 5 | E→D→C→B→A | Pass |\n| 5 | Random | Pass |\n| 10 | Sequential | Pass |\n\n### Performance Targets\n- 3 clones: \u003c 2 seconds\n- 5 clones: \u003c 5 seconds\n- 10 clones: \u003c 15 seconds\n\n## Dependencies\n- Requires bd-cbed9619.5, bd-cbed9619.4, bd-cbed9619.3, bd-dcd6f14b to be completed\n- TestThreeCloneCollision must pass first\n\n## Success Metrics\n- All tests pass for N ∈ {3, 5, 10}\n- Convergence time scales linearly (O(N))\n- Memory usage reasonable (\u003c 100MB for 10 clones)\n- No data corruption or loss in any scenario","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-29T19:52:05.462747-07:00","updated_at":"2025-10-31T12:00:43.198413-07:00","closed_at":"2025-10-31T12:00:43.198413-07:00"} {"id":"bd-5b40a0bf","content_hash":"fd8a94f744b6504f677cc8e196c5a8d8b5d13b230200add5a8d9b7a54a537eb9","title":"Batch test 5","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-29T15:29:02.136118-07:00","updated_at":"2025-10-31T12:00:43.181513-07:00","closed_at":"2025-10-31T12:00:43.181513-07:00"} +{"id":"bd-5b6e","content_hash":"f82a86b4aae21311f23c8511a242f16e96d03836300995fadd43b8bea945cefa","title":"Add tests for helper functions (GetDirtyIssueHash, GetAllDependencyRecords, export hashes)","description":"Several utility functions have 0% coverage:\n- GetDirtyIssueHash (dirty.go)\n- GetAllDependencyRecords (dependencies.go)\n- GetExportHash, SetExportHash, ClearAllExportHashes (hash.go)\n\nThese are lower priority but should have basic coverage.","status":"open","priority":4,"issue_type":"task","created_at":"2025-11-01T22:40:58.989976-07:00","updated_at":"2025-11-01T22:40:58.989976-07:00"} {"id":"bd-5dae5504","content_hash":"4f2b5a203d6a7b5e38176dd6ef68afb4b7d0e11889718381e28bf006f4e83a16","title":"Export deduplication breaks when JSONL and export_hashes table diverge","description":"## Problem\n\nThe export deduplication feature (timestamp-only skipping) breaks when the JSONL file and export_hashes table get out of sync, causing exports to skip issues that aren't actually in the file.\n\n## Symptoms\n\n- `bd export` reports \"Skipped 128 issue(s) with timestamp-only changes\"\n- JSONL file only has 38 lines but DB has 149 issues\n- export_hashes table has 149 entries\n- Auto-import doesn't trigger (hash matches despite missing data)\n- Two repos on same commit show different issue counts\n\n## Root Cause\n\nshouldSkipExport() in autoflush.go compares current issue hash with stored export_hashes entry. If they match, it skips export assuming the issue is already in the JSONL.\n\nThis assumption fails when:\n1. Git operations (pull, reset, checkout) change JSONL without clearing export_hashes\n2. Manual JSONL edits or corruption\n3. Import operations that modify DB but don't update export_hashes\n4. Partial exports that update export_hashes but don't complete\n\n## Impact\n\n- **Critical data loss risk**: Issues appear to be tracked but aren't persisted to git\n- Breaks multi-repo sync (root cause of today's debugging session)\n- Auto-import fails to detect staleness (hash matches despite missing data)\n- Silent data corruption (no error messages, just missing issues)\n\n## Reproduction\n\n1. Have DB with 149 issues, all in export_hashes table\n2. Truncate JSONL to 38 lines (simulate git reset or corruption)\n3. Run `bd export` - it skips 128 issues\n4. JSONL still has only 38 lines but export thinks it succeeded\n\n## Current Workaround\n\n```bash\nsqlite3 .beads/beads.db \"DELETE FROM export_hashes\"\nbd export -o .beads/beads.jsonl\n```\n\n## Proposed Solutions\n\n**Option 1: Verify JSONL integrity before skipping**\n- Count lines in JSONL, compare with export_hashes count\n- If mismatch, clear export_hashes and force full export\n- Safe but adds I/O overhead\n\n**Option 2: Hash-based JSONL validation**\n- Store hash of entire JSONL file in metadata\n- Before export, check if JSONL hash matches\n- If mismatch, clear export_hashes\n- More efficient, detects any JSONL corruption\n\n**Option 3: Disable timestamp-only deduplication**\n- Remove the feature entirely\n- Always export all issues\n- Simplest and safest, but creates larger git commits\n\n**Option 4: Clear export_hashes on git operations**\n- Add post-merge hook to clear export_hashes\n- Clear on any import operation\n- Defensive approach but may over-clear\n\n## Recommended Fix\n\nCombination of Options 2 + 4:\n1. Store JSONL file hash in metadata after export\n2. Check hash before export, clear export_hashes if mismatch \n3. Clear export_hashes on import operations\n4. Add `bd validate` check for JSONL/export_hashes sync\n\n## Files Involved\n\n- cmd/bd/autoflush.go (shouldSkipExport)\n- cmd/bd/export.go (export with deduplication)\n- internal/storage/sqlite/metadata.go (export_hashes table)","notes":"## Recovery Session (2025-10-29 21:30)\n\n### What Happened\n- Created 14 new hash ID issues (bd-f8b764c9 through bd-f8b764c9.1) \n- bd sync appeared to succeed\n- Canonical repo (~/src/beads): 162 issues in DB + JSONL ✓\n- Secondary repo (fred/beads): Only 145 issues vs 162 in canonical ✗\n- Both repos on same git commit but different issue counts!\n\n### Bug Manifestation During Recovery\n\n1. **Initial state**: fred/beads had 145 issues, 145 lines in JSONL, 145 export_hashes entries\n\n2. **After git reset --hard origin/main**: \n - JSONL: 162 lines (from git)\n - DB: 150 issues (auto-import partially worked)\n - Auto-import failed with UNIQUE constraint error\n\n3. **After manual import --resolve-collisions**:\n - DB: 160 issues\n - JSONL: Still 162 lines\n - export_hashes: 159 entries\n\n4. **After bd export**: \n - **JSONL reduced to 17 lines!** ← The bug in action\n - export_hashes: 159 entries (skipped exporting 142 issues)\n - Silent data loss - no error message\n\n5. **After clearing export_hashes and re-export**:\n - JSONL: 159 lines (missing 3 issues still)\n - DB: 159 issues\n - Still diverged from canonical\n\n### The Bug Loop\nOnce export_hashes and JSONL diverge:\n- Export skips issues already in export_hashes\n- But those issues aren't actually in JSONL\n- This creates corrupt JSONL with missing issues\n- Auto-import can't detect the problem (file hash matches what was exported)\n- Data is lost with no error messages\n\n### Recovery Solution\nCouldn't break the loop with export alone. Had to:\n1. Copy .beads/beads.db from canonical repo\n2. Clear export_hashes\n3. Full re-export\n4. Finally converged to 162 issues\n\n### Key Learnings\n\n1. **The bug is worse than we thought**: It can create corrupt exports (17 lines instead of 162!)\n\n2. **Auto-import can't save you**: Once export is corrupt, auto-import just imports the corrupt data\n\n3. **Silent failure**: No warnings, no errors, just missing issues\n\n4. **Git operations trigger it**: git reset, git pull, etc. change JSONL without clearing export_hashes\n\n5. **Import operations populate export_hashes**: Even manual imports update export_hashes, setting up future export failures\n\n### Immediate Action Required\n\n**DISABLE EXPORT DEDUPLICATION NOW**\n\nThis feature is fundamentally broken and causes data loss. Should be disabled until properly fixed.\n\nQuick fix options:\n- Set environment variable to disable feature\n- Comment out shouldSkipExport check\n- Always clear export_hashes before export\n- Add validation that DB count == JSONL line count before allowing export\n\n### Long-term Fix\n\nNeed Option 2 + 4 from proposed solutions:\n1. Store JSONL file hash after every successful export\n2. Before export, verify JSONL hash matches expected\n3. If mismatch, log WARNING and clear export_hashes\n4. Clear export_hashes on every import operation\n5. Add git post-merge hook to clear export_hashes\n6. Add `bd validate` command to detect divergence\n","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-10-29T23:05:13.959435-07:00","updated_at":"2025-10-30T17:12:58.207148-07:00","closed_at":"2025-10-29T21:57:03.06641-07:00"} {"id":"bd-5e1f","content_hash":"3e3467773e73eb9dbb8dd8f213be7157c27d72b53c6cc9776616154db96c3864","title":"Issue with desc","description":"This is a description","status":"open","priority":2,"issue_type":"bug","created_at":"2025-10-31T21:41:11.128718-07:00","updated_at":"2025-10-31T21:41:11.128718-07:00"} -{"id":"bd-5f26","content_hash":"75bc96be4d465a5eb39bdf0b636c42cdd7b8ac7daf90b47b7b2a015991b87512","title":"Refactor daemon.go into internal/daemonrunner","description":"Extract daemon runtime from daemon.go (1,565 lines) into internal/daemonrunner with focused modules: config.go, daemon.go, process.go, rpc_server.go, sync.go, git.go. Keep cobra command thin.","design":"New structure:\n- internal/daemonrunner/config.go: Config struct\n- internal/daemonrunner/daemon.go: Daemon struct + Start/Stop\n- internal/daemonrunner/process.go: PID/lock/socket handling\n- internal/daemonrunner/rpc_server.go: RPC lifecycle\n- internal/daemonrunner/sync.go: Export/import/commit/push logic\n- internal/daemonrunner/git.go: Git operations interface\n- cmd/bd/daemon.go: Thin cobra command","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-11-01T11:41:14.821017-07:00","updated_at":"2025-11-01T22:34:00.944402-07:00","closed_at":"2025-11-01T22:34:00.944402-07:00"} +{"id":"bd-5f26","content_hash":"75bc96be4d465a5eb39bdf0b636c42cdd7b8ac7daf90b47b7b2a015991b87512","title":"Refactor daemon.go into internal/daemonrunner","description":"Extract daemon runtime from daemon.go (1,565 lines) into internal/daemonrunner with focused modules: config.go, daemon.go, process.go, rpc_server.go, sync.go, git.go. Keep cobra command thin.","design":"New structure:\n- internal/daemonrunner/config.go: Config struct\n- internal/daemonrunner/daemon.go: Daemon struct + Start/Stop\n- internal/daemonrunner/process.go: PID/lock/socket handling\n- internal/daemonrunner/rpc_server.go: RPC lifecycle\n- internal/daemonrunner/sync.go: Export/import/commit/push logic\n- internal/daemonrunner/git.go: Git operations interface\n- cmd/bd/daemon.go: Thin cobra command","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-11-01T11:41:14.821017-07:00","updated_at":"2025-11-01T21:44:44.507747-07:00","closed_at":"2025-11-01T21:44:44.507747-07:00"} {"id":"bd-5f483051","content_hash":"d69f64f7f0bdc46a539dfe0b699a8977309c9c8d59f3e9beffbbe4484275a16b","title":"Implement bd resolve-conflicts (git merge conflicts in JSONL)","description":"Automatically detect and resolve git merge conflicts in .beads/issues.jsonl file.\n\nFeatures:\n- Detect conflict markers in JSONL\n- Parse conflicting issues from HEAD and BASE\n- Provide mechanical resolution (remap duplicate IDs)\n- Support AI-assisted resolution (requires internal/ai package)\n\nSee repair_commands.md lines 125-353 for design.","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T19:37:55.722827-07:00","updated_at":"2025-10-30T17:12:58.179718-07:00"} {"id":"bd-6214875c","content_hash":"d4d20e71bbf5c08f1fe1ed07f67b7554167aa165d4972ea51b5cacc1b256c4c1","title":"Split internal/rpc/server.go into focused modules","description":"The file `internal/rpc/server.go` is 2,273 lines with 50+ methods, making it difficult to navigate and prone to merge conflicts. Split into 8 focused files with clear responsibilities.\n\nCurrent structure: Single 2,273-line file with:\n- Connection handling\n- Request routing\n- All 40+ RPC method implementations\n- Storage caching\n- Health checks \u0026 metrics\n- Cleanup loops\n\nTarget structure:\n```\ninternal/rpc/\n├── server.go # Core server, connection handling (~300 lines)\n├── methods_issue.go # Issue operations (~400 lines)\n├── methods_deps.go # Dependency operations (~200 lines)\n├── methods_labels.go # Label operations (~150 lines)\n├── methods_ready.go # Ready work queries (~150 lines)\n├── methods_compact.go # Compaction operations (~200 lines)\n├── methods_comments.go # Comment operations (~150 lines)\n├── storage_cache.go # Storage caching logic (~300 lines)\n└── health.go # Health \u0026 metrics (~200 lines)\n```\n\nMigration strategy:\n1. Create new files with appropriate methods\n2. Keep `server.go` as main file with core server logic\n3. Test incrementally after each file split\n4. Final verification with full test suite","acceptance_criteria":"- All 50 methods split into appropriate files\n- Each file \u003c500 LOC\n- All methods remain on `*Server` receiver (no behavior change)\n- All tests pass: `go test ./internal/rpc/...`\n- Verify daemon works: start daemon, run operations, check health\n- Update internal documentation if needed\n- No change to public API","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T14:21:37.51524-07:00","updated_at":"2025-10-30T17:12:58.2179-07:00","closed_at":"2025-10-28T14:11:04.399811-07:00"} {"id":"bd-6221bdcd","content_hash":"3bf15bc9e418180e1e91691261817c872330e182dbc1bcb756522faa42416667","title":"Improve cmd/bd test coverage (currently 20.2%)","description":"CLI commands need better test coverage. Focus on:\n- Command argument parsing\n- Error handling paths\n- Edge cases in create, update, close commands\n- Daemon commands\n- Import/export workflows","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-29T14:06:27.951656-07:00","updated_at":"2025-10-30T17:12:58.185819-07:00","dependencies":[{"issue_id":"bd-6221bdcd","depends_on_id":"bd-4d7fca8a","type":"blocks","created_at":"2025-10-29T19:52:05.532391-07:00","created_by":"import-remap"}]} @@ -115,6 +117,7 @@ {"id":"bd-9ae788be","content_hash":"19599f6bcc268e97438593e08eb6343b551ce765f0d91956591aa811cbb90690","title":"Implement clone-scoped ID allocation to prevent N-way collisions","description":"## Problem\nCurrent ID allocation uses per-clone atomic counters (issue_counters table) that sync based on local database state. In N-way collision scenarios:\n- Clone B sees {test-1} locally, allocates test-2\n- Clone D sees {test-1, test-2, test-3} locally, allocates test-4\n- When same content gets assigned test-2 and test-4, convergence fails\n\nRoot cause: Each clone independently allocates IDs without global coordination, leading to overlapping assignments for the same content.\n\n## Solution\nAdd clone UUID to ID allocation to make every ID globally unique:\n\n**Current format:** `test-1`, `test-2`, `test-3`\n**New format:** `test-1-a7b3`, `test-2-a7b3`, `test-3-c4d9`\n\nWhere suffix is first 4 chars of clone UUID.\n\n## Implementation\n\n### 1. Add clone_identity table\n```sql\nCREATE TABLE clone_identity (\n clone_uuid TEXT PRIMARY KEY,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n```\n\n### 2. Modify getNextIDForPrefix()\n```go\nfunc (s *SQLiteStorage) getNextIDForPrefix(ctx context.Context, prefix string) (string, error) {\n cloneUUID := s.getOrCreateCloneUUID(ctx)\n shortUUID := cloneUUID[:4]\n \n nextNum := s.getNextCounterForPrefix(ctx, prefix)\n return fmt.Sprintf(\"%s-%d-%s\", prefix, nextNum, shortUUID), nil\n}\n```\n\n### 3. Update ID parsing logic\nAll places that parse IDs (utils.ExtractIssueNumber, etc.) need to handle new format.\n\n### 4. Migration strategy\n- Existing IDs remain unchanged (no suffix)\n- New IDs get clone suffix automatically\n- Display layer can hide suffix in UI: `bd-cb64c226.3-a7b3` → `#42`\n\n## Benefits\n- **Zero collision risk**: Same content in different clones gets different IDs\n- **Maintains readability**: Still sequential numbering within clone\n- **No coordination needed**: Works offline, no central authority\n- **Scales to 100+ clones**: 4-char hex = 65,536 unique clones\n\n## Concerns\n- ID format change may break existing integrations\n- Need migration path for existing databases\n- Display logic needs update to hide/show suffixes appropriately\n\n## Success Criteria\n- 10+ clone collision test passes without failures\n- Existing issues continue to work (backward compatibility)\n- Documentation updated with new ID format\n- Migration guide for v1.x → v2.x\n\n## Timeline\nMedium-term (v1.1-v1.2), 2-3 weeks implementation\n\n## References\n- Related to bd-e6d71828 (immediate fix)\n- See beads_nway_test.go for failing N-way tests","status":"open","priority":2,"issue_type":"feature","created_at":"2025-10-29T10:22:52.260524-07:00","updated_at":"2025-10-30T17:12:58.18193-07:00"} {"id":"bd-9e8d","content_hash":"8a2616a707f5ae932357049b0bc922716f4d729724fb8c38b256c91e292f851b","title":"Test Issue","description":"","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-31T21:41:11.107393-07:00","updated_at":"2025-11-01T20:02:28.292279-07:00","closed_at":"2025-11-01T20:02:28.292279-07:00"} {"id":"bd-9f1fce5d","content_hash":"14b0d330680e979e504043d2c560bd2eda204698f5622c3bdc6f91816f861d22","title":"Add internal/ai package for LLM integration","description":"Shared AI client for repair commands.\n\nProviders:\n- Anthropic (Claude)\n- OpenAI (GPT)\n- Ollama (local)\n\nEnv vars:\n- BEADS_AI_PROVIDER\n- BEADS_AI_API_KEY\n- BEADS_AI_MODEL\n\nFiles: internal/ai/client.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:29.072473-07:00","updated_at":"2025-10-30T17:12:58.219706-07:00"} +{"id":"bd-9f20","content_hash":"523ec11b4c3e82c8d5b36deeb284ebae19eee2090a7051b599da79366c642238","title":"DetectCycles SQL query has bug preventing cycle detection","description":"The DetectCycles function's SQL query has a bug in the LIKE filter that prevents it from detecting cycles.\n\nCurrent code (line 571):\n```sql\nAND p.path NOT LIKE '%' || d.depends_on_id || '→%'\n```\n\nThis prevents ANY revisit to nodes, including returning to the start node to complete a cycle.\n\nFix:\n```sql\nAND (d.depends_on_id = p.start_id OR p.path NOT LIKE '%' || d.depends_on_id || '→%')\n```\n\nThis allows revisiting the start node (to detect the cycle) while still preventing intermediate node revisits.\n\nImpact: Currently DetectCycles cannot detect any cycles, but this hasn't been noticed because AddDependency prevents cycles from being created. The function would only matter if cycles were manually inserted into the database.","status":"closed","priority":3,"issue_type":"bug","created_at":"2025-11-01T22:50:32.552763-07:00","updated_at":"2025-11-01T22:52:02.247443-07:00","closed_at":"2025-11-01T22:52:02.247443-07:00"} {"id":"bd-a03d5e36","content_hash":"b4ee73e439a133a77e5df27e3e457674dbc9968fdbee0dc630175726960bb8cf","title":"Improve integration test coverage for stateful features","description":"","design":"## Context\n\nbd-70419816 revealed a critical gap: the export deduplication feature had unit tests but no integration tests simulating real-world git operations. This led to silent data loss in production.\n\n## Root Cause\n- Unit tests only tested functions in isolation\n- No integration tests for git operations (pull, reset, checkout) modifying JSONL\n- No tests validating export_hashes and JSONL stay in sync\n- Missing tests for stateful distributed system interactions (DB + JSONL + git)\n\n## Completed (bd-70419816)\n✓ TestJSONLIntegrityValidation - unit tests for validation logic\n✓ TestImportClearsExportHashes - tests import clears hashes\n✓ TestExportIntegrityAfterJSONLTruncation - simulates git reset (would have caught bd-70419816)\n✓ TestExportIntegrityAfterJSONLDeletion - tests recovery from file deletion\n✓ TestMultipleExportsStayConsistent - tests repeated exports\n\n## Still Needed (High Priority)\n1. Multi-repo sync test - two clones staying in sync after push/pull\n2. Auto-flush integration test - JSONL integrity preserved during auto-flush\n3. Daemon auto-sync integration test - complex state management\n4. Import after corruption test - recovery from partial data loss\n\n## Medium Priority\n- Partial export failure handling (disk full, network interruption)\n- Concurrent export/import race conditions\n- Large dataset performance tests (1000+ issues)\n- Export hash migration tests (version upgrades)\n\n## Testing Principles\n1. Test real-world scenarios: git ops, user errors, system failures, concurrent ops\n2. Integration tests for stateful systems (DB + files + git)\n3. Regression test for every bug fix\n4. Test invariants: JSONL count == DB count, hash consistency, etc.\n\n## Key Lesson\nStateful distributed systems need integration tests, not just unit tests.","acceptance_criteria":"- [ ] Multi-repo sync test implemented\n- [ ] Auto-flush integration test implemented \n- [ ] Daemon auto-sync integration test implemented\n- [ ] Testing guidelines added to CONTRIBUTING.md\n- [ ] CI runs integration tests\n- [ ] All critical workflows have integration test coverage","status":"open","priority":2,"issue_type":"epic","created_at":"2025-10-29T21:53:15.397137-07:00","updated_at":"2025-10-30T17:12:58.206063-07:00"} {"id":"bd-a1691807","content_hash":"23f0119ee9df98f1bf6d648dba06065c156963064ef1c7308dfb02c8bdd5bc58","title":"Integration test: mutation to export latency","description":"Measure time from bd create to JSONL update. Verify \u003c500ms latency. Test with multiple rapid mutations to verify batching.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-29T20:49:49.105247-07:00","updated_at":"2025-10-31T12:00:43.198883-07:00","closed_at":"2025-10-31T12:00:43.198883-07:00"} {"id":"bd-a40f374f","content_hash":"599448515198700decd2494cf0cea3335b013c573bdcbda90a151564585cf845","title":"bd validate - Comprehensive health check","description":"Run all validation checks in one command.\n\nChecks:\n- Duplicates\n- Orphaned dependencies\n- Test pollution\n- Git conflicts\n\nSupports --fix-all for auto-repair.\n\nDepends on bd-cbed9619.1, bd-0dcea000, bd-31aab707, bd-9826b69a.\n\nFiles: cmd/bd/validate.go (new)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-29T20:02:47.956664-07:00","updated_at":"2025-10-30T17:12:58.195108-07:00","closed_at":"2025-10-29T20:02:15.318966-07:00"} @@ -147,11 +150,13 @@ {"id":"bd-cbed9619.3","content_hash":"ecebc4a18d5355bafc88e778ee87365717f894d3590d325a97ecf8b3f763d54d","title":"Implement global N-way collision resolution algorithm","description":"## Overview\nPhase 3: Replace pairwise collision resolution with global N-way resolution that produces deterministic results regardless of sync order.\n\n## Current Problem\nScoreCollisions (collision.go:228) compares issues pairwise:\n```go\ncollision.RemapIncoming = existingHash \u003c incomingHash\n```\n\nThis works for 2-way but fails for 3+ way because:\n- Each clone makes local decisions without global context\n- No guarantee intermediate states are consistent\n- Remapping decisions depend on sync order\n- Can't detect transitive remap chains (test-1 → test-2 → test-3)\n\n## Solution\nImplement global resolution that:\n1. Collects ALL versions of same logical issue\n2. Sorts by content hash (deterministic)\n3. Assigns sequential IDs based on sorted order\n4. All clones converge to same assignments\n\n## Implementation Tasks\n\n### 1. Create ResolveNWayCollisions function\nFile: internal/storage/sqlite/collision.go\n\nReplace ScoreCollisions with:\n```go\n// ResolveNWayCollisions handles N-way collisions deterministically.\n// Groups all versions with same base ID, sorts by content hash,\n// assigns sequential IDs. Returns mapping of old ID → new ID.\nfunc ResolveNWayCollisions(ctx context.Context, s *SQLiteStorage,\n collisions []*CollisionDetail, incoming []*types.Issue) (map[string]string, error) {\n \n if len(collisions) == 0 {\n return make(map[string]string), nil\n }\n \n // Group by base ID pattern (e.g., test-1, test-2 → base \"test-1\")\n groups := groupCollisionsByBaseID(collisions)\n \n idMapping := make(map[string]string)\n \n for baseID, versions := range groups {\n // 1. Collect all unique versions by content hash\n uniqueVersions := deduplicateVersionsByContentHash(versions)\n \n // 2. Sort by content hash (deterministic!)\n sort.Slice(uniqueVersions, func(i, j int) bool {\n return uniqueVersions[i].ContentHash \u003c uniqueVersions[j].ContentHash\n })\n \n // 3. Assign sequential IDs based on sorted order\n prefix := extractPrefix(baseID)\n baseNum := extractNumber(baseID)\n \n for i, version := range uniqueVersions {\n targetID := fmt.Sprintf(\"%s-%d\", prefix, baseNum+i)\n \n // Map this version to its deterministic ID\n if version.ID != targetID {\n idMapping[version.ID] = targetID\n }\n }\n }\n \n return idMapping, nil\n}\n```\n\n### 2. Implement helper functions\n\n```go\n// groupCollisionsByBaseID groups collisions by their logical base ID\nfunc groupCollisionsByBaseID(collisions []*CollisionDetail) map[string][]*types.Issue {\n groups := make(map[string][]*types.Issue)\n for _, c := range collisions {\n baseID := c.ID // All share same ID (that's why they collide)\n groups[baseID] = append(groups[baseID], c.ExistingIssue, c.IncomingIssue)\n }\n return groups\n}\n\n// deduplicateVersionsByContentHash keeps one issue per unique content hash\nfunc deduplicateVersionsByContentHash(issues []*types.Issue) []*types.Issue {\n seen := make(map[string]*types.Issue)\n for _, issue := range issues {\n if _, found := seen[issue.ContentHash]; !found {\n seen[issue.ContentHash] = issue\n }\n }\n result := make([]*types.Issue, 0, len(seen))\n for _, issue := range seen {\n result = append(result, issue)\n }\n return result\n}\n```\n\n### 3. Update handleCollisions in importer\nFile: internal/importer/importer.go\n\nReplace ScoreCollisions call with:\n```go\n// OLD:\nif err := sqlite.ScoreCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues); err != nil {\n return nil, fmt.Errorf(\"failed to score collisions: %w\", err)\n}\n\n// NEW:\nidMapping, err := sqlite.ResolveNWayCollisions(ctx, sqliteStore, \n collisionResult.Collisions, issues)\nif err != nil {\n return nil, fmt.Errorf(\"failed to resolve collisions: %w\", err)\n}\n```\n\n### 4. Update RemapCollisions\nRemapCollisions currently uses collision.RemapIncoming field. Update to use idMapping directly:\n- Remove RemapIncoming logic\n- Use idMapping to determine what to remap\n- Simplify to just apply the computed mapping\n\n### 5. Add comprehensive tests\n\nTest cases:\n1. 3-way collision with different content → 3 sequential IDs\n2. 3-way collision with 2 identical content → 2 IDs (dedupe works)\n3. Sync order independence (A→B→C vs C→A→B produce same result)\n4. Content hash ordering is respected\n5. Works with 5+ clones\n\n## Acceptance Criteria\n- ResolveNWayCollisions implemented and replaces ScoreCollisions\n- Groups all versions of same ID together\n- Deduplicates by content hash\n- Sorts by content hash deterministically\n- Assigns sequential IDs starting from base ID\n- Returns complete mapping (old ID → new ID)\n- All clones converge to same ID assignments\n- Works for arbitrary N-way collisions\n- TestThreeCloneCollision passes (or gets much closer)\n\n## Files to Modify\n- internal/storage/sqlite/collision.go (new function, helpers)\n- internal/importer/importer.go (call new function)\n- internal/storage/sqlite/collision_test.go (comprehensive tests)\n\n## Testing Strategy\n\n### Unit Tests\n- groupCollisionsByBaseID correctly groups\n- deduplicateVersionsByContentHash removes duplicates\n- Sorting by hash is stable and deterministic\n- Sequential ID assignment is correct\n\n### Integration Tests\n- 3-way collision resolves to 3 issues\n- Sync order doesn't affect final IDs\n- Content hash ordering determines winner\n\n### Property Tests\n- For any N clones with same content, all converge to same IDs\n- Idempotent: running resolution twice produces same result\n\n## Dependencies\n- Requires bd-cbed9619.5 (ContentHash field) to be completed first\n- Requires bd-cbed9619.4 (read-only detection) for clean integration\n\n## Notes\nThis is the core algorithm that enables convergence. The key insight:\n**Sort by content hash globally, not pairwise comparison.**","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T18:37:42.85616-07:00","updated_at":"2025-10-30T17:12:58.228707-07:00","closed_at":"2025-10-28T20:03:26.675257-07:00","dependencies":[{"issue_id":"bd-cbed9619.3","depends_on_id":"bd-325da116","type":"parent-child","created_at":"2025-10-28T18:39:20.593102-07:00","created_by":"daemon"},{"issue_id":"bd-cbed9619.3","depends_on_id":"bd-cbed9619.5","type":"blocks","created_at":"2025-10-28T18:39:28.30886-07:00","created_by":"daemon"},{"issue_id":"bd-cbed9619.3","depends_on_id":"bd-cbed9619.4","type":"blocks","created_at":"2025-10-28T18:39:28.336312-07:00","created_by":"daemon"}]} {"id":"bd-cbed9619.4","content_hash":"49aad5fa2497f7f88fb74d54553825b93c1021ed7db04cfb2e58682699d8dca9","title":"Make DetectCollisions read-only (separate detection from modification)","description":"## Overview\nPhase 2: Separate collision detection from state modification to enable safe, composable collision resolution.\n\n## Current Problem\nDetectCollisions (collision.go:38-111) modifies database state during detection:\n- Line 83-86: Deletes issues when content matches but ID differs\n- This violates separation of concerns\n- Causes race conditions when processing multiple issues\n- Makes contentToDBIssue map stale after first deletion\n- Partial failures leave DB in inconsistent state\n\n## Solution\nMake DetectCollisions purely read-only. Move all modifications to a separate ApplyCollisionResolution function.\n\n## Implementation Tasks\n\n### 1. Add RenameDetail to CollisionResult\nFile: internal/storage/sqlite/collision.go\n```go\ntype CollisionResult struct {\n ExactMatches []string\n Collisions []*CollisionDetail\n NewIssues []string\n Renames []*RenameDetail // NEW\n}\n\ntype RenameDetail struct {\n OldID string // ID in database\n NewID string // ID in incoming\n Issue *types.Issue // The issue with new ID\n}\n```\n\n### 2. Remove deletion from DetectCollisions\nReplace lines 83-86:\n```go\n// OLD (DELETE THIS):\nif err := s.DeleteIssue(ctx, dbMatch.ID); err != nil {\n return nil, fmt.Errorf(\"failed to delete renamed issue...\")\n}\n\n// NEW (ADD THIS):\nresult.Renames = append(result.Renames, \u0026RenameDetail{\n OldID: dbMatch.ID,\n NewID: incoming.ID,\n Issue: incoming,\n})\ncontinue // Don't mark as NewIssue yet\n```\n\n### 3. Create ApplyCollisionResolution function\nNew function to apply all modifications atomically:\n```go\nfunc ApplyCollisionResolution(ctx context.Context, s *SQLiteStorage,\n result *CollisionResult, mapping map[string]string) error {\n \n // Phase 1: Handle renames (delete old IDs)\n for _, rename := range result.Renames {\n if err := s.DeleteIssue(ctx, rename.OldID); err != nil {\n return fmt.Errorf(\"failed to delete renamed issue %s: %w\", \n rename.OldID, err)\n }\n }\n \n // Phase 2: Create new IDs (from mapping)\n // Phase 3: Update references\n return nil\n}\n```\n\n### 4. Update callers to use two-phase approach\nFile: internal/importer/importer.go (handleCollisions)\n```go\n// Phase 1: Detect (read-only)\ncollisionResult, err := sqlite.DetectCollisions(ctx, sqliteStore, issues)\n\n// Phase 2: Resolve (compute mapping)\nmapping, err := sqlite.ResolveNWayCollisions(ctx, sqliteStore, collisionResult)\n\n// Phase 3: Apply (modify DB)\nerr = sqlite.ApplyCollisionResolution(ctx, sqliteStore, collisionResult, mapping)\n```\n\n### 5. Update tests\n- Verify DetectCollisions doesn't modify DB\n- Test ApplyCollisionResolution separately\n- Add test for rename detection without modification\n\n## Acceptance Criteria\n- DetectCollisions performs zero writes to database\n- DetectCollisions returns RenameDetail entries for content matches\n- ApplyCollisionResolution handles all modifications\n- All existing tests still pass\n- New test verifies read-only detection\n- contentToDBIssue map stays consistent throughout detection\n\n## Files to Modify\n- internal/storage/sqlite/collision.go (DetectCollisions, new function)\n- internal/importer/importer.go (handleCollisions caller)\n- internal/storage/sqlite/collision_test.go (add tests)\n\n## Testing\n- Unit test: DetectCollisions with content match doesn't delete DB issue\n- Unit test: RenameDetail correctly populated\n- Unit test: ApplyCollisionResolution applies renames\n- Integration test: Full flow still works end-to-end\n\n## Risk Mitigation\nThis is a significant refactor of core collision logic. Recommend:\n1. Add comprehensive tests before modifying\n2. Use feature flag to enable/disable new behavior\n3. Test thoroughly with TestTwoCloneCollision first","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T18:37:09.652326-07:00","updated_at":"2025-10-30T17:12:58.228266-07:00","closed_at":"2025-10-28T19:08:17.715416-07:00","dependencies":[{"issue_id":"bd-cbed9619.4","depends_on_id":"bd-325da116","type":"parent-child","created_at":"2025-10-28T18:39:20.570276-07:00","created_by":"daemon"},{"issue_id":"bd-cbed9619.4","depends_on_id":"bd-cbed9619.5","type":"blocks","created_at":"2025-10-28T18:39:28.285653-07:00","created_by":"daemon"}]} {"id":"bd-cbed9619.5","content_hash":"12cd30dee3c08ba58d03e4468e6fe261a47d58c3b75397d9f14f38ee644fab6e","title":"Add content-addressable identity to Issue type","description":"## Overview\nPhase 1: Add content hashing to enable global identification of issues regardless of their assigned IDs.\n\n## Current Problem\nThe system identifies issues only by ID (e.g., test-1, test-2). When multiple clones create the same ID with different content, there's no way to identify that these are semantically different issues without comparing all fields.\n\n## Solution\nAdd a ContentHash field to the Issue type that represents the canonical content fingerprint.\n\n## Implementation Tasks\n\n### 1. Add ContentHash field to Issue type\nFile: internal/types/types.go\n```go\ntype Issue struct {\n ID string\n ContentHash string // SHA256 of canonical content\n // ... existing fields\n}\n```\n\n### 2. Add content hash computation method\nUse existing hashIssueContent from collision.go:186 as foundation:\n```go\nfunc (i *Issue) ComputeContentHash() string {\n return hashIssueContent(i)\n}\n```\n\n### 3. Compute hash at creation time\n- Modify CreateIssue to compute and store ContentHash\n- Modify CreateIssues (batch) to compute hashes\n\n### 4. Compute hash at import time \n- Modify ImportIssues to compute ContentHash for all incoming issues\n- Store hash in database\n\n### 5. Add database column\n- Add migration to add content_hash column to issues table\n- Update SELECT/INSERT statements to include content_hash\n- Index on content_hash for fast lookups\n\n### 6. Populate existing issues\n- Add migration step to compute ContentHash for all existing issues\n- Use hashIssueContent function\n\n## Acceptance Criteria\n- Issue type has ContentHash field\n- Hash is computed automatically at creation time\n- Hash is computed for imported issues\n- Database stores content_hash column\n- All existing issues have non-empty ContentHash\n- Hash is deterministic (same content → same hash)\n- Hash excludes ID, timestamps (only semantic content)\n\n## Files to Modify\n- internal/types/types.go\n- internal/storage/sqlite/sqlite.go (schema, CreateIssue, CreateIssues)\n- internal/storage/sqlite/migrations.go (new migration)\n- internal/importer/importer.go (compute hash during import)\n- cmd/bd/create.go (compute hash at creation)\n\n## Testing\n- Unit test: same content produces same hash\n- Unit test: different content produces different hash \n- Unit test: hash excludes ID and timestamps\n- Integration test: hash persists in database\n- Migration test: existing issues get hashes populated","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T18:36:44.914967-07:00","updated_at":"2025-10-30T17:12:58.2279-07:00","closed_at":"2025-10-28T18:57:10.985198-07:00","dependencies":[{"issue_id":"bd-cbed9619.5","depends_on_id":"bd-325da116","type":"parent-child","created_at":"2025-10-28T18:39:20.547325-07:00","created_by":"daemon"}]} +{"id":"bd-cdf7","content_hash":"ebe962c7eb6dba6d112f7ccf59a8920e0354ea9cd8b039974a8fc4a58373809b","title":"Add tests for DetectCycles to improve coverage from 29.6%","description":"DetectCycles currently has 29.6% coverage. Need comprehensive tests for:\n- Simple cycles (A-\u003eB-\u003eA)\n- Complex multi-node cycles\n- Acyclic graphs (should not detect cycles)\n- Self-loops\n- Multiple independent cycles\n- Edge cases (empty graph, single node)","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-01T22:40:58.977156-07:00","updated_at":"2025-11-01T22:52:02.243223-07:00","closed_at":"2025-11-01T22:52:02.243223-07:00"} {"id":"bd-ce37850f","content_hash":"3ef2872c3fcb1e5acc90d33fd5a76291742cbcecfbf697b611aa5b4d8ce80078","title":"Add embedding generation for duplicate detection","description":"Use embeddings for scalable duplicate detection.\n\nModel: text-embedding-3-small (OpenAI) or all-MiniLM-L6-v2 (local)\nStorage: SQLite vector extension or in-memory\nCost: ~/bin/bash.0002 per 100 issues\n\nMuch cheaper than LLM comparisons for large databases.\n\nFiles: internal/embeddings/ (new package)","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T14:48:29.072913-07:00","updated_at":"2025-10-30T17:12:58.219921-07:00"} {"id":"bd-cf349eb3","content_hash":"1b42289a0cb1da0626a69c6f004bf62fc9ba6e3a0f8eb70159c5f1446497020b","title":"Update LINTING.md with current baseline","description":"After cleanup, document the remaining acceptable baseline in LINTING.md so we can track regression.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-27T23:20:10.39272-07:00","updated_at":"2025-10-30T17:12:58.215471-07:00","closed_at":"2025-10-27T23:05:31.945614-07:00"} -{"id":"bd-d33c","content_hash":"0c3eb277be0ec16edae305156aa8824b6bc9c37fbd6151477f235e859e9b6181","title":"Separate process/lock/PID concerns into process.go","description":"Create internal/daemonrunner/process.go with: acquireDaemonLock, PID file read/write, stopDaemon, isDaemonRunning, getPIDFilePath, socket path helpers, version check.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-01T11:41:14.871122-07:00","updated_at":"2025-11-01T22:56:08.368079-07:00","closed_at":"2025-11-01T22:56:08.368079-07:00"} +{"id":"bd-d33c","content_hash":"0c3eb277be0ec16edae305156aa8824b6bc9c37fbd6151477f235e859e9b6181","title":"Separate process/lock/PID concerns into process.go","description":"Create internal/daemonrunner/process.go with: acquireDaemonLock, PID file read/write, stopDaemon, isDaemonRunning, getPIDFilePath, socket path helpers, version check.","status":"open","priority":1,"issue_type":"task","created_at":"2025-11-01T11:41:14.871122-07:00","updated_at":"2025-11-01T11:41:14.871122-07:00"} {"id":"bd-d355a07d","content_hash":"b4f98403e209eadf33dd4913660c1538fd922c89339a9ed034ef504aac358662","title":"Import validation falsely reports data loss on collision resolution","description":"## Problem\n\nPost-import validation reports 'data loss detected!' when import count reduces due to legitimate collision resolution.\n\n## Example\n\n```\nImport complete: 1 created, 8 updated, 142 unchanged, 19 skipped, 1 issues remapped\nPost-import validation failed: import reduced issue count: 165 → 164 (data loss detected!)\n```\n\nThis was actually successful collision resolution (bd-70419816 duplicated → remapped to-70419816), not data loss.\n\n## Impact\n\n- False alarms waste investigation time\n- Undermines confidence in import validation\n- Confuses users/agents about sync health\n\n## Solution\n\nImprove validation to distinguish:\n- Collision-resolution merges (expected count reduction)\n- Actual data loss (unexpected disappearance)\n\nTrack remapped issue count and adjust expected post-import count accordingly.","status":"open","priority":2,"issue_type":"bug","created_at":"2025-10-29T23:15:00.815227-07:00","updated_at":"2025-10-31T19:38:09.19996-07:00"} {"id":"bd-d4ec5a82","content_hash":"872448809bfa26d39d68ba6cac5071379756c30bcd3b08dc75de6da56c133956","title":"Add MCP functions for repair commands","description":"Add repair commands to beads-mcp for agent access:\n- beads_resolve_conflicts()\n- beads_find_duplicates()\n- beads_detect_pollution()\n- beads_validate()\n\nFiles: integrations/beads-mcp/src/beads_mcp/server.py","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T14:48:29.071495-07:00","updated_at":"2025-10-30T17:12:58.219499-07:00"} +{"id":"bd-d68f","content_hash":"4b5b5340749fba1c419c22f9937717b363ee8a49e4c5e0a5e0066a24b652a936","title":"Add tests for Comments API (AddIssueComment, GetIssueComments)","description":"Comments API currently has 0% coverage. Need tests for:\n- AddIssueComment - adding comments to issues\n- GetIssueComments - retrieving comments\n- Comment ordering and pagination\n- Edge cases (non-existent issues, empty comments)","status":"closed","priority":3,"issue_type":"task","created_at":"2025-11-01T22:40:58.980688-07:00","updated_at":"2025-11-01T22:53:42.124391-07:00","closed_at":"2025-11-01T22:53:42.124391-07:00"} {"id":"bd-d7e88238","content_hash":"b69ec861618b03129fad7807b085ee6365860cfd2e9901b49eb846e192b95a0d","title":"Rapid 3","description":"","status":"open","priority":3,"issue_type":"task","created_at":"2025-10-29T19:11:57.459655-07:00","updated_at":"2025-10-30T17:12:58.189494-07:00"} {"id":"bd-d9e0","content_hash":"67a706abbb956ca078d37d718b8f4d2d6c0c1b71f7a2267c64a67dab4938f433","title":"Extract validation functions to validators.go","description":"Move validatePriority, validateStatus, validateIssueType, validateTitle, validateEstimatedMinutes, validateFieldUpdate to validators.go","status":"open","priority":1,"issue_type":"task","created_at":"2025-11-01T19:28:54.915909-07:00","updated_at":"2025-11-01T19:28:54.915909-07:00"} {"id":"bd-da4d8951","content_hash":"a4e81b23d88d41c8fd3fe31fb7ef387f99cb54ea42a6baa210ede436ecce3288","title":"Replace getStorageForRequest with Direct Access","description":"Replace all getStorageForRequest(req) calls with s.storage","acceptance_criteria":"- No references to getStorageForRequest() in codebase (except in deleted file)\n- All handlers use s.storage directly\n- Code compiles without errors\n\nFiles to update:\n- internal/rpc/server_issues_epics.go (~8 calls)\n- internal/rpc/server_labels_deps_comments.go (~4 calls)\n- internal/rpc/server_compact.go (~2 calls)\n- internal/rpc/server_export_import_auto.go (~2 calls)\n- internal/rpc/server_routing_validation_diagnostics.go (~1 call)\n\nPattern: store, err := s.getStorageForRequest(req) → store := s.storage","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T16:20:02.430127-07:00","updated_at":"2025-10-30T17:12:58.22099-07:00","closed_at":"2025-10-28T19:20:58.312809-07:00"} @@ -184,7 +189,7 @@ {"id":"bd-f8b764c9.7","content_hash":"0cd1edbd677f73f3a21e571709eca026db7c6e0d158a3d41d7c4e37deb8bc4c2","title":"CLI accepts both hash IDs and aliases","description":"Update all CLI commands to accept both hash IDs (bd-af78e9a2) and aliases (#42, or just 42).\n\n## Parsing Logic\n```go\n// internal/utils/id_parser.go\nfunc ParseIssueID(input string) (issueID string, err error) {\n // Hash ID: bd-af78e9a2\n if strings.HasPrefix(input, \"bd-\") {\n return input, nil\n }\n \n // Alias: #42 or 42\n aliasStr := strings.TrimPrefix(input, \"#\")\n alias, err := strconv.Atoi(aliasStr)\n if err != nil {\n return \"\", fmt.Errorf(\"invalid issue ID: %s\", input)\n }\n \n // Resolve alias to hash ID\n return storage.GetIssueIDByAlias(alias)\n}\n```\n\n## Commands to Update\nAll commands that accept issue IDs:\n\n### 1. bd show\n```bash\nbd show bd-af78e9a2 # Hash ID\nbd show #42 # Alias\nbd show 42 # Alias (shorthand)\nbd show bd-af78e9a2 #42 # Mixed (multiple IDs)\n```\n\n### 2. bd update\n```bash\nbd update #42 --status in_progress\nbd update bd-af78e9a2 --priority 1\n```\n\n### 3. bd close\n```bash\nbd close #42 --reason \"Done\"\n```\n\n### 4. bd dep add/tree\n```bash\nbd dep add #42 #1 --type blocks\nbd dep tree bd-af78e9a2\n```\n\n### 5. bd label add/remove\n```bash\nbd label add #42 critical\n```\n\n### 6. bd merge\n```bash\nbd merge #42 #43 --into #41\n```\n\n## Display Format\nDefault to showing aliases in output:\n```bash\n$ bd list\n#1 Fix authentication bug P1 open\n#2 Add logging to daemon P2 open \n#42 Investigate jujutsu integration P3 open\n```\n\nWith `--format=hash` flag:\n```bash\n$ bd list --format=hash\nbd-af78e9a2 Fix authentication bug P1 open\nbd-e5f6a7b8 Add logging to daemon P2 open\nbd-1a2b3c4d Investigate jujutsu integration P3 open\n```\n\n## Files to Modify\n- internal/utils/id_parser.go (new)\n- cmd/bd/show.go\n- cmd/bd/update.go\n- cmd/bd/close.go\n- cmd/bd/reopen.go\n- cmd/bd/dep.go\n- cmd/bd/label.go\n- cmd/bd/merge.go\n- cmd/bd/list.go (add --format flag)\n\n## Testing\n- Test hash ID parsing\n- Test alias parsing (#42, 42)\n- Test mixed IDs in single command\n- Test error on invalid ID\n- Test alias resolution failure","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-29T21:25:06.256317-07:00","updated_at":"2025-10-31T12:32:32.609634-07:00","closed_at":"2025-10-31T12:32:32.609634-07:00","dependencies":[{"issue_id":"bd-f8b764c9.7","depends_on_id":"bd-f8b764c9","type":"parent-child","created_at":"2025-10-29T21:25:06.257796-07:00","created_by":"stevey"},{"issue_id":"bd-f8b764c9.7","depends_on_id":"bd-f8b764c9.10","type":"blocks","created_at":"2025-10-29T21:25:06.258307-07:00","created_by":"stevey"},{"issue_id":"bd-f8b764c9.7","depends_on_id":"bd-f8b764c9.8","type":"blocks","created_at":"2025-10-29T21:29:45.993274-07:00","created_by":"stevey"}]} {"id":"bd-f8b764c9.8","content_hash":"d047b706654f9e5b0cc59fde73e88fed242932c3c024d90af22327a5c2f2fced","title":"Update JSONL format to use hash IDs","description":"Update JSONL import/export to use hash IDs, store aliases separately.\n\n## Current JSONL Format\n```jsonl\n{\"id\":\"bd-1c63eb84\",\"title\":\"Fix bug\",\"status\":\"open\",...}\n{\"id\":\"bd-9063acda\",\"title\":\"Add test\",\"status\":\"open\",...}\n```\n\n## New JSONL Format (Option A: Include Alias)\n```jsonl\n{\"id\":\"bd-af78e9a2\",\"alias\":1,\"title\":\"Fix bug\",\"status\":\"open\",...}\n{\"id\":\"bd-e5f6a7b8\",\"alias\":2,\"title\":\"Add test\",\"status\":\"open\",...}\n```\n\n## New JSONL Format (Option B: Hash ID Only)\n```jsonl\n{\"id\":\"bd-af78e9a2\",\"title\":\"Fix bug\",\"status\":\"open\",...}\n{\"id\":\"bd-e5f6a7b8\",\"title\":\"Add test\",\"status\":\"open\",...}\n```\n\nStore aliases in separate .beads/aliases.jsonl (local only, git-ignored):\n```jsonl\n{\"hash\":\"bd-af78e9a2\",\"alias\":1}\n{\"hash\":\"bd-e5f6a7b8\",\"alias\":2}\n```\n\n**Recommendation**: Option B (hash only in main JSONL)\n- Cleaner git diffs (no alias conflicts)\n- Aliases are workspace-local preference\n- Main JSONL is canonical, portable\n\n## Export Changes\nFile: cmd/bd/export.go\n```go\n// Export issues with hash IDs\nfor _, issue := range issues {\n json := marshalIssue(issue) // Uses issue.ID (hash)\n // Don't include alias in JSONL\n}\n\n// Separately export aliases to .beads/aliases.jsonl\nexportAliases(issues)\n```\n\n## Import Changes \nFile: cmd/bd/import.go, internal/importer/importer.go\n```go\n// Import issues by hash ID\nissue := unmarshalIssue(line)\n// Assign new alias on import (don't use incoming alias)\nissue.Alias = getNextAlias()\n\n// No collision detection needed! Hash IDs are globally unique\n```\n\n## Dependency Reference Format\nNo change needed - already uses issue IDs:\n```json\n{\"depends_on_id\":\"bd-af78e9a2\",\"type\":\"blocks\"}\n```\n\n## Files to Modify\n- cmd/bd/export.go (use hash IDs)\n- cmd/bd/import.go (import hash IDs, assign aliases)\n- internal/importer/importer.go (remove collision detection!)\n- .gitignore (add .beads/aliases.jsonl)\n\n## Testing\n- Test export produces hash IDs\n- Test import assigns new aliases\n- Test dependencies preserved with hash IDs\n- Test no collision detection triggered","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-29T21:24:47.408106-07:00","updated_at":"2025-10-31T12:32:32.609925-07:00","closed_at":"2025-10-31T12:32:32.609925-07:00","dependencies":[{"issue_id":"bd-f8b764c9.8","depends_on_id":"bd-f8b764c9","type":"parent-child","created_at":"2025-10-29T21:24:47.409489-07:00","created_by":"stevey"},{"issue_id":"bd-f8b764c9.8","depends_on_id":"bd-f8b764c9.9","type":"blocks","created_at":"2025-10-29T21:24:47.409977-07:00","created_by":"stevey"},{"issue_id":"bd-f8b764c9.8","depends_on_id":"bd-f8b764c9.10","type":"blocks","created_at":"2025-10-29T21:29:45.975499-07:00","created_by":"stevey"}]} {"id":"bd-f8b764c9.9","content_hash":"5b040bec47ef5353a04d90d73ac8237f0302137c27ef7f6bfc84379b84cbb7ec","title":"Implement hash ID generation in CreateIssue","description":"Replace sequential ID generation with hash-based IDs in CreateIssue function.\n\n## Current Behavior (internal/storage/sqlite/sqlite.go)\n```go\nfunc (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue) error {\n // ID comes from auto-increment counter\n // Collisions possible across clones\n}\n```\n\n## New Behavior\n```go\nfunc (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue) error {\n // Generate hash ID if not provided\n if issue.ID == \"\" {\n issue.ID = idgen.GenerateHashID(\n issue.Title,\n issue.Description,\n time.Now(),\n s.workspaceID,\n )\n }\n \n // Assign next alias\n issue.Alias = s.getNextAlias()\n \n // Insert with hash ID + alias\n // ...\n}\n```\n\n## Workspace ID Generation\nAdd to database initialization:\n```go\n// Generate stable workspace ID (persisted in .beads/workspace_id)\nworkspaceID := getOrCreateWorkspaceID()\n```\n\nOptions for workspace ID:\n1. Hostname + random suffix\n2. UUID (random)\n3. Git remote URL hash (deterministic per repo)\n\nRecommended: Option 3 (git remote hash) for reproducibility\n\n## Hash Collision Detection\n```go\n// On insert, check for collision (unlikely but possible)\nexisting, err := s.GetIssue(ctx, issue.ID)\nif err == nil {\n // Hash collision! Add random suffix and retry\n issue.ID = issue.ID + \"-\" + randomSuffix(4)\n}\n```\n\n## Files to Create/Modify\n- internal/types/id_generator.go (new)\n- internal/storage/sqlite/sqlite.go (CreateIssue)\n- internal/storage/sqlite/workspace.go (new - workspace ID management)\n- .beads/workspace_id (new file, git-ignored)\n\n## Testing\n- Test hash ID generation is deterministic\n- Test collision detection and retry\n- Test workspace ID persistence\n- Benchmark: hash generation performance (\u003c1μs)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-29T21:24:29.412237-07:00","updated_at":"2025-10-31T12:32:32.610403-07:00","closed_at":"2025-10-31T12:32:32.610403-07:00","dependencies":[{"issue_id":"bd-f8b764c9.9","depends_on_id":"bd-f8b764c9","type":"parent-child","created_at":"2025-10-29T21:24:29.413417-07:00","created_by":"stevey"},{"issue_id":"bd-f8b764c9.9","depends_on_id":"bd-f8b764c9.11","type":"blocks","created_at":"2025-10-29T21:24:29.413823-07:00","created_by":"stevey"}]} -{"id":"bd-fb05","content_hash":"f72d292b5bf2e258a5f59a1097339daa0db7d6cd9c7c7f21faa476b1a40e6041","title":"Refactor sqlite.go into focused modules","description":"Split sqlite.go (2,298 lines) into focused modules: migrations.go, ids.go, issues.go, events.go, dirty.go, db.go. This will improve maintainability and reduce cognitive load.","design":"Files to create:\n- migrations.go: Migration registry + runner\n- ids.go: ID generation/validation \n- issues.go: CRUD helpers\n- events.go: Event helpers\n- dirty.go: dirty_issues helpers\n- db.go: New/open, DSN construction\n- tx.go: Transaction helper","status":"open","priority":1,"issue_type":"epic","created_at":"2025-11-01T11:41:14.805895-07:00","updated_at":"2025-11-01T11:41:14.805895-07:00"} +{"id":"bd-fb05","content_hash":"1d99061e4ac1564982acc5e141674adf3bb7db789d546c12deb9108435af6450","title":"Refactor sqlite.go into focused modules","description":"Split sqlite.go (2,298 lines) into focused modules: migrations.go, ids.go, issues.go, events.go, dirty.go, db.go. This will improve maintainability and reduce cognitive load.","design":"Files to create:\n- migrations.go: Migration registry + runner\n- ids.go: ID generation/validation \n- issues.go: CRUD helpers\n- events.go: Event helpers\n- dirty.go: dirty_issues helpers\n- db.go: New/open, DSN construction\n- tx.go: Transaction helper","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-11-01T11:41:14.805895-07:00","updated_at":"2025-11-01T22:56:21.902164-07:00","closed_at":"2025-11-01T22:56:21.90217-07:00"} {"id":"bd-fb95094c","content_hash":"99f456d7a5d3a4288c3f60dd65212480c54d3b0161e57d7eccffe01875d2eb5e","title":"Code Health \u0026 Technical Debt Cleanup","description":"Comprehensive codebase cleanup to remove dead code, refactor monolithic files, deduplicate utilities, and improve maintainability. Based on ultrathink code health analysis conducted 2025-10-27.\n\nGoals:\n- Remove ~1,500 LOC of dead/unreachable code\n- Split 2 monolithic files (server.go 2,273 LOC, sqlite.go 2,136 LOC) into focused modules\n- Deduplicate scattered utility functions (normalizeLabels, BD_DEBUG checks)\n- Consolidate test coverage (2,019 LOC of collision tests)\n- Improve code navigation and reduce merge conflicts\n\nImpact: Reduces codebase by ~6-8%, improves maintainability, faster CI/CD\n\nEstimated Effort: 11 days across 4 phases","acceptance_criteria":"- All unreachable code identified by `deadcode` analyzer is removed\n- RPC server split into \u003c500 LOC files with clear responsibilities\n- Duplicate utility functions centralized\n- Test coverage maintained or improved\n- All tests passing\n- Documentation updated","status":"open","priority":2,"issue_type":"epic","created_at":"2025-10-27T20:39:22.22227-07:00","updated_at":"2025-10-30T17:12:58.210295-07:00","labels":["cleanup","epic"]} {"id":"bd-fb95094c.1","content_hash":"d28bd9b00ae5586a782aec012344d1c29eec3bc9fdfa06d5804984a3b3c78e4f","title":"Run final validation and cleanup checks","description":"Final validation pass to ensure all cleanup objectives met and no regressions introduced.\n\nValidation checklist:\n1. Dead code verification: `go run golang.org/x/tools/cmd/deadcode@latest -test ./...`\n2. Test coverage: `go test -cover ./...`\n3. Build verification: `go build ./cmd/bd/`\n4. Linting: `golangci-lint run`\n5. Integration tests\n6. Metrics verification\n7. Git clean check\n\nFinal metrics to report:\n- LOC removed: ~____\n- Files deleted: ____\n- Files created: ____\n- Test coverage: ____%\n- Build time: ____ (before/after)\n- Test run time: ____ (before/after)\n\nImpact: Confirms all cleanup objectives achieved successfully","acceptance_criteria":"- Zero unreachable functions per deadcode analyzer\n- All tests pass: `go test ./...`\n- Test coverage maintained or improved\n- Builds cleanly: `go build ./...`\n- Linting shows improvements\n- Integration tests all pass\n- LOC reduction target achieved (~2,500 LOC)\n- No unintended behavior changes\n- Git commit messages document all changes","notes":"Validation completed:\n- LOC: 52,372 lines total\n- Dead code: 4 functions in import_shared.go (tracked in bd-6fe4622f)\n- Build: ✓ Successful\n- Test coverage: ~20-82% across packages\n- Test failure: TestTwoCloneCollision (timeout issue)\n- Linting: errcheck warnings present (defer close, fmt errors)\n- Test time: ~20s\n\nIssues found:\n1. bd-6fe4622f: Remove unreachable import functions (renameImportedIssuePrefixes, etc)\n2. TestTwoCloneCollision: Daemon killall timeout causing test failure\n3. Linting: errcheck violations need fixing","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-27T20:32:00.14166-07:00","updated_at":"2025-10-30T17:12:58.209988-07:00","closed_at":"2025-10-28T14:11:25.218801-07:00","labels":["phase-4","validation"],"dependencies":[{"issue_id":"bd-fb95094c.1","depends_on_id":"bd-fb95094c","type":"parent-child","created_at":"2025-10-27T20:32:00.144113-07:00","created_by":"daemon"}]} {"id":"bd-fb95094c.10","content_hash":"0d499f79a6336ca36c7e459e3393cd7cfe471d184e5e443fa9757a22740744ab","title":"Consider central serialization package for JSON handling","description":"Multiple parts of the codebase handle JSON serialization of issues with slightly different approaches. Consider creating a centralized serialization package to ensure consistency.\n\nCurrent serialization locations:\n- `cmd/bd/export.go` - JSONL export (issues to file)\n- `cmd/bd/import.go` - JSONL import (file to issues)\n- `internal/rpc/protocol.go` - RPC JSON marshaling\n- `internal/storage/memory/memory.go` - In-memory marshaling\n\nPotential benefits:\n- Single source of truth for JSON format\n- Consistent field naming\n- Easier to add new fields\n- Centralized validation\n\nNote: This is marked **optional** because:\n- Current serialization mostly works\n- May not provide enough benefit to justify refactor\n- Risk of breaking compatibility\n\nDecision point: Evaluate if benefits outweigh refactoring cost\n\nImpact: TBD based on investigation - may defer to future work","acceptance_criteria":"- Create serialization package with documented JSON format\n- Migrate export/import to use centralized serialization\n- All existing JSONL files can be read with new code\n- All tests pass: `go test ./...`\n- Export/import round-trip works perfectly\n- RPC protocol unchanged (or backwards compatible)","status":"open","priority":3,"issue_type":"task","created_at":"2025-10-27T20:31:19.090608-07:00","updated_at":"2025-10-30T17:12:58.208871-07:00","labels":["deduplication","optional","phase-3","refactor","serialization"],"dependencies":[{"issue_id":"bd-fb95094c.10","depends_on_id":"bd-fb95094c","type":"parent-child","created_at":"2025-10-27T20:31:19.092328-07:00","created_by":"daemon"}]} @@ -196,6 +201,6 @@ {"id":"bd-fb95094c.7","content_hash":"af7f41ff73c3aaba006d9cfbf8e35332e25d5b42f9e620b5e94d41c05550ea81","title":"Extract SQLite migrations into separate files","description":"The file `internal/storage/sqlite/sqlite.go` is 2,136 lines and contains 11 sequential migrations alongside core storage logic. Extract migrations into a versioned system.\n\nCurrent issues:\n- 11 migration functions mixed with core logic\n- Hard to see migration history\n- Sequential migrations slow database open\n- No clear migration versioning\n\nMigration functions to extract:\n- `migrateDirtyIssuesTable()`\n- `migrateIssueCountersTable()`\n- `migrateExternalRefColumn()`\n- `migrateCompositeIndexes()`\n- `migrateClosedAtConstraint()`\n- `migrateCompactionColumns()`\n- `migrateSnapshotsTable()`\n- `migrateCompactionConfig()`\n- `migrateCompactedAtCommitColumn()`\n- `migrateExportHashesTable()`\n- Plus 1 more (11 total)\n\nTarget structure:\n```\ninternal/storage/sqlite/\n├── sqlite.go # Core storage (~800 lines)\n├── schema.go # Table definitions (~200 lines)\n├── migrations.go # Migration orchestration (~200 lines)\n└── migrations/ # Individual migrations\n ├── 001_initial_schema.go\n ├── 002_dirty_issues.go\n ├── 003_issue_counters.go\n [... through 011_export_hashes.go]\n```\n\nBenefits:\n- Clear migration history\n- Each migration self-contained\n- Easier to review migration changes in PRs\n- Future migrations easier to add","acceptance_criteria":"- All 11 migrations extracted to separate files\n- Migration version tracking in database\n- Migrations run in order on fresh database\n- Existing databases upgrade correctly\n- All tests pass: `go test ./internal/storage/sqlite/...`\n- Database initialization time unchanged or improved\n- Add migration rollback capability (optional, nice-to-have)","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-27T20:30:47.870671-07:00","updated_at":"2025-10-30T17:12:58.207682-07:00","labels":["database","phase-2","refactor"],"dependencies":[{"issue_id":"bd-fb95094c.7","depends_on_id":"bd-fb95094c","type":"parent-child","created_at":"2025-10-27T20:30:47.875564-07:00","created_by":"daemon"}]} {"id":"bd-fb95094c.8","content_hash":"8a8df680150f73fef6ac9cede6a1b2b0033406b35553a8a3795b13a542cd62f1","title":"Remove unreachable utility functions","description":"Several small utility functions are unreachable:\n\nFiles to clean:\n1. `internal/storage/sqlite/hash.go` - `computeIssueContentHash` (line 17)\n - Check if entire file can be deleted if only contains this function\n\n2. `internal/config/config.go` - `FileUsed` (line 151)\n - Delete unused config helper\n\n3. `cmd/bd/git_sync_test.go` - `verifyIssueOpen` (line 300)\n - Delete dead test helper\n\n4. `internal/compact/haiku.go` - `HaikuClient.SummarizeTier2` (line 81)\n - Tier 2 summarization not implemented\n - Options: implement feature OR delete method\n\nImpact: Removes 50-100 LOC depending on decisions","acceptance_criteria":"- Remove unreachable functions\n- If entire files can be deleted (like hash.go), delete them\n- For SummarizeTier2: decide to implement or delete, document decision\n- All tests pass: `go test ./...`\n- Verify no callers exist for each function","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-27T20:30:19.963392-07:00","updated_at":"2025-10-30T17:12:58.206334-07:00","closed_at":"2025-10-28T14:14:55.724226-07:00","labels":["cleanup","dead-code","phase-1"],"dependencies":[{"issue_id":"bd-fb95094c.8","depends_on_id":"bd-fb95094c","type":"parent-child","created_at":"2025-10-27T20:30:19.968126-07:00","created_by":"daemon"}]} {"id":"bd-fb95094c.9","content_hash":"404b82a19dde2fdece7eb6bb3b816db7906e81a03a5a05341ed631af7a2a8e87","title":"Remove unreachable RPC methods","description":"Several RPC server and client methods are unreachable and should be removed:\n\nServer methods (internal/rpc/server.go):\n- `Server.GetLastImportTime` (line 2116)\n- `Server.SetLastImportTime` (line 2123)\n- `Server.findJSONLPath` (line 2255)\n\nClient methods (internal/rpc/client.go):\n- `Client.Import` (line 311) - RPC import not used (daemon uses autoimport)\n\nEvidence:\n```bash\ngo run golang.org/x/tools/cmd/deadcode@latest -test ./...\n```\n\nImpact: Removes ~80 LOC of unused RPC code","acceptance_criteria":"- Remove the 4 unreachable methods (~80 LOC total)\n- Verify no callers: `grep -r \"GetLastImportTime\\|SetLastImportTime\\|findJSONLPath\" .`\n- All tests pass: `go test ./internal/rpc/...`\n- Daemon functionality works: test daemon start/stop/operations","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-27T20:30:19.962209-07:00","updated_at":"2025-10-30T17:12:58.201466-07:00","closed_at":"2025-10-28T16:07:26.103703-07:00","labels":["cleanup","dead-code","phase-1","rpc"],"dependencies":[{"issue_id":"bd-fb95094c.9","depends_on_id":"bd-fb95094c","type":"parent-child","created_at":"2025-10-27T20:30:19.965239-07:00","created_by":"daemon"}]} -{"id":"bd-fc2d","content_hash":"f3677662a3659a99c69bfaaa3fa019a3c525ce9a8c2d25ae3cd786e05eb1098f","title":"Refactor sqlite.go (2298 lines)","description":"Break down internal/storage/sqlite/sqlite.go into smaller, more focused modules. The file is currently 2298 lines and should be split into logical components.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-11-01T19:28:40.899111-07:00","updated_at":"2025-11-01T21:43:55.870724-07:00","closed_at":"2025-11-01T21:43:55.870724-07:00"} -{"id":"bd-fd56","content_hash":"71946aa48a3fa7836f905d340f73428416aeb8a190069ff3bb737eafbeed0d5e","title":"Wrap git operations in GitClient interface","description":"Create internal/daemonrunner/git.go with GitClient interface (HasUpstream, HasChanges, Commit, Push, Pull). Default implementation using os/exec. Use in Syncer and Run loop for testability.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-01T11:41:14.88734-07:00","updated_at":"2025-11-01T22:56:08.354697-07:00","closed_at":"2025-11-01T22:56:08.354697-07:00"} +{"id":"bd-fc2d","content_hash":"f3677662a3659a99c69bfaaa3fa019a3c525ce9a8c2d25ae3cd786e05eb1098f","title":"Refactor sqlite.go (2298 lines)","description":"Break down internal/storage/sqlite/sqlite.go into smaller, more focused modules. The file is currently 2298 lines and should be split into logical components.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-11-01T19:28:40.899111-07:00","updated_at":"2025-11-01T22:21:01.729379-07:00","closed_at":"2025-11-01T22:21:01.729379-07:00"} +{"id":"bd-fd56","content_hash":"71946aa48a3fa7836f905d340f73428416aeb8a190069ff3bb737eafbeed0d5e","title":"Wrap git operations in GitClient interface","description":"Create internal/daemonrunner/git.go with GitClient interface (HasUpstream, HasChanges, Commit, Push, Pull). Default implementation using os/exec. Use in Syncer and Run loop for testability.","status":"open","priority":1,"issue_type":"task","created_at":"2025-11-01T11:41:14.88734-07:00","updated_at":"2025-11-01T11:41:14.88734-07:00"} {"id":"bd-fd8753d9","content_hash":"ae13fc833baa7d586a48ca62648dd4f0ee61fcc96aa1f238fb2639b6657b07da","title":"Document bd edit command and verify MCP exclusion","description":"Follow-up from PR #152:\n1. Add \"bd edit\" to AGENTS.md with \"Humans only\" note\n2. Verify MCP server doesn't expose bd edit command\n3. Consider adding test for command registration","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-26T13:23:47.982295-07:00","updated_at":"2025-10-30T17:12:58.226229-07:00"} diff --git a/internal/storage/sqlite/collision_test.go b/internal/storage/sqlite/collision_test.go new file mode 100644 index 00000000..e3506a71 --- /dev/null +++ b/internal/storage/sqlite/collision_test.go @@ -0,0 +1,375 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +func TestDetectCollisions(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + ctx := context.Background() + + // Create existing issue + existing := &types.Issue{ + ID: "bd-1", + Title: "Existing Issue", + Description: "Original description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, existing, "test"); err != nil { + t.Fatalf("Failed to create existing issue: %v", err) + } + + tests := []struct { + name string + incoming []*types.Issue + wantExactMatches int + wantCollisions int + wantNewIssues int + checkCollisionID string + expectedConflicts []string + }{ + { + name: "exact match - idempotent", + incoming: []*types.Issue{ + { + ID: "bd-1", + Title: "Existing Issue", + Description: "Original description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + }, + }, + wantExactMatches: 1, + wantCollisions: 0, + wantNewIssues: 0, + }, + { + name: "collision - different title", + incoming: []*types.Issue{ + { + ID: "bd-1", + Title: "Modified Title", + Description: "Original description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + }, + }, + wantExactMatches: 0, + wantCollisions: 1, + wantNewIssues: 0, + checkCollisionID: "bd-1", + expectedConflicts: []string{"title"}, + }, + { + name: "collision - multiple fields", + incoming: []*types.Issue{ + { + ID: "bd-1", + Title: "Modified Title", + Description: "Modified description", + Status: types.StatusInProgress, + Priority: 2, + IssueType: types.TypeTask, + }, + }, + wantExactMatches: 0, + wantCollisions: 1, + wantNewIssues: 0, + checkCollisionID: "bd-1", + expectedConflicts: []string{"title", "description", "status", "priority"}, + }, + { + name: "new issue", + incoming: []*types.Issue{ + { + ID: "bd-2", + Title: "New Issue", + Description: "New description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeBug, + }, + }, + wantExactMatches: 0, + wantCollisions: 0, + wantNewIssues: 1, + }, + { + name: "mixed - exact, collision, and new", + incoming: []*types.Issue{ + { + ID: "bd-1", + Title: "Existing Issue", + Description: "Original description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + }, + { + ID: "bd-2", + Title: "New Issue", + Description: "New description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeBug, + }, + }, + wantExactMatches: 1, + wantCollisions: 0, + wantNewIssues: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := DetectCollisions(ctx, store, tt.incoming) + if err != nil { + t.Fatalf("DetectCollisions failed: %v", err) + } + + if len(result.ExactMatches) != tt.wantExactMatches { + t.Errorf("ExactMatches: got %d, want %d", len(result.ExactMatches), tt.wantExactMatches) + } + if len(result.Collisions) != tt.wantCollisions { + t.Errorf("Collisions: got %d, want %d", len(result.Collisions), tt.wantCollisions) + } + if len(result.NewIssues) != tt.wantNewIssues { + t.Errorf("NewIssues: got %d, want %d", len(result.NewIssues), tt.wantNewIssues) + } + + // Check collision details if expected + if tt.checkCollisionID != "" && len(result.Collisions) > 0 { + collision := result.Collisions[0] + if collision.ID != tt.checkCollisionID { + t.Errorf("Collision ID: got %s, want %s", collision.ID, tt.checkCollisionID) + } + if len(collision.ConflictingFields) != len(tt.expectedConflicts) { + t.Errorf("ConflictingFields count: got %d, want %d", len(collision.ConflictingFields), len(tt.expectedConflicts)) + } + for i, field := range tt.expectedConflicts { + if i >= len(collision.ConflictingFields) || collision.ConflictingFields[i] != field { + t.Errorf("ConflictingFields[%d]: got %v, want %s", i, collision.ConflictingFields, field) + } + } + } + }) + } +} + +func TestCompareIssues(t *testing.T) { + base := &types.Issue{ + ID: "test-1", + Title: "Base", + Description: "Base description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + Assignee: "alice", + Design: "Base design", + AcceptanceCriteria: "Base acceptance", + Notes: "Base notes", + } + + tests := []struct { + name string + modify func(*types.Issue) *types.Issue + wantConflicts []string + wantNoConflicts bool + }{ + { + name: "identical issues", + modify: func(i *types.Issue) *types.Issue { + copy := *i + return © + }, + wantNoConflicts: true, + }, + { + name: "different title", + modify: func(i *types.Issue) *types.Issue { + copy := *i + copy.Title = "Modified" + return © + }, + wantConflicts: []string{"title"}, + }, + { + name: "different description", + modify: func(i *types.Issue) *types.Issue { + copy := *i + copy.Description = "Modified" + return © + }, + wantConflicts: []string{"description"}, + }, + { + name: "different status", + modify: func(i *types.Issue) *types.Issue { + copy := *i + copy.Status = types.StatusClosed + return © + }, + wantConflicts: []string{"status"}, + }, + { + name: "different priority", + modify: func(i *types.Issue) *types.Issue { + copy := *i + copy.Priority = 2 + return © + }, + wantConflicts: []string{"priority"}, + }, + { + name: "different assignee", + modify: func(i *types.Issue) *types.Issue { + copy := *i + copy.Assignee = "bob" + return © + }, + wantConflicts: []string{"assignee"}, + }, + { + name: "multiple differences", + modify: func(i *types.Issue) *types.Issue { + copy := *i + copy.Title = "Modified" + copy.Priority = 2 + copy.Status = types.StatusClosed + return © + }, + wantConflicts: []string{"title", "status", "priority"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + modified := tt.modify(base) + conflicts := compareIssues(base, modified) + + if tt.wantNoConflicts { + if len(conflicts) != 0 { + t.Errorf("Expected no conflicts, got %v", conflicts) + } + return + } + + if len(conflicts) != len(tt.wantConflicts) { + t.Errorf("Conflict count: got %d, want %d (conflicts: %v)", len(conflicts), len(tt.wantConflicts), conflicts) + } + + for _, wantField := range tt.wantConflicts { + found := false + for _, gotField := range conflicts { + if gotField == wantField { + found = true + break + } + } + if !found { + t.Errorf("Expected conflict field %s not found in %v", wantField, conflicts) + } + } + }) + } +} + +func TestHashIssueContent(t *testing.T) { + issue1 := &types.Issue{ + ID: "test-1", + Title: "Issue", + Description: "Description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + Assignee: "alice", + Design: "Design", + AcceptanceCriteria: "Acceptance", + Notes: "Notes", + } + + issue2 := &types.Issue{ + ID: "test-1", + Title: "Issue", + Description: "Description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + Assignee: "alice", + Design: "Design", + AcceptanceCriteria: "Acceptance", + Notes: "Notes", + } + + issue3 := &types.Issue{ + ID: "test-1", + Title: "Different", + Description: "Description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + Assignee: "alice", + Design: "Design", + AcceptanceCriteria: "Acceptance", + Notes: "Notes", + } + + hash1 := hashIssueContent(issue1) + hash2 := hashIssueContent(issue2) + hash3 := hashIssueContent(issue3) + + if hash1 != hash2 { + t.Errorf("Expected identical issues to have same hash, got %s vs %s", hash1, hash2) + } + + if hash1 == hash3 { + t.Errorf("Expected different issues to have different hashes") + } + + // Verify hash is deterministic + hash1Again := hashIssueContent(issue1) + if hash1 != hash1Again { + t.Errorf("Hash function not deterministic: %s vs %s", hash1, hash1Again) + } +} + +func TestHashIssueContentWithExternalRef(t *testing.T) { + ref1 := "JIRA-123" + ref2 := "JIRA-456" + + issueWithRef1 := &types.Issue{ + ID: "test-1", + Title: "Issue", + ExternalRef: &ref1, + } + + issueWithRef2 := &types.Issue{ + ID: "test-1", + Title: "Issue", + ExternalRef: &ref2, + } + + issueNoRef := &types.Issue{ + ID: "test-1", + Title: "Issue", + } + + hash1 := hashIssueContent(issueWithRef1) + hash2 := hashIssueContent(issueWithRef2) + hash3 := hashIssueContent(issueNoRef) + + if hash1 == hash2 { + t.Errorf("Expected different external refs to produce different hashes") + } + + if hash1 == hash3 { + t.Errorf("Expected issue with external ref to differ from issue without") + } +} diff --git a/internal/storage/sqlite/comments_test.go b/internal/storage/sqlite/comments_test.go new file mode 100644 index 00000000..73f3d305 --- /dev/null +++ b/internal/storage/sqlite/comments_test.go @@ -0,0 +1,368 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +// TestAddIssueComment tests basic comment addition +func TestAddIssueComment(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create an issue + issue := &types.Issue{ + Title: "Test issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Add a comment + comment, err := store.AddIssueComment(ctx, issue.ID, "alice", "This is a test comment") + if err != nil { + t.Fatalf("AddIssueComment failed: %v", err) + } + + // Verify comment fields + if comment.IssueID != issue.ID { + t.Errorf("Expected IssueID %s, got %s", issue.ID, comment.IssueID) + } + if comment.Author != "alice" { + t.Errorf("Expected Author 'alice', got '%s'", comment.Author) + } + if comment.Text != "This is a test comment" { + t.Errorf("Expected Text 'This is a test comment', got '%s'", comment.Text) + } + if comment.ID == 0 { + t.Error("Expected non-zero comment ID") + } + if comment.CreatedAt.IsZero() { + t.Error("Expected non-zero CreatedAt timestamp") + } +} + +// TestAddIssueCommentNonexistentIssue tests adding comment to non-existent issue +func TestAddIssueCommentNonexistentIssue(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Try to add comment to non-existent issue + _, err := store.AddIssueComment(ctx, "nonexistent-id", "alice", "comment") + if err == nil { + t.Fatal("Expected error when adding comment to non-existent issue, got nil") + } +} + +// TestGetIssueComments tests retrieving comments +func TestGetIssueComments(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create an issue + issue := &types.Issue{ + Title: "Test issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Add multiple comments + testComments := []struct { + author string + text string + }{ + {"alice", "First comment"}, + {"bob", "Second comment"}, + {"charlie", "Third comment"}, + } + + for _, tc := range testComments { + _, err := store.AddIssueComment(ctx, issue.ID, tc.author, tc.text) + if err != nil { + t.Fatalf("AddIssueComment failed: %v", err) + } + } + + // Retrieve comments + comments, err := store.GetIssueComments(ctx, issue.ID) + if err != nil { + t.Fatalf("GetIssueComments failed: %v", err) + } + + // Verify number of comments + if len(comments) != len(testComments) { + t.Fatalf("Expected %d comments, got %d", len(testComments), len(comments)) + } + + // Verify comment content and ordering (should be chronological) + for i, comment := range comments { + if comment.Author != testComments[i].author { + t.Errorf("Comment %d: expected author %s, got %s", i, testComments[i].author, comment.Author) + } + if comment.Text != testComments[i].text { + t.Errorf("Comment %d: expected text %s, got %s", i, testComments[i].text, comment.Text) + } + if comment.IssueID != issue.ID { + t.Errorf("Comment %d: expected IssueID %s, got %s", i, issue.ID, comment.IssueID) + } + } +} + +// TestGetIssueCommentsOrdering tests that comments are returned in chronological order +func TestGetIssueCommentsOrdering(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create an issue + issue := &types.Issue{ + Title: "Test issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Add comments with identifiable ordering + for i := 1; i <= 5; i++ { + text := "Comment " + string(rune('0'+i)) + _, err := store.AddIssueComment(ctx, issue.ID, "alice", text) + if err != nil { + t.Fatalf("AddIssueComment failed: %v", err) + } + } + + // Retrieve comments + comments, err := store.GetIssueComments(ctx, issue.ID) + if err != nil { + t.Fatalf("GetIssueComments failed: %v", err) + } + + // Verify chronological ordering + if len(comments) != 5 { + t.Fatalf("Expected 5 comments, got %d", len(comments)) + } + + for i := 0; i < len(comments); i++ { + expectedText := "Comment " + string(rune('0'+i+1)) + if comments[i].Text != expectedText { + t.Errorf("Comment %d: expected text %s, got %s", i, expectedText, comments[i].Text) + } + + // Verify timestamps are in ascending order + if i > 0 && comments[i].CreatedAt.Before(comments[i-1].CreatedAt) { + t.Errorf("Comments not in chronological order: comment %d (%v) is before comment %d (%v)", + i, comments[i].CreatedAt, i-1, comments[i-1].CreatedAt) + } + } +} + +// TestGetIssueCommentsEmpty tests retrieving comments for issue with no comments +func TestGetIssueCommentsEmpty(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create an issue + issue := &types.Issue{ + Title: "Test issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Retrieve comments (should be empty) + comments, err := store.GetIssueComments(ctx, issue.ID) + if err != nil { + t.Fatalf("GetIssueComments failed: %v", err) + } + + if len(comments) != 0 { + t.Errorf("Expected 0 comments, got %d", len(comments)) + } +} + +// TestGetIssueCommentsNonexistentIssue tests retrieving comments for non-existent issue +func TestGetIssueCommentsNonexistentIssue(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Retrieve comments for non-existent issue + comments, err := store.GetIssueComments(ctx, "nonexistent-id") + if err != nil { + t.Fatalf("GetIssueComments failed: %v", err) + } + + // Should return empty slice, not error + if len(comments) != 0 { + t.Errorf("Expected 0 comments for non-existent issue, got %d", len(comments)) + } +} + +// TestAddIssueCommentEmptyText tests adding comment with empty text +func TestAddIssueCommentEmptyText(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create an issue + issue := &types.Issue{ + Title: "Test issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Add comment with empty text (should succeed - validation is caller's responsibility) + comment, err := store.AddIssueComment(ctx, issue.ID, "alice", "") + if err != nil { + t.Fatalf("AddIssueComment with empty text failed: %v", err) + } + + if comment.Text != "" { + t.Errorf("Expected empty text, got '%s'", comment.Text) + } +} + +// TestAddIssueCommentMarksDirty tests that adding a comment marks the issue dirty +func TestAddIssueCommentMarksDirty(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create an issue + issue := &types.Issue{ + Title: "Test issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Clear dirty flag (simulating after export) + if err := store.ClearDirtyIssuesByID(ctx, []string{issue.ID}); err != nil { + t.Fatalf("ClearDirtyIssuesByID failed: %v", err) + } + + // Add a comment + _, err := store.AddIssueComment(ctx, issue.ID, "alice", "test comment") + if err != nil { + t.Fatalf("AddIssueComment failed: %v", err) + } + + // Verify issue is marked dirty + var exists bool + err = store.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM dirty_issues WHERE issue_id = ?)`, issue.ID).Scan(&exists) + if err != nil { + t.Fatalf("Failed to check dirty flag: %v", err) + } + + if !exists { + t.Error("Expected issue to be marked dirty after adding comment") + } +} + +// TestGetIssueCommentsMultipleIssues tests that comments are properly isolated per issue +func TestGetIssueCommentsMultipleIssues(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create two issues + issue1 := &types.Issue{ + Title: "Issue 1", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + issue2 := &types.Issue{ + Title: "Issue 2", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issue1, "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + if err := store.CreateIssue(ctx, issue2, "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Add comments to each issue + _, err := store.AddIssueComment(ctx, issue1.ID, "alice", "Comment for issue 1") + if err != nil { + t.Fatalf("AddIssueComment failed: %v", err) + } + _, err = store.AddIssueComment(ctx, issue1.ID, "bob", "Another comment for issue 1") + if err != nil { + t.Fatalf("AddIssueComment failed: %v", err) + } + _, err = store.AddIssueComment(ctx, issue2.ID, "charlie", "Comment for issue 2") + if err != nil { + t.Fatalf("AddIssueComment failed: %v", err) + } + + // Retrieve comments for issue 1 + comments1, err := store.GetIssueComments(ctx, issue1.ID) + if err != nil { + t.Fatalf("GetIssueComments failed: %v", err) + } + + // Retrieve comments for issue 2 + comments2, err := store.GetIssueComments(ctx, issue2.ID) + if err != nil { + t.Fatalf("GetIssueComments failed: %v", err) + } + + // Verify each issue has the correct number of comments + if len(comments1) != 2 { + t.Errorf("Expected 2 comments for issue 1, got %d", len(comments1)) + } + if len(comments2) != 1 { + t.Errorf("Expected 1 comment for issue 2, got %d", len(comments2)) + } + + // Verify comments belong to correct issues + for _, c := range comments1 { + if c.IssueID != issue1.ID { + t.Errorf("Comment has wrong IssueID: expected %s, got %s", issue1.ID, c.IssueID) + } + } + for _, c := range comments2 { + if c.IssueID != issue2.ID { + t.Errorf("Comment has wrong IssueID: expected %s, got %s", issue2.ID, c.IssueID) + } + } +} diff --git a/internal/storage/sqlite/cycle_detection_test.go b/internal/storage/sqlite/cycle_detection_test.go new file mode 100644 index 00000000..395ae67f --- /dev/null +++ b/internal/storage/sqlite/cycle_detection_test.go @@ -0,0 +1,506 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +// TestDetectCyclesSimple tests simple 2-node cycles +func TestDetectCyclesSimple(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create two issues + issue1 := &types.Issue{Title: "Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue2 := &types.Issue{Title: "Issue 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + if err := store.CreateIssue(ctx, issue1, "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + if err := store.CreateIssue(ctx, issue2, "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Manually create a cycle by inserting directly into dependencies table + // (bypassing AddDependency's cycle prevention) + _, err := store.db.ExecContext(ctx, ` + INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at) + VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP) + `, issue1.ID, issue2.ID, types.DepBlocks) + if err != nil { + t.Fatalf("Insert dependency failed: %v", err) + } + + _, err = store.db.ExecContext(ctx, ` + INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at) + VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP) + `, issue2.ID, issue1.ID, types.DepBlocks) + if err != nil { + t.Fatalf("Insert dependency failed: %v", err) + } + + // Detect cycles + cycles, err := store.DetectCycles(ctx) + if err != nil { + t.Fatalf("DetectCycles failed: %v", err) + } + + if len(cycles) == 0 { + t.Fatal("Expected to detect a cycle, but found none") + } + + // Verify the cycle contains both issues + cycle := cycles[0] + if len(cycle) != 2 { + t.Logf("Cycle issues: %v", cycle) + for i, iss := range cycle { + t.Logf(" [%d] ID=%s Title=%s", i, iss.ID, iss.Title) + } + t.Errorf("Expected cycle of length 2, got %d", len(cycle)) + } + + // Verify both issues are in the cycle + foundIDs := make(map[string]bool) + for _, issue := range cycle { + foundIDs[issue.ID] = true + } + + if !foundIDs[issue1.ID] || !foundIDs[issue2.ID] { + t.Errorf("Cycle missing expected issues. Got: %v", foundIDs) + } +} + +// TestDetectCyclesComplex tests a more complex multi-node cycle +func TestDetectCyclesComplex(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create a 4-node cycle: A → B → C → D → A + issues := make([]*types.Issue, 4) + for i := 0; i < 4; i++ { + issues[i] = &types.Issue{ + Title: "Issue " + string(rune('A'+i)), + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + } + + // Create cycle: 0→1→2→3→0 + for i := 0; i < 4; i++ { + nextIdx := (i + 1) % 4 + _, err := store.db.ExecContext(ctx, ` + INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at) + VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP) + `, issues[i].ID, issues[nextIdx].ID, types.DepBlocks) + if err != nil { + t.Fatalf("Insert dependency failed: %v", err) + } + } + + // Detect cycles + cycles, err := store.DetectCycles(ctx) + if err != nil { + t.Fatalf("DetectCycles failed: %v", err) + } + + if len(cycles) == 0 { + t.Fatal("Expected to detect a cycle, but found none") + } + + // Verify the cycle contains all 4 issues + cycle := cycles[0] + if len(cycle) != 4 { + t.Errorf("Expected cycle of length 4, got %d", len(cycle)) + } + + // Verify all issues are in the cycle + foundIDs := make(map[string]bool) + for _, issue := range cycle { + foundIDs[issue.ID] = true + } + + for _, issue := range issues { + if !foundIDs[issue.ID] { + t.Errorf("Cycle missing issue %s", issue.ID) + } + } +} + +// TestDetectCyclesSelfLoop tests detection of self-loops (A → A) +func TestDetectCyclesSelfLoop(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + issue := &types.Issue{Title: "Self Loop", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + if err := store.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Create self-loop + _, err := store.db.ExecContext(ctx, ` + INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at) + VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP) + `, issue.ID, issue.ID, types.DepBlocks) + if err != nil { + t.Fatalf("Insert dependency failed: %v", err) + } + + // Detect cycles + cycles, err := store.DetectCycles(ctx) + if err != nil { + t.Fatalf("DetectCycles failed: %v", err) + } + + if len(cycles) == 0 { + t.Fatal("Expected to detect a self-loop cycle, but found none") + } + + // Verify the cycle contains the issue + cycle := cycles[0] + if len(cycle) != 1 { + t.Errorf("Expected self-loop cycle of length 1, got %d", len(cycle)) + } + + if cycle[0].ID != issue.ID { + t.Errorf("Expected cycle to contain issue %s, got %s", issue.ID, cycle[0].ID) + } +} + +// TestDetectCyclesMultipleIndependent tests detection of multiple independent cycles +func TestDetectCyclesMultipleIndependent(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create two independent cycles: + // Cycle 1: A → B → A + // Cycle 2: C → D → C + + cycle1 := make([]*types.Issue, 2) + cycle2 := make([]*types.Issue, 2) + + for i := 0; i < 2; i++ { + cycle1[i] = &types.Issue{ + Title: "Cycle1-" + string(rune('A'+i)), + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + cycle2[i] = &types.Issue{ + Title: "Cycle2-" + string(rune('A'+i)), + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, cycle1[i], "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + if err := store.CreateIssue(ctx, cycle2[i], "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + } + + // Create first cycle: 0→1→0 + _, err := store.db.ExecContext(ctx, ` + INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at) + VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP) + `, cycle1[0].ID, cycle1[1].ID, types.DepBlocks) + if err != nil { + t.Fatalf("Insert dependency failed: %v", err) + } + _, err = store.db.ExecContext(ctx, ` + INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at) + VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP) + `, cycle1[1].ID, cycle1[0].ID, types.DepBlocks) + if err != nil { + t.Fatalf("Insert dependency failed: %v", err) + } + + // Create second cycle: 0→1→0 + _, err = store.db.ExecContext(ctx, ` + INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at) + VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP) + `, cycle2[0].ID, cycle2[1].ID, types.DepBlocks) + if err != nil { + t.Fatalf("Insert dependency failed: %v", err) + } + _, err = store.db.ExecContext(ctx, ` + INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at) + VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP) + `, cycle2[1].ID, cycle2[0].ID, types.DepBlocks) + if err != nil { + t.Fatalf("Insert dependency failed: %v", err) + } + + // Detect cycles + cycles, err := store.DetectCycles(ctx) + if err != nil { + t.Fatalf("DetectCycles failed: %v", err) + } + + // The SQL may detect the same cycle from different entry points, + // so we might get more than 2 cycles reported. Verify we have at least 2. + if len(cycles) < 2 { + t.Errorf("Expected to detect at least 2 independent cycles, got %d", len(cycles)) + } + + // Verify we found cycles involving all 4 issues + foundIssues := make(map[string]bool) + for _, cycle := range cycles { + for _, issue := range cycle { + foundIssues[issue.ID] = true + } + } + + allCycleIssues := append(cycle1, cycle2...) + for _, issue := range allCycleIssues { + if !foundIssues[issue.ID] { + t.Errorf("Cycle detection missing issue %s", issue.ID) + } + } +} + +// TestDetectCyclesAcyclicGraph tests that no cycles are detected in an acyclic graph +func TestDetectCyclesAcyclicGraph(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create a DAG: A → B → C → D (no cycles) + issues := make([]*types.Issue, 4) + for i := 0; i < 4; i++ { + issues[i] = &types.Issue{ + Title: "Issue " + string(rune('A'+i)), + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + } + + // Create chain: 0→1→2→3 (no cycle) + for i := 0; i < 3; i++ { + _, err := store.db.ExecContext(ctx, ` + INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at) + VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP) + `, issues[i].ID, issues[i+1].ID, types.DepBlocks) + if err != nil { + t.Fatalf("Insert dependency failed: %v", err) + } + } + + // Detect cycles + cycles, err := store.DetectCycles(ctx) + if err != nil { + t.Fatalf("DetectCycles failed: %v", err) + } + + if len(cycles) != 0 { + t.Errorf("Expected no cycles in acyclic graph, but found %d", len(cycles)) + } +} + +// TestDetectCyclesEmptyGraph tests cycle detection on empty graph +func TestDetectCyclesEmptyGraph(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Detect cycles on empty database + cycles, err := store.DetectCycles(ctx) + if err != nil { + t.Fatalf("DetectCycles failed: %v", err) + } + + if len(cycles) != 0 { + t.Errorf("Expected no cycles in empty graph, but found %d", len(cycles)) + } +} + +// TestDetectCyclesSingleNode tests cycle detection with a single node (no dependencies) +func TestDetectCyclesSingleNode(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create a single issue with no dependencies + issue := &types.Issue{Title: "Lonely Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + if err := store.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Detect cycles + cycles, err := store.DetectCycles(ctx) + if err != nil { + t.Fatalf("DetectCycles failed: %v", err) + } + + if len(cycles) != 0 { + t.Errorf("Expected no cycles for single node with no dependencies, but found %d", len(cycles)) + } +} + +// TestDetectCyclesDiamond tests cycle detection in a diamond pattern (no cycle) +func TestDetectCyclesDiamond(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create a diamond pattern: A → B → D, A → C → D (no cycle) + issues := make([]*types.Issue, 4) + names := []string{"A", "B", "C", "D"} + for i := 0; i < 4; i++ { + issues[i] = &types.Issue{ + Title: "Issue " + names[i], + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + } + + // Create dependencies: A→B, A→C, B→D, C→D + deps := [][2]int{{0, 1}, {0, 2}, {1, 3}, {2, 3}} + for _, dep := range deps { + _, err := store.db.ExecContext(ctx, ` + INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at) + VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP) + `, issues[dep[0]].ID, issues[dep[1]].ID, types.DepBlocks) + if err != nil { + t.Fatalf("Insert dependency failed: %v", err) + } + } + + // Detect cycles + cycles, err := store.DetectCycles(ctx) + if err != nil { + t.Fatalf("DetectCycles failed: %v", err) + } + + if len(cycles) != 0 { + t.Errorf("Expected no cycles in diamond pattern, but found %d", len(cycles)) + } +} + +// TestDetectCyclesLongCycle tests detection of a long cycle (10 nodes) +func TestDetectCyclesLongCycle(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create a 10-node cycle + const cycleLength = 10 + issues := make([]*types.Issue, cycleLength) + for i := 0; i < cycleLength; i++ { + issues[i] = &types.Issue{ + Title: "Issue " + string(rune('0'+i)), + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + } + + // Create cycle: 0→1→2→...→9→0 + for i := 0; i < cycleLength; i++ { + nextIdx := (i + 1) % cycleLength + _, err := store.db.ExecContext(ctx, ` + INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at) + VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP) + `, issues[i].ID, issues[nextIdx].ID, types.DepBlocks) + if err != nil { + t.Fatalf("Insert dependency failed: %v", err) + } + } + + // Detect cycles + cycles, err := store.DetectCycles(ctx) + if err != nil { + t.Fatalf("DetectCycles failed: %v", err) + } + + if len(cycles) == 0 { + t.Fatal("Expected to detect a cycle, but found none") + } + + // Verify the cycle contains all 10 issues + cycle := cycles[0] + if len(cycle) != cycleLength { + t.Errorf("Expected cycle of length %d, got %d", cycleLength, len(cycle)) + } +} + +// TestDetectCyclesMixedTypes tests cycle detection with different dependency types +func TestDetectCyclesMixedTypes(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create a cycle using different dependency types + issues := make([]*types.Issue, 3) + for i := 0; i < 3; i++ { + issues[i] = &types.Issue{ + Title: "Issue " + string(rune('A'+i)), + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + } + + // Create cycle with mixed types: A -blocks-> B -related-> C -parent-child-> A + depTypes := []types.DependencyType{types.DepBlocks, types.DepRelated, types.DepParentChild} + for i := 0; i < 3; i++ { + nextIdx := (i + 1) % 3 + _, err := store.db.ExecContext(ctx, ` + INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at) + VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP) + `, issues[i].ID, issues[nextIdx].ID, depTypes[i]) + if err != nil { + t.Fatalf("Insert dependency failed: %v", err) + } + } + + // Detect cycles + cycles, err := store.DetectCycles(ctx) + if err != nil { + t.Fatalf("DetectCycles failed: %v", err) + } + + if len(cycles) == 0 { + t.Fatal("Expected to detect a cycle with mixed types, but found none") + } + + // Verify the cycle contains all 3 issues + cycle := cycles[0] + if len(cycle) != 3 { + t.Errorf("Expected cycle of length 3, got %d", len(cycle)) + } +} diff --git a/internal/storage/sqlite/delete_test.go b/internal/storage/sqlite/delete_test.go new file mode 100644 index 00000000..59f933a9 --- /dev/null +++ b/internal/storage/sqlite/delete_test.go @@ -0,0 +1,276 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +func TestDeleteIssues(t *testing.T) { + ctx := context.Background() + + t.Run("delete non-existent issue", func(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + result, err := store.DeleteIssues(ctx, []string{"bd-999"}, false, false, false) + if err != nil { + t.Fatalf("DeleteIssues failed: %v", err) + } + if result.DeletedCount != 0 { + t.Errorf("Expected 0 deletions, got %d", result.DeletedCount) + } + }) + + t.Run("delete with dependents - should fail without force or cascade", func(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + + // Create issues with dependency + issue1 := &types.Issue{ID: "bd-1", Title: "Parent", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue2 := &types.Issue{ID: "bd-2", Title: "Child", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + if err := store.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatalf("Failed to create issue1: %v", err) + } + if err := store.CreateIssue(ctx, issue2, "test"); err != nil { + t.Fatalf("Failed to create issue2: %v", err) + } + dep := &types.Dependency{IssueID: "bd-2", DependsOnID: "bd-1", Type: types.DepBlocks} + if err := store.AddDependency(ctx, dep, "test"); err != nil { + t.Fatalf("Failed to add dependency: %v", err) + } + + _, err := store.DeleteIssues(ctx, []string{"bd-1"}, false, false, false) + if err == nil { + t.Fatal("Expected error when deleting issue with dependents") + } + }) + + t.Run("delete with cascade - should delete all dependents", func(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + + // Create chain: bd-1 -> bd-2 -> bd-3 + issue1 := &types.Issue{ID: "bd-1", Title: "Cascade Parent", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue2 := &types.Issue{ID: "bd-2", Title: "Cascade Child", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue3 := &types.Issue{ID: "bd-3", Title: "Cascade Grandchild", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + if err := store.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatalf("Failed to create issue1: %v", err) + } + if err := store.CreateIssue(ctx, issue2, "test"); err != nil { + t.Fatalf("Failed to create issue2: %v", err) + } + if err := store.CreateIssue(ctx, issue3, "test"); err != nil { + t.Fatalf("Failed to create issue3: %v", err) + } + + dep1 := &types.Dependency{IssueID: "bd-2", DependsOnID: "bd-1", Type: types.DepBlocks} + if err := store.AddDependency(ctx, dep1, "test"); err != nil { + t.Fatalf("Failed to add dependency: %v", err) + } + dep2 := &types.Dependency{IssueID: "bd-3", DependsOnID: "bd-2", Type: types.DepBlocks} + if err := store.AddDependency(ctx, dep2, "test"); err != nil { + t.Fatalf("Failed to add dependency: %v", err) + } + + result, err := store.DeleteIssues(ctx, []string{"bd-1"}, true, false, false) + if err != nil { + t.Fatalf("DeleteIssues with cascade failed: %v", err) + } + if result.DeletedCount != 3 { + t.Errorf("Expected 3 deletions (cascade), got %d", result.DeletedCount) + } + + // Verify all deleted + if issue, _ := store.GetIssue(ctx, "bd-1"); issue != nil { + t.Error("bd-1 should be deleted") + } + if issue, _ := store.GetIssue(ctx, "bd-2"); issue != nil { + t.Error("bd-2 should be deleted") + } + if issue, _ := store.GetIssue(ctx, "bd-3"); issue != nil { + t.Error("bd-3 should be deleted") + } + }) + + t.Run("delete with force - should orphan dependents", func(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + + // Create chain: bd-1 -> bd-2 -> bd-3 + issue1 := &types.Issue{ID: "bd-1", Title: "Force Parent", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue2 := &types.Issue{ID: "bd-2", Title: "Force Child", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue3 := &types.Issue{ID: "bd-3", Title: "Force Grandchild", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + if err := store.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatalf("Failed to create issue1: %v", err) + } + if err := store.CreateIssue(ctx, issue2, "test"); err != nil { + t.Fatalf("Failed to create issue2: %v", err) + } + if err := store.CreateIssue(ctx, issue3, "test"); err != nil { + t.Fatalf("Failed to create issue3: %v", err) + } + + dep1 := &types.Dependency{IssueID: "bd-2", DependsOnID: "bd-1", Type: types.DepBlocks} + if err := store.AddDependency(ctx, dep1, "test"); err != nil { + t.Fatalf("Failed to add dependency: %v", err) + } + dep2 := &types.Dependency{IssueID: "bd-3", DependsOnID: "bd-2", Type: types.DepBlocks} + if err := store.AddDependency(ctx, dep2, "test"); err != nil { + t.Fatalf("Failed to add dependency: %v", err) + } + + result, err := store.DeleteIssues(ctx, []string{"bd-1"}, false, true, false) + if err != nil { + t.Fatalf("DeleteIssues with force failed: %v", err) + } + if result.DeletedCount != 1 { + t.Errorf("Expected 1 deletion (force), got %d", result.DeletedCount) + } + if len(result.OrphanedIssues) != 1 || result.OrphanedIssues[0] != "bd-2" { + t.Errorf("Expected bd-2 to be orphaned, got %v", result.OrphanedIssues) + } + + // Verify bd-1 deleted, bd-2 and bd-3 still exist + if issue, _ := store.GetIssue(ctx, "bd-1"); issue != nil { + t.Error("bd-1 should be deleted") + } + if issue, _ := store.GetIssue(ctx, "bd-2"); issue == nil { + t.Error("bd-2 should still exist") + } + if issue, _ := store.GetIssue(ctx, "bd-3"); issue == nil { + t.Error("bd-3 should still exist") + } + }) + + t.Run("dry run - should not delete", func(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + + issue1 := &types.Issue{ID: "bd-1", Title: "DryRun Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue2 := &types.Issue{ID: "bd-2", Title: "DryRun Issue 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + if err := store.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatalf("Failed to create issue1: %v", err) + } + if err := store.CreateIssue(ctx, issue2, "test"); err != nil { + t.Fatalf("Failed to create issue2: %v", err) + } + + result, err := store.DeleteIssues(ctx, []string{"bd-1", "bd-2"}, false, true, true) + if err != nil { + t.Fatalf("DeleteIssues dry run failed: %v", err) + } + + // Should report what would be deleted + if result.DeletedCount != 2 { + t.Errorf("Dry run should report 2 deletions, got %d", result.DeletedCount) + } + + // But issues should still exist + if issue, _ := store.GetIssue(ctx, "bd-1"); issue == nil { + t.Error("bd-1 should still exist after dry run") + } + if issue, _ := store.GetIssue(ctx, "bd-2"); issue == nil { + t.Error("bd-2 should still exist after dry run") + } + }) + + t.Run("delete multiple issues at once", func(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + + independent1 := &types.Issue{ID: "bd-10", Title: "Independent 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + independent2 := &types.Issue{ID: "bd-11", Title: "Independent 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + if err := store.CreateIssue(ctx, independent1, "test"); err != nil { + t.Fatalf("Failed to create independent1: %v", err) + } + if err := store.CreateIssue(ctx, independent2, "test"); err != nil { + t.Fatalf("Failed to create independent2: %v", err) + } + + result, err := store.DeleteIssues(ctx, []string{"bd-10", "bd-11"}, false, false, false) + if err != nil { + t.Fatalf("DeleteIssues failed: %v", err) + } + if result.DeletedCount != 2 { + t.Errorf("Expected 2 deletions, got %d", result.DeletedCount) + } + + // Verify both deleted + if issue, _ := store.GetIssue(ctx, "bd-10"); issue != nil { + t.Error("bd-10 should be deleted") + } + if issue, _ := store.GetIssue(ctx, "bd-11"); issue != nil { + t.Error("bd-11 should be deleted") + } + }) +} + +func TestDeleteIssue(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + ctx := context.Background() + + issue := &types.Issue{ + ID: "bd-1", + Title: "Single Delete Test Issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + + if err := store.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("Failed to create issue: %v", err) + } + + // Delete it + if err := store.DeleteIssue(ctx, "bd-1"); err != nil { + t.Fatalf("DeleteIssue failed: %v", err) + } + + // Verify deleted + if issue, _ := store.GetIssue(ctx, "bd-1"); issue != nil { + t.Error("Issue should be deleted") + } + + // Delete non-existent - should error + if err := store.DeleteIssue(ctx, "bd-999"); err == nil { + t.Error("DeleteIssue of non-existent should error") + } +} + +func TestBuildIDSet(t *testing.T) { + ids := []string{"bd-1", "bd-2", "bd-3"} + idSet := buildIDSet(ids) + + if len(idSet) != 3 { + t.Errorf("Expected set size 3, got %d", len(idSet)) + } + + for _, id := range ids { + if !idSet[id] { + t.Errorf("ID %s should be in set", id) + } + } + + if idSet["bd-999"] { + t.Error("bd-999 should not be in set") + } +} + +func TestBuildSQLInClause(t *testing.T) { + ids := []string{"bd-1", "bd-2", "bd-3"} + inClause, args := buildSQLInClause(ids) + + expectedClause := "?,?,?" + if inClause != expectedClause { + t.Errorf("Expected clause %s, got %s", expectedClause, inClause) + } + + if len(args) != 3 { + t.Errorf("Expected 3 args, got %d", len(args)) + } + + for i, id := range ids { + if args[i] != id { + t.Errorf("Args[%d]: expected %s, got %v", i, id, args[i]) + } + } +} diff --git a/internal/storage/sqlite/dependencies.go b/internal/storage/sqlite/dependencies.go index a6336326..8aa66076 100644 --- a/internal/storage/sqlite/dependencies.go +++ b/internal/storage/sqlite/dependencies.go @@ -560,17 +560,17 @@ func (s *SQLiteStorage) DetectCycles(ctx context.Context) ([][]*types.Issue, err UNION ALL SELECT - d.issue_id, - d.depends_on_id, - p.start_id, - p.path || '→' || d.depends_on_id, - p.depth + 1 + d.issue_id, + d.depends_on_id, + p.start_id, + p.path || '→' || d.depends_on_id, + p.depth + 1 FROM dependencies d JOIN paths p ON d.issue_id = p.depends_on_id WHERE p.depth < ? - AND p.path NOT LIKE '%' || d.depends_on_id || '→%' + AND (d.depends_on_id = p.start_id OR p.path NOT LIKE '%' || d.depends_on_id || '→%') ) - SELECT DISTINCT path || '→' || start_id as cycle_path + SELECT DISTINCT path as cycle_path FROM paths WHERE depends_on_id = start_id ORDER BY cycle_path