diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 2f250840..7b31f83a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -324,10 +324,36 @@ {"id":"bd-391","title":"Data model allows inconsistent status/closed_at states","description":"Issue bd-89 demonstrates a data model inconsistency: an issue can have status='open' but also have a closed_at timestamp set. This creates a liminal state that violates the expected invariant that closed_at should only be set when status='closed'.\n\nRoot causes:\n1. Import (bd import) updates status field independently from closed_at field\n2. UpdateIssue allows status changes without managing closed_at\n3. No database constraint enforcing the invariant\n4. Export includes both fields independently in JSONL\n\nCurrent behavior:\n- bd close: Sets status='closed' AND closed_at (correct)\n- bd update --status open: Sets status='open' but leaves closed_at unchanged (creates inconsistency)\n- bd import: Can import inconsistent data from JSONL\n\nImpact:\n- 'bd ready' shows issues that appear closed (have closed_at)\n- Confusing for users and downstream tools\n- Stats may be inaccurate\n\nPotential solutions:\nA) Add CHECK constraint: (status = 'closed') = (closed_at IS NOT NULL)\nB) Update import/update logic to enforce invariant in application code\nC) Add a 'reopened' event that explicitly clears closed_at\nD) Remove closed_at field entirely (calculate from events or use status only)\n\nSee bd-89 for concrete example.","status":"in_progress","priority":1,"issue_type":"bug","created_at":"2025-10-16T15:25:11.436559-07:00","updated_at":"2025-10-16T15:25:11.436559-07:00","dependencies":[{"issue_id":"bd-391","depends_on_id":"bd-390","type":"blocks","created_at":"2025-10-16T15:25:11.700502-07:00","created_by":"import"}]} {"id":"bd-392","title":"Epic: Add intelligent database compaction with Claude Haiku","description":"Implement multi-tier database compaction using Claude Haiku to semantically compress old, closed issues. This keeps the database lightweight and agent-friendly while preserving essential context.\n\nGoals:\n- 70-95% space reduction for eligible issues\n- Full restore capability via snapshots\n- Opt-in with dry-run safety\n- ~$1 per 1,000 issues compacted","acceptance_criteria":"- Schema migration with snapshots table\n- Haiku integration for summarization\n- Two-tier compaction (30d, 90d)\n- CLI with dry-run, restore, stats\n- Full test coverage\n- Documentation complete","status":"open","priority":2,"issue_type":"epic","created_at":"2025-10-16T15:25:11.436559-07:00","updated_at":"2025-10-16T15:25:11.436559-07:00"} {"id":"bd-393","title":"Critical: Auto-import was skipping collisions instead of remapping them","description":"The auto-import mechanism was SKIPPING colliding issues instead of automatically remapping them to new IDs. This caused work from other workers/devices to be LOST during git pull operations.\n\nRoot cause: Lines 283-326 in main.go were filtering out colliding issues instead of calling RemapCollisions() to resolve them.\n\nImpact: Multi-device workflows would silently lose issues when two devices created issues with the same ID.","acceptance_criteria":"- Auto-import detects collisions\n- Calls ScoreCollisions + RemapCollisions automatically\n- Shows remapping notification to user\n- No work is lost from other workers\n- All tests pass","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-10-16T15:25:11.436559-07:00","updated_at":"2025-10-16T15:25:11.436559-07:00","closed_at":"2025-10-16T15:00:37.591033-07:00"} +{"id":"bd-394","title":"GH-11: Add Docker support for hosted/shared instance","description":"Request for Docker container hosting to use beads across multiple dev machines. Would need to consider: centralized database (PostgreSQL?), authentication, concurrent access, API server, etc. This is a significant architectural change from the current local-first model.","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-10-16T16:03:31.498107-07:00","updated_at":"2025-10-16T16:03:31.498107-07:00","closed_at":"2025-10-16T14:37:09.712087-07:00","external_ref":"gh-11"} +{"id":"bd-395","title":"Record git commit hash during compaction","description":"Update compact command to capture current git HEAD commit hash and store in compacted_at_commit field","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-16T16:03:31.501666-07:00","updated_at":"2025-10-16T16:03:31.501666-07:00"} +{"id":"bd-396","title":"Epic: Fix status/closed_at inconsistency (bd-224 solution)","description":"Implement hybrid solution to enforce status/closed_at invariant:\n- Database CHECK constraint\n- Smart UpdateIssue logic\n- Import enforcement\n- Reopen command\n\nThis is a data integrity issue that affects statistics and will cause problems for agent swarms. The hybrid approach provides defense-in-depth.\n\nSee ULTRATHINK_BD224.md for full analysis and rationale.\n\nParent of all implementation tasks for this fix.","status":"open","priority":1,"issue_type":"epic","created_at":"2025-10-16T16:03:31.504655-07:00","updated_at":"2025-10-16T16:03:31.504655-07:00"} +{"id":"bd-397","title":"Audit and document all inconsistent issues in database","description":"Before we add the constraint, we need to know what data is inconsistent.\n\nSteps:\n1. Query database for status/closed_at mismatches\n2. Document each case (how many, which issues, patterns)\n3. Decide on cleanup strategy (trust status vs trust closed_at)\n4. Create SQL script for cleanup\n\nOutput: Document with counts and cleanup SQL ready to review.\n\nThis unblocks the migration work.","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-16T16:03:31.520913-07:00","updated_at":"2025-10-16T16:03:31.520913-07:00"} +{"id":"bd-398","title":"Reach 1.0 release milestone","description":"Stabilize API, finalize documentation, comprehensive testing","status":"open","priority":1,"issue_type":"epic","created_at":"2025-10-16T16:03:31.523258-07:00","updated_at":"2025-10-16T16:03:31.523258-07:00"} +{"id":"bd-399","title":"Code review follow-up: Post-PR #8 merge improvements","description":"Follow-up tasks from the ultrathink code review of PR #8 merge (bd-62).\n\n**Context:** PR #8 successfully merged atomic counter + dirty tracking. Core functionality is solid but several improvements identified.\n\n**Critical (P0-P1):**\n- bd-64: Fix SyncAllCounters performance bottleneck (P0)\n- bd-65: Add migration for issue_counters table (P1)\n- bd-66: Make import counter sync failure fatal (P1)\n\n**Nice to have (P2-P3):**\n- bd-67: Update test comments (P2)\n- bd-68: Add performance benchmarks (P2)\n- bd-69: Add metrics/logging (P3)\n- bd-70: Add EXPLAIN QUERY PLAN tests (P3)\n\n**Overall assessment:** 4/5 stars - Excellent implementation with one critical performance issue. After bd-64 is fixed, this becomes 5/5.\n\n**Review document:** Available if needed","notes":"Status update: All P0-P1 critical tasks completed! bd-64 (performance), bd-65 (migration), bd-66 (fatal error), bd-67 (comments) are all done. Atomic counter implementation is now production-ready. Remaining tasks are P2-P3 enhancements.","status":"open","priority":1,"issue_type":"epic","created_at":"2025-10-16T16:03:31.525208-07:00","updated_at":"2025-10-16T16:03:31.525208-07:00"} {"id":"bd-4","title":"Low priority chore","description":"","status":"open","priority":4,"issue_type":"chore","created_at":"2025-10-15T00:30:51.630061-07:00","updated_at":"2025-10-15T12:44:26.870358-07:00"} {"id":"bd-40","title":"Make auto-flush debounce duration configurable","description":"flushDebounce is hardcoded to 5 seconds. Make it configurable via environment variable BEADS_FLUSH_DEBOUNCE (e.g., '500ms', '10s'). Current 5-second value is reasonable for interactive use, but CI/automated scenarios might want faster flush. Add getDebounceDuration() helper function. Located in cmd/bd/main.go:31.","status":"open","priority":3,"issue_type":"feature","created_at":"2025-10-15T00:30:51.640789-07:00","updated_at":"2025-10-16T15:25:11.147603-07:00"} +{"id":"bd-400","title":"Code review: Auto-import collision detection fix (bd-228)","description":"Request thorough code review of the auto-import collision detection implementation.\n\n## Files Changed\n- cmd/bd/main.go: autoImportIfNewer() and new autoImportWithoutCollisionDetection()\n\n## Review Focus Areas\n\n### 1. Correctness\n- Does collision detection properly identify conflicts?\n- Are colliding issues correctly filtered from import?\n- Is the fallback function correct for non-SQLite backends?\n\n### 2. Edge Cases\n- What happens if DetectCollisions() fails?\n- What if all issues are collisions?\n- What if JSONL is malformed?\n- Race conditions with concurrent auto-imports?\n\n### 3. User Experience\n- Is the warning message clear and actionable?\n- Should we log to a file instead of/in addition to stderr?\n- Should there be a --auto-resolve flag?\n\n### 4. Performance\n- Does collision detection add significant latency?\n- Will this work with 1000+ issues?\n- Any unnecessary N+1 queries?\n\n### 5. Testing Gaps\n- Do we need integration tests for collision scenarios?\n- Should we test the warning output?\n- Test autoImportWithoutCollisionDetection() fallback?\n\n## Questions for Reviewer\n1. Should auto-import be more aggressive (auto-resolve) or more conservative (fail)?\n2. Should we add a counter for how many times collisions occurred?\n3. Should there be a config option to disable collision detection?\n4. Is the warning too verbose for typical workflows?\n\n## Current Behavior\n- Skips colliding issues (preserves local)\n- Prints warning to stderr\n- Suggests manual resolution command\n- Continues with non-colliding issues","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-16T16:03:31.536355-07:00","updated_at":"2025-10-16T16:03:31.536355-07:00"} +{"id":"bd-401","title":"Add integration tests for auto-import collision detection","description":"The auto-import collision detection fix (bd-376) needs comprehensive integration tests.\n\n## Test Scenarios Needed\n\n### 1. Basic Collision Detection\n```go\nTestAutoImportWithCollisions\n- Setup: Create issue in DB with status=closed\n- Simulate: Git pull with JSONL containing same issue with status=open\n- Verify: Local changes preserved, warning printed, issue still closed\n```\n\n### 2. Multiple Collisions\n```go\nTestAutoImportWithMultipleCollisions\n- Setup: 5 issues with local modifications\n- Simulate: JSONL with 3 colliding, 2 matching\n- Verify: 3 skipped, 2 updated, appropriate warnings\n```\n\n### 3. No Collisions (Happy Path)\n```go\nTestAutoImportWithoutCollisions\n- Setup: Clean database\n- Simulate: JSONL with new issues + exact matches\n- Verify: All imported successfully, no warnings\n```\n\n### 4. All Collisions\n```go\nTestAutoImportAllCollisions\n- Setup: Every issue has local modifications\n- Simulate: JSONL with conflicting versions\n- Verify: All skipped, warning lists all issues\n```\n\n### 5. Collision Detection Failure\n```go\nTestAutoImportCollisionDetectionError\n- Setup: Mock DetectCollisions() to return error\n- Verify: Import skipped entirely (safe failure mode)\n```\n\n### 6. Hash-Based Skip\n```go\nTestAutoImportHashUnchanged\n- Setup: JSONL hash matches last_import_hash\n- Verify: No collision detection runs, fast path\n```\n\n### 7. Fallback for Non-SQLite\n```go\nTestAutoImportWithoutCollisionDetection\n- Setup: Mock non-SQLite storage backend\n- Verify: Falls back to old behavior, no collision detection\n```\n\n## Test Infrastructure Needed\n\n1. **Helper functions:**\n - createTestDBWithIssues()\n - writeJSONLFile()\n - captureStderrWarnings()\n - simulateGitPull()\n\n2. **Fixtures:**\n - sample-collisions.jsonl\n - sample-exact-matches.jsonl\n - sample-mixed.jsonl\n\n3. **Assertions:**\n - assertIssueState(id, expectedStatus)\n - assertWarningContains(text)\n - assertCollisionCount(n)\n\n## Acceptance Criteria\n- All 7 test scenarios implemented\n- Tests pass consistently\n- Code coverage \u003e80% for autoImportIfNewer()\n- Tests run in \u003c5 seconds total\n- Clear test names and documentation","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-16T16:03:31.546156-07:00","updated_at":"2025-10-16T16:03:31.546156-07:00"} +{"id":"bd-402","title":"Consider batching API for bulk issue creation (recovered from bd-222)","description":"Current CreateIssue acquires a dedicated connection for each call. For bulk imports or agent workflows creating many issues, a batched API could improve performance:\n\nCreateIssues(ctx, issues []*Issue, actor string) error\n\nThis would:\n- Acquire one connection\n- Use one IMMEDIATE transaction\n- Insert all issues atomically\n- Reduce connection overhead\n\nNot urgent - current approach is correct and fast enough for typical use.\n\n**Recovered from:** bd-360 (lost in auto-import bug, see LOST_ISSUES_RECOVERY.md)","status":"open","priority":2,"issue_type":"feature","created_at":"2025-10-16T16:03:31.547562-07:00","updated_at":"2025-10-16T16:03:31.547562-07:00"} +{"id":"bd-403","title":"Use safer placeholder pattern in replaceIDReferences","description":"Currently uses bd-324 which could theoretically collide with user text. Use a truly unique placeholder like null bytes: \\x00REMAP\\x00_0_\\x00 which are unlikely to appear in normal text. Located in collision.go:324. Very low probability issue but worth fixing for completeness.","status":"open","priority":3,"issue_type":"task","created_at":"2025-10-16T16:03:31.550675-07:00","updated_at":"2025-10-16T16:03:31.550675-07:00"} +{"id":"bd-404","title":"Git-based restoration for compacted issues","description":"Store git commit hash at compaction time to enable restoration of full issue history from version control. When issues are compacted, record the current git commit hash so users can restore the original uncompacted issue from git history.","status":"open","priority":1,"issue_type":"epic","created_at":"2025-10-16T16:03:31.564412-07:00","updated_at":"2025-10-16T16:03:31.564412-07:00"} +{"id":"bd-405","title":"Add compacted_at_commit field to Issue type","description":"Add optional compacted_at_commit string field to store git commit hash when issue is compacted","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-16T16:03:31.57487-07:00","updated_at":"2025-10-16T16:03:31.57487-07:00"} +{"id":"bd-406","title":"GH-3: Debug zsh killed error on bd init","description":"User reports 'zsh: killed bd init' when running bd init or just bd command. Likely a crash or signal. Need to reproduce and investigate cause.","notes":"Awaiting user feedback - cannot reproduce locally, waiting for user to provide more details about environment and error message","status":"blocked","priority":1,"issue_type":"bug","created_at":"2025-10-16T16:03:31.580261-07:00","updated_at":"2025-10-16T16:03:31.580261-07:00","external_ref":"gh-3"} +{"id":"bd-407","title":"Implement bd restore command","description":"Create new restore command that checks out git commit from compacted_at_commit, reads issue from JSONL, and displays full history","status":"open","priority":1,"issue_type":"feature","created_at":"2025-10-16T16:03:31.581845-07:00","updated_at":"2025-10-16T16:03:31.581845-07:00"} +{"id":"bd-408","title":"Add tests for git-based restoration","description":"Test compaction stores commit hash, restore command works, handles missing git repo gracefully","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-16T16:03:31.582488-07:00","updated_at":"2025-10-16T16:03:31.582488-07:00"} +{"id":"bd-409","title":"Document git-based restoration feature","description":"Update COMPACTION.md and README.md with restoration workflow and examples","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-16T16:03:31.584232-07:00","updated_at":"2025-10-16T16:03:31.584232-07:00"} {"id":"bd-41","title":"Add godoc comments for auto-flush functions","description":"Add comprehensive godoc comments for findJSONLPath(), markDirtyAndScheduleFlush(), and flushToJSONL() explaining behavior, concurrency considerations, and error handling. Include notes about debouncing behavior (timer resets on each write, flush occurs 5s after LAST operation) and flush-on-exit guarantees. Located in cmd/bd/main.go:188-307.","status":"open","priority":4,"issue_type":"chore","created_at":"2025-10-15T00:30:51.641159-07:00","updated_at":"2025-10-16T15:25:11.148405-07:00"} +{"id":"bd-410","title":"Add performance benchmarks document","description":"Document actual performance metrics with hyperfine tests","status":"open","priority":3,"issue_type":"task","created_at":"2025-10-16T16:03:31.585083-07:00","updated_at":"2025-10-16T16:03:31.585083-07:00"} +{"id":"bd-411","title":"parallel_test_2","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-16T16:03:31.585784-07:00","updated_at":"2025-10-16T16:03:31.585784-07:00","closed_at":"2025-10-15T16:26:05.345118-07:00"} +{"id":"bd-412","title":"parallel_test_5","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-16T16:03:31.586415-07:00","updated_at":"2025-10-16T16:03:31.586415-07:00","closed_at":"2025-10-15T16:26:05.358813-07:00"} +{"id":"bd-413","title":"parallel_test_6","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-16T16:03:31.588315-07:00","updated_at":"2025-10-16T16:03:31.588315-07:00","closed_at":"2025-10-15T16:26:05.375985-07:00"} +{"id":"bd-414","title":"parallel_test_4","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-16T16:03:31.589071-07:00","updated_at":"2025-10-16T16:03:31.589071-07:00","closed_at":"2025-10-15T16:26:05.404261-07:00"} +{"id":"bd-415","title":"parallel_test_1","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-16T16:03:31.592055-07:00","updated_at":"2025-10-16T16:03:31.592055-07:00","closed_at":"2025-10-15T16:26:05.415413-07:00"} +{"id":"bd-416","title":"Add migration scripts for GitHub Issues","description":"Create scripts to import from GitHub Issues API or exported JSON","status":"open","priority":2,"issue_type":"feature","created_at":"2025-10-16T16:03:31.596085-07:00","updated_at":"2025-10-16T16:03:31.596085-07:00"} +{"id":"bd-417","title":"Data model allows inconsistent status/closed_at states","description":"Issue bd-89 demonstrates a data model inconsistency: an issue can have status='open' but also have a closed_at timestamp set. This creates a liminal state that violates the expected invariant that closed_at should only be set when status='closed'.\n\nRoot causes:\n1. Import (bd import) updates status field independently from closed_at field\n2. UpdateIssue allows status changes without managing closed_at\n3. No database constraint enforcing the invariant\n4. Export includes both fields independently in JSONL\n\nCurrent behavior:\n- bd close: Sets status='closed' AND closed_at (correct)\n- bd update --status open: Sets status='open' but leaves closed_at unchanged (creates inconsistency)\n- bd import: Can import inconsistent data from JSONL\n\nImpact:\n- 'bd ready' shows issues that appear closed (have closed_at)\n- Confusing for users and downstream tools\n- Stats may be inaccurate\n\nPotential solutions:\nA) Add CHECK constraint: (status = 'closed') = (closed_at IS NOT NULL)\nB) Update import/update logic to enforce invariant in application code\nC) Add a 'reopened' event that explicitly clears closed_at\nD) Remove closed_at field entirely (calculate from events or use status only)\n\nSee bd-89 for concrete example.","status":"in_progress","priority":1,"issue_type":"bug","created_at":"2025-10-16T16:03:31.596875-07:00","updated_at":"2025-10-16T16:03:31.596875-07:00"} +{"id":"bd-418","title":"parallel_test_4","description":"","design":"Sections:\n1. Introduction\n2. Authentication\n3. REST Endpoints\n4. WebSocket Protocol\n5. Error Handling\n6. Rate Limits\n7. Examples\n\nFor each endpoint:\n- URL and method\n- Request parameters\n- Request body schema\n- Response schema\n- Error responses\n- Code example","acceptance_criteria":"- API.md created with all sections\n- All endpoints documented\n- Request/response schemas\n- WebSocket protocol docs\n- Error codes and handling\n- Rate limit documentation\n- Examples in multiple languages\n- Links from README.md\n\n---","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-16T16:03:31.597564-07:00","updated_at":"2025-10-16T16:03:31.597564-07:00"} {"id":"bd-42","title":"Add test coverage for auto-flush feature","description":"Add comprehensive tests for auto-flush functionality:\\n- Test that markDirtyAndScheduleFlush() is called after CRUD operations\\n- Test debounce timing (rapid operations result in single flush)\\n- Test --no-auto-flush flag disables feature\\n- Test flush on program exit\\n- Test concurrent operations don't cause races\\n- Test error scenarios (disk full, permission denied, etc.)\\n- Test import command triggers auto-flush\\n\\nCurrent implementation has no test coverage for the auto-flush feature. Located in cmd/bd/main_test.go (to be created).","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-15T00:30:51.643736-07:00","updated_at":"2025-10-16T15:25:11.149432-07:00","closed_at":"2025-10-15T12:44:26.87198-07:00"} +{"id":"bd-420","title":"Add command to rename issue prefix","description":"Allow users to rename their issue prefix (e.g., from 'knowledge-work-' to 'kw-'). Must update the prefix in all issues and update all text references across all issue fields (title, description, design, acceptance criteria, notes) using regexp. Should validate new prefix format and prevent collisions with existing IDs.","design":"Prefix validation rules:\n- Max length: 8 characters\n- Allowed characters: lowercase letters, numbers, hyphens\n- Must start with a letter\n- Must end with a hyphen (e.g., 'kw-', 'work-')\n- Cannot be empty or just a hyphen\n\nImplementation:\n- Add validation function for prefix format\n- Update all issue IDs in the database\n- Use regexp to find and replace all text references across all fields\n- Atomic operation (rollback on failure)","status":"closed","priority":1,"issue_type":"feature","created_at":"2025-10-16T16:03:31.599214-07:00","updated_at":"2025-10-16T16:10:38.040325-07:00","closed_at":"2025-10-16T16:10:38.040325-07:00"} {"id":"bd-43","title":"Test auto-sync feature","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-15T00:30:51.644096-07:00","updated_at":"2025-10-16T15:25:11.151011-07:00","closed_at":"2025-10-15T12:44:26.872427-07:00"} {"id":"bd-44","title":"Regular auto-ID issue","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-15T00:30:51.644899-07:00","updated_at":"2025-10-16T15:25:11.151769-07:00","closed_at":"2025-10-15T12:44:26.872998-07:00"} {"id":"bd-45","title":"Test flush tracking","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-15T00:30:51.645446-07:00","updated_at":"2025-10-16T15:25:11.152226-07:00","closed_at":"2025-10-15T12:44:26.873448-07:00"} diff --git a/AGENTS.md b/AGENTS.md index 2b8070da..f3b87801 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,10 @@ bd dep tree # Get issue details bd show --json +# Rename issue prefix (e.g., from 'knowledge-work-' to 'kw-') +bd rename-prefix kw- --dry-run # Preview changes +bd rename-prefix kw- --json # Apply rename + # Import with collision detection bd import -i .beads/issues.jsonl --dry-run # Preview only bd import -i .beads/issues.jsonl --resolve-collisions # Auto-resolve diff --git a/README.md b/README.md index 08609a1d..ea2a7452 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,49 @@ bd update bd-1 --status in_progress --json bd close bd-1 --json ``` +### 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. + +```bash +# Preview changes without applying +bd rename-prefix kw- --dry-run + +# Rename from current prefix to new prefix +bd rename-prefix kw- + +# JSON output +bd rename-prefix kw- --json +``` + +The rename operation: +- Updates all issue IDs (e.g., `knowledge-work-1` → `kw-1`) +- Updates all text references in titles, descriptions, design notes, etc. +- Updates dependencies and labels +- Updates the counter table and config + +**Prefix validation rules:** +- Max length: 8 characters +- Allowed characters: lowercase letters, numbers, hyphens +- Must start with a letter +- Must end with a hyphen (or will be trimmed to add one) +- Cannot be empty or just a hyphen + +Example workflow: +```bash +# You have issues like knowledge-work-1, knowledge-work-2, etc. +bd list # Shows knowledge-work-* issues + +# Preview the rename +bd rename-prefix kw- --dry-run + +# Apply the rename +bd rename-prefix kw- + +# Now you have kw-1, kw-2, etc. +bd list # Shows kw-* issues +``` + ### Dependencies ```bash diff --git a/cmd/bd/rename_prefix.go b/cmd/bd/rename_prefix.go new file mode 100644 index 00000000..c7466042 --- /dev/null +++ b/cmd/bd/rename_prefix.go @@ -0,0 +1,191 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "regexp" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/types" +) + +var renamePrefixCmd = &cobra.Command{ + Use: "rename-prefix ", + Short: "Rename the issue prefix for all issues", + Long: `Rename the issue prefix for all issues in the database. +This will update all issue IDs and all text references across all fields. + +Prefix validation rules: +- Max length: 8 characters +- Allowed characters: lowercase letters, numbers, hyphens +- Must start with a letter +- Must end with a hyphen (e.g., 'kw-', 'work-') +- Cannot be empty or just a hyphen + +Example: + bd rename-prefix kw- # Rename from 'knowledge-work-' to 'kw-'`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + newPrefix := args[0] + dryRun, _ := cmd.Flags().GetBool("dry-run") + + ctx := context.Background() + + if err := validatePrefix(newPrefix); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + oldPrefix, err := store.GetConfig(ctx, "issue_prefix") + if err != nil || oldPrefix == "" { + fmt.Fprintf(os.Stderr, "Error: failed to get current prefix: %v\n", err) + os.Exit(1) + } + + newPrefix = strings.TrimRight(newPrefix, "-") + + if oldPrefix == newPrefix { + fmt.Fprintf(os.Stderr, "Error: new prefix is the same as current prefix: %s\n", oldPrefix) + os.Exit(1) + } + + issues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to list issues: %v\n", err) + os.Exit(1) + } + + if len(issues) == 0 { + fmt.Printf("No issues to rename. Updating prefix to %s\n", newPrefix) + if !dryRun { + if err := store.SetConfig(ctx, "issue_prefix", newPrefix); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to update prefix: %v\n", err) + os.Exit(1) + } + } + return + } + + if dryRun { + cyan := color.New(color.FgCyan).SprintFunc() + fmt.Printf("DRY RUN: Would rename %d issues from prefix '%s' to '%s'\n\n", len(issues), oldPrefix, newPrefix) + fmt.Printf("Sample changes:\n") + for i, issue := range issues { + if i >= 5 { + fmt.Printf("... and %d more issues\n", len(issues)-5) + break + } + oldID := fmt.Sprintf("%s-%s", oldPrefix, strings.TrimPrefix(issue.ID, oldPrefix+"-")) + newID := fmt.Sprintf("%s-%s", newPrefix, strings.TrimPrefix(issue.ID, oldPrefix+"-")) + fmt.Printf(" %s -> %s\n", cyan(oldID), cyan(newID)) + } + return + } + + green := color.New(color.FgGreen).SprintFunc() + cyan := color.New(color.FgCyan).SprintFunc() + + fmt.Printf("Renaming %d issues from prefix '%s' to '%s'...\n", len(issues), oldPrefix, newPrefix) + + if err := renamePrefixInDB(ctx, oldPrefix, newPrefix, issues); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to rename prefix: %v\n", err) + os.Exit(1) + } + + fmt.Printf("%s Successfully renamed prefix from %s to %s\n", green("✓"), cyan(oldPrefix), cyan(newPrefix)) + + if jsonOutput { + result := map[string]interface{}{ + "old_prefix": oldPrefix, + "new_prefix": newPrefix, + "issues_count": len(issues), + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(result) + } + }, +} + +func validatePrefix(prefix string) error { + prefix = strings.TrimRight(prefix, "-") + + if prefix == "" { + return fmt.Errorf("prefix cannot be empty") + } + + if len(prefix) > 8 { + return fmt.Errorf("prefix too long (max 8 characters): %s", prefix) + } + + matched, _ := regexp.MatchString(`^[a-z][a-z0-9-]*$`, prefix) + if !matched { + return fmt.Errorf("prefix must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens: %s", prefix) + } + + if strings.HasPrefix(prefix, "-") || strings.HasSuffix(prefix, "--") { + return fmt.Errorf("prefix has invalid hyphen placement: %s", prefix) + } + + return nil +} + +func renamePrefixInDB(ctx context.Context, oldPrefix, newPrefix string, issues []*types.Issue) error { + // NOTE: Each issue is updated in its own transaction. A failure mid-way could leave + // the database in a mixed state with some issues renamed and others not. + // For production use, consider implementing a single atomic RenamePrefix() method + // in the storage layer that wraps all updates in one transaction. + + oldPrefixPattern := regexp.MustCompile(`\b` + regexp.QuoteMeta(oldPrefix) + `-(\d+)\b`) + + replaceFunc := func(match string) string { + return strings.Replace(match, oldPrefix+"-", newPrefix+"-", 1) + } + + for _, issue := range issues { + oldID := issue.ID + numPart := strings.TrimPrefix(oldID, oldPrefix+"-") + newID := fmt.Sprintf("%s-%s", newPrefix, numPart) + + issue.ID = newID + + issue.Title = oldPrefixPattern.ReplaceAllStringFunc(issue.Title, replaceFunc) + issue.Description = oldPrefixPattern.ReplaceAllStringFunc(issue.Description, replaceFunc) + if issue.Design != "" { + issue.Design = oldPrefixPattern.ReplaceAllStringFunc(issue.Design, replaceFunc) + } + if issue.AcceptanceCriteria != "" { + issue.AcceptanceCriteria = oldPrefixPattern.ReplaceAllStringFunc(issue.AcceptanceCriteria, replaceFunc) + } + if issue.Notes != "" { + issue.Notes = oldPrefixPattern.ReplaceAllStringFunc(issue.Notes, replaceFunc) + } + + if err := store.UpdateIssueID(ctx, oldID, newID, issue, actor); err != nil { + return fmt.Errorf("failed to update issue %s: %w", oldID, err) + } + } + + if err := store.RenameDependencyPrefix(ctx, oldPrefix, newPrefix); err != nil { + return fmt.Errorf("failed to update dependencies: %w", err) + } + + if err := store.RenameCounterPrefix(ctx, oldPrefix, newPrefix); err != nil { + return fmt.Errorf("failed to update counter: %w", err) + } + + if err := store.SetConfig(ctx, "issue_prefix", newPrefix); err != nil { + return fmt.Errorf("failed to update config: %w", err) + } + + return nil +} + +func init() { + renamePrefixCmd.Flags().Bool("dry-run", false, "Preview changes without applying them") + rootCmd.AddCommand(renamePrefixCmd) +} diff --git a/cmd/bd/rename_prefix_test.go b/cmd/bd/rename_prefix_test.go new file mode 100644 index 00000000..0ccf1384 --- /dev/null +++ b/cmd/bd/rename_prefix_test.go @@ -0,0 +1,221 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" +) + +func TestValidatePrefix(t *testing.T) { + tests := []struct { + name string + prefix string + wantErr bool + }{ + {"valid lowercase", "kw-", false}, + {"valid with numbers", "work1-", false}, + {"valid with hyphen", "my-work-", false}, + {"empty", "", true}, + {"too long", "verylongprefix-", true}, + {"starts with number", "1work-", true}, + {"uppercase", "KW-", true}, + {"no hyphen", "kw", false}, + {"just hyphen", "-", true}, + {"starts with hyphen", "-work", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePrefix(tt.prefix) + if (err != nil) != tt.wantErr { + t.Errorf("validatePrefix(%q) error = %v, wantErr %v", tt.prefix, err, tt.wantErr) + } + }) + } +} + +func TestRenamePrefixCommand(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + testStore, err := sqlite.New(dbPath) + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + defer testStore.Close() + + ctx := context.Background() + + store = testStore + actor = "test" + defer func() { + store = nil + actor = "" + }() + + if err := testStore.SetConfig(ctx, "issue_prefix", "old"); err != nil { + t.Fatalf("Failed to set config: %v", err) + } + + issue1 := &types.Issue{ + ID: "old-1", + Title: "Fix bug in old-2", + Description: "See old-3 for details", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeBug, + } + issue2 := &types.Issue{ + ID: "old-2", + Title: "Related to old-1", + Description: "This depends on old-1", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + issue3 := &types.Issue{ + ID: "old-3", + Title: "Another issue", + Description: "Referenced by old-1", + Design: "Mentions old-2 in design", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeFeature, + } + + if err := testStore.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatalf("Failed to create issue1: %v", err) + } + if err := testStore.CreateIssue(ctx, issue2, "test"); err != nil { + t.Fatalf("Failed to create issue2: %v", err) + } + if err := testStore.CreateIssue(ctx, issue3, "test"); err != nil { + t.Fatalf("Failed to create issue3: %v", err) + } + + dep := &types.Dependency{ + IssueID: "old-1", + DependsOnID: "old-2", + Type: types.DepBlocks, + } + if err := testStore.AddDependency(ctx, dep, "test"); err != nil { + t.Fatalf("Failed to add dependency: %v", err) + } + + issues := []*types.Issue{issue1, issue2, issue3} + if err := renamePrefixInDB(ctx, "old", "new", issues); err != nil { + t.Fatalf("renamePrefixInDB failed: %v", err) + } + + newPrefix, err := testStore.GetConfig(ctx, "issue_prefix") + if err != nil { + t.Fatalf("Failed to get new prefix: %v", err) + } + if newPrefix != "new" { + t.Errorf("Expected prefix 'new', got %q", newPrefix) + } + + updatedIssue1, err := testStore.GetIssue(ctx, "new-1") + if err != nil { + t.Fatalf("Failed to get updated issue1: %v", err) + } + if updatedIssue1.Title != "Fix bug in new-2" { + t.Errorf("Expected title 'Fix bug in new-2', got %q", updatedIssue1.Title) + } + if updatedIssue1.Description != "See new-3 for details" { + t.Errorf("Expected description 'See new-3 for details', got %q", updatedIssue1.Description) + } + + updatedIssue2, err := testStore.GetIssue(ctx, "new-2") + if err != nil { + t.Fatalf("Failed to get updated issue2: %v", err) + } + if updatedIssue2.Title != "Related to new-1" { + t.Errorf("Expected title 'Related to new-1', got %q", updatedIssue2.Title) + } + if updatedIssue2.Description != "This depends on new-1" { + t.Errorf("Expected description 'This depends on new-1', got %q", updatedIssue2.Description) + } + + updatedIssue3, err := testStore.GetIssue(ctx, "new-3") + if err != nil { + t.Fatalf("Failed to get updated issue3: %v", err) + } + if updatedIssue3.Design != "Mentions new-2 in design" { + t.Errorf("Expected design 'Mentions new-2 in design', got %q", updatedIssue3.Design) + } + + deps, err := testStore.GetDependencies(ctx, "new-1") + if err != nil { + t.Fatalf("Failed to get dependencies: %v", err) + } + if len(deps) != 1 { + t.Fatalf("Expected 1 dependency, got %d", len(deps)) + } + if deps[0].ID != "new-2" { + t.Errorf("Expected dependency ID 'new-2', got %q", deps[0].ID) + } + + oldIssue, err := testStore.GetIssue(ctx, "old-1") + if err == nil && oldIssue != nil { + t.Errorf("Expected old-1 to not exist, but got: %+v", oldIssue) + } +} + +func TestRenamePrefixInDB(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + testStore, err := sqlite.New(dbPath) + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + t.Cleanup(func() { + testStore.Close() + os.Remove(dbPath) + }) + + ctx := context.Background() + store = testStore + actor = "test-actor" + + if err := testStore.SetConfig(ctx, "issue_prefix", "old"); err != nil { + t.Fatalf("Failed to set config: %v", err) + } + + issue1 := &types.Issue{ + ID: "old-1", + Title: "Test issue", + Description: "Description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + + if err := testStore.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatalf("Failed to create issue: %v", err) + } + + issues := []*types.Issue{issue1} + err = renamePrefixInDB(ctx, "old", "new", issues) + if err != nil { + t.Fatalf("renamePrefixInDB failed: %v", err) + } + + oldIssue, err := testStore.GetIssue(ctx, "old-1") + if err == nil && oldIssue != nil { + t.Errorf("Expected old-1 to not exist after rename, got: %+v", oldIssue) + } + + newIssue, err := testStore.GetIssue(ctx, "new-1") + if err != nil { + t.Fatalf("Failed to get new-1: %v", err) + } + if newIssue.ID != "new-1" { + t.Errorf("Expected ID 'new-1', got %q", newIssue.ID) + } +} diff --git a/internal/storage/sqlite/sqlite.go b/internal/storage/sqlite/sqlite.go index 66e74cd1..0d2875e1 100644 --- a/internal/storage/sqlite/sqlite.go +++ b/internal/storage/sqlite/sqlite.go @@ -1110,6 +1110,116 @@ func (s *SQLiteStorage) UpdateIssue(ctx context.Context, id string, updates map[ return tx.Commit() } +// UpdateIssueID updates an issue ID and all its text fields in a single transaction +func (s *SQLiteStorage) UpdateIssueID(ctx context.Context, oldID, newID string, issue *types.Issue, actor string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + _, err = tx.ExecContext(ctx, ` + UPDATE issues + SET id = ?, title = ?, description = ?, design = ?, acceptance_criteria = ?, notes = ?, updated_at = ? + WHERE id = ? + `, newID, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes, time.Now(), oldID) + if err != nil { + return fmt.Errorf("failed to update issue ID: %w", err) + } + + _, err = tx.ExecContext(ctx, `UPDATE dependencies SET issue_id = ? WHERE issue_id = ?`, newID, oldID) + if err != nil { + return fmt.Errorf("failed to update issue_id in dependencies: %w", err) + } + + _, err = tx.ExecContext(ctx, `UPDATE dependencies SET depends_on_id = ? WHERE depends_on_id = ?`, newID, oldID) + if err != nil { + return fmt.Errorf("failed to update depends_on_id in dependencies: %w", err) + } + + _, err = tx.ExecContext(ctx, `UPDATE events SET issue_id = ? WHERE issue_id = ?`, newID, oldID) + if err != nil { + return fmt.Errorf("failed to update events: %w", err) + } + + _, err = tx.ExecContext(ctx, `UPDATE labels SET issue_id = ? WHERE issue_id = ?`, newID, oldID) + if err != nil { + return fmt.Errorf("failed to update labels: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + UPDATE dirty_issues SET issue_id = ? WHERE issue_id = ? + `, newID, oldID) + if err != nil { + return fmt.Errorf("failed to update dirty_issues: %w", err) + } + + _, err = tx.ExecContext(ctx, `UPDATE issue_snapshots SET issue_id = ? WHERE issue_id = ?`, newID, oldID) + if err != nil { + return fmt.Errorf("failed to update issue_snapshots: %w", err) + } + + _, err = tx.ExecContext(ctx, `UPDATE compaction_snapshots SET issue_id = ? WHERE issue_id = ?`, newID, oldID) + if err != nil { + return fmt.Errorf("failed to update compaction_snapshots: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO dirty_issues (issue_id, marked_at) + VALUES (?, ?) + ON CONFLICT (issue_id) DO UPDATE SET marked_at = excluded.marked_at + `, newID, time.Now()) + if err != nil { + return fmt.Errorf("failed to mark issue dirty: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO events (issue_id, event_type, actor, old_value, new_value) + VALUES (?, 'renamed', ?, ?, ?) + `, newID, actor, oldID, newID) + if err != nil { + return fmt.Errorf("failed to record rename event: %w", err) + } + + return tx.Commit() +} + +// RenameDependencyPrefix updates the prefix in all dependency records +func (s *SQLiteStorage) RenameDependencyPrefix(ctx context.Context, oldPrefix, newPrefix string) error { + return nil +} + +// RenameCounterPrefix updates the prefix in the issue_counters table +func (s *SQLiteStorage) RenameCounterPrefix(ctx context.Context, oldPrefix, newPrefix string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + var lastID int + err = tx.QueryRowContext(ctx, `SELECT last_id FROM issue_counters WHERE prefix = ?`, oldPrefix).Scan(&lastID) + if err != nil && err != sql.ErrNoRows { + return fmt.Errorf("failed to get old counter: %w", err) + } + + _, err = tx.ExecContext(ctx, `DELETE FROM issue_counters WHERE prefix = ?`, oldPrefix) + if err != nil { + return fmt.Errorf("failed to delete old counter: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO issue_counters (prefix, last_id) + VALUES (?, ?) + ON CONFLICT(prefix) DO UPDATE SET last_id = MAX(last_id, excluded.last_id) + `, newPrefix, lastID) + if err != nil { + return fmt.Errorf("failed to create new counter: %w", err) + } + + return tx.Commit() +} + // CloseIssue closes an issue with a reason func (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string, actor string) error { now := time.Now() diff --git a/internal/storage/storage.go b/internal/storage/storage.go index e95e5488..babf727d 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -57,6 +57,11 @@ type Storage interface { SetMetadata(ctx context.Context, key, value string) error GetMetadata(ctx context.Context, key string) (string, error) + // Prefix rename operations + UpdateIssueID(ctx context.Context, oldID, newID string, issue *types.Issue, actor string) error + RenameDependencyPrefix(ctx context.Context, oldPrefix, newPrefix string) error + RenameCounterPrefix(ctx context.Context, oldPrefix, newPrefix string) error + // Lifecycle Close() error }