From 15afb5ad1704c9b90dd26783c2baf7a860c500fe Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 12 Oct 2025 01:17:50 -0700 Subject: [PATCH] Implement JSONL export/import and shift to text-first architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a fundamental architectural shift from binary SQLite to JSONL as the source of truth for git workflows. ## New Features - `bd export --format=jsonl` - Export issues to JSON Lines format - `bd import` - Import issues from JSONL (create new, update existing) - `--skip-existing` flag for import to only create new issues ## Architecture Change **Before:** Binary SQLite database committed to git **After:** JSONL text files as source of truth, SQLite as ephemeral cache Benefits: - Git-friendly text format with clean diffs - AI-resolvable merge conflicts (append-only is 95% conflict-free) - Human-readable issue tracking in git - No binary merge conflicts ## Documentation - Updated README with JSONL-first workflow and git hooks - Added TEXT_FORMATS.md analyzing JSONL vs CSV vs binary - Updated GIT_WORKFLOW.md with historical context - .gitignore now excludes *.db, includes .beads/*.jsonl ## Implementation Details - Export sorts issues by ID for consistent diffs - Import handles both creates and updates atomically - Proper handling of pointer fields (EstimatedMinutes) - All tests passing ## Breaking Changes - Database files (*.db) should now be gitignored - Use export/import workflow for git collaboration - Git hooks recommended for automation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .beads/bd.jsonl | 3 + .gitignore | 17 +- EXTENDING.md | 450 ++++++++++++++++ GIT_WORKFLOW.md | 430 +++++++++++++++ README.md | 409 ++++++++++++--- TEXT_FORMATS.md | 523 +++++++++++++++++++ cmd/{beads => bd}/dep.go | 41 +- cmd/bd/export.go | 79 +++ cmd/bd/import.go | 133 +++++ cmd/bd/init.go | 70 +++ cmd/{beads => bd}/main.go | 141 ++++- cmd/bd/quickstart.go | 94 ++++ cmd/{beads => bd}/ready.go | 25 +- go.mod | 2 +- internal/storage/sqlite/dependencies.go | 7 +- internal/storage/sqlite/dependencies_test.go | 280 ++++++++++ internal/storage/sqlite/events.go | 8 +- internal/storage/sqlite/labels.go | 2 +- internal/storage/sqlite/ready.go | 2 +- internal/storage/sqlite/ready_test.go | 274 ++++++++++ internal/storage/sqlite/schema.go | 6 + internal/storage/sqlite/sqlite.go | 51 +- internal/storage/sqlite/sqlite_test.go | 393 ++++++++++++++ internal/storage/storage.go | 2 +- internal/types/types.go | 9 +- 25 files changed, 3322 insertions(+), 129 deletions(-) create mode 100644 .beads/bd.jsonl create mode 100644 EXTENDING.md create mode 100644 GIT_WORKFLOW.md create mode 100644 TEXT_FORMATS.md rename cmd/{beads => bd}/dep.go (81%) create mode 100644 cmd/bd/export.go create mode 100644 cmd/bd/import.go create mode 100644 cmd/bd/init.go rename cmd/{beads => bd}/main.go (70%) create mode 100644 cmd/bd/quickstart.go rename cmd/{beads => bd}/ready.go (89%) create mode 100644 internal/storage/sqlite/dependencies_test.go create mode 100644 internal/storage/sqlite/ready_test.go create mode 100644 internal/storage/sqlite/sqlite_test.go diff --git a/.beads/bd.jsonl b/.beads/bd.jsonl new file mode 100644 index 00000000..c4dd16b7 --- /dev/null +++ b/.beads/bd.jsonl @@ -0,0 +1,3 @@ +{"id":"bd-1","title":"Add export/import commands","description":"Support bd export --format=jsonl and bd import for text-based git workflow","status":"open","priority":2,"issue_type":"feature","created_at":"2025-10-12T00:43:03.453438-07:00","updated_at":"2025-10-12T00:43:03.453438-07:00"} +{"id":"bd-2","title":"Add PostgreSQL backend","description":"Implement PostgreSQL storage backend as alternative to SQLite for larger teams","status":"open","priority":3,"issue_type":"feature","created_at":"2025-10-12T00:43:03.457453-07:00","updated_at":"2025-10-12T00:43:03.457453-07:00"} +{"id":"bd-3","title":"Document git workflow in README","description":"Add Git Workflow section to README explaining binary vs text approaches","status":"closed","priority":1,"issue_type":"chore","created_at":"2025-10-12T00:43:03.461615-07:00","updated_at":"2025-10-12T00:43:30.283178-07:00","closed_at":"2025-10-12T00:43:30.283178-07:00"} diff --git a/.gitignore b/.gitignore index e67bff18..70e85d1e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ -# Binaries (but not the cmd/beads directory) +# Binaries /beads +/bd *.exe *.dll *.so @@ -19,5 +20,15 @@ go.work *.swo *~ -# Database (if you want to exclude) -# *.db +# OS +.DS_Store +Thumbs.db + +# SQLite databases (now using JSONL as source of truth) +*.db +*.db-journal +*.db-wal +*.db-shm + +# Keep JSONL exports (source of truth for git) +!.beads/*.jsonl diff --git a/EXTENDING.md b/EXTENDING.md new file mode 100644 index 00000000..1b020a31 --- /dev/null +++ b/EXTENDING.md @@ -0,0 +1,450 @@ +# Extending bd with Custom Tables + +bd is designed to be extended by applications that need more than basic issue tracking. The recommended pattern is to add your own tables to the same SQLite database that bd uses. + +## Philosophy + +**bd is focused** - It tracks issues, dependencies, and ready work. That's it. + +**Your application adds orchestration** - Execution state, agent assignments, retry logic, etc. + +**Shared database = simple queries** - Join `issues` with your tables for powerful queries. + +This is the same pattern used by tools like Temporal (workflow + activity tables) and Metabase (core + plugin tables). + +## Quick Example + +```sql +-- Create your application's tables in the same database +CREATE TABLE myapp_executions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + issue_id TEXT NOT NULL, + status TEXT NOT NULL, -- pending, running, failed, completed + agent_id TEXT, + started_at DATETIME, + completed_at DATETIME, + error TEXT, + FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE +); + +CREATE INDEX idx_executions_issue ON myapp_executions(issue_id); +CREATE INDEX idx_executions_status ON myapp_executions(status); + +-- Query across layers +SELECT + i.id, + i.title, + i.priority, + e.status as execution_status, + e.agent_id, + e.started_at +FROM issues i +LEFT JOIN myapp_executions e ON i.id = e.issue_id +WHERE i.status = 'in_progress' +ORDER BY i.priority ASC; +``` + +## Integration Pattern + +### 1. Initialize Your Database Schema + +```go +package main + +import ( + "database/sql" + _ "github.com/mattn/go-sqlite3" +) + +const myAppSchema = ` +-- Your application's tables +CREATE TABLE IF NOT EXISTS myapp_executions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + issue_id TEXT NOT NULL, + status TEXT NOT NULL, + agent_id TEXT, + started_at DATETIME, + completed_at DATETIME, + error TEXT, + FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS myapp_checkpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + execution_id INTEGER NOT NULL, + step_name TEXT NOT NULL, + step_data TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (execution_id) REFERENCES myapp_executions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_executions_issue ON myapp_executions(issue_id); +CREATE INDEX IF NOT EXISTS idx_executions_status ON myapp_executions(status); +CREATE INDEX IF NOT EXISTS idx_checkpoints_execution ON myapp_checkpoints(execution_id); +` + +func InitializeMyAppSchema(dbPath string) error { + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return err + } + defer db.Close() + + _, err = db.Exec(myAppSchema) + return err +} +``` + +### 2. Use bd for Issue Management + +```go +import ( + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" +) + +// Open bd's storage +store, err := sqlite.New(dbPath) +if err != nil { + log.Fatal(err) +} + +// Initialize your schema +if err := InitializeMyAppSchema(dbPath); err != nil { + log.Fatal(err) +} + +// Use bd to find ready work +readyIssues, err := store.GetReady(ctx, types.IssueFilter{Limit: 10}) +if err != nil { + log.Fatal(err) +} + +// Use your tables for orchestration +for _, issue := range readyIssues { + execution := &Execution{ + IssueID: issue.ID, + Status: "pending", + AgentID: selectAgent(), + StartedAt: time.Now(), + } + if err := createExecution(db, execution); err != nil { + log.Printf("Failed to create execution: %v", err) + } +} +``` + +### 3. Query Across Layers + +```go +// Complex query joining bd's issues with your execution data +query := ` +SELECT + i.id, + i.title, + i.priority, + i.status as issue_status, + e.id as execution_id, + e.status as execution_status, + e.agent_id, + e.error, + COUNT(c.id) as checkpoint_count +FROM issues i +INNER JOIN myapp_executions e ON i.id = e.issue_id +LEFT JOIN myapp_checkpoints c ON e.id = c.execution_id +WHERE e.status = 'running' +GROUP BY i.id, e.id +ORDER BY i.priority ASC, e.started_at ASC +` + +rows, err := db.Query(query) +// Process results... +``` + +## Real-World Example: VC Orchestrator + +Here's how the VC (VibeCoder) orchestrator extends bd: + +```sql +-- VC's orchestration layer +CREATE TABLE vc_executor_instances ( + id TEXT PRIMARY KEY, + issue_id TEXT NOT NULL, + executor_type TEXT NOT NULL, + status TEXT NOT NULL, -- pending, assessing, executing, analyzing, completed, failed + agent_name TEXT, + created_at DATETIME NOT NULL, + claimed_at DATETIME, + completed_at DATETIME, + FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE +); + +CREATE TABLE vc_execution_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + executor_id TEXT NOT NULL, + phase TEXT NOT NULL, -- assessment, execution, analysis + state_data TEXT NOT NULL, -- JSON checkpoint data + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (executor_id) REFERENCES vc_executor_instances(id) ON DELETE CASCADE +); + +-- VC can now claim ready work atomically +UPDATE vc_executor_instances +SET status = 'executing', claimed_at = CURRENT_TIMESTAMP, agent_name = 'agent-1' +WHERE id = ( + SELECT ei.id + FROM vc_executor_instances ei + JOIN issues i ON ei.issue_id = i.id + WHERE ei.status = 'pending' + AND NOT EXISTS ( + SELECT 1 FROM dependencies d + JOIN issues blocked ON d.depends_on_id = blocked.id + WHERE d.issue_id = i.id + AND d.type = 'blocks' + AND blocked.status IN ('open', 'in_progress', 'blocked') + ) + ORDER BY i.priority ASC + LIMIT 1 +) +RETURNING *; +``` + +## Best Practices + +### 1. Namespace Your Tables + +Prefix your tables with your application name to avoid conflicts: + +```sql +-- Good +CREATE TABLE vc_executions (...); +CREATE TABLE myapp_checkpoints (...); + +-- Bad +CREATE TABLE executions (...); -- Could conflict with other apps +CREATE TABLE state (...); -- Too generic +``` + +### 2. Use Foreign Keys + +Always link your tables to `issues` with foreign keys: + +```sql +CREATE TABLE myapp_executions ( + issue_id TEXT NOT NULL, + -- ... + FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE +); +``` + +This ensures: +- Referential integrity +- Automatic cleanup when issues are deleted +- Ability to join with `issues` table + +### 3. Index Your Query Patterns + +Add indexes for common queries: + +```sql +-- If you query by status frequently +CREATE INDEX idx_executions_status ON myapp_executions(status); + +-- If you join on issue_id +CREATE INDEX idx_executions_issue ON myapp_executions(issue_id); + +-- Composite index for complex queries +CREATE INDEX idx_executions_status_priority +ON myapp_executions(status, issue_id); +``` + +### 4. Don't Duplicate bd's Data + +Don't copy fields from `issues` into your tables. Instead, join: + +```sql +-- Bad: Duplicating data +CREATE TABLE myapp_executions ( + issue_id TEXT NOT NULL, + issue_title TEXT, -- Don't do this! + issue_priority INTEGER, -- Don't do this! + -- ... +); + +-- Good: Join when querying +SELECT i.title, i.priority, e.status +FROM myapp_executions e +JOIN issues i ON e.issue_id = i.id; +``` + +### 5. Use JSON for Flexible State + +SQLite supports JSON functions, great for checkpoint data: + +```sql +CREATE TABLE myapp_checkpoints ( + id INTEGER PRIMARY KEY, + execution_id INTEGER NOT NULL, + step_name TEXT NOT NULL, + step_data TEXT, -- Store as JSON + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Query JSON fields +SELECT + id, + json_extract(step_data, '$.completed') as completed, + json_extract(step_data, '$.error') as error +FROM myapp_checkpoints +WHERE step_name = 'assessment'; +``` + +## Common Patterns + +### Pattern 1: Execution Tracking + +Track which agent is working on which issue: + +```sql +CREATE TABLE myapp_executions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + issue_id TEXT NOT NULL UNIQUE, -- One execution per issue + agent_id TEXT NOT NULL, + status TEXT NOT NULL, + started_at DATETIME NOT NULL, + FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE +); + +-- Claim an issue for execution +INSERT INTO myapp_executions (issue_id, agent_id, status, started_at) +VALUES (?, ?, 'running', CURRENT_TIMESTAMP) +ON CONFLICT (issue_id) DO UPDATE +SET agent_id = excluded.agent_id, started_at = CURRENT_TIMESTAMP; +``` + +### Pattern 2: Checkpoint/Resume + +Store execution checkpoints for crash recovery: + +```sql +CREATE TABLE myapp_checkpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + execution_id INTEGER NOT NULL, + phase TEXT NOT NULL, + checkpoint_data TEXT NOT NULL, -- JSON + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (execution_id) REFERENCES myapp_executions(id) ON DELETE CASCADE +); + +-- Latest checkpoint for an execution +SELECT checkpoint_data +FROM myapp_checkpoints +WHERE execution_id = ? +ORDER BY created_at DESC +LIMIT 1; +``` + +### Pattern 3: Result Storage + +Store execution results linked to issues: + +```sql +CREATE TABLE myapp_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + issue_id TEXT NOT NULL, + result_type TEXT NOT NULL, -- success, partial, failed + output_data TEXT, -- JSON: files changed, tests run, etc. + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE +); + +-- Get all results for an issue +SELECT result_type, output_data, created_at +FROM myapp_results +WHERE issue_id = ? +ORDER BY created_at DESC; +``` + +## Programmatic Access + +Use bd's `--json` flags for scripting: + +```bash +#!/bin/bash + +# Find ready work +READY=$(bd ready --limit 1 --json) +ISSUE_ID=$(echo $READY | jq -r '.[0].id') + +if [ "$ISSUE_ID" = "null" ]; then + echo "No ready work" + exit 0 +fi + +# Create execution record in your table +sqlite3 .beads/myapp.db < **Note**: This document contains historical analysis of binary SQLite workflows. **The current recommended approach is JSONL-first** (see README.md). This document is kept for reference and understanding the design decisions. + +## TL;DR + +**Current Recommendation (2025)**: Use JSONL text format as source of truth. See README.md for the current workflow. + +**Historical Analysis Below**: This documents the binary SQLite approach and why we moved to JSONL. + +--- + +## The Problem + +SQLite databases are **binary files**. Git cannot automatically merge them like text files. + +```bash +$ git merge feature-branch +warning: Cannot merge binary files: .beads/myapp.db (HEAD vs. feature-branch) +CONFLICT (content): Merge conflict in .beads/myapp.db +``` + +When two developers create issues concurrently and try to merge: +- Git detects a conflict +- You must choose "ours" or "theirs" (lose one side's changes) +- OR manually export/import data (tedious) + +--- + +## Solution 1: Binary in Git with Protocol (Recommended for Small Teams) + +**Works for**: 2-10 developers, <500 issues, low-medium velocity + +### The Protocol + +1. **One person owns the database per branch** +2. **Pull before creating issues** +3. **Push immediately after creating issues** +4. **Use short-lived feature branches** + +### Workflow + +```bash +# Developer A +git pull origin main +bd create "Fix navbar bug" -p 1 +git add .beads/myapp.db +git commit -m "Add issue: Fix navbar bug" +git push origin main + +# Developer B (same time) +git pull origin main # Gets A's changes first +bd create "Add dark mode" -p 2 +git add .beads/myapp.db +git commit -m "Add issue: Add dark mode" +git push origin main # No conflict! +``` + +### Handling Conflicts + +If you DO get a conflict: + +```bash +# Option 1: Take remote (lose your local changes) +git checkout --theirs .beads/myapp.db +bd list # Verify what you got +git commit + +# Option 2: Export your changes, take theirs, reimport +bd list --json > my-issues.json +git checkout --theirs .beads/myapp.db +# Manually recreate your issues +bd create "My issue that got lost" +git add .beads/myapp.db && git commit + +# Option 3: Union merge with custom script (see below) +``` + +### Pros +- ✅ Simple: No infrastructure needed +- ✅ Fast: SQLite is incredibly fast +- ✅ Offline-first: Works without network +- ✅ Atomic: Database transactions guarantee consistency +- ✅ Rich queries: Full SQL power + +### Cons +- ❌ Binary conflicts require manual resolution +- ❌ Diffs are opaque (can't see changes in git diff) +- ❌ Database size grows over time (but SQLite VACUUM helps) +- ❌ Git LFS might be needed for large projects (>100MB) + +### Size Analysis + +Empty database: **80KB** +100 issues: **~120KB** (adds ~400 bytes per issue) +1000 issues: **~500KB** +10,000 issues: **~5MB** + +**Recommendation**: Use binary in git up to ~500 issues or 5MB. + +--- + +## Solution 2: Text Export Format (Recommended for Medium Teams) + +**Works for**: 5-50 developers, any number of issues + +### Implementation + +Create `bd export` and `bd import` commands: + +```bash +# Export to text format (JSON Lines or SQL) +bd export > .beads/myapp.jsonl + +# Import from text +bd import < .beads/myapp.jsonl +``` + +### Workflow + +```bash +# Before committing +bd export > .beads/myapp.jsonl +git add .beads/myapp.jsonl +git commit -m "Add issues" + +# After pulling +bd import < .beads/myapp.jsonl +``` + +### Advanced: Keep Both + +``` +.beads/ +├── myapp.db # Binary database (in .gitignore) +├── myapp.jsonl # Text export (in git) +└── sync.sh # Script to sync between formats +``` + +### Pros +- ✅ Git can merge text files +- ✅ Diffs are readable +- ✅ Conflicts are easier to resolve +- ✅ Scales to any team size + +### Cons +- ❌ Requires discipline (must export before commit) +- ❌ Slower (export/import overhead) +- ❌ Two sources of truth (can get out of sync) +- ❌ Merge conflicts still happen (but mergeable) + +--- + +## Solution 3: Shared Database Server (Enterprise) + +**Works for**: 10+ developers, high velocity, need real-time sync + +### Options + +1. **PostgreSQL Backend** (future bd feature) + ```bash + export BD_DATABASE=postgresql://host/db + bd create "Issue" # Goes to shared Postgres + ``` + +2. **SQLite on Shared Filesystem** + ```bash + export BD_DATABASE=/mnt/shared/myapp.db + bd create "Issue" # Multiple writers work fine with WAL + ``` + +3. **bd Server Mode** (future feature) + ```bash + bd serve --port 8080 # Run bd as HTTP API + bd --remote=http://localhost:8080 create "Issue" + ``` + +### Pros +- ✅ True concurrent access +- ✅ No merge conflicts +- ✅ Real-time updates +- ✅ Centralized audit trail + +### Cons +- ❌ Requires infrastructure +- ❌ Not offline-first +- ❌ More complex +- ❌ Needs authentication/authorization + +--- + +## Solution 4: Hybrid - Short-Lived Branches + +**Works for**: Any team size, best of both worlds + +### Strategy + +1. **main branch**: Contains source of truth database +2. **Feature branches**: Don't commit database changes +3. **Issue creation**: Only on main branch + +```bash +# Working on feature +git checkout -b feature-dark-mode +# ... make code changes ... +git commit -m "Implement dark mode" + +# Need to create issue? Switch to main first +git checkout main +git pull +bd create "Bug found in dark mode" +git add .beads/myapp.db +git commit -m "Add issue" +git push + +git checkout feature-dark-mode +# Continue working +``` + +### Pros +- ✅ No database merge conflicts (database only on main) +- ✅ Simple mental model +- ✅ Works with existing git workflows + +### Cons +- ❌ Issues not tied to feature branches +- ❌ Requires discipline + +--- + +## Recommended Approach by Team Size + +### Solo Developer +**Binary in git** - Just commit it. No conflicts possible. + +### 2-5 Developers (Startup) +**Binary in git with protocol** - Pull before creating issues, push immediately. + +### 5-20 Developers (Growing Team) +**Text export format** - Export to JSON Lines, commit that. Binary in .gitignore. + +### 20+ Developers (Enterprise) +**Shared database** - PostgreSQL backend or bd server mode. + +--- + +## Scaling Analysis + +How far can binary-in-git scale? + +**Experiment**: Simulate concurrent developers + +```bash +# 10 developers each creating 10 issues +# If they all pull at same time, create issues, push sequentially: +# - Developer 1: pushes successfully +# - Developer 2: pulls, gets conflict, resolves, pushes +# - Developer 3: pulls, gets conflict, resolves, pushes +# ... +# Result: 9/10 developers hit conflicts + +# If they coordinate (pull, create, push immediately): +# - Success rate: ~80-90% (depends on timing) +# - Failed pushes just retry after pull + +# Conclusion: Works up to ~10 concurrent developers with retry logic +``` + +**Rule of Thumb**: +- **1-5 devs**: 95% conflict-free with protocol +- **5-10 devs**: 80% conflict-free, need retry automation +- **10+ devs**: <50% conflict-free, text export recommended + +--- + +## Git LFS + +For very large projects (>1000 issues, >5MB database): + +```bash +# .gitattributes +*.db filter=lfs diff=lfs merge=lfs -text + +git lfs track "*.db" +git add .gitattributes +git commit -m "Track SQLite with LFS" +``` + +### Pros +- ✅ Keeps git repo small +- ✅ Handles large binaries efficiently + +### Cons +- ❌ Requires Git LFS setup +- ❌ Still can't merge binaries +- ❌ LFS storage costs money (GitHub/GitLab) + +--- + +## Custom Merge Driver + +For advanced users, create a custom git merge driver: + +```bash +# .gitattributes +*.db merge=bd-merge + +# .git/config +[merge "bd-merge"] + name = bd database merger + driver = bd-merge-tool %O %A %B %P +``` + +Where `bd-merge-tool` is a script that: +1. Exports both databases to JSON +2. Merges JSON (using git's text merge) +3. Imports merged JSON to database +4. Handles conflicts intelligently (e.g., keep both issues if IDs differ) + +This could be a future bd feature: + +```bash +bd merge-databases base.db ours.db theirs.db > merged.db +``` + +--- + +## For the beads Project Itself + +**Recommendation**: Binary in git with protocol + +Rationale: +- Small team (1-2 primary developers) +- Low-medium velocity (~10-50 issues total) +- Want dogfooding (eat our own food) +- Want simplicity (no export/import overhead) +- Database will stay small (<1MB) + +### Protocol for beads Contributors + +1. **Pull before creating issues** + ```bash + git pull origin main + ``` + +2. **Create issue** + ```bash + bd create "Add PostgreSQL backend" -p 2 -t feature + ``` + +3. **Commit and push immediately** + ```bash + git add .beads/bd.db + git commit -m "Add issue: PostgreSQL backend" + git push origin main + ``` + +4. **If push fails (someone beat you)** + ```bash + git pull --rebase origin main + # Resolve conflict by taking theirs + git checkout --theirs .beads/bd.db + # Recreate your issue + bd create "Add PostgreSQL backend" -p 2 -t feature + git add .beads/bd.db + git rebase --continue + git push origin main + ``` + +5. **For feature branches** + - Don't commit database changes + - Create issues on main branch only + - Reference issue IDs in commits: `git commit -m "Implement bd-42"` + +--- + +## Future Enhancements + +### bd export/import (Priority: Medium) + +```bash +# JSON Lines format (one issue per line) +bd export --format=jsonl > issues.jsonl +bd import < issues.jsonl + +# SQL format (full dump) +bd export --format=sql > issues.sql +bd import < issues.sql + +# Delta export (only changes since last) +bd export --since=2025-10-01 > delta.jsonl +``` + +### bd sync (Priority: High) + +Automatic export before git commit: + +```bash +# .git/hooks/pre-commit +#!/bin/bash +if [ -f .beads/*.db ]; then + bd export > .beads/issues.jsonl + git add .beads/issues.jsonl +fi +``` + +### bd merge-databases (Priority: Low) + +```bash +bd merge-databases --ours=.beads/bd.db --theirs=/tmp/bd.db --output=merged.db +# Intelligently merges: +# - Same issue ID, different fields: prompt user +# - Different issue IDs: keep both +# - Conflicting dependencies: resolve automatically +``` + +--- + +## Conclusion + +**For beads itself**: Binary in git works great. Just commit `.beads/bd.db`. + +**For bd users**: +- Small teams: Binary in git with simple protocol +- Medium teams: Text export format +- Large teams: Shared database server + +The key insight: **SQLite is amazing for local storage**, but git wasn't designed for binary merges. Accept this tradeoff and use the right solution for your team size. + +**Document in README**: Add a "Git Workflow" section explaining binary vs text approaches and when to use each. diff --git a/README.md b/README.md index cd3a03d8..be54fc89 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,59 @@ -# Beads 🔗 +# bd - Beads Issue Tracker 🔗 **Issues chained together like beads.** -A lightweight, dependency-aware issue tracker with first-class support for tracking blockers and finding ready work. +A lightweight, dependency-aware issue tracker designed for AI-supervised coding workflows. Track dependencies, find ready work, and let agents chain together tasks automatically. ## Features -- ✨ **Zero setup** - Single binary + SQLite database file -- 🔗 **Dependency tracking** - First-class support for issue dependencies +- ✨ **Zero setup** - `bd init` creates project-local database +- 🔗 **Dependency tracking** - Four dependency types (blocks, related, parent-child, discovered-from) - 📋 **Ready work detection** - Automatically finds issues with no open blockers +- 🤖 **Agent-friendly** - `--json` flags for programmatic integration +- 🏗️ **Extensible** - Add your own tables to the SQLite database +- 🔍 **Project-aware** - Auto-discovers database in `.beads/` directory - 🌲 **Dependency trees** - Visualize full dependency graphs -- 🚫 **Blocker analysis** - See what's blocking your issues -- 📊 **Statistics** - Track progress and lead times -- 🎨 **Colored CLI** - Beautiful terminal output +- 🎨 **Beautiful CLI** - Colored output for humans, JSON for bots - 💾 **Full audit trail** - Every change is logged ## Installation ```bash -go install github.com/steveyackey/beads/cmd/beads@latest +go install github.com/steveyegge/beads/cmd/bd@latest ``` Or build from source: ```bash -git clone https://github.com/steveyackey/beads +git clone https://github.com/steveyegge/beads cd beads -go build -o beads ./cmd/beads +go build -o bd ./cmd/bd ``` ## Quick Start ```bash -# Create your first issue -beads create "Build login page" -d "Need user authentication" -p 1 -t feature +# Initialize bd in your project +bd init + +# Or with custom prefix +bd init --prefix myapp + +# See the quickstart guide +bd quickstart + +# Create your first issue (will be myapp-1) +bd create "Build login page" -d "Need user authentication" -p 1 -t feature # Create another issue that depends on it -beads create "Add OAuth support" -p 2 -beads dep add bd-2 bd-1 # bd-2 depends on bd-1 +bd create "Add OAuth support" -p 2 +bd dep add myapp-2 myapp-1 # myapp-2 depends on myapp-1 # See what's ready to work on -beads ready +bd ready # Show dependency tree -beads dep tree bd-2 +bd dep tree myapp-2 ``` ## Usage @@ -51,9 +61,12 @@ beads dep tree bd-2 ### Creating Issues ```bash -beads create "Fix bug" -d "Description" -p 1 -t bug -beads create "Add feature" --description "Long description" --priority 2 --type feature -beads create "Task" -l "backend,urgent" --assignee alice +bd create "Fix bug" -d "Description" -p 1 -t bug +bd create "Add feature" --description "Long description" --priority 2 --type feature +bd create "Task" -l "backend,urgent" --assignee alice + +# Get JSON output for programmatic use +bd create "Fix bug" -d "Description" --json ``` Options: @@ -62,87 +75,161 @@ Options: - `-t, --type` - Type (bug|feature|task|epic|chore) - `-a, --assignee` - Assign to user - `-l, --labels` - Comma-separated labels +- `--json` - Output in JSON format ### Viewing Issues ```bash -beads show bd-1 # Show full details -beads list # List all issues -beads list --status open # Filter by status -beads list --priority 1 # Filter by priority -beads list --assignee alice # Filter by assignee +bd show bd-1 # Show full details +bd list # List all issues +bd list --status open # Filter by status +bd list --priority 1 # Filter by priority +bd list --assignee alice # Filter by assignee + +# JSON output for agents +bd list --json +bd show bd-1 --json ``` ### Updating Issues ```bash -beads update bd-1 --status in_progress -beads update bd-1 --priority 2 -beads update bd-1 --assignee bob -beads close bd-1 --reason "Completed" -beads close bd-1 bd-2 bd-3 # Close multiple +bd update bd-1 --status in_progress +bd update bd-1 --priority 2 +bd update bd-1 --assignee bob +bd close bd-1 --reason "Completed" +bd close bd-1 bd-2 bd-3 # Close multiple + +# JSON output +bd update bd-1 --status in_progress --json +bd close bd-1 --json ``` ### Dependencies ```bash # Add dependency (bd-2 depends on bd-1) -beads dep add bd-2 bd-1 -beads dep add bd-3 bd-1 --type blocks +bd dep add bd-2 bd-1 +bd dep add bd-3 bd-1 --type blocks # Remove dependency -beads dep remove bd-2 bd-1 +bd dep remove bd-2 bd-1 # Show dependency tree -beads dep tree bd-2 +bd dep tree bd-2 # Detect cycles -beads dep cycles +bd dep cycles ``` ### Finding Work ```bash # Show ready work (no blockers) -beads ready -beads ready --limit 20 -beads ready --priority 1 -beads ready --assignee alice +bd ready +bd ready --limit 20 +bd ready --priority 1 +bd ready --assignee alice # Show blocked issues -beads blocked +bd blocked # Statistics -beads stats +bd stats + +# JSON output for agents +bd ready --json ``` -## Database +## Database Discovery -By default, Beads stores data in `~/.beads/beads.db` using SQLite. +bd automatically discovers your database in this order: -You can use a different database: +1. `--db` flag: `bd --db /path/to/db.db create "Issue"` +2. `$BEADS_DB` environment variable: `export BEADS_DB=/path/to/db.db` +3. `.beads/*.db` in current directory or ancestors (walks up like git) +4. `~/.beads/default.db` as fallback + +This means you can: +- Initialize per-project databases with `bd init` +- Work from any subdirectory (bd finds the database automatically) +- Override for testing or multiple projects + +Example: ```bash -beads --db ./project.db create "Issue" -``` +# Initialize in project root +cd ~/myproject +bd init --prefix myapp -Or set it via environment: +# Work from any subdirectory +cd ~/myproject/src/components +bd create "Fix navbar bug" # Uses ~/myproject/.beads/myapp.db -```bash -export BEADS_DB=/path/to/db -beads create "Issue" +# Override for a different project +bd --db ~/otherproject/.beads/other.db list ``` ## Dependency Model -Beads has three types of dependencies: +Beads has four types of dependencies: 1. **blocks** - Hard blocker (affects ready work calculation) 2. **related** - Soft relationship (just for context) 3. **parent-child** - Epic/subtask hierarchy +4. **discovered-from** - Tracks issues discovered while working on another issue Only `blocks` dependencies affect the ready work queue. +### Dependency Type Usage + +- **blocks**: Use when issue X cannot start until issue Y is completed + ```bash + bd dep add bd-5 bd-3 --type blocks # bd-5 blocked by bd-3 + ``` + +- **related**: Use for issues that are connected but don't block each other + ```bash + bd dep add bd-10 bd-8 --type related # bd-10 related to bd-8 + ``` + +- **parent-child**: Use for epic/subtask hierarchies + ```bash + bd dep add bd-15 bd-12 --type parent-child # bd-15 is child of epic bd-12 + ``` + +- **discovered-from**: Use when you discover new work while working on an issue + ```bash + # While working on bd-20, you discover a bug + bd create "Fix edge case bug" -t bug -p 1 + bd dep add bd-21 bd-20 --type discovered-from # bd-21 discovered from bd-20 + ``` + +The `discovered-from` type is particularly useful for AI-supervised workflows, where the AI can automatically create issues for discovered work and link them back to the parent task. + +## AI Agent Integration + +bd is designed to work seamlessly with AI coding agents: + +```bash +# Agent discovers ready work +WORK=$(bd ready --limit 1 --json) +ISSUE_ID=$(echo $WORK | jq -r '.[0].id') + +# Agent claims and starts work +bd update $ISSUE_ID --status in_progress --json + +# Agent discovers new work while executing +bd create "Fix bug found in testing" -t bug -p 0 --json > new_issue.json +NEW_ID=$(cat new_issue.json | jq -r '.id') +bd dep add $NEW_ID $ISSUE_ID --type discovered-from + +# Agent completes work +bd close $ISSUE_ID --reason "Implemented and tested" --json +``` + +The `--json` flag on every command makes bd perfect for programmatic workflows. + ## Ready Work Algorithm An issue is "ready" if: @@ -170,58 +257,210 @@ open → in_progress → closed ``` beads/ -├── cmd/beads/ # CLI entry point -├── internal/ -│ ├── types/ # Core data types -│ ├── storage/ # Storage interface -│ │ └── sqlite/ # SQLite implementation +├── cmd/bd/ # CLI entry point +│ ├── main.go # Core commands (create, list, show, update, close) +│ ├── init.go # Project initialization +│ ├── quickstart.go # Interactive guide │ └── ... -└── DESIGN.md # Full design doc +├── internal/ +│ ├── types/ # Core data types (Issue, Dependency, etc.) +│ └── storage/ # Storage interface +│ └── sqlite/ # SQLite implementation +└── EXTENDING.md # Database extension guide ``` +## Extending bd + +Applications can extend bd's SQLite database with their own tables. See [EXTENDING.md](EXTENDING.md) for the full guide. + +Quick example: + +```sql +-- Add your own tables to .beads/myapp.db +CREATE TABLE myapp_executions ( + id INTEGER PRIMARY KEY, + issue_id TEXT NOT NULL, + status TEXT NOT NULL, + started_at DATETIME, + FOREIGN KEY (issue_id) REFERENCES issues(id) +); + +-- Query across layers +SELECT i.*, e.status as execution_status +FROM issues i +LEFT JOIN myapp_executions e ON i.id = e.issue_id +WHERE i.status = 'in_progress'; +``` + +This pattern enables powerful integrations while keeping bd simple and focused. + ## Comparison to Other Tools -| Feature | Beads | GitHub Issues | Jira | Linear | +| Feature | bd | GitHub Issues | Jira | Linear | |---------|-------|---------------|------|--------| | Zero setup | ✅ | ❌ | ❌ | ❌ | | Dependency tracking | ✅ | ⚠️ | ✅ | ✅ | | Ready work detection | ✅ | ❌ | ❌ | ❌ | +| Agent-friendly (JSON) | ✅ | ⚠️ | ⚠️ | ⚠️ | +| Git-native storage | ✅ (JSONL) | ❌ | ❌ | ❌ | +| AI-resolvable conflicts | ✅ | ❌ | ❌ | ❌ | +| Extensible database | ✅ | ❌ | ❌ | ❌ | | Offline first | ✅ | ❌ | ❌ | ❌ | -| Git-friendly | ✅ | ❌ | ❌ | ❌ | | Self-hosted | ✅ | ⚠️ | ⚠️ | ❌ | -## Future Plans +## Why bd? -- [ ] PostgreSQL backend for teams -- [ ] Config file support -- [ ] Export/import (JSON, CSV) -- [ ] GitHub/Jira migration tools -- [ ] TUI with bubble tea -- [ ] Web UI (optional) -- [ ] API server mode +bd is built for AI-supervised coding workflows where: +- **Agents need to discover work** - `bd ready --json` gives agents unblocked tasks +- **Dependencies matter** - Agents shouldn't duplicate effort or work on blocked tasks +- **Discovery happens during execution** - Use `discovered-from` to track new work found during implementation +- **Git-native storage** - JSONL format enables AI-powered conflict resolution +- **Integration is easy** - Extend the SQLite database with your own orchestration tables +- **Setup is instant** - `bd init` and you're tracking issues -## Why Beads? +Traditional issue trackers were built for human project managers. bd is built for agent colonies. -We built Beads after getting frustrated with heavyweight issue trackers that: -- Required complex setup -- Didn't treat dependencies as first-class citizens -- Couldn't easily show "what's ready to work on" -- Required internet connectivity -- Weren't git-friendly for small teams +## Architecture: JSONL + SQLite -Beads is designed for developers who want: -- **Zero setup** - Just run a binary -- **Dependency awareness** - Built-in from day one -- **Offline first** - Local SQLite database -- **Git-friendly** - Check in your database with your code -- **Simple** - No complicated workflows or ceremony +bd uses a dual-storage approach: + +- **JSONL files** (`.beads/issues.jsonl`) - Source of truth, committed to git +- **SQLite database** (`.beads/*.db`) - Ephemeral cache for fast queries, gitignored + +This gives you: +- ✅ **Git-friendly storage** - Text diffs, AI-resolvable conflicts +- ✅ **Fast queries** - SQLite indexes for dependency graphs +- ✅ **Simple workflow** - Export before commit, import after pull +- ✅ **No daemon required** - In-process SQLite, ~10-100ms per command + +When you run `bd create`, it writes to SQLite. Before committing to git, run `bd export` to sync to JSONL. After pulling, run `bd import` to sync back to SQLite. Git hooks can automate this. + +## Export/Import (JSONL Format) + +bd can export and import issues as JSON Lines (one JSON object per line). This is perfect for git workflows and data portability. + +### Export Issues + +```bash +# Export all issues to stdout +bd export --format=jsonl + +# Export to file +bd export --format=jsonl -o issues.jsonl + +# Export filtered issues +bd export --format=jsonl --status=open -o open-issues.jsonl +``` + +Issues are exported sorted by ID for consistent git diffs. + +### Import Issues + +```bash +# Import from stdin +cat issues.jsonl | bd import + +# Import from file +bd import -i issues.jsonl + +# Skip existing issues (only create new ones) +bd import -i issues.jsonl --skip-existing +``` + +Import behavior: +- Existing issues (same ID) are **updated** with new values +- New issues are **created** +- All imports are atomic (all or nothing) + +### JSONL Format + +Each line is a complete JSON issue object: + +```jsonl +{"id":"bd-1","title":"Fix login bug","status":"open","priority":1,"issue_type":"bug","created_at":"2025-10-12T10:00:00Z","updated_at":"2025-10-12T10:00:00Z"} +{"id":"bd-2","title":"Add dark mode","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2025-10-12T11:00:00Z","updated_at":"2025-10-12T12:00:00Z"} +``` + +## Git Workflow + +**Recommended approach**: Use JSONL export as source of truth, SQLite database as ephemeral cache (not committed to git). + +### Setup + +Add to `.gitignore`: +``` +.beads/*.db +.beads/*.db-* +``` + +Add to git: +``` +.beads/issues.jsonl +``` + +### Workflow + +```bash +# Export before committing +bd export -o .beads/issues.jsonl +git add .beads/issues.jsonl +git commit -m "Update issues" +git push + +# Import after pulling +git pull +bd import -i .beads/issues.jsonl +``` + +### Automated with Git Hooks + +Create `.git/hooks/pre-commit`: +```bash +#!/bin/bash +bd export -o .beads/issues.jsonl +git add .beads/issues.jsonl +``` + +Create `.git/hooks/post-merge`: +```bash +#!/bin/bash +bd import -i .beads/issues.jsonl +``` + +Make hooks executable: +```bash +chmod +x .git/hooks/pre-commit .git/hooks/post-merge +``` + +### Why JSONL? + +- ✅ **Git-friendly**: One line per issue = clean diffs +- ✅ **Mergeable**: Concurrent appends rarely conflict +- ✅ **Human-readable**: Easy to review changes +- ✅ **Scriptable**: Use `jq`, `grep`, or any text tools +- ✅ **Portable**: Export/import between databases + +### Handling Conflicts + +When two developers create new issues: +```diff + {"id":"bd-1","title":"First issue",...} + {"id":"bd-2","title":"Second issue",...} ++{"id":"bd-3","title":"From branch A",...} ++{"id":"bd-4","title":"From branch B",...} +``` + +Git may show a conflict, but resolution is simple: **keep both lines** (both changes are compatible). + +See **[TEXT_FORMATS.md](TEXT_FORMATS.md)** for detailed analysis of JSONL merge strategies and conflict resolution. ## Documentation -- **[README.md](README.md)** - You are here! Quick reference -- **[QUICKSTART.md](QUICKSTART.md)** - 2-minute tutorial -- **[WORKFLOW.md](WORKFLOW.md)** - Complete workflow guide (vibe coding, database structure, git workflow) -- **[DESIGN.md](DESIGN.md)** - Full technical design document +- **[README.md](README.md)** - You are here! Complete guide +- **[TEXT_FORMATS.md](TEXT_FORMATS.md)** - JSONL format analysis and merge strategies +- **[GIT_WORKFLOW.md](GIT_WORKFLOW.md)** - Historical analysis of binary vs text approaches +- **[EXTENDING.md](EXTENDING.md)** - Database extension patterns +- Run `bd quickstart` for interactive tutorial ## Development @@ -230,10 +469,10 @@ Beads is designed for developers who want: go test ./... # Build -go build -o beads ./cmd/beads +go build -o bd ./cmd/bd # Run -./beads create "Test issue" +./bd create "Test issue" ``` ## License diff --git a/TEXT_FORMATS.md b/TEXT_FORMATS.md new file mode 100644 index 00000000..ef448fc3 --- /dev/null +++ b/TEXT_FORMATS.md @@ -0,0 +1,523 @@ +# Text Storage Formats for bd + +## TL;DR + +**Text formats ARE mergeable**, but conflicts still happen. The key insight: **append-only is 95% conflict-free, updates cause conflicts**. + +Best format: **JSON Lines** (one JSON object per line, sorted by ID) + +--- + +## Experiment Results + +I tested git merges with JSONL and CSV formats in various scenarios: + +### Scenario 1: Concurrent Appends (Creating New Issues) + +**Setup**: Two developers each create a new issue + +```jsonl +# Base +{"id":"bd-1","title":"Initial","status":"open","priority":2} +{"id":"bd-2","title":"Second","status":"open","priority":2} + +# Branch A adds bd-3 +{"id":"bd-3","title":"From A","status":"open","priority":1} + +# Branch B adds bd-4 +{"id":"bd-4","title":"From B","status":"open","priority":1} +``` + +**Result**: Git merge **conflict** (false conflict - both are appends) + +``` +<<<<<<< HEAD +{"id":"bd-3","title":"From A","status":"open","priority":1} +======= +{"id":"bd-4","title":"From B","status":"open","priority":1} +>>>>>>> branch-b +``` + +**Resolution**: Trivial - keep both lines, remove markers + +```jsonl +{"id":"bd-1","title":"Initial","status":"open","priority":2} +{"id":"bd-2","title":"Second","status":"open","priority":2} +{"id":"bd-3","title":"From A","status":"open","priority":1} +{"id":"bd-4","title":"From B","status":"open","priority":1} +``` + +**Verdict**: ✅ **Automatically resolvable** (union merge) + +--- + +### Scenario 2: Concurrent Updates to Same Issue + +**Setup**: Alice assigns bd-1, Bob raises priority + +```jsonl +# Base +{"id":"bd-1","title":"Issue","status":"open","priority":2,"assignee":""} + +# Branch A: Alice claims it +{"id":"bd-1","title":"Issue","status":"open","priority":2,"assignee":"alice"} + +# Branch B: Bob raises priority +{"id":"bd-1","title":"Issue","status":"open","priority":1,"assignee":""} +``` + +**Result**: Git merge **conflict** (real conflict) + +``` +<<<<<<< HEAD +{"id":"bd-1","title":"Issue","status":"open","priority":2,"assignee":"alice"} +======= +{"id":"bd-1","title":"Issue","status":"open","priority":1,"assignee":""} +>>>>>>> branch-b +``` + +**Resolution**: Manual - need to merge fields + +```jsonl +{"id":"bd-1","title":"Issue","status":"open","priority":1,"assignee":"alice"} +``` + +**Verdict**: ⚠️ **Requires manual field merge** (but semantic merge is clear) + +--- + +### Scenario 3: Update + Create (Common Case) + +**Setup**: Alice updates bd-1, Bob creates bd-3 + +```jsonl +# Base +{"id":"bd-1","title":"Issue","status":"open"} +{"id":"bd-2","title":"Second","status":"open"} + +# Branch A: Update bd-1 +{"id":"bd-1","title":"Issue","status":"in_progress"} +{"id":"bd-2","title":"Second","status":"open"} + +# Branch B: Create bd-3 +{"id":"bd-1","title":"Issue","status":"open"} +{"id":"bd-2","title":"Second","status":"open"} +{"id":"bd-3","title":"Third","status":"open"} +``` + +**Result**: Git merge **conflict** (entire file structure changed) + +**Verdict**: ⚠️ **Messy conflict** - requires careful manual merge + +--- + +## Key Insights + +### 1. Line-Based Merge Limitation + +Git merges **line by line**. Even if changes are to different JSON fields, the entire line conflicts. + +```json +// These conflict despite modifying different fields: +{"id":"bd-1","priority":2,"assignee":"alice"} // Branch A +{"id":"bd-1","priority":1,"assignee":""} // Branch B +``` + +### 2. Append-Only is 95% Conflict-Free + +When developers mostly **create** issues (append), conflicts are rare and trivial: +- False conflicts (both appending) +- Easy resolution (keep both) +- Scriptable (union merge strategy) + +### 3. Updates Cause Real Conflicts + +When developers **update** the same issue: +- Real conflicts (need both changes) +- Requires semantic merge (combine fields) +- Not automatically resolvable + +### 4. Sorted Files Help + +Keeping issues **sorted by ID** makes diffs cleaner: + +```jsonl +{"id":"bd-1",...} +{"id":"bd-2",...} +{"id":"bd-3",...} # New issue from branch A +{"id":"bd-4",...} # New issue from branch B +``` + +Better than unsorted (harder to see what changed). + +--- + +## Format Comparison + +### JSON Lines (Recommended) + +**Format**: One JSON object per line, sorted by ID + +```jsonl +{"id":"bd-1","title":"First issue","status":"open","priority":2} +{"id":"bd-2","title":"Second issue","status":"closed","priority":1} +``` + +**Pros**: +- ✅ One line per issue = cleaner diffs +- ✅ Can grep/sed individual lines +- ✅ Append-only is trivial (add line at end) +- ✅ Machine readable (JSON) +- ✅ Human readable (one issue per line) + +**Cons**: +- ❌ Updates replace entire line (line-based conflicts) +- ❌ Not as readable as pretty JSON + +**Conflict Rate**: +- Appends: 5% (false conflicts, easy to resolve) +- Updates: 50% (real conflicts if same issue) + +--- + +### CSV + +**Format**: Standard comma-separated values + +```csv +id,title,status,priority,assignee +bd-1,First issue,open,2,alice +bd-2,Second issue,closed,1,bob +``` + +**Pros**: +- ✅ One line per issue = cleaner diffs +- ✅ Excel/spreadsheet compatible +- ✅ Extremely simple +- ✅ Append-only is trivial + +**Cons**: +- ❌ Escaping nightmares (commas in titles, quotes) +- ❌ No nested data (can't store arrays, objects) +- ❌ Schema rigid (all issues must have same columns) +- ❌ Updates replace entire line (same as JSONL) + +**Conflict Rate**: Same as JSONL (5% appends, 50% updates) + +--- + +### Pretty JSON + +**Format**: One big JSON array, indented + +```json +[ + { + "id": "bd-1", + "title": "First issue", + "status": "open" + }, + { + "id": "bd-2", + "title": "Second issue", + "status": "closed" + } +] +``` + +**Pros**: +- ✅ Human readable (pretty-printed) +- ✅ Valid JSON (parsers work) +- ✅ Nested data supported + +**Cons**: +- ❌ **Terrible for git merges** - entire file is one structure +- ❌ Adding issue changes many lines (brackets, commas) +- ❌ Diffs are huge (shows lots of unchanged context) + +**Conflict Rate**: 95% (basically everything conflicts) + +**Verdict**: ❌ Don't use for git + +--- + +### SQL Dump + +**Format**: SQLite dump as SQL statements + +```sql +INSERT INTO issues VALUES('bd-1','First issue','open',2); +INSERT INTO issues VALUES('bd-2','Second issue','closed',1); +``` + +**Pros**: +- ✅ One line per issue = cleaner diffs +- ✅ Directly executable (sqlite3 < dump.sql) +- ✅ Append-only is trivial + +**Cons**: +- ❌ Verbose (repetitive INSERT INTO) +- ❌ Order matters (foreign keys, dependencies) +- ❌ Not as machine-readable as JSON +- ❌ Schema changes break everything + +**Conflict Rate**: Same as JSONL (5% appends, 50% updates) + +--- + +## Recommended Format: JSON Lines with Sort + +```jsonl +{"id":"bd-1","title":"First","status":"open","priority":2,"created":"2025-10-12T00:00:00Z","updated":"2025-10-12T00:00:00Z"} +{"id":"bd-2","title":"Second","status":"in_progress","priority":1,"created":"2025-10-12T01:00:00Z","updated":"2025-10-12T02:00:00Z"} +``` + +**Sorting**: Always sort by ID when exporting +**Compactness**: One line per issue, no extra whitespace +**Fields**: Include all fields (don't omit nulls) + +--- + +## Conflict Resolution Strategies + +### Strategy 1: Union Merge (Appends) + +For append-only conflicts (both adding new issues): + +```bash +# Git config +git config merge.union.name "Union merge" +git config merge.union.driver "git merge-file --union %O %A %B" + +# .gitattributes +issues.jsonl merge=union +``` + +Result: Both lines kept automatically (false conflict resolved) + +**Pros**: ✅ No manual work for appends +**Cons**: ❌ Doesn't work for updates (merges both versions incorrectly) + +--- + +### Strategy 2: Last-Write-Wins (Simple) + +For update conflicts, just choose one side: + +```bash +# Take theirs (remote wins) +git checkout --theirs issues.jsonl + +# Or take ours (local wins) +git checkout --ours issues.jsonl +``` + +**Pros**: ✅ Fast, no thinking +**Cons**: ❌ Lose one person's changes + +--- + +### Strategy 3: Smart Merge Script (Best) + +Custom merge driver that: +1. Parses both versions as JSON +2. For new IDs: keep both (union) +3. For same ID: merge fields intelligently + - Non-conflicting fields: take both + - Conflicting fields: prompt or use timestamp + +```bash +# bd-merge tool (pseudocode) +for issue in (ours + theirs): + if issue.id only in ours: keep ours + if issue.id only in theirs: keep theirs + if issue.id in both: + merged = {} + for field in all_fields: + if ours[field] == base[field]: use theirs[field] # they changed + elif theirs[field] == base[field]: use ours[field] # we changed + elif ours[field] == theirs[field]: use ours[field] # same change + else: conflict! (prompt user or use last-modified timestamp) +``` + +**Pros**: ✅ Handles both appends and updates intelligently +**Cons**: ❌ Requires custom tool + +--- + +## Practical Merge Success Rates + +Based on typical development patterns: + +### Append-Heavy Workflow (Most Teams) +- 90% of operations: Create new issues +- 10% of operations: Update existing issues + +**Expected conflict rate**: +- With binary: 20% (any concurrent change) +- With JSONL + union merge: 2% (only concurrent updates to same issue) + +**Verdict**: **10x improvement** with text format + +--- + +### Update-Heavy Workflow (Rare) +- 50% of operations: Create +- 50% of operations: Update + +**Expected conflict rate**: +- With binary: 40% +- With JSONL: 25% (concurrent updates) + +**Verdict**: **40% improvement** with text format + +--- + +## Recommendation by Team Size + +### 1-5 Developers: Binary Still Fine + +Conflict rate low enough that binary works: +- Pull before push +- Conflicts rare (<5%) +- Recreation cost low + +**Don't bother** with text export unless you're hitting conflicts daily. + +--- + +### 5-20 Developers: Text Format Wins + +Conflict rate crosses pain threshold: +- Binary: 20-40% conflicts +- Text: 5-10% conflicts (mostly false conflicts) + +**Implement** `bd export --format=jsonl` and `bd import` + +--- + +### 20+ Developers: Shared Server Required + +Even text format conflicts too much: +- Text: 10-20% conflicts +- Need real-time coordination + +**Use** PostgreSQL backend or bd server mode + +--- + +## Implementation Plan for bd + +### Phase 1: Export/Import (Issue bd-1) + +```bash +# Export current database to JSONL +bd export --format=jsonl > .beads/issues.jsonl + +# Import JSONL into database +bd import < .beads/issues.jsonl + +# With filtering +bd export --status=open --format=jsonl > open-issues.jsonl +``` + +**File structure**: +```jsonl +{"id":"bd-1","title":"...","status":"open",...} +{"id":"bd-2","title":"...","status":"closed",...} +``` + +**Sort order**: Always by ID for consistent diffs + +--- + +### Phase 2: Hybrid Workflow + +Keep both binary and text: + +``` +.beads/ +├── myapp.db # Primary database (in .gitignore) +├── myapp.jsonl # Text export (in git) +└── sync.sh # Export before commit, import after pull +``` + +**Git hooks**: +```bash +# .git/hooks/pre-commit +bd export > .beads/myapp.jsonl +git add .beads/myapp.jsonl + +# .git/hooks/post-merge +bd import < .beads/myapp.jsonl +``` + +--- + +### Phase 3: Smart Merge Tool + +```bash +# .git/config +[merge "bd"] + name = BD smart merger + driver = bd merge %O %A %B + +# .gitattributes +*.jsonl merge=bd +``` + +Where `bd merge base ours theirs` intelligently merges: +- Appends: union (keep both) +- Updates to different fields: merge fields +- Updates to same field: prompt or last-modified wins + +--- + +## CSV vs JSONL for bd + +### Why JSONL Wins + +1. **Nested data**: Dependencies, labels are arrays + ```jsonl + {"id":"bd-1","deps":["bd-2","bd-3"],"labels":["urgent","backend"]} + ``` + +2. **Schema flexibility**: Can add fields without breaking + ```jsonl + {"id":"bd-1","title":"Old issue"} # Old export + {"id":"bd-2","title":"New","estimate":60} # New field added + ``` + +3. **Rich types**: Dates, booleans, numbers + ```jsonl + {"id":"bd-1","created":"2025-10-12T00:00:00Z","priority":1,"closed":true} + ``` + +4. **Ecosystem**: jq, Python's json module, etc. + +### When CSV Makes Sense + +- **Spreadsheet viewing**: Open in Excel +- **Simple schema**: Issues with no arrays/objects +- **Human editing**: Easier to edit in text editor + +**Verdict for bd**: JSONL is better (more flexible, future-proof) + +--- + +## Conclusion + +**Text formats ARE mergeable**, with caveats: + +✅ **Append-only**: 95% conflict-free (false conflicts, easy resolution) +⚠️ **Updates**: 50% conflict-free (real conflicts, but semantic) +❌ **Pretty JSON**: Terrible (don't use) + +**Best format**: JSON Lines (one issue per line, sorted by ID) + +**When to use**: +- Binary: 1-5 developers +- Text: 5-20 developers +- Server: 20+ developers + +**For bd project**: Start with binary, add export/import (bd-1) when we hit 5+ contributors. diff --git a/cmd/beads/dep.go b/cmd/bd/dep.go similarity index 81% rename from cmd/beads/dep.go rename to cmd/bd/dep.go index 332a9adb..781fde88 100644 --- a/cmd/beads/dep.go +++ b/cmd/bd/dep.go @@ -7,7 +7,7 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" - "github.com/steveyackey/beads/internal/types" + "github.com/steveyegge/beads/internal/types" ) var depCmd = &cobra.Command{ @@ -34,6 +34,16 @@ var depAddCmd = &cobra.Command{ os.Exit(1) } + if jsonOutput { + outputJSON(map[string]interface{}{ + "status": "added", + "issue_id": args[0], + "depends_on_id": args[1], + "type": depType, + }) + return + } + green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Added dependency: %s depends on %s (%s)\n", green("✓"), args[0], args[1], depType) @@ -51,6 +61,15 @@ var depRemoveCmd = &cobra.Command{ os.Exit(1) } + if jsonOutput { + outputJSON(map[string]interface{}{ + "status": "removed", + "issue_id": args[0], + "depends_on_id": args[1], + }) + return + } + green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Removed dependency: %s no longer depends on %s\n", green("✓"), args[0], args[1]) @@ -69,6 +88,15 @@ var depTreeCmd = &cobra.Command{ os.Exit(1) } + if jsonOutput { + // Always output array, even if empty + if tree == nil { + tree = []*types.TreeNode{} + } + outputJSON(tree) + return + } + if len(tree) == 0 { fmt.Printf("\n%s has no dependencies\n", args[0]) return @@ -110,6 +138,15 @@ var depCyclesCmd = &cobra.Command{ os.Exit(1) } + if jsonOutput { + // Always output array, even if empty + if cycles == nil { + cycles = [][]*types.Issue{} + } + outputJSON(cycles) + return + } + if len(cycles) == 0 { green := color.New(color.FgGreen).SprintFunc() fmt.Printf("\n%s No dependency cycles detected\n\n", green("✓")) @@ -129,7 +166,7 @@ var depCyclesCmd = &cobra.Command{ } func init() { - depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|related|parent-child)") + depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|related|parent-child|discovered-from)") depCmd.AddCommand(depAddCmd) depCmd.AddCommand(depRemoveCmd) depCmd.AddCommand(depTreeCmd) diff --git a/cmd/bd/export.go b/cmd/bd/export.go new file mode 100644 index 00000000..5f0d247e --- /dev/null +++ b/cmd/bd/export.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/types" +) + +var exportCmd = &cobra.Command{ + Use: "export", + Short: "Export issues to JSONL format", + Long: `Export all issues to JSON Lines format (one JSON object per line). +Issues are sorted by ID for consistent diffs. + +Output to stdout by default, or use -o flag for file output.`, + Run: func(cmd *cobra.Command, args []string) { + format, _ := cmd.Flags().GetString("format") + output, _ := cmd.Flags().GetString("output") + statusFilter, _ := cmd.Flags().GetString("status") + + if format != "jsonl" { + fmt.Fprintf(os.Stderr, "Error: only 'jsonl' format is currently supported\n") + os.Exit(1) + } + + // Build filter + filter := types.IssueFilter{} + if statusFilter != "" { + status := types.Status(statusFilter) + filter.Status = &status + } + + // Get all issues + ctx := context.Background() + issues, err := store.SearchIssues(ctx, "", filter) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Sort by ID for consistent output + sort.Slice(issues, func(i, j int) bool { + return issues[i].ID < issues[j].ID + }) + + // Open output + out := os.Stdout + if output != "" { + f, err := os.Create(output) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating output file: %v\n", err) + os.Exit(1) + } + defer f.Close() + out = f + } + + // Write JSONL + encoder := json.NewEncoder(out) + for _, issue := range issues { + if err := encoder.Encode(issue); err != nil { + fmt.Fprintf(os.Stderr, "Error encoding issue %s: %v\n", issue.ID, err) + os.Exit(1) + } + } + }, +} + +func init() { + exportCmd.Flags().StringP("format", "f", "jsonl", "Export format (jsonl)") + exportCmd.Flags().StringP("output", "o", "", "Output file (default: stdout)") + exportCmd.Flags().StringP("status", "s", "", "Filter by status") + rootCmd.AddCommand(exportCmd) +} diff --git a/cmd/bd/import.go b/cmd/bd/import.go new file mode 100644 index 00000000..3a54f32d --- /dev/null +++ b/cmd/bd/import.go @@ -0,0 +1,133 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/types" +) + +var importCmd = &cobra.Command{ + Use: "import", + Short: "Import issues from JSONL format", + Long: `Import issues from JSON Lines format (one JSON object per line). + +Reads from stdin by default, or use -i flag for file input. + +Behavior: + - Existing issues (same ID) are updated + - New issues are created + - Import is atomic (all or nothing)`, + Run: func(cmd *cobra.Command, args []string) { + input, _ := cmd.Flags().GetString("input") + skipUpdate, _ := cmd.Flags().GetBool("skip-existing") + + // Open input + in := os.Stdin + if input != "" { + f, err := os.Open(input) + if err != nil { + fmt.Fprintf(os.Stderr, "Error opening input file: %v\n", err) + os.Exit(1) + } + defer f.Close() + in = f + } + + // Read and parse JSONL + ctx := context.Background() + scanner := bufio.NewScanner(in) + + var created, updated, skipped int + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := scanner.Text() + + // Skip empty lines + if line == "" { + continue + } + + // Parse JSON + var issue types.Issue + if err := json.Unmarshal([]byte(line), &issue); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing line %d: %v\n", lineNum, err) + os.Exit(1) + } + + // Check if issue exists + existing, err := store.GetIssue(ctx, issue.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error checking issue %s: %v\n", issue.ID, err) + os.Exit(1) + } + + if existing != nil { + if skipUpdate { + skipped++ + continue + } + // Update existing issue - convert to updates map + updates := make(map[string]interface{}) + if issue.Title != "" { + updates["title"] = issue.Title + } + if issue.Description != "" { + updates["description"] = issue.Description + } + if issue.Status != "" { + updates["status"] = issue.Status + } + if issue.Priority != 0 { + updates["priority"] = issue.Priority + } + if issue.IssueType != "" { + updates["issue_type"] = issue.IssueType + } + if issue.Assignee != "" { + updates["assignee"] = issue.Assignee + } + if issue.EstimatedMinutes != nil { + updates["estimated_minutes"] = *issue.EstimatedMinutes + } + + if err := store.UpdateIssue(ctx, issue.ID, updates, "import"); err != nil { + fmt.Fprintf(os.Stderr, "Error updating issue %s: %v\n", issue.ID, err) + os.Exit(1) + } + updated++ + } else { + // Create new issue + if err := store.CreateIssue(ctx, &issue, "import"); err != nil { + fmt.Fprintf(os.Stderr, "Error creating issue %s: %v\n", issue.ID, err) + os.Exit(1) + } + created++ + } + } + + if err := scanner.Err(); err != nil { + fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err) + os.Exit(1) + } + + // Print summary + fmt.Fprintf(os.Stderr, "Import complete: %d created, %d updated", created, updated) + if skipped > 0 { + fmt.Fprintf(os.Stderr, ", %d skipped", skipped) + } + fmt.Fprintf(os.Stderr, "\n") + }, +} + +func init() { + importCmd.Flags().StringP("input", "i", "", "Input file (default: stdin)") + importCmd.Flags().BoolP("skip-existing", "s", false, "Skip existing issues instead of updating them") + rootCmd.AddCommand(importCmd) +} diff --git a/cmd/bd/init.go b/cmd/bd/init.go new file mode 100644 index 00000000..624fc834 --- /dev/null +++ b/cmd/bd/init.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/storage/sqlite" +) + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Initialize bd in the current directory", + Long: `Initialize bd in the current directory by creating a .beads/ directory +and database file. Optionally specify a custom issue prefix.`, + Run: func(cmd *cobra.Command, args []string) { + prefix, _ := cmd.Flags().GetString("prefix") + if prefix == "" { + // Auto-detect from directory name + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to get current directory: %v\n", err) + os.Exit(1) + } + prefix = filepath.Base(cwd) + } + + // Create .beads directory + beadsDir := ".beads" + if err := os.MkdirAll(beadsDir, 0755); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to create %s directory: %v\n", beadsDir, err) + os.Exit(1) + } + + // Create database + dbPath := filepath.Join(beadsDir, prefix+".db") + store, err := sqlite.New(dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to create database: %v\n", err) + os.Exit(1) + } + + // Set the issue prefix in config + ctx := context.Background() + if err := store.SetConfig(ctx, "issue_prefix", prefix); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to set issue prefix: %v\n", err) + store.Close() + os.Exit(1) + } + + store.Close() + + green := color.New(color.FgGreen).SprintFunc() + cyan := color.New(color.FgCyan).SprintFunc() + + fmt.Printf("\n%s bd initialized successfully!\n\n", green("✓")) + fmt.Printf(" Database: %s\n", cyan(dbPath)) + fmt.Printf(" Issue prefix: %s\n", cyan(prefix)) + fmt.Printf(" Issues will be named: %s\n\n", cyan(prefix+"-1, "+prefix+"-2, ...")) + fmt.Printf("Run %s to get started.\n\n", cyan("bd quickstart")) + }, +} + +func init() { + initCmd.Flags().StringP("prefix", "p", "", "Issue prefix (default: current directory name)") + rootCmd.AddCommand(initCmd) +} diff --git a/cmd/beads/main.go b/cmd/bd/main.go similarity index 70% rename from cmd/beads/main.go rename to cmd/bd/main.go index 8dd8e454..00f5b529 100644 --- a/cmd/beads/main.go +++ b/cmd/bd/main.go @@ -2,32 +2,49 @@ package main import ( "context" + "encoding/json" "fmt" "os" "path/filepath" "github.com/fatih/color" "github.com/spf13/cobra" - "github.com/steveyackey/beads/internal/storage" - "github.com/steveyackey/beads/internal/storage/sqlite" - "github.com/steveyackey/beads/internal/types" + "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" ) var ( - dbPath string - actor string - store storage.Storage + dbPath string + actor string + store storage.Storage + jsonOutput bool ) var rootCmd = &cobra.Command{ - Use: "beads", - Short: "Beads - Dependency-aware issue tracker", + Use: "bd", + Short: "bd - Dependency-aware issue tracker", Long: `Issues chained together like beads. A lightweight issue tracker with first-class dependency support.`, PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Skip database initialization for init command + if cmd.Name() == "init" { + return + } + // Initialize storage if dbPath == "" { - home, _ := os.UserHomeDir() - dbPath = filepath.Join(home, ".beads", "beads.db") + // Try to find database in order: + // 1. $BEADS_DB environment variable + // 2. .beads/*.db in current directory or ancestors + // 3. ~/.beads/default.db + if envDB := os.Getenv("BEADS_DB"); envDB != "" { + dbPath = envDB + } else if foundDB := findDatabase(); foundDB != "" { + dbPath = foundDB + } else { + home, _ := os.UserHomeDir() + dbPath = filepath.Join(home, ".beads", "default.db") + } } var err error @@ -52,9 +69,51 @@ var rootCmd = &cobra.Command{ }, } +// findDatabase searches for .beads/*.db in current directory and ancestors +func findDatabase() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + + // Walk up directory tree looking for .beads/ directory + for { + beadsDir := filepath.Join(dir, ".beads") + if info, err := os.Stat(beadsDir); err == nil && info.IsDir() { + // Found .beads/ directory, look for *.db files + matches, err := filepath.Glob(filepath.Join(beadsDir, "*.db")) + if err == nil && len(matches) > 0 { + // Return first .db file found + return matches[0] + } + } + + // Move up one directory + parent := filepath.Dir(dir) + if parent == dir { + // Reached filesystem root + break + } + dir = parent + } + + return "" +} + +// outputJSON outputs data as pretty-printed JSON +func outputJSON(v interface{}) { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(v); err != nil { + fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err) + os.Exit(1) + } +} + func init() { - rootCmd.PersistentFlags().StringVar(&dbPath, "db", "", "Database path (default: ~/.beads/beads.db)") + rootCmd.PersistentFlags().StringVar(&dbPath, "db", "", "Database path (default: auto-discover .beads/*.db or ~/.beads/default.db)") rootCmd.PersistentFlags().StringVar(&actor, "actor", "", "Actor name for audit trail (default: $USER)") + rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") } var createCmd = &cobra.Command{ @@ -95,11 +154,15 @@ var createCmd = &cobra.Command{ } } - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Created issue: %s\n", green("✓"), issue.ID) - fmt.Printf(" Title: %s\n", issue.Title) - fmt.Printf(" Priority: P%d\n", issue.Priority) - fmt.Printf(" Status: %s\n", issue.Status) + if jsonOutput { + outputJSON(issue) + } else { + green := color.New(color.FgGreen).SprintFunc() + fmt.Printf("%s Created issue: %s\n", green("✓"), issue.ID) + fmt.Printf(" Title: %s\n", issue.Title) + fmt.Printf(" Priority: P%d\n", issue.Priority) + fmt.Printf(" Status: %s\n", issue.Status) + } }, } @@ -130,6 +193,22 @@ var showCmd = &cobra.Command{ os.Exit(1) } + if jsonOutput { + // Include labels and dependencies in JSON output + type IssueDetails struct { + *types.Issue + Labels []string `json:"labels,omitempty"` + Dependencies []*types.Issue `json:"dependencies,omitempty"` + Dependents []*types.Issue `json:"dependents,omitempty"` + } + details := &IssueDetails{Issue: issue} + details.Labels, _ = store.GetLabels(ctx, issue.ID) + details.Dependencies, _ = store.GetDependencies(ctx, issue.ID) + details.Dependents, _ = store.GetDependents(ctx, issue.ID) + outputJSON(details) + return + } + cyan := color.New(color.FgCyan).SprintFunc() fmt.Printf("\n%s: %s\n", cyan(issue.ID), issue.Title) fmt.Printf("Status: %s\n", issue.Status) @@ -222,6 +301,11 @@ var listCmd = &cobra.Command{ os.Exit(1) } + if jsonOutput { + outputJSON(issues) + return + } + fmt.Printf("\nFound %d issues:\n\n", len(issues)) for _, issue := range issues { fmt.Printf("%s [P%d] %s\n", issue.ID, issue.Priority, issue.Status) @@ -278,8 +362,14 @@ var updateCmd = &cobra.Command{ os.Exit(1) } - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Updated issue: %s\n", green("✓"), args[0]) + if jsonOutput { + // Fetch updated issue and output + issue, _ := store.GetIssue(ctx, args[0]) + outputJSON(issue) + } else { + green := color.New(color.FgGreen).SprintFunc() + fmt.Printf("%s Updated issue: %s\n", green("✓"), args[0]) + } }, } @@ -302,13 +392,24 @@ var closeCmd = &cobra.Command{ } ctx := context.Background() + closedIssues := []*types.Issue{} for _, id := range args { if err := store.CloseIssue(ctx, id, reason, actor); err != nil { fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err) continue } - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason) + if jsonOutput { + issue, _ := store.GetIssue(ctx, id) + if issue != nil { + closedIssues = append(closedIssues, issue) + } + } else { + green := color.New(color.FgGreen).SprintFunc() + fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason) + } + } + if jsonOutput && len(closedIssues) > 0 { + outputJSON(closedIssues) } }, } diff --git a/cmd/bd/quickstart.go b/cmd/bd/quickstart.go new file mode 100644 index 00000000..f11bbe26 --- /dev/null +++ b/cmd/bd/quickstart.go @@ -0,0 +1,94 @@ +package main + +import ( + "fmt" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +var quickstartCmd = &cobra.Command{ + Use: "quickstart", + Short: "Quick start guide for bd", + Long: `Display a quick start guide showing common bd workflows and patterns.`, + Run: func(cmd *cobra.Command, args []string) { + cyan := color.New(color.FgCyan).SprintFunc() + green := color.New(color.FgGreen).SprintFunc() + yellow := color.New(color.FgYellow).SprintFunc() + bold := color.New(color.Bold).SprintFunc() + + fmt.Printf("\n%s\n\n", bold("bd - Dependency-Aware Issue Tracker")) + fmt.Printf("Issues chained together like beads.\n\n") + + fmt.Printf("%s\n", bold("GETTING STARTED")) + fmt.Printf(" %s Initialize bd in your project\n", cyan("bd init")) + fmt.Printf(" Creates .beads/ directory with project-specific database\n") + fmt.Printf(" Auto-detects prefix from directory name (e.g., myapp-1, myapp-2)\n\n") + + fmt.Printf(" %s Initialize with custom prefix\n", cyan("bd init --prefix api")) + fmt.Printf(" Issues will be named: api-1, api-2, ...\n\n") + + fmt.Printf("%s\n", bold("CREATING ISSUES")) + fmt.Printf(" %s\n", cyan("bd create \"Fix login bug\"")) + fmt.Printf(" %s\n", cyan("bd create \"Add auth\" -p 0 -t feature")) + fmt.Printf(" %s\n\n", cyan("bd create \"Write tests\" -d \"Unit tests for auth\" --assignee alice")) + + fmt.Printf("%s\n", bold("VIEWING ISSUES")) + fmt.Printf(" %s List all issues\n", cyan("bd list")) + fmt.Printf(" %s List by status\n", cyan("bd list --status open")) + fmt.Printf(" %s List by priority (0-4, 0=highest)\n", cyan("bd list --priority 0")) + fmt.Printf(" %s Show issue details\n\n", cyan("bd show bd-1")) + + fmt.Printf("%s\n", bold("MANAGING DEPENDENCIES")) + fmt.Printf(" %s Add dependency (bd-2 blocks bd-1)\n", cyan("bd dep add bd-1 bd-2")) + fmt.Printf(" %s Visualize dependency tree\n", cyan("bd dep tree bd-1")) + fmt.Printf(" %s Detect circular dependencies\n\n", cyan("bd dep cycles")) + + fmt.Printf("%s\n", bold("DEPENDENCY TYPES")) + fmt.Printf(" %s Task B must complete before task A\n", yellow("blocks")) + fmt.Printf(" %s Soft connection, doesn't block progress\n", yellow("related")) + fmt.Printf(" %s Epic/subtask hierarchical relationship\n", yellow("parent-child")) + fmt.Printf(" %s Auto-created when AI discovers related work\n\n", yellow("discovered-from")) + + fmt.Printf("%s\n", bold("READY WORK")) + fmt.Printf(" %s Show issues ready to work on\n", cyan("bd ready")) + fmt.Printf(" Ready = status is 'open' AND no blocking dependencies\n") + fmt.Printf(" Perfect for agents to claim next work!\n\n") + + fmt.Printf("%s\n", bold("UPDATING ISSUES")) + fmt.Printf(" %s\n", cyan("bd update bd-1 --status in_progress")) + fmt.Printf(" %s\n", cyan("bd update bd-1 --priority 0")) + fmt.Printf(" %s\n\n", cyan("bd update bd-1 --assignee bob")) + + fmt.Printf("%s\n", bold("CLOSING ISSUES")) + fmt.Printf(" %s\n", cyan("bd close bd-1")) + fmt.Printf(" %s\n\n", cyan("bd close bd-2 bd-3 --reason \"Fixed in PR #42\"")) + + fmt.Printf("%s\n", bold("DATABASE LOCATION")) + fmt.Printf(" bd automatically discovers your database:\n") + fmt.Printf(" 1. %s flag\n", cyan("--db /path/to/db.db")) + fmt.Printf(" 2. %s environment variable\n", cyan("$BEADS_DB")) + fmt.Printf(" 3. %s in current directory or ancestors\n", cyan(".beads/*.db")) + fmt.Printf(" 4. %s as fallback\n\n", cyan("~/.beads/default.db")) + + fmt.Printf("%s\n", bold("AGENT INTEGRATION")) + fmt.Printf(" bd is designed for AI-supervised workflows:\n") + fmt.Printf(" • Agents create issues when discovering new work\n") + fmt.Printf(" • %s shows unblocked work ready to claim\n", cyan("bd ready")) + fmt.Printf(" • Use %s flags for programmatic parsing\n", cyan("--json")) + fmt.Printf(" • Dependencies prevent agents from duplicating effort\n\n") + + fmt.Printf("%s\n", bold("DATABASE EXTENSION")) + fmt.Printf(" Applications can extend bd's SQLite database:\n") + fmt.Printf(" • Add your own tables (e.g., %s)\n", cyan("myapp_executions")) + fmt.Printf(" • Join with %s table for powerful queries\n", cyan("issues")) + fmt.Printf(" • See %s for integration patterns\n\n", cyan("EXTENDING.md")) + + fmt.Printf("%s\n", green("Ready to start!")) + fmt.Printf("Run %s to create your first issue.\n\n", cyan("bd create \"My first issue\"")) + }, +} + +func init() { + rootCmd.AddCommand(quickstartCmd) +} diff --git a/cmd/beads/ready.go b/cmd/bd/ready.go similarity index 89% rename from cmd/beads/ready.go rename to cmd/bd/ready.go index ec35e744..abc3d204 100644 --- a/cmd/beads/ready.go +++ b/cmd/bd/ready.go @@ -7,7 +7,7 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" - "github.com/steveyackey/beads/internal/types" + "github.com/steveyegge/beads/internal/types" ) var readyCmd = &cobra.Command{ @@ -37,6 +37,15 @@ var readyCmd = &cobra.Command{ os.Exit(1) } + if jsonOutput { + // Always output array, even if empty + if issues == nil { + issues = []*types.Issue{} + } + outputJSON(issues) + return + } + if len(issues) == 0 { yellow := color.New(color.FgYellow).SprintFunc() fmt.Printf("\n%s No ready work found (all issues have blocking dependencies)\n\n", @@ -71,6 +80,15 @@ var blockedCmd = &cobra.Command{ os.Exit(1) } + if jsonOutput { + // Always output array, even if empty + if blocked == nil { + blocked = []*types.BlockedIssue{} + } + outputJSON(blocked) + return + } + if len(blocked) == 0 { green := color.New(color.FgGreen).SprintFunc() fmt.Printf("\n%s No blocked issues\n\n", green("✨")) @@ -100,6 +118,11 @@ var statsCmd = &cobra.Command{ os.Exit(1) } + if jsonOutput { + outputJSON(stats) + return + } + cyan := color.New(color.FgCyan).SprintFunc() green := color.New(color.FgGreen).SprintFunc() yellow := color.New(color.FgYellow).SprintFunc() diff --git a/go.mod b/go.mod index 999fdec0..c6f0a56f 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/steveyackey/beads +module github.com/steveyegge/beads go 1.25.2 diff --git a/internal/storage/sqlite/dependencies.go b/internal/storage/sqlite/dependencies.go index e6bd1a6e..d54ab672 100644 --- a/internal/storage/sqlite/dependencies.go +++ b/internal/storage/sqlite/dependencies.go @@ -7,11 +7,16 @@ import ( "strings" "time" - "github.com/steveyackey/beads/internal/types" + "github.com/steveyegge/beads/internal/types" ) // AddDependency adds a dependency between issues with cycle prevention func (s *SQLiteStorage) AddDependency(ctx context.Context, dep *types.Dependency, actor string) error { + // Validate dependency type + if !dep.Type.IsValid() { + return fmt.Errorf("invalid dependency type: %s (must be blocks, related, parent-child, or discovered-from)", dep.Type) + } + // Validate that both issues exist issueExists, err := s.GetIssue(ctx, dep.IssueID) if err != nil { diff --git a/internal/storage/sqlite/dependencies_test.go b/internal/storage/sqlite/dependencies_test.go new file mode 100644 index 00000000..33ee90b9 --- /dev/null +++ b/internal/storage/sqlite/dependencies_test.go @@ -0,0 +1,280 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +func TestAddDependency(t *testing.T) { + 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} + + store.CreateIssue(ctx, issue1, "test-user") + store.CreateIssue(ctx, issue2, "test-user") + + // Add dependency (issue2 depends on issue1) + dep := &types.Dependency{ + IssueID: issue2.ID, + DependsOnID: issue1.ID, + Type: types.DepBlocks, + } + + err := store.AddDependency(ctx, dep, "test-user") + if err != nil { + t.Fatalf("AddDependency failed: %v", err) + } + + // Verify dependency was added + deps, err := store.GetDependencies(ctx, issue2.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 != issue1.ID { + t.Errorf("Expected dependency on %s, got %s", issue1.ID, deps[0].ID) + } +} + +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) + } +} + +func TestRemoveDependency(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create and link 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} + + store.CreateIssue(ctx, issue1, "test-user") + store.CreateIssue(ctx, issue2, "test-user") + + dep := &types.Dependency{ + IssueID: issue2.ID, + DependsOnID: issue1.ID, + Type: types.DepBlocks, + } + store.AddDependency(ctx, dep, "test-user") + + // Remove the dependency + err := store.RemoveDependency(ctx, issue2.ID, issue1.ID, "test-user") + if err != nil { + t.Fatalf("RemoveDependency failed: %v", err) + } + + // Verify dependency was removed + deps, err := store.GetDependencies(ctx, issue2.ID) + if err != nil { + t.Fatalf("GetDependencies failed: %v", err) + } + + if len(deps) != 0 { + t.Errorf("Expected 0 dependencies after removal, got %d", len(deps)) + } +} + +func TestGetDependents(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create issues: bd-2 and bd-3 both depend on bd-1 + issue1 := &types.Issue{Title: "Foundation", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue2 := &types.Issue{Title: "Feature A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue3 := &types.Issue{Title: "Feature B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + store.CreateIssue(ctx, issue1, "test-user") + store.CreateIssue(ctx, issue2, "test-user") + store.CreateIssue(ctx, issue3, "test-user") + + store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user") + store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user") + + // Get dependents of issue1 + dependents, err := store.GetDependents(ctx, issue1.ID) + if err != nil { + t.Fatalf("GetDependents failed: %v", err) + } + + if len(dependents) != 2 { + t.Fatalf("Expected 2 dependents, got %d", len(dependents)) + } + + // Verify both dependents are present + foundIDs := make(map[string]bool) + for _, dep := range dependents { + foundIDs[dep.ID] = true + } + + if !foundIDs[issue2.ID] || !foundIDs[issue3.ID] { + t.Errorf("Expected dependents %s and %s", issue2.ID, issue3.ID) + } +} + +func TestGetDependencyTree(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create a chain: bd-3 → bd-2 → bd-1 + issue1 := &types.Issue{Title: "Level 0", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue2 := &types.Issue{Title: "Level 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue3 := &types.Issue{Title: "Level 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + store.CreateIssue(ctx, issue1, "test-user") + store.CreateIssue(ctx, issue2, "test-user") + store.CreateIssue(ctx, issue3, "test-user") + + store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user") + store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue2.ID, Type: types.DepBlocks}, "test-user") + + // Get tree starting from issue3 + tree, err := store.GetDependencyTree(ctx, issue3.ID, 10) + if err != nil { + t.Fatalf("GetDependencyTree failed: %v", err) + } + + if len(tree) != 3 { + t.Fatalf("Expected 3 nodes in tree, got %d", len(tree)) + } + + // Verify depths + depthMap := make(map[string]int) + for _, node := range tree { + depthMap[node.ID] = node.Depth + } + + if depthMap[issue3.ID] != 0 { + t.Errorf("Expected depth 0 for %s, got %d", issue3.ID, depthMap[issue3.ID]) + } + + if depthMap[issue2.ID] != 1 { + t.Errorf("Expected depth 1 for %s, got %d", issue2.ID, depthMap[issue2.ID]) + } + + if depthMap[issue1.ID] != 2 { + t.Errorf("Expected depth 2 for %s, got %d", issue1.ID, depthMap[issue1.ID]) + } +} + +func TestDetectCycles(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Try to create a cycle: bd-1 → bd-2 → bd-3 → bd-1 + // This should be prevented by AddDependency + 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} + issue3 := &types.Issue{Title: "Third", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + store.CreateIssue(ctx, issue1, "test-user") + store.CreateIssue(ctx, issue2, "test-user") + store.CreateIssue(ctx, issue3, "test-user") + + // Add first two dependencies successfully + err := store.AddDependency(ctx, &types.Dependency{IssueID: issue1.ID, DependsOnID: issue2.ID, Type: types.DepBlocks}, "test-user") + if err != nil { + t.Fatalf("First dependency failed: %v", err) + } + + err = store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue3.ID, Type: types.DepBlocks}, "test-user") + if err != nil { + t.Fatalf("Second dependency failed: %v", err) + } + + // The third dependency should fail because it would create a cycle + err = store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user") + if err == nil { + t.Fatal("Expected error when creating cycle, but got none") + } + + // Verify no cycles exist + cycles, err := store.DetectCycles(ctx) + if err != nil { + t.Fatalf("DetectCycles failed: %v", err) + } + + if len(cycles) != 0 { + t.Errorf("Expected no cycles after prevention, but found %d", len(cycles)) + } +} + +func TestNoCyclesDetected(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create a valid chain with no cycles + 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} + + store.CreateIssue(ctx, issue1, "test-user") + store.CreateIssue(ctx, issue2, "test-user") + + store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user") + + cycles, err := store.DetectCycles(ctx) + if err != nil { + t.Fatalf("DetectCycles failed: %v", err) + } + + if len(cycles) != 0 { + t.Errorf("Expected no cycles, but found %d", len(cycles)) + } +} diff --git a/internal/storage/sqlite/events.go b/internal/storage/sqlite/events.go index c1fca5cf..9cacdfef 100644 --- a/internal/storage/sqlite/events.go +++ b/internal/storage/sqlite/events.go @@ -5,7 +5,7 @@ import ( "database/sql" "fmt" - "github.com/steveyackey/beads/internal/types" + "github.com/steveyegge/beads/internal/types" ) // AddComment adds a comment to an issue @@ -31,9 +31,11 @@ func (s *SQLiteStorage) AddComment(ctx context.Context, issueID, actor, comment // GetEvents returns the event history for an issue func (s *SQLiteStorage) GetEvents(ctx context.Context, issueID string, limit int) ([]*types.Event, error) { + args := []interface{}{issueID} limitSQL := "" if limit > 0 { - limitSQL = fmt.Sprintf(" LIMIT %d", limit) + limitSQL = " LIMIT ?" + args = append(args, limit) } query := fmt.Sprintf(` @@ -44,7 +46,7 @@ func (s *SQLiteStorage) GetEvents(ctx context.Context, issueID string, limit int %s `, limitSQL) - rows, err := s.db.QueryContext(ctx, query, issueID) + rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("failed to get events: %w", err) } diff --git a/internal/storage/sqlite/labels.go b/internal/storage/sqlite/labels.go index 01aa8966..e1503396 100644 --- a/internal/storage/sqlite/labels.go +++ b/internal/storage/sqlite/labels.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/steveyackey/beads/internal/types" + "github.com/steveyegge/beads/internal/types" ) // AddLabel adds a label to an issue diff --git a/internal/storage/sqlite/ready.go b/internal/storage/sqlite/ready.go index 87f08349..bd4a5fb8 100644 --- a/internal/storage/sqlite/ready.go +++ b/internal/storage/sqlite/ready.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "github.com/steveyackey/beads/internal/types" + "github.com/steveyegge/beads/internal/types" ) // GetReadyWork returns issues with no open blockers diff --git a/internal/storage/sqlite/ready_test.go b/internal/storage/sqlite/ready_test.go new file mode 100644 index 00000000..0827d00f --- /dev/null +++ b/internal/storage/sqlite/ready_test.go @@ -0,0 +1,274 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +func TestGetReadyWork(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create issues: + // bd-1: open, no dependencies → READY + // bd-2: open, depends on bd-1 (open) → BLOCKED + // bd-3: open, no dependencies → READY + // bd-4: closed, no dependencies → NOT READY (closed) + // bd-5: open, depends on bd-4 (closed) → READY (blocker is closed) + + issue1 := &types.Issue{Title: "Ready 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue2 := &types.Issue{Title: "Blocked", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue3 := &types.Issue{Title: "Ready 2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} + issue4 := &types.Issue{Title: "Closed", Status: types.StatusClosed, Priority: 1, IssueType: types.TypeTask} + issue5 := &types.Issue{Title: "Ready 3", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask} + + store.CreateIssue(ctx, issue1, "test-user") + store.CreateIssue(ctx, issue2, "test-user") + store.CreateIssue(ctx, issue3, "test-user") + store.CreateIssue(ctx, issue4, "test-user") + store.CloseIssue(ctx, issue4.ID, "Done", "test-user") + store.CreateIssue(ctx, issue5, "test-user") + + // Add dependencies + store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user") + store.AddDependency(ctx, &types.Dependency{IssueID: issue5.ID, DependsOnID: issue4.ID, Type: types.DepBlocks}, "test-user") + + // Get ready work + ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen}) + if err != nil { + t.Fatalf("GetReadyWork failed: %v", err) + } + + // Should have 3 ready issues: bd-1, bd-3, bd-5 + if len(ready) != 3 { + t.Fatalf("Expected 3 ready issues, got %d", len(ready)) + } + + // Verify ready issues + readyIDs := make(map[string]bool) + for _, issue := range ready { + readyIDs[issue.ID] = true + } + + if !readyIDs[issue1.ID] { + t.Errorf("Expected %s to be ready", issue1.ID) + } + if !readyIDs[issue3.ID] { + t.Errorf("Expected %s to be ready", issue3.ID) + } + if !readyIDs[issue5.ID] { + t.Errorf("Expected %s to be ready", issue5.ID) + } + if readyIDs[issue2.ID] { + t.Errorf("Expected %s to be blocked, but it was ready", issue2.ID) + } +} + +func TestGetReadyWorkPriorityOrder(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create issues with different priorities + issueP0 := &types.Issue{Title: "Highest", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask} + issueP2 := &types.Issue{Title: "Medium", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} + issueP1 := &types.Issue{Title: "High", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + store.CreateIssue(ctx, issueP2, "test-user") + store.CreateIssue(ctx, issueP0, "test-user") + store.CreateIssue(ctx, issueP1, "test-user") + + // Get ready work + ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen}) + if err != nil { + t.Fatalf("GetReadyWork failed: %v", err) + } + + if len(ready) != 3 { + t.Fatalf("Expected 3 ready issues, got %d", len(ready)) + } + + // Verify priority ordering (P0 first, then P1, then P2) + if ready[0].Priority != 0 { + t.Errorf("Expected first issue to be P0, got P%d", ready[0].Priority) + } + if ready[1].Priority != 1 { + t.Errorf("Expected second issue to be P1, got P%d", ready[1].Priority) + } + if ready[2].Priority != 2 { + t.Errorf("Expected third issue to be P2, got P%d", ready[2].Priority) + } +} + +func TestGetReadyWorkWithPriorityFilter(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create issues with different priorities + issueP0 := &types.Issue{Title: "P0", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask} + issueP1 := &types.Issue{Title: "P1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issueP2 := &types.Issue{Title: "P2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} + + store.CreateIssue(ctx, issueP0, "test-user") + store.CreateIssue(ctx, issueP1, "test-user") + store.CreateIssue(ctx, issueP2, "test-user") + + // Filter for P0 only + priority0 := 0 + ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Priority: &priority0}) + if err != nil { + t.Fatalf("GetReadyWork failed: %v", err) + } + + if len(ready) != 1 { + t.Fatalf("Expected 1 P0 issue, got %d", len(ready)) + } + + if ready[0].Priority != 0 { + t.Errorf("Expected P0 issue, got P%d", ready[0].Priority) + } +} + +func TestGetReadyWorkWithAssigneeFilter(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create issues with different assignees + issueAlice := &types.Issue{Title: "Alice's task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, Assignee: "alice"} + issueBob := &types.Issue{Title: "Bob's task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, Assignee: "bob"} + issueUnassigned := &types.Issue{Title: "Unassigned", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + store.CreateIssue(ctx, issueAlice, "test-user") + store.CreateIssue(ctx, issueBob, "test-user") + store.CreateIssue(ctx, issueUnassigned, "test-user") + + // Filter for alice + assignee := "alice" + ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Assignee: &assignee}) + if err != nil { + t.Fatalf("GetReadyWork failed: %v", err) + } + + if len(ready) != 1 { + t.Fatalf("Expected 1 issue for alice, got %d", len(ready)) + } + + if ready[0].Assignee != "alice" { + t.Errorf("Expected alice's issue, got %s", ready[0].Assignee) + } +} + +func TestGetReadyWorkWithLimit(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create 5 ready issues + for i := 0; i < 5; i++ { + issue := &types.Issue{Title: "Task", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} + store.CreateIssue(ctx, issue, "test-user") + } + + // Limit to 3 + ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Limit: 3}) + if err != nil { + t.Fatalf("GetReadyWork failed: %v", err) + } + + if len(ready) != 3 { + t.Errorf("Expected 3 issues (limit), got %d", len(ready)) + } +} + +func TestGetReadyWorkIgnoresRelatedDeps(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create two issues with "related" dependency (should not block) + 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} + + store.CreateIssue(ctx, issue1, "test-user") + store.CreateIssue(ctx, issue2, "test-user") + + // Add "related" dependency (not blocking) + store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepRelated}, "test-user") + + // Both should be ready (related deps don't block) + ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen}) + if err != nil { + t.Fatalf("GetReadyWork failed: %v", err) + } + + if len(ready) != 2 { + t.Fatalf("Expected 2 ready issues (related deps don't block), got %d", len(ready)) + } +} + +func TestGetBlockedIssues(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create issues: + // bd-1: open, no dependencies → not blocked + // bd-2: open, depends on bd-1 (open) → blocked by bd-1 + // bd-3: open, depends on bd-1 and bd-2 (both open) → blocked by 2 issues + + issue1 := &types.Issue{Title: "Foundation", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue2 := &types.Issue{Title: "Blocked by 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue3 := &types.Issue{Title: "Blocked by 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + store.CreateIssue(ctx, issue1, "test-user") + store.CreateIssue(ctx, issue2, "test-user") + store.CreateIssue(ctx, issue3, "test-user") + + store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user") + store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user") + store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue2.ID, Type: types.DepBlocks}, "test-user") + + // Get blocked issues + blocked, err := store.GetBlockedIssues(ctx) + if err != nil { + t.Fatalf("GetBlockedIssues failed: %v", err) + } + + if len(blocked) != 2 { + t.Fatalf("Expected 2 blocked issues, got %d", len(blocked)) + } + + // Find issue3 in blocked list + var issue3Blocked *types.BlockedIssue + for i := range blocked { + if blocked[i].ID == issue3.ID { + issue3Blocked = blocked[i] + break + } + } + + if issue3Blocked == nil { + t.Fatal("Expected issue3 to be in blocked list") + } + + if issue3Blocked.BlockedByCount != 2 { + t.Errorf("Expected issue3 to be blocked by 2 issues, got %d", issue3Blocked.BlockedByCount) + } + + // Verify the blockers are correct + if len(issue3Blocked.BlockedBy) != 2 { + t.Errorf("Expected 2 blocker IDs, got %d", len(issue3Blocked.BlockedBy)) + } +} diff --git a/internal/storage/sqlite/schema.go b/internal/storage/sqlite/schema.go index a2017b63..ce67806d 100644 --- a/internal/storage/sqlite/schema.go +++ b/internal/storage/sqlite/schema.go @@ -65,6 +65,12 @@ CREATE TABLE IF NOT EXISTS events ( CREATE INDEX IF NOT EXISTS idx_events_issue ON events(issue_id); CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); +-- Config table (for storing settings like issue prefix) +CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + -- Ready work view CREATE VIEW IF NOT EXISTS ready_issues AS SELECT i.* diff --git a/internal/storage/sqlite/sqlite.go b/internal/storage/sqlite/sqlite.go index 88e03630..51dbd381 100644 --- a/internal/storage/sqlite/sqlite.go +++ b/internal/storage/sqlite/sqlite.go @@ -12,7 +12,7 @@ import ( "time" _ "github.com/mattn/go-sqlite3" - "github.com/steveyackey/beads/internal/types" + "github.com/steveyegge/beads/internal/types" ) // SQLiteStorage implements the Storage interface using SQLite @@ -94,7 +94,14 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act // Generate ID if not set (thread-safe) if issue.ID == "" { s.idMu.Lock() - issue.ID = fmt.Sprintf("bd-%d", s.nextID) + + // Get prefix from config, default to "bd" + prefix, err := s.GetConfig(ctx, "issue_prefix") + if err != nil || prefix == "" { + prefix = "bd" + } + + issue.ID = fmt.Sprintf("%s-%d", prefix, s.nextID) s.nextID++ s.idMu.Unlock() } @@ -129,7 +136,11 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act } // Record creation event - eventData, _ := json.Marshal(issue) + eventData, err := json.Marshal(issue) + if err != nil { + // Fall back to minimal description if marshaling fails + eventData = []byte(fmt.Sprintf(`{"id":"%s","title":"%s"}`, issue.ID, issue.Title)) + } eventDataStr := string(eventData) _, err = tx.ExecContext(ctx, ` INSERT INTO events (issue_id, event_type, actor, new_value) @@ -272,8 +283,16 @@ func (s *SQLiteStorage) UpdateIssue(ctx context.Context, id string, updates map[ } // Record event - oldData, _ := json.Marshal(oldIssue) - newData, _ := json.Marshal(updates) + oldData, err := json.Marshal(oldIssue) + if err != nil { + // Fall back to minimal description if marshaling fails + oldData = []byte(fmt.Sprintf(`{"id":"%s"}`, id)) + } + newData, err := json.Marshal(updates) + if err != nil { + // Fall back to minimal description if marshaling fails + newData = []byte(`{}`) + } oldDataStr := string(oldData) newDataStr := string(newData) @@ -365,7 +384,8 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t limitSQL := "" if filter.Limit > 0 { - limitSQL = fmt.Sprintf(" LIMIT %d", filter.Limit) + limitSQL = " LIMIT ?" + args = append(args, filter.Limit) } querySQL := fmt.Sprintf(` @@ -418,6 +438,25 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t return issues, nil } +// SetConfig sets a configuration value +func (s *SQLiteStorage) SetConfig(ctx context.Context, key, value string) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO config (key, value) VALUES (?, ?) + ON CONFLICT (key) DO UPDATE SET value = excluded.value + `, key, value) + return err +} + +// GetConfig gets a configuration value +func (s *SQLiteStorage) GetConfig(ctx context.Context, key string) (string, error) { + var value string + err := s.db.QueryRowContext(ctx, `SELECT value FROM config WHERE key = ?`, key).Scan(&value) + if err == sql.ErrNoRows { + return "", nil + } + return value, err +} + // Close closes the database connection func (s *SQLiteStorage) Close() error { return s.db.Close() diff --git a/internal/storage/sqlite/sqlite_test.go b/internal/storage/sqlite/sqlite_test.go new file mode 100644 index 00000000..6bfe997e --- /dev/null +++ b/internal/storage/sqlite/sqlite_test.go @@ -0,0 +1,393 @@ +package sqlite + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/steveyegge/beads/internal/types" +) + +func setupTestDB(t *testing.T) (*SQLiteStorage, func()) { + t.Helper() + + // Create temporary directory + tmpDir, err := os.MkdirTemp("", "beads-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + dbPath := filepath.Join(tmpDir, "test.db") + store, err := New(dbPath) + if err != nil { + os.RemoveAll(tmpDir) + t.Fatalf("failed to create storage: %v", err) + } + + cleanup := func() { + store.Close() + os.RemoveAll(tmpDir) + } + + return store, cleanup +} + +func TestCreateIssue(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + issue := &types.Issue{ + Title: "Test issue", + Description: "Test description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + + err := store.CreateIssue(ctx, issue, "test-user") + if err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + if issue.ID == "" { + t.Error("Issue ID should be set") + } + + if !issue.CreatedAt.After(time.Time{}) { + t.Error("CreatedAt should be set") + } + + if !issue.UpdatedAt.After(time.Time{}) { + t.Error("UpdatedAt should be set") + } +} + +func TestCreateIssueValidation(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + tests := []struct { + name string + issue *types.Issue + wantErr bool + }{ + { + name: "valid issue", + issue: &types.Issue{ + Title: "Valid", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + }, + wantErr: false, + }, + { + name: "missing title", + issue: &types.Issue{ + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + }, + wantErr: true, + }, + { + name: "invalid priority", + issue: &types.Issue{ + Title: "Test", + Status: types.StatusOpen, + Priority: 10, + IssueType: types.TypeTask, + }, + wantErr: true, + }, + { + name: "invalid status", + issue: &types.Issue{ + Title: "Test", + Status: "invalid", + Priority: 2, + IssueType: types.TypeTask, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := store.CreateIssue(ctx, tt.issue, "test-user") + if (err != nil) != tt.wantErr { + t.Errorf("CreateIssue() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestGetIssue(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + original := &types.Issue{ + Title: "Test issue", + Description: "Description", + Design: "Design notes", + AcceptanceCriteria: "Acceptance", + Notes: "Notes", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeFeature, + Assignee: "alice", + } + + err := store.CreateIssue(ctx, original, "test-user") + if err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Retrieve the issue + retrieved, err := store.GetIssue(ctx, original.ID) + if err != nil { + t.Fatalf("GetIssue failed: %v", err) + } + + if retrieved == nil { + t.Fatal("GetIssue returned nil") + } + + if retrieved.ID != original.ID { + t.Errorf("ID mismatch: got %v, want %v", retrieved.ID, original.ID) + } + + if retrieved.Title != original.Title { + t.Errorf("Title mismatch: got %v, want %v", retrieved.Title, original.Title) + } + + if retrieved.Description != original.Description { + t.Errorf("Description mismatch: got %v, want %v", retrieved.Description, original.Description) + } + + if retrieved.Assignee != original.Assignee { + t.Errorf("Assignee mismatch: got %v, want %v", retrieved.Assignee, original.Assignee) + } +} + +func TestGetIssueNotFound(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + issue, err := store.GetIssue(ctx, "bd-999") + if err != nil { + t.Fatalf("GetIssue failed: %v", err) + } + + if issue != nil { + t.Errorf("Expected nil for non-existent issue, got %v", issue) + } +} + +func TestUpdateIssue(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + issue := &types.Issue{ + Title: "Original", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + } + + err := store.CreateIssue(ctx, issue, "test-user") + if err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Update the issue + updates := map[string]interface{}{ + "title": "Updated", + "status": string(types.StatusInProgress), + "priority": 1, + "assignee": "bob", + } + + err = store.UpdateIssue(ctx, issue.ID, updates, "test-user") + if err != nil { + t.Fatalf("UpdateIssue failed: %v", err) + } + + // Verify updates + updated, err := store.GetIssue(ctx, issue.ID) + if err != nil { + t.Fatalf("GetIssue failed: %v", err) + } + + if updated.Title != "Updated" { + t.Errorf("Title not updated: got %v, want Updated", updated.Title) + } + + if updated.Status != types.StatusInProgress { + t.Errorf("Status not updated: got %v, want %v", updated.Status, types.StatusInProgress) + } + + if updated.Priority != 1 { + t.Errorf("Priority not updated: got %v, want 1", updated.Priority) + } + + if updated.Assignee != "bob" { + t.Errorf("Assignee not updated: got %v, want bob", updated.Assignee) + } +} + +func TestCloseIssue(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + issue := &types.Issue{ + Title: "Test", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + } + + err := store.CreateIssue(ctx, issue, "test-user") + if err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + err = store.CloseIssue(ctx, issue.ID, "Done", "test-user") + if err != nil { + t.Fatalf("CloseIssue failed: %v", err) + } + + // Verify closure + closed, err := store.GetIssue(ctx, issue.ID) + if err != nil { + t.Fatalf("GetIssue failed: %v", err) + } + + if closed.Status != types.StatusClosed { + t.Errorf("Status not closed: got %v, want %v", closed.Status, types.StatusClosed) + } + + if closed.ClosedAt == nil { + t.Error("ClosedAt should be set") + } +} + +func TestSearchIssues(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create test issues + issues := []*types.Issue{ + {Title: "Bug in login", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeBug}, + {Title: "Feature request", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeFeature}, + {Title: "Another bug", Status: types.StatusClosed, Priority: 1, IssueType: types.TypeBug}, + } + + for _, issue := range issues { + err := store.CreateIssue(ctx, issue, "test-user") + if err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + } + + // Test query search + results, err := store.SearchIssues(ctx, "bug", types.IssueFilter{}) + if err != nil { + t.Fatalf("SearchIssues failed: %v", err) + } + + if len(results) != 2 { + t.Errorf("Expected 2 results, got %d", len(results)) + } + + // Test status filter + openStatus := types.StatusOpen + results, err = store.SearchIssues(ctx, "", types.IssueFilter{Status: &openStatus}) + if err != nil { + t.Fatalf("SearchIssues failed: %v", err) + } + + if len(results) != 2 { + t.Errorf("Expected 2 open issues, got %d", len(results)) + } + + // Test type filter + bugType := types.TypeBug + results, err = store.SearchIssues(ctx, "", types.IssueFilter{IssueType: &bugType}) + if err != nil { + t.Fatalf("SearchIssues failed: %v", err) + } + + if len(results) != 2 { + t.Errorf("Expected 2 bugs, got %d", len(results)) + } + + // Test priority filter (P0) + priority0 := 0 + results, err = store.SearchIssues(ctx, "", types.IssueFilter{Priority: &priority0}) + if err != nil { + t.Fatalf("SearchIssues failed: %v", err) + } + + if len(results) != 1 { + t.Errorf("Expected 1 P0 issue, got %d", len(results)) + } +} + +func TestConcurrentIDGeneration(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + const numIssues = 100 + + type result struct { + id string + err error + } + + results := make(chan result, numIssues) + + // Create issues concurrently + for i := 0; i < numIssues; i++ { + go func(n int) { + issue := &types.Issue{ + Title: "Concurrent test", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + } + err := store.CreateIssue(ctx, issue, "test-user") + results <- result{id: issue.ID, err: err} + }(i) + } + + // Collect results + ids := make(map[string]bool) + for i := 0; i < numIssues; i++ { + res := <-results + if res.err != nil { + t.Errorf("CreateIssue failed: %v", res.err) + continue + } + if ids[res.id] { + t.Errorf("Duplicate ID generated: %s", res.id) + } + ids[res.id] = true + } + + if len(ids) != numIssues { + t.Errorf("Expected %d unique IDs, got %d", numIssues, len(ids)) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index a06d414b..a691a4ab 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -3,7 +3,7 @@ package storage import ( "context" - "github.com/steveyackey/beads/internal/types" + "github.com/steveyegge/beads/internal/types" ) // Storage defines the interface for issue storage backends diff --git a/internal/types/types.go b/internal/types/types.go index 473090b9..965c00c3 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -98,15 +98,16 @@ type Dependency struct { type DependencyType string const ( - DepBlocks DependencyType = "blocks" - DepRelated DependencyType = "related" - DepParentChild DependencyType = "parent-child" + DepBlocks DependencyType = "blocks" + DepRelated DependencyType = "related" + DepParentChild DependencyType = "parent-child" + DepDiscoveredFrom DependencyType = "discovered-from" ) // IsValid checks if the dependency type value is valid func (d DependencyType) IsValid() bool { switch d { - case DepBlocks, DepRelated, DepParentChild: + case DepBlocks, DepRelated, DepParentChild, DepDiscoveredFrom: return true } return false