From ee94d817ed6d77f4a9572e60b49b98a6045463cc Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 17 Oct 2025 21:13:23 -0700 Subject: [PATCH] feat: Add batch deletion support (bd-127) - Add DeleteIssues() method in sqlite.go for atomic batch deletion - Support multiple issue IDs as arguments or from file - Add --from-file flag to read IDs from file (supports comments) - Add --dry-run mode for safe preview without deleting - Add --cascade flag for recursive deletion of dependents - Add --force flag to orphan dependents instead of failing - Pre-collect connected issues before deletion for text reference updates - Add orphan deduplication to prevent duplicate IDs - Add rows.Err() checks in all row iteration loops - Full transaction safety - all deletions succeed or none do - Comprehensive statistics tracking (deleted, dependencies, labels, events) - Update README and CHANGELOG with batch deletion docs Fixed critical code review issues: - Dry-run mode now properly uses dryRun parameter instead of deleting data - Text references are pre-collected before deletion so they update correctly - Added orphan deduplication and error checks - Updated defer rollback pattern per Go best practices --- .beads/issues.jsonl | 16 +- CHANGELOG.md | 10 + README.md | 52 +++++ cmd/bd/delete.go | 327 +++++++++++++++++++++++++++++- internal/storage/sqlite/sqlite.go | 253 +++++++++++++++++++++++ 5 files changed, 649 insertions(+), 9 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 6f475d4f..2c403d01 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -23,8 +23,22 @@ {"id":"bd-119","title":"Issue in repo1","description":"This should go to repo1 database","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-17T16:35:02.535742-07:00","updated_at":"2025-10-17T18:13:19.394276-07:00","closed_at":"2025-10-17T18:13:19.394276-07:00"} {"id":"bd-12","title":"Implement reference scoring algorithm","description":"Count references for each colliding issue: text mentions in descriptions/notes/design fields + dependency references. Sort collisions by score ascending (fewest refs first). This minimizes total updates during renumbering.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-16T20:46:08.971822-07:00","updated_at":"2025-10-17T01:32:00.634423-07:00","closed_at":"2025-10-14T02:51:52.198288-07:00","dependencies":[{"issue_id":"bd-12","depends_on_id":"bd-48","type":"parent-child","created_at":"2025-10-16T21:51:08.913972-07:00","created_by":"renumber"}]} {"id":"bd-120","title":"Fix nil pointer crash in bd export command","description":"When running `bd export -o .beads/issues.jsonl`, the command crashes with a nil pointer dereference.\n\n## Error\n```\npanic: runtime error: invalid memory address or nil pointer dereference\n[signal SIGSEGV: segmentation violation code=0x2 addr=0x108 pc=0x1034456fc]\n\ngoroutine 1 [running]:\nmain.init.func14(0x103c24380, {0x1034a9695?, 0x4?, 0x1034a95c9?})\n /Users/stevey/src/vc/adar/beads/cmd/bd/export.go:74 +0x15c\n```\n\n## Context\n- This happened after closing bd-105, bd-114, bd-115\n- Auto-export from daemon still works fine\n- Only the manual `bd export` command crashes\n- Data was already synced via auto-export, so no data loss\n\n## Location\nFile: `cmd/bd/export.go` line 74","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-17T17:34:05.014619-07:00","updated_at":"2025-10-17T17:35:41.414218-07:00","closed_at":"2025-10-17T17:35:41.414218-07:00"} +{"id":"bd-121","title":"Add --global flag to daemon for multi-repo support","description":"Currently daemon creates socket at .beads/bd.sock in each repo. For multi-repo support, add --global flag to create socket in ~/.beads/bd.sock that can serve requests from any repository.\n\nImplementation:\n- Add --global flag to daemon command\n- When --global is set, use ~/.beads/bd.sock instead of ./.beads/bd.sock \n- Don't require being in a git repo when --global is used\n- Update daemon discovery logic to check ~/.beads/bd.sock as fallback\n- Document that global daemon can serve multiple repos simultaneously\n\nBenefits:\n- Single daemon serves all repos on the system\n- No need to start daemon per-repo\n- Better resource usage\n- Enables system-wide task tracking\n\nContext: Per-request context routing (bd-115) already implemented - daemon can handle multiple repos. This issue is about making the UX better.\n\nRelated: bd-73 (parent issue for multi-repo support)","status":"open","priority":1,"issue_type":"feature","created_at":"2025-10-17T20:43:47.080685-07:00","updated_at":"2025-10-17T20:43:47.080685-07:00","dependencies":[{"issue_id":"bd-121","depends_on_id":"bd-73","type":"parent-child","created_at":"2025-10-17T20:44:02.2335-07:00","created_by":"daemon"}]} +{"id":"bd-122","title":"Document multi-repo workflow with daemon","description":"The daemon already supports multi-repo via per-request context routing (bd-115), but this isn't documented. Users need to know how to use beads across multiple projects.\n\nAdd documentation for:\n1. How daemon serves multiple repos simultaneously\n2. Starting daemon in one repo, using from others\n3. MCP server multi-repo configuration\n4. Example: tracking work across a dozen projects\n5. Comparison to workspace/global instance approaches\n\nDocumentation locations:\n- README.md (Multi-repo section)\n- AGENTS.md (MCP multi-repo config)\n- integrations/beads-mcp/README.md (working_dir parameter)\n\nInclude:\n- Architecture diagram showing one daemon, many repos\n- Example MCP config with BEADS_WORKING_DIR\n- CLI workflow example\n- Reference to test_multi_repo.py as proof of concept\n\nContext: Feature already works (proven by test_multi_repo.py), just needs user-facing docs.","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-17T20:43:48.91315-07:00","updated_at":"2025-10-17T20:43:48.91315-07:00","dependencies":[{"issue_id":"bd-122","depends_on_id":"bd-73","type":"parent-child","created_at":"2025-10-17T20:44:03.261924-07:00","created_by":"daemon"}]} +{"id":"bd-123","title":"Add 'bd repos' command for multi-repo aggregation","description":"When using daemon in multi-repo mode, users need commands to view/manage work across all active repositories.\n\nAdd 'bd repos' subcommand with:\n\n1. bd repos list\n - Show all repositories daemon has cached\n - Display: path, prefix, issue count, last activity\n - Example output:\n ~/src/project1 [p1-] 45 issues (active)\n ~/src/project2 [p2-] 12 issues (2m ago)\n\n2. bd repos ready --all \n - Aggregate ready work across all repos\n - Group by repo or show combined list\n - Support priority/assignee filters\n\n3. bd repos stats\n - Combined statistics across all repos\n - Total issues, breakdown by status/priority\n - Per-repo breakdown\n\n4. bd repos clear-cache\n - Close all cached storage connections\n - Useful for freeing resources\n\nImplementation notes:\n- Requires daemon to track active storage instances\n- May need RPC protocol additions for multi-repo queries\n- Should gracefully handle repos that no longer exist\n\nDepends on: Global daemon flag (makes this more useful)\n\nContext: This provides the UX layer on top of existing multi-repo support. The daemon can already serve multiple repos - this makes it easy to work with them.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-10-17T20:43:49.816998-07:00","updated_at":"2025-10-17T20:43:49.816998-07:00","dependencies":[{"issue_id":"bd-123","depends_on_id":"bd-73","type":"parent-child","created_at":"2025-10-17T20:44:04.407138-07:00","created_by":"daemon"},{"issue_id":"bd-123","depends_on_id":"bd-121","type":"blocks","created_at":"2025-10-17T20:44:13.681626-07:00","created_by":"daemon"}]} +{"id":"bd-124","title":"Add daemon auto-start on first use","description":"Currently users must manually start daemon with 'bd daemon'. For better UX, auto-start daemon when first bd command is run.\n\nImplementation:\n\n1. In PersistentPreRun, check if daemon is running\n2. If not, check if auto-start is enabled (default: true)\n3. Start daemon with appropriate flags (--global if configured)\n4. Wait for socket to be ready (with timeout)\n5. Retry connection to newly-started daemon\n6. Silently fail back to direct mode if daemon won't start\n\nConfiguration:\n- BEADS_AUTO_START_DAEMON env var (default: true)\n- --no-auto-daemon flag to disable\n- Config file option: auto_start_daemon = true\n\nSafety considerations:\n- Don't auto-start if daemon failed recently (exponential backoff)\n- Log auto-start to daemon.log\n- Clear error messages if auto-start fails\n- Never auto-start if --no-daemon flag is set\n\nBenefits:\n- Zero-configuration experience\n- Daemon benefits (speed, multi-repo) automatic\n- Still supports direct mode as fallback\n\nDepends on: Global daemon flag would make this more useful","status":"open","priority":2,"issue_type":"feature","created_at":"2025-10-17T20:43:50.961453-07:00","updated_at":"2025-10-17T20:43:50.961453-07:00","dependencies":[{"issue_id":"bd-124","depends_on_id":"bd-73","type":"parent-child","created_at":"2025-10-17T20:44:05.502634-07:00","created_by":"daemon"},{"issue_id":"bd-124","depends_on_id":"bd-121","type":"blocks","created_at":"2025-10-17T20:44:14.987308-07:00","created_by":"daemon"}]} +{"id":"bd-125","title":"Add workspace config file for multi-repo management (optional enhancement)","description":"For users who want explicit control over multi-repo setup without daemon, add optional workspace config file.\n\nConfig file: ~/.beads/workspaces.toml\n\nExample:\n[workspaces]\ncurrent = \"global\"\n\n[workspace.global]\ndb = \"~/.beads/global.db\"\ndescription = \"System-wide tasks\"\n\n[workspace.project1] \ndb = \"~/src/project1/.beads/db.sqlite\"\ndescription = \"Main product\"\n\n[workspace.project2]\ndb = \"~/src/project2/.beads/db.sqlite\"\ndescription = \"Internal tools\"\n\nCommands:\nbd workspace list # Show all workspaces\nbd workspace add NAME PATH # Add workspace\nbd workspace remove NAME # Remove workspace \nbd workspace use NAME # Switch active workspace\nbd workspace current # Show current workspace\nbd --workspace NAME \u003ccommand\u003e # Override for single command\n\nImplementation:\n- Load config in PersistentPreRun\n- Override dbPath based on current workspace\n- Store workspace state in config file\n- Support both workspace config AND auto-discovery\n- Workspace config takes precedence over auto-discovery\n\nPriority rationale:\n- Priority 3 (low) because daemon approach already solves this\n- Only implement if users request explicit workspace management\n- Adds complexity vs daemon's automatic discovery\n\nAlternative: Users can use BEADS_DB env var for manual workspace switching today.","status":"open","priority":3,"issue_type":"feature","created_at":"2025-10-17T20:43:52.348572-07:00","updated_at":"2025-10-17T20:43:52.348572-07:00","dependencies":[{"issue_id":"bd-125","depends_on_id":"bd-73","type":"parent-child","created_at":"2025-10-17T20:44:06.411344-07:00","created_by":"daemon"}]} +{"id":"bd-126","title":"Add cross-repo issue references (future enhancement)","description":"Support referencing issues across different beads repositories. Useful for tracking dependencies between separate projects.\n\nProposed syntax:\n- Local reference: bd-123 (current behavior)\n- Cross-repo by path: ~/src/other-project#bd-456\n- Cross-repo by workspace name: @project2:bd-789\n\nUse cases:\n1. Frontend project depends on backend API issue\n2. Shared library changes blocking multiple projects\n3. System administrator tracking work across machines\n4. Monorepo with separate beads databases per component\n\nImplementation challenges:\n- Storage layer needs to query external databases\n- Dependency resolution across repos\n- What if external repo not available?\n- How to handle in JSONL export/import?\n- Security: should repos be able to read others?\n\nDesign questions to resolve first:\n1. Read-only references vs full cross-repo dependencies?\n2. How to handle repo renames/moves?\n3. Absolute paths vs workspace names vs git remotes?\n4. Should bd-73 auto-discover related repos?\n\nRecommendation: \n- Gather user feedback first\n- Start with read-only references\n- Implement as plugin/extension?\n\nContext: This is mentioned in bd-73 as approach #2. Much more complex than daemon multi-repo approach. Only implement if there's strong user demand.\n\nPriority: Backlog (4) - wait for user feedback before designing","status":"open","priority":4,"issue_type":"feature","created_at":"2025-10-17T20:43:54.04594-07:00","updated_at":"2025-10-17T20:43:54.04594-07:00","dependencies":[{"issue_id":"bd-126","depends_on_id":"bd-73","type":"parent-child","created_at":"2025-10-17T20:44:07.576103-07:00","created_by":"daemon"}]} +{"id":"bd-127","title":"Add batch deletion command for issues","description":"Support deleting multiple issues efficiently instead of one at a time.\n\n**Use Cases:**\n- Cleaning up duplicate/spam issues (e.g., bd-100 to bd-117 watchdog spam)\n- Removing test-only issues after feature removal\n- Bulk cleanup of obsolete/spurious bugs\n- Renumbering prep: delete ranges before compaction\n\n**Proposed Syntax Options:**\n\n**Option 1: Multiple IDs as arguments**\n```bash\nbd delete vc-1 vc-2 vc-3 --force\nbd delete vc-{1..20} --force # Shell expansion\n```\n\n**Option 2: Read from file (RECOMMENDED)**\n```bash\nbd delete --from-file deletions.txt --force --dry-run # Preview\nbd delete --from-file deletions.txt --force # Execute\n# File format: one issue ID per line\n```\n\n**Option 3: Query-based deletion**\n```bash\nbd delete --where \"priority=3 AND type=chore\" --force\nbd delete --label test-only --force\nbd delete --prefix bd- --status open --force\n```\n\n**Must-Have Features:**\n\n1. **Dry-run mode**: `--dry-run` to preview what would be deleted\n - Show issue IDs, titles, dependency counts\n - Warn about issues with dependents\n\n2. **Dependency handling**:\n - `--cascade`: Delete dependents recursively\n - `--force`: Delete even if dependents exist (orphans them)\n - Default: Fail if any issue has dependents\n\n3. **Summary output**:\n ```\n Deleted 162 issues\n Removed 347 dependencies\n Removed 89 labels\n Orphaned 5 issues (use --cascade to delete)\n ```\n\n4. **Transaction safety**: All-or-nothing for file/query input\n - Either all deletions succeed or none do\n - Rollback on error\n\n**Nice-to-Have Features:**\n\n1. **Interactive confirmation** for large batches (\u003e10 issues)\n ```\n About to delete 162 issues. Continue? [y/N]\n (Use --force to skip confirmation)\n ```\n\n2. **Progress indicator** for large batches (\u003e50 deletions)\n ```\n Deleting issues... [####------] 42/162 (26%)\n ```\n\n3. **Undo support**:\n ```bash\n bd undelete --last-batch # Restore from snapshots\n bd undelete bd-100 # Restore single issue\n ```\n\n**Implementation Notes:**\n\n- Leverage existing `DeleteIssue()` in storage layer\n- Wrap in transaction for atomicity\n- Consider adding `DeleteIssues(ctx, []string)` for efficiency\n- May need to query dependents before deletion\n- File format should support comments (#) and blank lines\n- JSON output mode should list all deleted IDs\n\n**Example Workflow:**\n```bash\n# Identify issues to delete\nbd list --label test-only --json | jq -r '.[].id' \u003e /tmp/delete.txt\n\n# Preview deletion\nbd delete --from-file /tmp/delete.txt --dry-run\n\n# Execute with cascade\nbd delete --from-file /tmp/delete.txt --cascade --force\n\n# Verify\nbd stats\n```\n\n**Security Considerations:**\n- Require explicit `--force` flag to prevent accidents\n- Warn when deleting issues with dependencies\n- Log deletions to audit trail\n- Consider requiring confirmation for \u003e100 deletions even with --force\n\n**Requested by:** Another agent during cleanup of bd-100 to bd-117 watchdog spam","notes":"Fixed critical issues found in code review:\n1. Dry-run mode now properly uses dryRun parameter instead of deleting data\n2. Text references are pre-collected before deletion so they update correctly\n3. Added orphan deduplication to prevent duplicate IDs\n4. Added rows.Err() checks in all row iteration loops\n5. Updated defer to ignore rollback error per Go best practices","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-10-17T20:49:30.921943-07:00","updated_at":"2025-10-17T21:11:58.670841-07:00","closed_at":"2025-10-17T21:03:29.165515-07:00"} {"id":"bd-13","title":"Implement ID remapping with reference updates","description":"Allocate new IDs for colliding issues. Update all text field references using word-boundary regex (\\bbd-10\\b). Update dependency records. Build id_mapping for reporting. Handle chain dependencies properly.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-16T20:46:08.971822-07:00","updated_at":"2025-10-17T01:32:00.643252-07:00","closed_at":"2025-10-14T02:51:52.198356-07:00","dependencies":[{"issue_id":"bd-13","depends_on_id":"bd-48","type":"parent-child","created_at":"2025-10-16T21:51:08.92251-07:00","created_by":"renumber"}]} +{"id":"bd-130","title":"Test issue 3","description":"","status":"open","priority":3,"issue_type":"task","created_at":"2025-10-17T20:59:40.171507-07:00","updated_at":"2025-10-17T20:59:40.171507-07:00"} +{"id":"bd-131","title":"Test issue 4","description":"","status":"open","priority":3,"issue_type":"task","created_at":"2025-10-17T20:59:40.234886-07:00","updated_at":"2025-10-17T20:59:40.234886-07:00"} +{"id":"bd-132","title":"Batch test 1","description":"","status":"open","priority":3,"issue_type":"task","created_at":"2025-10-17T21:01:21.047341-07:00","updated_at":"2025-10-17T21:01:21.047341-07:00"} +{"id":"bd-133","title":"Batch test 2","description":"","status":"open","priority":3,"issue_type":"task","created_at":"2025-10-17T21:01:21.055026-07:00","updated_at":"2025-10-17T21:01:21.055026-07:00"} +{"id":"bd-134","title":"Batch test 3","description":"","status":"open","priority":3,"issue_type":"task","created_at":"2025-10-17T21:01:21.055526-07:00","updated_at":"2025-10-17T21:01:21.055526-07:00"} +{"id":"bd-139","title":"Child issue","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-17T21:01:25.104232-07:00","updated_at":"2025-10-17T21:01:25.104232-07:00"} {"id":"bd-14","title":"Add --resolve-collisions flag and user reporting","description":"Add import flags: --resolve-collisions (auto-fix) and --dry-run (preview). Display clear report: collisions detected, remappings applied (old→new with scores), reference counts updated. Default behavior: fail on collision (safe).","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-16T20:46:08.971822-07:00","updated_at":"2025-10-17T01:32:00.645323-07:00","closed_at":"2025-10-16T10:07:34.003238-07:00","dependencies":[{"issue_id":"bd-14","depends_on_id":"bd-48","type":"parent-child","created_at":"2025-10-16T21:51:08.923374-07:00","created_by":"renumber"}]} +{"id":"bd-144","title":"Parent with desc","description":"See [deleted:bd-143] for details","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-17T21:11:37.608527-07:00","updated_at":"2025-10-17T21:11:42.32958-07:00"} {"id":"bd-15","title":"Write comprehensive collision resolution tests","description":"Test cases: simple collision, multiple collisions, dependency updates, text reference updates, chain dependencies, edge cases (partial ID matches, case sensitivity, triple merges). Add to import_test.go and collision_test.go.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-16T20:46:08.971822-07:00","updated_at":"2025-10-17T01:32:00.647268-07:00","closed_at":"2025-10-16T10:07:34.007864-07:00","dependencies":[{"issue_id":"bd-15","depends_on_id":"bd-48","type":"parent-child","created_at":"2025-10-16T21:51:08.917092-07:00","created_by":"renumber"}]} {"id":"bd-16","title":"Update documentation for collision resolution","description":"Update README.md with collision resolution section. Update CLAUDE.md with new workflow. Document --resolve-collisions and --dry-run flags. Add example scenarios showing branch merge workflows.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-16T20:46:08.971822-07:00","updated_at":"2025-10-17T01:32:00.648113-07:00","closed_at":"2025-10-16T10:07:34.028648-07:00","dependencies":[{"issue_id":"bd-16","depends_on_id":"bd-48","type":"parent-child","created_at":"2025-10-16T21:51:08.924312-07:00","created_by":"renumber"}]} {"id":"bd-17","title":"bd should auto-detect .beads/*.db in current directory","description":"When bd is run without --db flag, it defaults to beads' own database instead of looking for a .beads/*.db file in the current working directory. This causes confusion when working on other projects that use beads for issue tracking (like vc).\n\nExpected behavior: bd should search for .beads/*.db in cwd and use that if found, before falling back to default beads database.\n\nExample: Running 'bd ready' in /Users/stevey/src/vc/vc/ should automatically find and use .beads/vc.db without requiring --db flag every time.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-16T20:46:08.971822-07:00","updated_at":"2025-10-17T01:32:00.650584-07:00","closed_at":"2025-10-16T10:07:34.046944-07:00"} @@ -89,7 +103,7 @@ {"id":"bd-70","title":"Document hierarchical blocking behavior in README","description":"The fix for bd-65 changes user-visible behavior: children of blocked epics are now automatically blocked.\n\n**What needs documenting:**\n1. README.md dependency section should explain blocking propagation\n2. Clarify that 'blocks' + 'parent-child' together create transitive blocking\n3. Note that 'related' and 'discovered-from' do NOT propagate blocking\n4. Add example showing epic → child blocking propagation\n\n**Example to add:**\n```bash\n# If epic is blocked, children are too\nbd create \"Epic 1\" -t epic -p 1\nbd create \"Task 1\" -t task -p 1\nbd dep add task-1 epic-1 --type parent-child\n\n# Block the epic\nbd create \"Blocker\" -t task -p 0\nbd dep add epic-1 blocker-1 --type blocks\n\n# Now both epic-1 AND task-1 are blocked\nbd ready # Neither will show up\n```","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-16T20:46:08.971822-07:00","updated_at":"2025-10-17T01:32:01.020334-07:00","closed_at":"2025-10-14T13:10:38.482538-07:00"} {"id":"bd-71","title":"Document versioning and release strategy","description":"Create comprehensive versioning strategy for beads ecosystem.\n\nComponents to document:\n1. bd CLI (Go binary) - main version number\n2. Plugin (Claude Code) - tracks CLI version\n3. MCP server (Python) - bundled with plugin\n4. Release workflow - how to sync all three\n\nDecisions to make:\n- Should plugin.json auto-update from bd CLI version?\n- Should we have a VERSION file at repo root?\n- How to handle breaking changes across components?\n- What's the update notification strategy?\n\nReferences:\n- plugin.json engines field now requires bd \u003e=0.9.0\n- /bd-version command added for checking compatibility\n- PLUGIN.md now documents update workflow","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-16T20:46:08.971822-07:00","updated_at":"2025-10-17T01:32:00.91836-07:00","closed_at":"2025-10-14T13:55:59.178075-07:00"} {"id":"bd-72","title":"Create version bump script","description":"Create scripts/bump-version.sh to automate version syncing across all components.\n\nThe script should:\n1. Take a version number as argument (e.g., ./scripts/bump-version.sh 0.9.3)\n2. Update all version files:\n - cmd/bd/version.go (Version constant)\n - .claude-plugin/plugin.json (version field)\n - .claude-plugin/marketplace.json (plugins[].version)\n - integrations/beads-mcp/pyproject.toml (version field)\n - README.md (Alpha version mention)\n - PLUGIN.md (version requirements)\n3. Validate semantic versioning format\n4. Show diff preview before applying\n5. Optionally create git commit with standard message\n\nThis prevents the version mismatch issue that occurred when only version.go was updated.\n\nRelated: bd-73 (version sync issue)","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-16T20:46:08.971822-07:00","updated_at":"2025-10-17T01:32:01.02371-07:00","closed_at":"2025-10-14T13:49:22.368581-07:00"} -{"id":"bd-73","title":"Add system-wide/multi-repo support for beads","description":"GitHub issue #4 requests ability to use beads across multiple projects and for system-wide task tracking.\n\nCurrent limitation: beads is per-repository isolated. Each project has its own .beads/ directory and issues cannot reference issues in other projects.\n\nPotential approaches:\n1. Global beads instance in ~/.beads/global.db for cross-project work\n2. Project references - allow issues to link across repos\n3. Multi-project workspace support - one beads instance managing multiple repos\n4. Integration with existing MCP server to provide remote multi-project access\n\nUse cases:\n- System administrators tracking work across multiple machines/repos\n- Developers working on a dozen+ projects simultaneously\n- Cross-cutting concerns that span multiple repositories\n- Global todo list with project-specific subtasks\n\nRelated:\n- GitHub issue #4: https://github.com/steveyegge/beads/issues/4\n- Comparison to membank MCP which already supports multi-project via centralized server\n- MCP server at integrations/beads-mcp/ could be extended for this\n\nSee also: Testing framework for plugins (also from GH #4)","status":"open","priority":2,"issue_type":"feature","created_at":"2025-10-16T20:46:08.971822-07:00","updated_at":"2025-10-17T01:32:00.919661-07:00"} +{"id":"bd-73","title":"Add system-wide/multi-repo support for beads","description":"GitHub issue #4 requests ability to use beads across multiple projects and for system-wide task tracking.\n\nCurrent limitation: beads is per-repository isolated. Each project has its own .beads/ directory and issues cannot reference issues in other projects.\n\nPotential approaches:\n1. Global beads instance in ~/.beads/global.db for cross-project work\n2. Project references - allow issues to link across repos\n3. Multi-project workspace support - one beads instance managing multiple repos\n4. Integration with existing MCP server to provide remote multi-project access\n\nUse cases:\n- System administrators tracking work across multiple machines/repos\n- Developers working on a dozen+ projects simultaneously\n- Cross-cutting concerns that span multiple repositories\n- Global todo list with project-specific subtasks\n\nRelated:\n- GitHub issue #4: https://github.com/steveyegge/beads/issues/4\n- Comparison to membank MCP which already supports multi-project via centralized server\n- MCP server at integrations/beads-mcp/ could be extended for this\n\nSee also: Testing framework for plugins (also from GH #4)","notes":"Multi-repo support breakdown:\n\n**High Priority (P1) - Quick Wins:**\n- bd-121: Add --global flag to daemon \n- bd-122: Document multi-repo workflow\n\n**Medium Priority (P2) - Enhanced UX:**\n- bd-123: Add 'bd repos' command for aggregation (blocked by bd-121)\n- bd-124: Add daemon auto-start (blocked by bd-121)\n\n**Low Priority (P3-P4) - Future/Optional:**\n- bd-125: Add workspace config file (alternative to daemon approach)\n- bd-126: Add cross-repo issue references (complex, wait for feedback)\n\n**Current Status:**\nThe daemon ALREADY supports multi-repo via per-request context routing (bd-115). Proven by test_multi_repo.py. The missing pieces are UX polish and documentation.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-10-16T20:46:08.971822-07:00","updated_at":"2025-10-17T20:45:19.361241-07:00"} {"id":"bd-74","title":"Implement storage driver interface for pluggable backends","description":"Create abstraction layer for storage to support multiple backends (SQLite, Postgres, Turso, in-memory testing, etc.).\n\n**Current state:** All storage logic hardcoded to SQLite in internal/storage/sqlite/sqlite.go\n\n**Proposed design:**\n\n```go\n// internal/storage/storage.go\ntype Store interface {\n // Issue CRUD\n CreateIssue(issue *Issue) error\n GetIssue(id string) (*Issue, error)\n UpdateIssue(id string, updates *Issue) error\n DeleteIssue(id string) error\n ListIssues(filter *Filter) ([]*Issue, error)\n \n // Dependencies\n AddDependency(from, to string, depType DependencyType) error\n RemoveDependency(from, to string, depType DependencyType) error\n GetDependencies(id string) ([]*Dependency, error)\n \n // Counters, stats\n GetNextID(prefix string) (string, error)\n GetStats() (*Stats, error)\n \n Close() error\n}\n```\n\n**Benefits:**\n- Better testing (mock/in-memory stores)\n- Future flexibility (Postgres, cloud APIs, etc.)\n- Clean architecture (business logic decoupled from storage)\n- Enable Turso or other backends without refactoring everything\n\n**Implementation steps:**\n1. Define Store interface in internal/storage/storage.go\n2. Refactor SQLiteStore to implement interface\n3. Update all commands to use interface, not concrete type\n4. Add MemoryStore for testing\n5. Add driver selection via config (storage.driver = sqlite|turso|postgres)\n6. Update tests to use interface\n\n**Note:** This is valuable even without adopting Turso. Good architecture practice.\n\n**Context:** From GH issue #2 RFC evaluation. Driver interface is low-cost, high-value regardless of whether we add alternative backends.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-10-16T20:46:08.971822-07:00","updated_at":"2025-10-17T01:32:00.920278-07:00"} {"id":"bd-75","title":"Test issue with --deps flag","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-16T20:46:08.971822-07:00","updated_at":"2025-10-17T01:32:00.921065-07:00","closed_at":"2025-10-16T10:07:34.027923-07:00"} {"id":"bd-76","title":"Fix: bd init --prefix test -q flag not recognized","description":"The init command doesn't recognize the -q flag. When running 'bd init --prefix test -q', it fails silently or behaves unexpectedly. The flag should either be implemented for quiet mode or removed from documentation if not supported.","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-10-16T20:46:08.971822-07:00","updated_at":"2025-10-17T01:32:00.921744-07:00","closed_at":"2025-10-17T00:09:18.921816-07:00"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2988a0e8..aee0299f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Batch Deletion**: Enhanced `bd delete` command with batch operations (bd-127) + - Delete multiple issues at once: `bd delete bd-1 bd-2 bd-3 --force` + - Read from file: `bd delete --from-file deletions.txt --force` + - Dry-run mode: `--dry-run` to preview deletions before execution + - Cascade mode: `--cascade` to recursively delete all dependents + - Force mode: `--force` to orphan dependents instead of failing + - Atomic transactions: all deletions succeed or none do + - Comprehensive statistics: tracks deleted issues, dependencies, labels, and events + ## [0.9.9] - 2025-10-17 ### Added diff --git a/README.md b/README.md index 6dd2e865..877494a2 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,58 @@ bd update bd-1 --status in_progress --json bd close bd-1 --json ``` +### Deleting Issues + +Delete one or more issues, with automatic cleanup of references and dependencies: + +```bash +# Single issue deletion (preview mode) +bd delete bd-1 + +# Force single deletion +bd delete bd-1 --force + +# Batch deletion +bd delete bd-1 bd-2 bd-3 --force + +# Delete from file (one ID per line, supports # comments) +bd delete --from-file deletions.txt --force + +# Dry-run mode (preview without changes) +bd delete --from-file deletions.txt --dry-run + +# Cascade deletion (recursively delete dependents) +bd delete bd-1 --cascade --force + +# Force deletion (orphan dependents instead of failing) +bd delete bd-1 --force + +# JSON output +bd delete bd-1 bd-2 --force --json +``` + +The delete operation: +- Removes all dependency links (both directions) +- Updates text references to `[deleted:ID]` in connected issues +- Deletes the issue from database and JSONL +- Atomic: all deletions succeed or none do + +**Dependency handling:** +- **Default**: Fails if any issue has dependents not in deletion set +- **`--cascade`**: Recursively deletes all dependent issues +- **`--force`**: Deletes and orphans dependents (use with caution) + +**File format** for `--from-file`: +```text +# Cleanup test issues +bd-100 +bd-101 +bd-102 + +# Another batch +bd-200 +``` + ### Renaming Prefix Change the issue prefix for all issues in your database. This is useful if your prefix is too long or you want to standardize naming. diff --git a/cmd/bd/delete.go b/cmd/bd/delete.go index a4bdd162..6b8567d7 100644 --- a/cmd/bd/delete.go +++ b/cmd/bd/delete.go @@ -11,24 +11,80 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" ) var deleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete an issue and clean up references", - Long: `Delete an issue and clean up all references to it. + Use: "delete [issue-id...]", + Short: "Delete one or more issues and clean up references", + Long: `Delete one or more issues and clean up all references to them. This command will: -1. Remove all dependency links (any type, both directions) involving the issue +1. Remove all dependency links (any type, both directions) involving the issues 2. Update text references to "[deleted:ID]" in directly connected issues -3. Delete the issue from the database +3. Delete the issues from the database -This is a destructive operation that cannot be undone. Use with caution.`, - Args: cobra.ExactArgs(1), +This is a destructive operation that cannot be undone. Use with caution. + +BATCH DELETION: + +Delete multiple issues at once: + bd delete bd-1 bd-2 bd-3 --force + +Delete from file (one ID per line): + bd delete --from-file deletions.txt --force + +Preview before deleting: + bd delete --from-file deletions.txt --dry-run + +DEPENDENCY HANDLING: + +Default: Fails if any issue has dependents not in deletion set + bd delete bd-1 bd-2 + +Cascade: Recursively delete all dependents + bd delete bd-1 --cascade --force + +Force: Delete and orphan dependents + bd delete bd-1 --force`, + Args: cobra.MinimumNArgs(0), Run: func(cmd *cobra.Command, args []string) { - issueID := args[0] + fromFile, _ := cmd.Flags().GetString("from-file") force, _ := cmd.Flags().GetBool("force") + dryRun, _ := cmd.Flags().GetBool("dry-run") + cascade, _ := cmd.Flags().GetBool("cascade") + + // Collect issue IDs from args and/or file + issueIDs := make([]string, 0, len(args)) + issueIDs = append(issueIDs, args...) + + if fromFile != "" { + fileIDs, err := readIssueIDsFromFile(fromFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) + os.Exit(1) + } + issueIDs = append(issueIDs, fileIDs...) + } + + if len(issueIDs) == 0 { + fmt.Fprintf(os.Stderr, "Error: no issue IDs provided\n") + cmd.Usage() + os.Exit(1) + } + + // Remove duplicates + issueIDs = uniqueStrings(issueIDs) + + // Handle batch deletion + if len(issueIDs) > 1 { + deleteBatch(cmd, issueIDs, force, dryRun, cascade) + return + } + + // Single issue deletion (legacy behavior) + issueID := issueIDs[0] ctx := context.Background() @@ -300,7 +356,262 @@ func removeIssueFromJSONL(issueID string) error { return nil } +// deleteBatch handles deletion of multiple issues +func deleteBatch(cmd *cobra.Command, issueIDs []string, force bool, dryRun bool, cascade bool) { + ctx := context.Background() + + // Type assert to SQLite storage + d, ok := store.(*sqlite.SQLiteStorage) + if !ok { + fmt.Fprintf(os.Stderr, "Error: batch delete not supported by this storage backend\n") + os.Exit(1) + } + + // Verify all issues exist + issues := make(map[string]*types.Issue) + notFound := []string{} + for _, id := range issueIDs { + issue, err := d.GetIssue(ctx, id) + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting issue %s: %v\n", id, err) + os.Exit(1) + } + if issue == nil { + notFound = append(notFound, id) + } else { + issues[id] = issue + } + } + + if len(notFound) > 0 { + fmt.Fprintf(os.Stderr, "Error: issues not found: %s\n", strings.Join(notFound, ", ")) + os.Exit(1) + } + + // Dry-run or preview mode + if dryRun || !force { + result, err := d.DeleteIssues(ctx, issueIDs, cascade, false, true) + if err != nil { + // Try to show preview even if there are dependency issues + showDeletionPreview(issueIDs, issues, cascade, err) + os.Exit(1) + } + + showDeletionPreview(issueIDs, issues, cascade, nil) + fmt.Printf("\nWould delete: %d issues\n", result.DeletedCount) + fmt.Printf("Would remove: %d dependencies, %d labels, %d events\n", + result.DependenciesCount, result.LabelsCount, result.EventsCount) + if len(result.OrphanedIssues) > 0 { + fmt.Printf("Would orphan: %d issues\n", len(result.OrphanedIssues)) + } + + if dryRun { + fmt.Printf("\n(Dry-run mode - no changes made)\n") + } else { + yellow := color.New(color.FgYellow).SprintFunc() + fmt.Printf("\n%s\n", yellow("This operation cannot be undone!")) + if cascade { + fmt.Printf("To proceed with cascade deletion, run: %s\n", + yellow("bd delete "+strings.Join(issueIDs, " ")+" --cascade --force")) + } else { + fmt.Printf("To proceed, run: %s\n", + yellow("bd delete "+strings.Join(issueIDs, " ")+" --force")) + } + } + return + } + + // Pre-collect connected issues before deletion (so we can update their text references) + connectedIssues := make(map[string]*types.Issue) + idSet := make(map[string]bool) + for _, id := range issueIDs { + idSet[id] = true + } + + for _, id := range issueIDs { + // Get dependencies (issues this one depends on) + deps, err := store.GetDependencies(ctx, id) + if err == nil { + for _, dep := range deps { + if !idSet[dep.ID] { + connectedIssues[dep.ID] = dep + } + } + } + + // Get dependents (issues that depend on this one) + dependents, err := store.GetDependents(ctx, id) + if err == nil { + for _, dep := range dependents { + if !idSet[dep.ID] { + connectedIssues[dep.ID] = dep + } + } + } + } + + // Actually delete + result, err := d.DeleteIssues(ctx, issueIDs, cascade, force, false) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Update text references in connected issues (using pre-collected issues) + updatedCount := updateTextReferencesInIssues(ctx, issueIDs, connectedIssues) + + // Remove from JSONL + for _, id := range issueIDs { + if err := removeIssueFromJSONL(id); err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to remove %s from JSONL: %v\n", id, err) + } + } + + // Schedule auto-flush + markDirtyAndScheduleFlush() + + // Output results + if jsonOutput { + outputJSON(map[string]interface{}{ + "deleted": issueIDs, + "deleted_count": result.DeletedCount, + "dependencies_removed": result.DependenciesCount, + "labels_removed": result.LabelsCount, + "events_removed": result.EventsCount, + "references_updated": updatedCount, + "orphaned_issues": result.OrphanedIssues, + }) + } else { + green := color.New(color.FgGreen).SprintFunc() + fmt.Printf("%s Deleted %d issue(s)\n", green("✓"), result.DeletedCount) + fmt.Printf(" Removed %d dependency link(s)\n", result.DependenciesCount) + fmt.Printf(" Removed %d label(s)\n", result.LabelsCount) + fmt.Printf(" Removed %d event(s)\n", result.EventsCount) + fmt.Printf(" Updated text references in %d issue(s)\n", updatedCount) + if len(result.OrphanedIssues) > 0 { + yellow := color.New(color.FgYellow).SprintFunc() + fmt.Printf(" %s Orphaned %d issue(s): %s\n", + yellow("⚠"), len(result.OrphanedIssues), strings.Join(result.OrphanedIssues, ", ")) + } + } +} + +// showDeletionPreview shows what would be deleted +func showDeletionPreview(issueIDs []string, issues map[string]*types.Issue, cascade bool, depError error) { + red := color.New(color.FgRed).SprintFunc() + yellow := color.New(color.FgYellow).SprintFunc() + + fmt.Printf("\n%s\n", red("⚠️ DELETE PREVIEW")) + fmt.Printf("\nIssues to delete (%d):\n", len(issueIDs)) + for _, id := range issueIDs { + if issue := issues[id]; issue != nil { + fmt.Printf(" %s: %s\n", id, issue.Title) + } + } + + if cascade { + fmt.Printf("\n%s Cascade mode enabled - will also delete all dependent issues\n", yellow("⚠")) + } + + if depError != nil { + fmt.Printf("\n%s\n", red(depError.Error())) + } +} + +// updateTextReferencesInIssues updates text references to deleted issues in pre-collected connected issues +func updateTextReferencesInIssues(ctx context.Context, deletedIDs []string, connectedIssues map[string]*types.Issue) int { + updatedCount := 0 + + // For each deleted issue, update references in all connected issues + for _, id := range deletedIDs { + // Build regex pattern + idPattern := `(^|[^A-Za-z0-9_-])(` + regexp.QuoteMeta(id) + `)($|[^A-Za-z0-9_-])` + re := regexp.MustCompile(idPattern) + replacementText := `$1[deleted:` + id + `]$3` + + for connID, connIssue := range connectedIssues { + updates := make(map[string]interface{}) + + if re.MatchString(connIssue.Description) { + updates["description"] = re.ReplaceAllString(connIssue.Description, replacementText) + } + if connIssue.Notes != "" && re.MatchString(connIssue.Notes) { + updates["notes"] = re.ReplaceAllString(connIssue.Notes, replacementText) + } + if connIssue.Design != "" && re.MatchString(connIssue.Design) { + updates["design"] = re.ReplaceAllString(connIssue.Design, replacementText) + } + if connIssue.AcceptanceCriteria != "" && re.MatchString(connIssue.AcceptanceCriteria) { + updates["acceptance_criteria"] = re.ReplaceAllString(connIssue.AcceptanceCriteria, replacementText) + } + + if len(updates) > 0 { + if err := store.UpdateIssue(ctx, connID, updates, actor); err == nil { + updatedCount++ + // Update the in-memory issue to avoid double-replacing + if desc, ok := updates["description"].(string); ok { + connIssue.Description = desc + } + if notes, ok := updates["notes"].(string); ok { + connIssue.Notes = notes + } + if design, ok := updates["design"].(string); ok { + connIssue.Design = design + } + if ac, ok := updates["acceptance_criteria"].(string); ok { + connIssue.AcceptanceCriteria = ac + } + } + } + } + } + + return updatedCount +} + +// readIssueIDsFromFile reads issue IDs from a file (one per line) +func readIssueIDsFromFile(filename string) ([]string, error) { + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() + + var ids []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + ids = append(ids, line) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return ids, nil +} + +// uniqueStrings removes duplicates from a slice of strings +func uniqueStrings(slice []string) []string { + seen := make(map[string]bool) + result := make([]string, 0, len(slice)) + for _, s := range slice { + if !seen[s] { + seen[s] = true + result = append(result, s) + } + } + return result +} + func init() { deleteCmd.Flags().BoolP("force", "f", false, "Actually delete (without this flag, shows preview)") + deleteCmd.Flags().String("from-file", "", "Read issue IDs from file (one per line)") + deleteCmd.Flags().Bool("dry-run", false, "Preview what would be deleted without making changes") + deleteCmd.Flags().Bool("cascade", false, "Recursively delete all dependent issues") rootCmd.AddCommand(deleteCmd) } diff --git a/internal/storage/sqlite/sqlite.go b/internal/storage/sqlite/sqlite.go index 59f5893a..499907ea 100644 --- a/internal/storage/sqlite/sqlite.go +++ b/internal/storage/sqlite/sqlite.go @@ -1348,6 +1348,259 @@ func (s *SQLiteStorage) DeleteIssue(ctx context.Context, id string) error { return tx.Commit() } +// DeleteIssuesResult contains statistics about a batch deletion operation +type DeleteIssuesResult struct { + DeletedCount int + DependenciesCount int + LabelsCount int + EventsCount int + OrphanedIssues []string +} + +// DeleteIssues deletes multiple issues in a single transaction +// If cascade is true, recursively deletes dependents +// If cascade is false but force is true, deletes issues and orphans their dependents +// If cascade and force are both false, returns an error if any issue has dependents +// If dryRun is true, only computes statistics without deleting +func (s *SQLiteStorage) DeleteIssues(ctx context.Context, ids []string, cascade bool, force bool, dryRun bool) (*DeleteIssuesResult, error) { + if len(ids) == 0 { + return &DeleteIssuesResult{}, nil + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + result := &DeleteIssuesResult{} + + // Build ID set for efficient lookup + idSet := make(map[string]bool, len(ids)) + for _, id := range ids { + idSet[id] = true + } + + // If cascade mode, find all dependent issues recursively + if cascade { + allToDelete, err := s.findAllDependentsRecursive(ctx, tx, ids) + if err != nil { + return nil, fmt.Errorf("failed to find dependents: %w", err) + } + // Update ids to include all dependents + for id := range allToDelete { + idSet[id] = true + } + ids = make([]string, 0, len(idSet)) + for id := range idSet { + ids = append(ids, id) + } + } else if !force { + // Check if any issue has dependents not in the deletion set + for _, id := range ids { + var depCount int + err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM dependencies WHERE depends_on_id = ?`, id).Scan(&depCount) + if err != nil { + return nil, fmt.Errorf("failed to check dependents for %s: %w", id, err) + } + if depCount > 0 { + // Check if all dependents are in deletion set + rows, err := tx.QueryContext(ctx, + `SELECT issue_id FROM dependencies WHERE depends_on_id = ?`, id) + if err != nil { + return nil, fmt.Errorf("failed to get dependents for %s: %w", id, err) + } + hasExternalDependents := false + for rows.Next() { + var depID string + if err := rows.Scan(&depID); err != nil { + rows.Close() + return nil, fmt.Errorf("failed to scan dependent: %w", err) + } + if !idSet[depID] { + hasExternalDependents = true + result.OrphanedIssues = append(result.OrphanedIssues, depID) + } + } + rows.Close() + if hasExternalDependents { + return nil, fmt.Errorf("issue %s has dependents not in deletion set; use --cascade to delete them or --force to orphan them", id) + } + } + } + } else { + // Force mode: track orphaned issues (deduplicate) + orphanSet := make(map[string]bool) + for _, id := range ids { + rows, err := tx.QueryContext(ctx, + `SELECT issue_id FROM dependencies WHERE depends_on_id = ?`, id) + if err != nil { + return nil, fmt.Errorf("failed to get dependents for %s: %w", id, err) + } + for rows.Next() { + var depID string + if err := rows.Scan(&depID); err != nil { + rows.Close() + return nil, fmt.Errorf("failed to scan dependent: %w", err) + } + if !idSet[depID] { + orphanSet[depID] = true + } + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, fmt.Errorf("failed to iterate dependents: %w", err) + } + rows.Close() + } + // Convert set to slice + for orphanID := range orphanSet { + result.OrphanedIssues = append(result.OrphanedIssues, orphanID) + } + } + + // Build placeholders for SQL IN clause + placeholders := make([]string, len(ids)) + args := make([]interface{}, len(ids)) + for i, id := range ids { + placeholders[i] = "?" + args[i] = id + } + inClause := strings.Join(placeholders, ",") + + // Count dependencies + var depCount int + err = tx.QueryRowContext(ctx, + fmt.Sprintf(`SELECT COUNT(*) FROM dependencies WHERE issue_id IN (%s) OR depends_on_id IN (%s)`, + inClause, inClause), + append(args, args...)...).Scan(&depCount) + if err != nil { + return nil, fmt.Errorf("failed to count dependencies: %w", err) + } + result.DependenciesCount = depCount + + // Count labels + var labelCount int + err = tx.QueryRowContext(ctx, + fmt.Sprintf(`SELECT COUNT(*) FROM labels WHERE issue_id IN (%s)`, inClause), + args...).Scan(&labelCount) + if err != nil { + return nil, fmt.Errorf("failed to count labels: %w", err) + } + result.LabelsCount = labelCount + + // Count events + var eventCount int + err = tx.QueryRowContext(ctx, + fmt.Sprintf(`SELECT COUNT(*) FROM events WHERE issue_id IN (%s)`, inClause), + args...).Scan(&eventCount) + if err != nil { + return nil, fmt.Errorf("failed to count events: %w", err) + } + result.EventsCount = eventCount + result.DeletedCount = len(ids) + + // If dry-run, return statistics without deleting + if dryRun { + return result, nil + } + + // Delete dependencies + _, err = tx.ExecContext(ctx, + fmt.Sprintf(`DELETE FROM dependencies WHERE issue_id IN (%s) OR depends_on_id IN (%s)`, + inClause, inClause), + append(args, args...)...) + if err != nil { + return nil, fmt.Errorf("failed to delete dependencies: %w", err) + } + + // Delete labels + _, err = tx.ExecContext(ctx, + fmt.Sprintf(`DELETE FROM labels WHERE issue_id IN (%s)`, inClause), + args...) + if err != nil { + return nil, fmt.Errorf("failed to delete labels: %w", err) + } + + // Delete events + _, err = tx.ExecContext(ctx, + fmt.Sprintf(`DELETE FROM events WHERE issue_id IN (%s)`, inClause), + args...) + if err != nil { + return nil, fmt.Errorf("failed to delete events: %w", err) + } + + // Delete from dirty_issues + _, err = tx.ExecContext(ctx, + fmt.Sprintf(`DELETE FROM dirty_issues WHERE issue_id IN (%s)`, inClause), + args...) + if err != nil { + return nil, fmt.Errorf("failed to delete dirty markers: %w", err) + } + + // Delete the issues themselves + deleteResult, err := tx.ExecContext(ctx, + fmt.Sprintf(`DELETE FROM issues WHERE id IN (%s)`, inClause), + args...) + if err != nil { + return nil, fmt.Errorf("failed to delete issues: %w", err) + } + + rowsAffected, err := deleteResult.RowsAffected() + if err != nil { + return nil, fmt.Errorf("failed to check rows affected: %w", err) + } + result.DeletedCount = int(rowsAffected) + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("failed to commit transaction: %w", err) + } + + return result, nil +} + +// findAllDependentsRecursive finds all issues that depend on the given issues, recursively +func (s *SQLiteStorage) findAllDependentsRecursive(ctx context.Context, tx *sql.Tx, ids []string) (map[string]bool, error) { + result := make(map[string]bool) + for _, id := range ids { + result[id] = true + } + + toProcess := make([]string, len(ids)) + copy(toProcess, ids) + + for len(toProcess) > 0 { + current := toProcess[0] + toProcess = toProcess[1:] + + rows, err := tx.QueryContext(ctx, + `SELECT issue_id FROM dependencies WHERE depends_on_id = ?`, current) + if err != nil { + return nil, err + } + + for rows.Next() { + var depID string + if err := rows.Scan(&depID); err != nil { + rows.Close() + return nil, err + } + if !result[depID] { + result[depID] = true + toProcess = append(toProcess, depID) + } + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + } + + return result, nil +} + // SearchIssues finds issues matching query and filters func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) { whereClauses := []string{}