diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..b621d5b3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,113 @@ +# Changelog + +All notable changes to the beads project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixed +- Fixed unchecked error in import.go when unmarshaling JSON +- Fixed unchecked error returns in test cleanup code +- Removed duplicate test code in dependencies_test.go +- Fixed Go version in go.mod (was incorrectly set to 1.25.2) + +### Added +- Added `bd version` command to display version information +- Added CHANGELOG.md to track project changes + +## [0.9.0] - 2025-10-12 + +### Added +- **Collision Resolution System**: Automatic ID remapping for import collisions + - Reference scoring algorithm to minimize updates during remapping + - Word-boundary regex matching to prevent false replacements + - Automatic updating of text references and dependencies + - `--resolve-collisions` flag for safe branch merging + - `--dry-run` flag to preview collision detection +- **Export/Import with JSONL**: Git-friendly text format + - Dependencies embedded in JSONL for complete portability + - Idempotent import (exact matches detected) + - Collision detection (same ID, different content) +- **Ready Work Algorithm**: Find issues with no open blockers + - `bd ready` command shows unblocked work + - `bd blocked` command shows what's waiting +- **Dependency Management**: Four dependency types + - `blocks`: Hard blocker (affects ready work) + - `related`: Soft relationship + - `parent-child`: Epic/subtask hierarchy + - `discovered-from`: Track issues discovered during work +- **Database Discovery**: Auto-find database in project hierarchy + - Walks up directory tree like git + - Supports `$BEADS_DB` environment variable + - Falls back to `~/.beads/default.db` +- **Comprehensive Documentation**: + - README.md with 900+ lines of examples and FAQs + - CLAUDE.md for AI agent integration patterns + - SECURITY.md with security policy and best practices + - TEXT_FORMATS.md analyzing JSONL approach + - EXTENDING.md for database extension patterns + - GIT_WORKFLOW.md for git integration +- **Examples**: Real-world integration patterns + - Python agent implementation + - Bash agent script + - Git hooks for automatic export/import + - Branch merge workflow with collision resolution + - Claude Desktop MCP integration (coming soon) + +### Changed +- Switched to JSONL as source of truth (from binary SQLite) +- SQLite database now acts as ephemeral cache +- Issue IDs generated with numerical max (not alphabetical) +- Export sorts issues by ID for consistent git diffs + +### Security +- SQL injection protection via allowlisted field names +- Input validation for all issue fields +- File path validation for database operations +- Warnings about not storing secrets in issues + +## [0.1.0] - Initial Development + +### Added +- Core issue tracking (create, update, list, show, close) +- SQLite storage backend +- Dependency tracking with cycle detection +- Label support +- Event audit trail +- Full-text search +- Statistics and reporting +- `bd init` for project initialization +- `bd quickstart` interactive tutorial + +--- + +## Version History + +- **0.9.0** (2025-10-12): Pre-release polish and collision resolution +- **0.1.0**: Initial development version + +## Upgrade Guide + +### Upgrading to 0.9.0 + +No breaking changes. The JSONL export format is backward compatible. + +If you have issues in your database: +1. Run `bd export -o .beads/issues.jsonl` to create the text file +2. Commit `.beads/issues.jsonl` to git +3. Add `.beads/*.db` to `.gitignore` + +New collaborators can clone the repo and run: +```bash +bd import -i .beads/issues.jsonl +``` + +The SQLite database will be automatically populated from the JSONL file. + +## Future Releases + +See open issues tagged with milestone markers for planned features in upcoming releases. + +For version 1.0, see: `bd dep tree bd-8` (the 1.0 milestone epic) diff --git a/README.md b/README.md index 1dbd0898..a44841f6 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ **Issues chained together like beads.** +> **⚠️ Alpha Status**: This project is in active development. The core features work well, but expect API changes before 1.0. Use for development/internal projects first. + A lightweight, dependency-aware issue tracker designed for AI-supervised coding workflows. Track dependencies, find ready work, and let agents chain together tasks automatically. ## The Problem @@ -720,13 +722,28 @@ See [examples/](examples/) for scripting patterns. Contributions welcome! ### Is this production-ready? -bd is in active development and used for real projects. The core functionality (create, update, dependencies, ready work) is stable. However: -- No 1.0 release yet -- API may change before 1.0 -- Use for development/internal projects first -- Expect rapid iteration +**Current status: Alpha (v0.9.0)** -Follow the repo for updates! +bd is in active development and being dogfooded on real projects. The core functionality (create, update, dependencies, ready work, collision resolution) is stable and well-tested. However: + +- ⚠️ **Alpha software** - No 1.0 release yet +- ⚠️ **API may change** - Command flags and JSONL format may evolve before 1.0 +- ✅ **Safe for development** - Use for development/internal projects +- ✅ **Data is portable** - JSONL format is human-readable and easy to migrate +- 📈 **Rapid iteration** - Expect frequent updates and improvements + +**When to use bd:** +- ✅ AI-assisted development workflows +- ✅ Internal team projects +- ✅ Personal productivity with dependency tracking +- ✅ Experimenting with agent-first tools + +**When to wait:** +- ❌ Mission-critical production systems (wait for 1.0) +- ❌ Large enterprise deployments (wait for stability guarantees) +- ❌ Long-term archival (though JSONL makes migration easy) + +Follow the repo for updates and the path to 1.0! ### How does bd handle scale? diff --git a/cmd/bd/export_import_test.go b/cmd/bd/export_import_test.go index 0840fb22..92de70b0 100644 --- a/cmd/bd/export_import_test.go +++ b/cmd/bd/export_import_test.go @@ -20,7 +20,11 @@ func TestExportImport(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("Warning: cleanup failed: %v", err) + } + }() dbPath := filepath.Join(tmpDir, "test.db") @@ -219,7 +223,11 @@ func TestExportEmpty(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("Warning: cleanup failed: %v", err) + } + }() dbPath := filepath.Join(tmpDir, "empty.db") store, err := sqlite.New(dbPath) @@ -263,7 +271,11 @@ func TestRoundTrip(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("Warning: cleanup failed: %v", err) + } + }() dbPath := filepath.Join(tmpDir, "original.db") store, err := sqlite.New(dbPath) diff --git a/cmd/bd/import.go b/cmd/bd/import.go index 1f163a57..d63dfc52 100644 --- a/cmd/bd/import.go +++ b/cmd/bd/import.go @@ -182,7 +182,10 @@ Behavior: // Parse raw JSON to detect which fields are present var rawData map[string]interface{} jsonBytes, _ := json.Marshal(issue) - json.Unmarshal(jsonBytes, &rawData) + if err := json.Unmarshal(jsonBytes, &rawData); err != nil { + // If unmarshaling fails, treat all fields as present + rawData = make(map[string]interface{}) + } updates := make(map[string]interface{}) if _, ok := rawData["title"]; ok { diff --git a/cmd/bd/import_collision_test.go b/cmd/bd/import_collision_test.go index 8ad21929..177740bb 100644 --- a/cmd/bd/import_collision_test.go +++ b/cmd/bd/import_collision_test.go @@ -20,14 +20,22 @@ func TestImportSimpleCollision(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("Warning: cleanup failed: %v", err) + } + }() dbPath := filepath.Join(tmpDir, "test.db") testStore, err := sqlite.New(dbPath) if err != nil { t.Fatalf("Failed to create storage: %v", err) } - defer testStore.Close() + defer func() { + if err := testStore.Close(); err != nil { + t.Logf("Warning: failed to close store: %v", err) + } + }() ctx := context.Background() @@ -124,14 +132,22 @@ func TestImportMultipleCollisions(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("Warning: cleanup failed: %v", err) + } + }() dbPath := filepath.Join(tmpDir, "test.db") testStore, err := sqlite.New(dbPath) if err != nil { t.Fatalf("Failed to create storage: %v", err) } - defer testStore.Close() + defer func() { + if err := testStore.Close(); err != nil { + t.Logf("Warning: failed to close store: %v", err) + } + }() ctx := context.Background() @@ -225,14 +241,22 @@ func TestImportDependencyUpdates(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("Warning: cleanup failed: %v", err) + } + }() dbPath := filepath.Join(tmpDir, "test.db") testStore, err := sqlite.New(dbPath) if err != nil { t.Fatalf("Failed to create storage: %v", err) } - defer testStore.Close() + defer func() { + if err := testStore.Close(); err != nil { + t.Logf("Warning: failed to close store: %v", err) + } + }() ctx := context.Background() @@ -363,14 +387,22 @@ func TestImportTextReferenceUpdates(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("Warning: cleanup failed: %v", err) + } + }() dbPath := filepath.Join(tmpDir, "test.db") testStore, err := sqlite.New(dbPath) if err != nil { t.Fatalf("Failed to create storage: %v", err) } - defer testStore.Close() + defer func() { + if err := testStore.Close(); err != nil { + t.Logf("Warning: failed to close store: %v", err) + } + }() ctx := context.Background() @@ -499,14 +531,22 @@ func TestImportChainDependencies(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("Warning: cleanup failed: %v", err) + } + }() dbPath := filepath.Join(tmpDir, "test.db") testStore, err := sqlite.New(dbPath) if err != nil { t.Fatalf("Failed to create storage: %v", err) } - defer testStore.Close() + defer func() { + if err := testStore.Close(); err != nil { + t.Logf("Warning: failed to close store: %v", err) + } + }() ctx := context.Background() @@ -593,14 +633,22 @@ func TestImportPartialIDMatch(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("Warning: cleanup failed: %v", err) + } + }() dbPath := filepath.Join(tmpDir, "test.db") testStore, err := sqlite.New(dbPath) if err != nil { t.Fatalf("Failed to create storage: %v", err) } - defer testStore.Close() + defer func() { + if err := testStore.Close(); err != nil { + t.Logf("Warning: failed to close store: %v", err) + } + }() ctx := context.Background() @@ -708,14 +756,22 @@ func TestImportExactMatch(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("Warning: cleanup failed: %v", err) + } + }() dbPath := filepath.Join(tmpDir, "test.db") testStore, err := sqlite.New(dbPath) if err != nil { t.Fatalf("Failed to create storage: %v", err) } - defer testStore.Close() + defer func() { + if err := testStore.Close(); err != nil { + t.Logf("Warning: failed to close store: %v", err) + } + }() ctx := context.Background() @@ -765,14 +821,22 @@ func TestImportMixedScenario(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("Warning: cleanup failed: %v", err) + } + }() dbPath := filepath.Join(tmpDir, "test.db") testStore, err := sqlite.New(dbPath) if err != nil { t.Fatalf("Failed to create storage: %v", err) } - defer testStore.Close() + defer func() { + if err := testStore.Close(); err != nil { + t.Logf("Warning: failed to close store: %v", err) + } + }() ctx := context.Background() @@ -843,14 +907,22 @@ func TestImportWithDependenciesInJSONL(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("Warning: cleanup failed: %v", err) + } + }() dbPath := filepath.Join(tmpDir, "test.db") testStore, err := sqlite.New(dbPath) if err != nil { t.Fatalf("Failed to create storage: %v", err) } - defer testStore.Close() + defer func() { + if err := testStore.Close(); err != nil { + t.Logf("Warning: failed to close store: %v", err) + } + }() ctx := context.Background() diff --git a/cmd/bd/version.go b/cmd/bd/version.go new file mode 100644 index 00000000..e56324c5 --- /dev/null +++ b/cmd/bd/version.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +const ( + // Version is the current version of bd + Version = "0.9.0" + // Build can be set via ldflags at compile time + Build = "dev" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print version information", + Run: func(cmd *cobra.Command, args []string) { + if jsonOutput { + outputJSON(map[string]string{ + "version": Version, + "build": Build, + }) + } else { + fmt.Printf("bd version %s (%s)\n", Version, Build) + } + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} diff --git a/go.mod b/go.mod index c6f0a56f..da0b36ed 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/steveyegge/beads -go 1.25.2 +go 1.21 require ( github.com/fatih/color v1.18.0 // indirect diff --git a/internal/storage/sqlite/dependencies_test.go b/internal/storage/sqlite/dependencies_test.go index 33ee90b9..76214b63 100644 --- a/internal/storage/sqlite/dependencies_test.go +++ b/internal/storage/sqlite/dependencies_test.go @@ -7,15 +7,18 @@ import ( "github.com/steveyegge/beads/internal/types" ) -func TestAddDependency(t *testing.T) { +// Helper function to test adding a dependency with a specific type +func testAddDependencyWithType(t *testing.T, depType types.DependencyType, title1, title2 string) { + t.Helper() + store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create two issues - issue1 := &types.Issue{Title: "First", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} - issue2 := &types.Issue{Title: "Second", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue1 := &types.Issue{Title: title1, Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue2 := &types.Issue{Title: title2, Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") @@ -24,7 +27,7 @@ func TestAddDependency(t *testing.T) { dep := &types.Dependency{ IssueID: issue2.ID, DependsOnID: issue1.ID, - Type: types.DepBlocks, + Type: depType, } err := store.AddDependency(ctx, dep, "test-user") @@ -47,44 +50,12 @@ func TestAddDependency(t *testing.T) { } } +func TestAddDependency(t *testing.T) { + testAddDependencyWithType(t, types.DepBlocks, "First", "Second") +} + func TestAddDependencyDiscoveredFrom(t *testing.T) { - store, cleanup := setupTestDB(t) - defer cleanup() - - ctx := context.Background() - - // Create two issues - parent := &types.Issue{Title: "Parent task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} - discovered := &types.Issue{Title: "Bug found during work", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeBug} - - store.CreateIssue(ctx, parent, "test-user") - store.CreateIssue(ctx, discovered, "test-user") - - // Add discovered-from dependency - dep := &types.Dependency{ - IssueID: discovered.ID, - DependsOnID: parent.ID, - Type: types.DepDiscoveredFrom, - } - - err := store.AddDependency(ctx, dep, "test-user") - if err != nil { - t.Fatalf("AddDependency with discovered-from failed: %v", err) - } - - // Verify dependency was added - deps, err := store.GetDependencies(ctx, discovered.ID) - if err != nil { - t.Fatalf("GetDependencies failed: %v", err) - } - - if len(deps) != 1 { - t.Fatalf("Expected 1 dependency, got %d", len(deps)) - } - - if deps[0].ID != parent.ID { - t.Errorf("Expected dependency on %s, got %s", parent.ID, deps[0].ID) - } + testAddDependencyWithType(t, types.DepDiscoveredFrom, "Parent task", "Bug found during work") } func TestRemoveDependency(t *testing.T) {