Merge fix/import-missing-parents: Parent resurrection with P0 bug fixes
This commit is contained in:
+62
-52
File diff suppressed because one or more lines are too long
@@ -213,6 +213,13 @@ bd import -i .beads/issues.jsonl --dry-run # Preview changes
|
||||
bd import -i .beads/issues.jsonl # Import and update issues
|
||||
bd import -i .beads/issues.jsonl --dedupe-after # Import + detect duplicates
|
||||
|
||||
# Note: Import automatically handles missing parents!
|
||||
# - If a hierarchical child's parent is missing (e.g., bd-abc.1 but no bd-abc)
|
||||
# - bd will search the JSONL history for the parent
|
||||
# - If found, creates a tombstone placeholder (Status=Closed, Priority=4)
|
||||
# - Dependencies are also resurrected on best-effort basis
|
||||
# - This prevents import failures after parent deletion
|
||||
|
||||
# Find and merge duplicate issues
|
||||
bd duplicates # Show all duplicates
|
||||
bd duplicates --auto-merge # Automatically merge all
|
||||
|
||||
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Parent Resurrection** (bd-58c0): Automatic resurrection of deleted parent issues from JSONL history
|
||||
- Prevents import failures when parent issues have been deleted
|
||||
- Creates tombstone placeholders for missing hierarchical parents
|
||||
- Best-effort dependency resurrection from JSONL
|
||||
|
||||
### Changed
|
||||
|
||||
- **Error Messages**: Improved error messages for missing parent issues
|
||||
- Old: `"parent issue X does not exist"`
|
||||
- New: `"parent issue X does not exist and could not be resurrected from JSONL history"`
|
||||
- **Breaking**: Scripts parsing exact error messages may need updates
|
||||
|
||||
### Fixed
|
||||
|
||||
- **JSONL Resurrection Logic**: Fixed to use LAST occurrence instead of FIRST (append-only semantics)
|
||||
|
||||
## [0.21.7] - 2025-11-04
|
||||
|
||||
### Fixed
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
# Next Session: Complete bd-d19a (Fix Import Failure on Missing Parents)
|
||||
|
||||
## Current Status
|
||||
|
||||
**Branch**: `fix/import-missing-parents`
|
||||
**Epic**: bd-d19a (P0 - Critical)
|
||||
**Progress**: Phase 1 & 2 Complete ✅
|
||||
|
||||
### Completed Work
|
||||
|
||||
#### Phase 1: Topological Sorting ✅
|
||||
- **Commit**: `f2cb91d`
|
||||
- **What**: Added depth-based sorting to `importer.go` to ensure parents are created before children
|
||||
- **Files**: `internal/importer/importer.go`
|
||||
- **Result**: Fixes latent ordering bug where parent-child pairs in same batch could fail
|
||||
|
||||
#### Phase 2: Parent Resurrection ✅
|
||||
- **Commit**: `b41d65d`
|
||||
- **Implemented Issues**:
|
||||
- bd-cc4f: `TryResurrectParent` function
|
||||
- bd-d76d: Modified `EnsureIDs` to call resurrection
|
||||
- bd-02a4: Modified `CreateIssue` to call resurrection
|
||||
- **Files Created**: `internal/storage/sqlite/resurrection.go`
|
||||
- **Files Modified**:
|
||||
- `internal/storage/sqlite/ids.go`
|
||||
- `internal/storage/sqlite/sqlite.go`
|
||||
- `internal/storage/sqlite/batch_ops.go`
|
||||
- `internal/storage/sqlite/batch_ops_test.go`
|
||||
|
||||
**How Resurrection Works**:
|
||||
1. When child issue has missing parent, search `.beads/issues.jsonl` for parent in git history
|
||||
2. If found, create tombstone issue (status=closed, priority=4)
|
||||
3. Tombstone preserves original title, type, created_at
|
||||
4. Description marked with `[RESURRECTED]` prefix + original description
|
||||
5. Dependencies copied if targets exist
|
||||
6. Recursively handles entire parent chains (e.g., `bd-abc.1.2` → resurrects both `bd-abc` and `bd-abc.1`)
|
||||
|
||||
---
|
||||
|
||||
## Next Steps: Phase 3 - Testing & Documentation
|
||||
|
||||
### 1. Add Comprehensive Tests
|
||||
|
||||
**Create**: `internal/storage/sqlite/resurrection_test.go`
|
||||
|
||||
**Test Cases Needed**:
|
||||
- ✅ Parent exists → no resurrection needed
|
||||
- ✅ Parent found in JSONL → successful resurrection
|
||||
- ✅ Parent not in JSONL → proper error message
|
||||
- ✅ Multi-level chain (`bd-abc.1.2`) → resurrects entire chain
|
||||
- ✅ JSONL file missing → graceful failure
|
||||
- ✅ Malformed JSONL lines → skip with warning
|
||||
- ✅ Dependencies preserved → only if targets exist
|
||||
- ✅ Tombstone properties → correct status, priority, description format
|
||||
- ✅ Concurrent resurrection → idempotent behavior
|
||||
|
||||
**Integration Test**:
|
||||
Add to `beads_integration_test.go`:
|
||||
```go
|
||||
TestImportWithDeletedParent
|
||||
- Create parent and child
|
||||
- Delete parent
|
||||
- Export to JSONL (preserves parent in git)
|
||||
- Clear DB
|
||||
- Import from JSONL
|
||||
- Verify: parent resurrected as tombstone, child imported successfully
|
||||
```
|
||||
|
||||
### 2. Update Documentation
|
||||
|
||||
**Files to Update**:
|
||||
1. `README.md` - Add resurrection behavior to import section
|
||||
2. `QUICKSTART.md` - Mention parent resurrection for multi-repo workflows
|
||||
3. `docs/import-bug-analysis-bd-3xq.md` - Add "Implementation Complete" section
|
||||
4. `AGENTS.md` - Document resurrection for AI agents
|
||||
|
||||
**Example Addition to README.md**:
|
||||
```markdown
|
||||
## Parent Resurrection
|
||||
|
||||
When importing issues with hierarchical IDs (e.g., `bd-abc.1`), bd automatically
|
||||
resurrects deleted parent issues from git history to maintain referential integrity.
|
||||
|
||||
Resurrected parents are created as tombstones:
|
||||
- Status: `closed`
|
||||
- Priority: 4 (lowest)
|
||||
- Description: `[RESURRECTED]` prefix + original description
|
||||
|
||||
This enables multi-repo workflows where different clones may delete different issues.
|
||||
```
|
||||
|
||||
### 3. Manual Testing Workflow
|
||||
|
||||
```bash
|
||||
# Terminal 1: Create test scenario
|
||||
cd /tmp/bd-test
|
||||
git init
|
||||
bd init --prefix test --quiet
|
||||
bd create "Parent epic" -t epic -p 1 --json # Returns test-abc123
|
||||
bd create "Child task" -p 1 --json # Auto-creates test-abc123.1
|
||||
|
||||
# Verify hierarchy
|
||||
bd dep tree test-abc123
|
||||
|
||||
# Delete parent (simulating normal database hygiene)
|
||||
bd delete test-abc123 --force
|
||||
|
||||
# Export state (child exists, parent deleted)
|
||||
bd export -o backup.jsonl
|
||||
|
||||
# Simulate fresh clone
|
||||
rm -rf .beads/beads.db
|
||||
bd init --prefix test --quiet
|
||||
|
||||
# Import - should resurrect parent as tombstone
|
||||
bd import -i backup.jsonl
|
||||
|
||||
# Verify resurrection
|
||||
bd show test-abc123 --json | grep -i resurrected
|
||||
bd show test-abc123.1 --json # Should exist
|
||||
bd dep tree test-abc123 # Should show full tree
|
||||
```
|
||||
|
||||
### 4. Edge Cases to Handle
|
||||
|
||||
**Potential Issues**:
|
||||
1. **JSONL path detection**: Currently assumes `.beads/issues.jsonl` - verify works with symlinks, worktrees
|
||||
2. **Performance**: Large JSONL files (10k+ issues) - may need optimization (indexing?)
|
||||
3. **Memory**: Scanner buffer is 1MB - test with very large issue descriptions
|
||||
4. **Concurrent access**: Multiple processes resurrecting same parent simultaneously
|
||||
|
||||
**Optimizations to Consider** (Future work):
|
||||
- Build in-memory index of JSONL on first resurrection call (cache for session)
|
||||
- Use `grep` or `ripgrep` for fast ID lookup before JSON parsing
|
||||
- Add resurrection stats to import summary (`Resurrected: 3 parents`)
|
||||
|
||||
### 5. Create Pull Request
|
||||
|
||||
Once testing complete:
|
||||
|
||||
```bash
|
||||
# Update CHANGELOG.md
|
||||
# Add entry under "Unreleased"
|
||||
|
||||
# Create PR
|
||||
gh pr create \
|
||||
--title "Fix import failure on missing parent issues (bd-d19a)" \
|
||||
--body "Implements topological sorting + parent resurrection.
|
||||
|
||||
Fixes #XXX (if there's a GitHub issue)
|
||||
|
||||
## Changes
|
||||
- Phase 1: Topological sorting for import ordering
|
||||
- Phase 2: Parent resurrection from JSONL history
|
||||
- Creates tombstones for deleted parents to preserve hierarchical structure
|
||||
|
||||
## Testing
|
||||
- [x] Unit tests for resurrection logic
|
||||
- [x] Integration test for deleted parent scenario
|
||||
- [x] Manual testing with multi-level hierarchies
|
||||
|
||||
See docs/import-bug-analysis-bd-3xq.md for full design rationale."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands for Next Session
|
||||
|
||||
```bash
|
||||
# Resume work
|
||||
cd /Users/stevey/src/dave/beads
|
||||
git checkout fix/import-missing-parents
|
||||
|
||||
# Run existing tests
|
||||
go test ./internal/storage/sqlite -v -run Resurrection
|
||||
|
||||
# Create new test file
|
||||
# (See test template above)
|
||||
|
||||
# Run integration tests
|
||||
go test -v -run TestImport
|
||||
|
||||
# Manual testing
|
||||
# (See workflow above)
|
||||
|
||||
# When ready to merge
|
||||
git checkout main
|
||||
git merge fix/import-missing-parents
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Issues Tracking
|
||||
|
||||
**Epic**: bd-d19a (Fix import failure on missing parent issues) - **OPEN**
|
||||
**Subtasks**:
|
||||
- bd-cc4f: Implement TryResurrectParent - **DONE** ✅
|
||||
- bd-d76d: Modify EnsureIDs - **DONE** ✅
|
||||
- bd-02a4: Modify CreateIssue - **DONE** ✅
|
||||
- **TODO**: Create test issue for Phase 3
|
||||
- **TODO**: Create docs issue for Phase 3
|
||||
|
||||
**Files Modified**:
|
||||
- ✅ `internal/importer/importer.go` (topological sorting)
|
||||
- ✅ `internal/storage/sqlite/resurrection.go` (new file)
|
||||
- ✅ `internal/storage/sqlite/ids.go`
|
||||
- ✅ `internal/storage/sqlite/sqlite.go`
|
||||
- ✅ `internal/storage/sqlite/batch_ops.go`
|
||||
- ✅ `internal/storage/sqlite/batch_ops_test.go`
|
||||
- ⏳ `internal/storage/sqlite/resurrection_test.go` (TODO)
|
||||
- ⏳ `beads_integration_test.go` (TODO - add import test)
|
||||
- ⏳ `README.md` (TODO - document resurrection)
|
||||
- ⏳ `AGENTS.md` (TODO - document for AI agents)
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Tombstone Status**: Using `closed` (not a new "deleted" status) to avoid schema changes
|
||||
2. **Search Strategy**: Linear scan of JSONL (acceptable for <10k issues, can optimize later)
|
||||
3. **Idempotency**: `TryResurrectParent` checks existence first, safe to call multiple times
|
||||
4. **Recursion**: `TryResurrectParentChain` handles multi-level hierarchies automatically
|
||||
5. **Dependencies**: Best-effort resurrection (logs warnings, doesn't fail if targets missing)
|
||||
|
||||
---
|
||||
|
||||
## Reference Documents
|
||||
|
||||
- **Design Doc**: `docs/import-bug-analysis-bd-3xq.md` (comprehensive analysis)
|
||||
- **Current Branch**: `fix/import-missing-parents`
|
||||
- **GitHub PR URL**: (To be created)
|
||||
- **Related Issues**: bd-4ms (multi-repo support), bd-a101 (separate branch workflow)
|
||||
|
||||
---
|
||||
|
||||
**Status**: Ready for Phase 3 (Testing & Documentation)
|
||||
**Estimate**: 2-3 hours for comprehensive tests + 1 hour for docs
|
||||
**Risk**: Low - core logic implemented and builds successfully
|
||||
@@ -5,6 +5,7 @@ package beads_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -434,3 +435,136 @@ func TestRoundTripIssue(t *testing.T) {
|
||||
h.assertEqual(original.IssueType, retrieved.IssueType, "IssueType")
|
||||
h.assertEqual(original.Assignee, retrieved.Assignee, "Assignee")
|
||||
}
|
||||
|
||||
// TestImportWithDeletedParent verifies parent resurrection during import
|
||||
// This tests the fix for bd-d19a (import failure on missing parent issues)
|
||||
func TestImportWithDeletedParent(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "beads-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
|
||||
// Create .beads directory
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create .beads dir: %v", err)
|
||||
}
|
||||
|
||||
// Phase 1: Create parent and child in JSONL (simulating historical git state)
|
||||
ctx := context.Background()
|
||||
|
||||
parent := beads.Issue{
|
||||
ID: "bd-parent",
|
||||
Title: "Parent Epic",
|
||||
Description: "Original parent description",
|
||||
Status: beads.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: beads.TypeEpic,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
child := beads.Issue{
|
||||
ID: "bd-parent.1",
|
||||
Title: "Child Task",
|
||||
Status: beads.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: beads.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Write both to JSONL (parent exists in git history)
|
||||
file, err := os.Create(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create JSONL: %v", err)
|
||||
}
|
||||
encoder := json.NewEncoder(file)
|
||||
if err := encoder.Encode(parent); err != nil {
|
||||
file.Close()
|
||||
t.Fatalf("Failed to encode parent: %v", err)
|
||||
}
|
||||
if err := encoder.Encode(child); err != nil {
|
||||
file.Close()
|
||||
t.Fatalf("Failed to encode child: %v", err)
|
||||
}
|
||||
file.Close()
|
||||
|
||||
// Phase 2: Create fresh database and import only the child
|
||||
// (simulating scenario where parent was deleted)
|
||||
store, err := beads.NewSQLiteStorage(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStorage failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
||||
t.Fatalf("Failed to set issue_prefix: %v", err)
|
||||
}
|
||||
|
||||
// Manually create only the child (parent missing)
|
||||
childToImport := &beads.Issue{
|
||||
ID: "bd-parent.1",
|
||||
Title: "Child Task",
|
||||
Status: beads.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: beads.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// This should trigger parent resurrection from JSONL
|
||||
if err := store.CreateIssue(ctx, childToImport, "test"); err != nil {
|
||||
t.Fatalf("Failed to create child (resurrection should have prevented error): %v", err)
|
||||
}
|
||||
|
||||
// Phase 3: Verify results
|
||||
|
||||
// Verify child was created successfully
|
||||
retrievedChild, err := store.GetIssue(ctx, "bd-parent.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve child: %v", err)
|
||||
}
|
||||
if retrievedChild == nil {
|
||||
t.Fatal("Child was not created")
|
||||
}
|
||||
if retrievedChild.Title != "Child Task" {
|
||||
t.Errorf("Expected child title 'Child Task', got %s", retrievedChild.Title)
|
||||
}
|
||||
|
||||
// Verify parent was resurrected as tombstone
|
||||
retrievedParent, err := store.GetIssue(ctx, "bd-parent")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve parent: %v", err)
|
||||
}
|
||||
if retrievedParent == nil {
|
||||
t.Fatal("Parent was not resurrected")
|
||||
}
|
||||
if retrievedParent.Status != beads.StatusClosed {
|
||||
t.Errorf("Expected parent status=closed, got %s", retrievedParent.Status)
|
||||
}
|
||||
if retrievedParent.Priority != 4 {
|
||||
t.Errorf("Expected parent priority=4 (lowest), got %d", retrievedParent.Priority)
|
||||
}
|
||||
if retrievedParent.Title != "Parent Epic" {
|
||||
t.Errorf("Expected original title preserved, got %s", retrievedParent.Title)
|
||||
}
|
||||
if retrievedParent.Description == "" {
|
||||
t.Error("Expected tombstone description to be set")
|
||||
}
|
||||
if retrievedParent.ClosedAt == nil {
|
||||
t.Error("Expected tombstone to have ClosedAt set")
|
||||
}
|
||||
|
||||
// Verify description contains resurrection marker
|
||||
if len(retrievedParent.Description) < 13 || retrievedParent.Description[:13] != "[RESURRECTED]" {
|
||||
t.Errorf("Expected [RESURRECTED] prefix in description, got: %s", retrievedParent.Description)
|
||||
}
|
||||
|
||||
t.Logf("✓ Parent %s successfully resurrected as tombstone", "bd-parent")
|
||||
t.Logf("✓ Child %s created successfully with resurrected parent", "bd-parent.1")
|
||||
}
|
||||
|
||||
@@ -537,12 +537,28 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues
|
||||
}
|
||||
}
|
||||
|
||||
// Batch create all new issues
|
||||
// Batch create all new issues with topological sorting
|
||||
// Sort by hierarchy depth to ensure parents are created before children
|
||||
// This prevents "parent does not exist" errors when importing hierarchical issues
|
||||
if len(newIssues) > 0 {
|
||||
if err := sqliteStore.CreateIssues(ctx, newIssues, "import"); err != nil {
|
||||
return fmt.Errorf("error creating issues: %w", err)
|
||||
SortByDepth(newIssues)
|
||||
|
||||
// Create issues in depth-order batches (max depth 3)
|
||||
// This handles parent-child pairs in the same import batch
|
||||
for depth := 0; depth <= 3; depth++ {
|
||||
var batchForDepth []*types.Issue
|
||||
for _, issue := range newIssues {
|
||||
if GetHierarchyDepth(issue.ID) == depth {
|
||||
batchForDepth = append(batchForDepth, issue)
|
||||
}
|
||||
}
|
||||
if len(batchForDepth) > 0 {
|
||||
if err := sqliteStore.CreateIssues(ctx, batchForDepth, "import"); err != nil {
|
||||
return fmt.Errorf("error creating depth-%d issues: %w", depth, err)
|
||||
}
|
||||
result.Created += len(batchForDepth)
|
||||
}
|
||||
}
|
||||
result.Created += len(newIssues)
|
||||
}
|
||||
|
||||
// REMOVED (bd-c7af): Counter sync after import - no longer needed with hash IDs
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// GetHierarchyDepth returns the depth of a hierarchical issue ID.
|
||||
// Depth is determined by the number of dots in the ID.
|
||||
// Examples:
|
||||
// - "bd-abc123" → 0 (top-level)
|
||||
// - "bd-abc123.1" → 1 (one level deep)
|
||||
// - "bd-abc123.1.2" → 2 (two levels deep)
|
||||
func GetHierarchyDepth(id string) int {
|
||||
return strings.Count(id, ".")
|
||||
}
|
||||
|
||||
// SortByDepth sorts issues by hierarchy depth (shallow to deep) with stable sorting.
|
||||
// Issues at the same depth are sorted by ID for deterministic ordering.
|
||||
// This ensures parent issues are processed before their children.
|
||||
func SortByDepth(issues []*types.Issue) {
|
||||
sort.SliceStable(issues, func(i, j int) bool {
|
||||
depthI := GetHierarchyDepth(issues[i].ID)
|
||||
depthJ := GetHierarchyDepth(issues[j].ID)
|
||||
if depthI != depthJ {
|
||||
return depthI < depthJ
|
||||
}
|
||||
return issues[i].ID < issues[j].ID
|
||||
})
|
||||
}
|
||||
|
||||
// GroupByDepth groups issues into buckets by hierarchy depth.
|
||||
// Returns a map where keys are depth levels and values are slices of issues at that depth.
|
||||
// Maximum supported depth is 3 (as per beads spec).
|
||||
func GroupByDepth(issues []*types.Issue) map[int][]*types.Issue {
|
||||
groups := make(map[int][]*types.Issue)
|
||||
for _, issue := range issues {
|
||||
depth := GetHierarchyDepth(issue.ID)
|
||||
groups[depth] = append(groups[depth], issue)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func TestGetHierarchyDepth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
expected int
|
||||
}{
|
||||
{"top-level", "bd-abc123", 0},
|
||||
{"one level", "bd-abc123.1", 1},
|
||||
{"two levels", "bd-abc123.1.2", 2},
|
||||
{"three levels", "bd-abc123.1.2.3", 3},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := GetHierarchyDepth(tt.id)
|
||||
if got != tt.expected {
|
||||
t.Errorf("GetHierarchyDepth(%q) = %d, want %d", tt.id, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortByDepth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []*types.Issue
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "already sorted",
|
||||
input: []*types.Issue{
|
||||
{ID: "bd-abc"},
|
||||
{ID: "bd-abc.1"},
|
||||
{ID: "bd-abc.2"},
|
||||
},
|
||||
expected: []string{"bd-abc", "bd-abc.1", "bd-abc.2"},
|
||||
},
|
||||
{
|
||||
name: "child before parent",
|
||||
input: []*types.Issue{
|
||||
{ID: "bd-abc.1"},
|
||||
{ID: "bd-abc"},
|
||||
},
|
||||
expected: []string{"bd-abc", "bd-abc.1"},
|
||||
},
|
||||
{
|
||||
name: "complex hierarchy",
|
||||
input: []*types.Issue{
|
||||
{ID: "bd-abc.1.2"},
|
||||
{ID: "bd-xyz"},
|
||||
{ID: "bd-abc"},
|
||||
{ID: "bd-abc.1"},
|
||||
},
|
||||
expected: []string{"bd-abc", "bd-xyz", "bd-abc.1", "bd-abc.1.2"},
|
||||
},
|
||||
{
|
||||
name: "stable sort same depth",
|
||||
input: []*types.Issue{
|
||||
{ID: "bd-zzz.1"},
|
||||
{ID: "bd-aaa.1"},
|
||||
{ID: "bd-mmm.1"},
|
||||
},
|
||||
expected: []string{"bd-aaa.1", "bd-mmm.1", "bd-zzz.1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
SortByDepth(tt.input)
|
||||
for i, issue := range tt.input {
|
||||
if issue.ID != tt.expected[i] {
|
||||
t.Errorf("Position %d: got %q, want %q", i, issue.ID, tt.expected[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupByDepth(t *testing.T) {
|
||||
input := []*types.Issue{
|
||||
{ID: "bd-abc"},
|
||||
{ID: "bd-xyz"},
|
||||
{ID: "bd-abc.1"},
|
||||
{ID: "bd-abc.2"},
|
||||
{ID: "bd-abc.1.1"},
|
||||
}
|
||||
|
||||
groups := GroupByDepth(input)
|
||||
|
||||
if len(groups[0]) != 2 {
|
||||
t.Errorf("Depth 0: got %d issues, want 2", len(groups[0]))
|
||||
}
|
||||
if len(groups[1]) != 2 {
|
||||
t.Errorf("Depth 1: got %d issues, want 2", len(groups[1]))
|
||||
}
|
||||
if len(groups[2]) != 1 {
|
||||
t.Errorf("Depth 2: got %d issues, want 1", len(groups[2]))
|
||||
}
|
||||
|
||||
if groups[0][0].ID != "bd-abc" && groups[0][1].ID != "bd-abc" {
|
||||
t.Error("bd-abc not found in depth 0")
|
||||
}
|
||||
if groups[0][0].ID != "bd-xyz" && groups[0][1].ID != "bd-xyz" {
|
||||
t.Error("bd-xyz not found in depth 0")
|
||||
}
|
||||
if groups[2][0].ID != "bd-abc.1.1" {
|
||||
t.Errorf("Depth 2: got %q, want bd-abc.1.1", groups[2][0].ID)
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func validateBatchIssues(issues []*types.Issue) error {
|
||||
}
|
||||
|
||||
// generateBatchIDs generates IDs for all issues that need them atomically
|
||||
func generateBatchIDs(ctx context.Context, conn *sql.Conn, issues []*types.Issue, actor string) error {
|
||||
func (s *SQLiteStorage) generateBatchIDs(ctx context.Context, conn *sql.Conn, issues []*types.Issue, actor string) error {
|
||||
// Get prefix from config (needed for both generation and validation)
|
||||
var prefix string
|
||||
err := conn.QueryRowContext(ctx, `SELECT value FROM config WHERE key = ?`, "issue_prefix").Scan(&prefix)
|
||||
@@ -45,7 +45,7 @@ func generateBatchIDs(ctx context.Context, conn *sql.Conn, issues []*types.Issue
|
||||
}
|
||||
|
||||
// Generate or validate IDs for all issues
|
||||
if err := EnsureIDs(ctx, conn, prefix, issues, actor); err != nil {
|
||||
if err := s.EnsureIDs(ctx, conn, prefix, issues, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ func (s *SQLiteStorage) CreateIssues(ctx context.Context, issues []*types.Issue,
|
||||
}()
|
||||
|
||||
// Phase 3: Generate IDs for issues that need them
|
||||
if err := generateBatchIDs(ctx, conn, issues, actor); err != nil {
|
||||
if err := s.generateBatchIDs(ctx, conn, issues, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ func TestGenerateBatchIDs(t *testing.T) {
|
||||
{Title: "Issue 3", Description: "Third", CreatedAt: time.Now()},
|
||||
}
|
||||
|
||||
err = generateBatchIDs(ctx, conn, issues, "test-actor")
|
||||
err = s.generateBatchIDs(ctx, conn, issues, "test-actor")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate IDs: %v", err)
|
||||
}
|
||||
@@ -299,7 +299,7 @@ func TestGenerateBatchIDs(t *testing.T) {
|
||||
{ID: "wrong-prefix-123", Title: "Wrong", CreatedAt: time.Now()},
|
||||
}
|
||||
|
||||
err = generateBatchIDs(ctx, conn, issues, "test-actor")
|
||||
err = s.generateBatchIDs(ctx, conn, issues, "test-actor")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong prefix")
|
||||
}
|
||||
|
||||
@@ -197,7 +197,9 @@ func TestCreateIssue_HierarchicalID_ParentNotExists(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Errorf("expected error for child without parent, got nil")
|
||||
}
|
||||
if err != nil && err.Error() != "parent issue bd-nonexistent does not exist" {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
// With resurrection feature, error message includes JSONL history check
|
||||
expectedErr := "parent issue bd-nonexistent does not exist and could not be resurrected from JSONL history"
|
||||
if err != nil && err.Error() != expectedErr {
|
||||
t.Errorf("unexpected error message: got %q, want %q", err.Error(), expectedErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ func GenerateBatchIssueIDs(ctx context.Context, conn *sql.Conn, prefix string, i
|
||||
// EnsureIDs generates or validates IDs for issues
|
||||
// For issues with empty IDs, generates unique hash-based IDs
|
||||
// For issues with existing IDs, validates they match the prefix and parent exists (if hierarchical)
|
||||
func EnsureIDs(ctx context.Context, conn *sql.Conn, prefix string, issues []*types.Issue, actor string) error {
|
||||
func (s *SQLiteStorage) EnsureIDs(ctx context.Context, conn *sql.Conn, prefix string, issues []*types.Issue, actor string) error {
|
||||
usedIDs := make(map[string]bool)
|
||||
|
||||
// First pass: record explicitly provided IDs
|
||||
@@ -186,21 +186,21 @@ func EnsureIDs(ctx context.Context, conn *sql.Conn, prefix string, issues []*typ
|
||||
return err
|
||||
}
|
||||
|
||||
// For hierarchical IDs (bd-a3f8e9.1), validate parent exists
|
||||
// For hierarchical IDs (bd-a3f8e9.1), ensure parent exists
|
||||
if strings.Contains(issues[i].ID, ".") {
|
||||
// Extract parent ID (everything before the last dot)
|
||||
lastDot := strings.LastIndex(issues[i].ID, ".")
|
||||
parentID := issues[i].ID[:lastDot]
|
||||
|
||||
var parentCount int
|
||||
err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, parentID).Scan(&parentCount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check parent existence: %w", err)
|
||||
}
|
||||
if parentCount == 0 {
|
||||
return fmt.Errorf("parent issue %s does not exist", parentID)
|
||||
}
|
||||
// Try to resurrect entire parent chain if any parents are missing
|
||||
// Use the conn-based version to participate in the same transaction
|
||||
resurrected, err := s.tryResurrectParentChainWithConn(ctx, conn, issues[i].ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resurrect parent chain for %s: %w", issues[i].ID, err)
|
||||
}
|
||||
if !resurrected {
|
||||
// Parent(s) not found in JSONL history - cannot proceed
|
||||
lastDot := strings.LastIndex(issues[i].ID, ".")
|
||||
parentID := issues[i].ID[:lastDot]
|
||||
return fmt.Errorf("parent issue %s does not exist and could not be resurrected from JSONL history", parentID)
|
||||
}
|
||||
}
|
||||
|
||||
usedIDs[issues[i].ID] = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// TryResurrectParent attempts to resurrect a deleted parent issue from JSONL history.
|
||||
// If the parent is found in the JSONL file, it creates a tombstone issue (status=closed)
|
||||
// to preserve referential integrity for hierarchical children.
|
||||
//
|
||||
// This function is called during import when a child issue references a missing parent.
|
||||
//
|
||||
// Returns:
|
||||
// - true if parent was successfully resurrected or already exists
|
||||
// - false if parent was not found in JSONL history
|
||||
// - error if resurrection failed for any other reason
|
||||
func (s *SQLiteStorage) TryResurrectParent(ctx context.Context, parentID string) (bool, error) {
|
||||
// Get a connection for the entire resurrection operation
|
||||
conn, err := s.db.Conn(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get database connection: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
return s.tryResurrectParentWithConn(ctx, conn, parentID)
|
||||
}
|
||||
|
||||
// tryResurrectParentWithConn is the internal version that accepts an existing connection.
|
||||
// This allows resurrection to participate in an existing transaction.
|
||||
func (s *SQLiteStorage) tryResurrectParentWithConn(ctx context.Context, conn *sql.Conn, parentID string) (bool, error) {
|
||||
// First check if parent already exists in database
|
||||
var count int
|
||||
err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, parentID).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check parent existence: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return true, nil // Parent already exists, nothing to do
|
||||
}
|
||||
|
||||
// Parent doesn't exist - try to find it in JSONL history
|
||||
parentIssue, err := s.findIssueInJSONL(parentID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to search JSONL history: %w", err)
|
||||
}
|
||||
if parentIssue == nil {
|
||||
return false, nil // Parent not found in history
|
||||
}
|
||||
|
||||
// Create tombstone version of the parent
|
||||
now := time.Now()
|
||||
tombstone := &types.Issue{
|
||||
ID: parentIssue.ID,
|
||||
ContentHash: parentIssue.ContentHash,
|
||||
Title: parentIssue.Title,
|
||||
Description: "[RESURRECTED] This issue was deleted but recreated as a tombstone to preserve hierarchical structure.",
|
||||
Status: types.StatusClosed,
|
||||
Priority: 4, // Lowest priority
|
||||
IssueType: parentIssue.IssueType,
|
||||
CreatedAt: parentIssue.CreatedAt,
|
||||
UpdatedAt: now,
|
||||
ClosedAt: &now,
|
||||
}
|
||||
|
||||
// If original issue had description, append it
|
||||
if parentIssue.Description != "" {
|
||||
tombstone.Description = fmt.Sprintf("%s\n\nOriginal description:\n%s", tombstone.Description, parentIssue.Description)
|
||||
}
|
||||
|
||||
// Insert tombstone into database using the provided connection
|
||||
if err := insertIssue(ctx, conn, tombstone); err != nil {
|
||||
return false, fmt.Errorf("failed to create tombstone for parent %s: %w", parentID, err)
|
||||
}
|
||||
|
||||
// Also copy dependencies if they exist in the JSONL
|
||||
if len(parentIssue.Dependencies) > 0 {
|
||||
for _, dep := range parentIssue.Dependencies {
|
||||
// Only resurrect dependencies if both source and target exist
|
||||
var targetCount int
|
||||
err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, dep.DependsOnID).Scan(&targetCount)
|
||||
if err == nil && targetCount > 0 {
|
||||
_, err := conn.ExecContext(ctx, `
|
||||
INSERT OR IGNORE INTO dependencies (issue_id, depends_on_id, type, created_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, parentID, dep.DependsOnID, dep.Type, "resurrection")
|
||||
if err != nil {
|
||||
// Log but don't fail - dependency resurrection is best-effort
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to resurrect dependency for %s: %v\n", parentID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// findIssueInJSONL searches the JSONL file for a specific issue ID.
|
||||
// Returns nil if not found, or the issue if found.
|
||||
func (s *SQLiteStorage) findIssueInJSONL(issueID string) (*types.Issue, error) {
|
||||
// Get database directory
|
||||
dbDir := filepath.Dir(s.dbPath)
|
||||
|
||||
// JSONL file is expected at .beads/issues.jsonl relative to repo root
|
||||
// The db is at .beads/beads.db, so we need the parent directory
|
||||
jsonlPath := filepath.Join(dbDir, "issues.jsonl")
|
||||
|
||||
// Check if JSONL file exists
|
||||
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
|
||||
return nil, nil // No JSONL file, can't resurrect
|
||||
}
|
||||
|
||||
// Open and scan JSONL file
|
||||
file, err := os.Open(jsonlPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open JSONL file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
// Increase buffer size for large issues
|
||||
const maxCapacity = 1024 * 1024 // 1MB
|
||||
buf := make([]byte, maxCapacity)
|
||||
scanner.Buffer(buf, maxCapacity)
|
||||
|
||||
lineNum := 0
|
||||
var lastMatch *types.Issue
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
|
||||
// Skip empty lines
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Quick check: does this line contain our issue ID?
|
||||
// This is an optimization to avoid parsing every JSON object
|
||||
if !strings.Contains(line, `"`+issueID+`"`) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
var issue types.Issue
|
||||
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
||||
// Skip malformed lines with warning
|
||||
fmt.Fprintf(os.Stderr, "Warning: skipping malformed JSONL line %d: %v\n", lineNum, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep the last occurrence (JSONL append-only semantics)
|
||||
if issue.ID == issueID {
|
||||
issueCopy := issue
|
||||
lastMatch = &issueCopy
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error reading JSONL file: %w", err)
|
||||
}
|
||||
|
||||
return lastMatch, nil // Returns last match or nil if not found
|
||||
}
|
||||
|
||||
// TryResurrectParentChain recursively resurrects all missing parents in a hierarchical ID chain.
|
||||
// For example, if resurrecting "bd-abc.1.2", this ensures both "bd-abc" and "bd-abc.1" exist.
|
||||
//
|
||||
// Returns:
|
||||
// - true if entire chain was successfully resurrected or already exists
|
||||
// - false if any parent in the chain was not found in JSONL history
|
||||
// - error if resurrection failed for any other reason
|
||||
func (s *SQLiteStorage) TryResurrectParentChain(ctx context.Context, childID string) (bool, error) {
|
||||
// Get a connection for the entire chain resurrection
|
||||
conn, err := s.db.Conn(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get database connection: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
return s.tryResurrectParentChainWithConn(ctx, conn, childID)
|
||||
}
|
||||
|
||||
// tryResurrectParentChainWithConn is the internal version that accepts an existing connection.
|
||||
func (s *SQLiteStorage) tryResurrectParentChainWithConn(ctx context.Context, conn *sql.Conn, childID string) (bool, error) {
|
||||
// Extract all parent IDs from the hierarchical chain
|
||||
parents := extractParentChain(childID)
|
||||
|
||||
// Resurrect from root to leaf (shallower to deeper)
|
||||
for _, parentID := range parents {
|
||||
resurrected, err := s.tryResurrectParentWithConn(ctx, conn, parentID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to resurrect parent %s: %w", parentID, err)
|
||||
}
|
||||
if !resurrected {
|
||||
return false, nil // Parent not found in history, can't continue
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// extractParentChain returns all parent IDs in a hierarchical chain, ordered from root to leaf.
|
||||
// Example: "bd-abc.1.2" → ["bd-abc", "bd-abc.1"]
|
||||
func extractParentChain(id string) []string {
|
||||
parts := strings.Split(id, ".")
|
||||
if len(parts) <= 1 {
|
||||
return nil // No parents (top-level ID)
|
||||
}
|
||||
|
||||
parents := make([]string, 0, len(parts)-1)
|
||||
for i := 1; i < len(parts); i++ {
|
||||
parent := strings.Join(parts[:i], ".")
|
||||
parents = append(parents, parent)
|
||||
}
|
||||
|
||||
return parents
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// TestTryResurrectParent_AlreadyExists verifies that resurrection is a no-op when parent exists
|
||||
func TestTryResurrectParent_AlreadyExists(t *testing.T) {
|
||||
s := newTestStore(t, "")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create parent issue
|
||||
parent := &types.Issue{
|
||||
ID: "bd-abc",
|
||||
Title: "Parent Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeEpic,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := s.CreateIssue(ctx, parent, "test"); err != nil {
|
||||
t.Fatalf("Failed to create parent: %v", err)
|
||||
}
|
||||
|
||||
// Try to resurrect - should succeed without doing anything
|
||||
resurrected, err := s.TryResurrectParent(ctx, "bd-abc")
|
||||
if err != nil {
|
||||
t.Fatalf("TryResurrectParent failed: %v", err)
|
||||
}
|
||||
if !resurrected {
|
||||
t.Fatal("Expected resurrected=true for existing parent")
|
||||
}
|
||||
|
||||
// Verify parent is still the original (not a tombstone)
|
||||
retrieved, err := s.GetIssue(ctx, "bd-abc")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve parent: %v", err)
|
||||
}
|
||||
if retrieved.Status != types.StatusOpen {
|
||||
t.Errorf("Expected status=%s, got %s", types.StatusOpen, retrieved.Status)
|
||||
}
|
||||
if retrieved.Priority != 1 {
|
||||
t.Errorf("Expected priority=1, got %d", retrieved.Priority)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryResurrectParent_FoundInJSONL verifies successful resurrection from JSONL
|
||||
func TestTryResurrectParent_FoundInJSONL(t *testing.T) {
|
||||
s := newTestStore(t, "")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a JSONL file with the parent issue
|
||||
dbDir := filepath.Dir(s.dbPath)
|
||||
jsonlPath := filepath.Join(dbDir, "issues.jsonl")
|
||||
|
||||
parentIssue := types.Issue{
|
||||
ID: "test-parent",
|
||||
ContentHash: "hash123",
|
||||
Title: "Original Parent",
|
||||
Description: "Original description text",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeEpic,
|
||||
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
// Write parent to JSONL
|
||||
if err := writeIssuesToJSONL(jsonlPath, []types.Issue{parentIssue}); err != nil {
|
||||
t.Fatalf("Failed to create JSONL: %v", err)
|
||||
}
|
||||
|
||||
// Try to resurrect
|
||||
resurrected, err := s.TryResurrectParent(ctx, "test-parent")
|
||||
if err != nil {
|
||||
t.Fatalf("TryResurrectParent failed: %v", err)
|
||||
}
|
||||
if !resurrected {
|
||||
t.Fatal("Expected successful resurrection")
|
||||
}
|
||||
|
||||
// Verify tombstone was created
|
||||
tombstone, err := s.GetIssue(ctx, "test-parent")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve resurrected parent: %v", err)
|
||||
}
|
||||
|
||||
// Check tombstone properties
|
||||
if tombstone.Status != types.StatusClosed {
|
||||
t.Errorf("Expected status=closed, got %s", tombstone.Status)
|
||||
}
|
||||
if tombstone.Priority != 4 {
|
||||
t.Errorf("Expected priority=4, got %d", tombstone.Priority)
|
||||
}
|
||||
if tombstone.ClosedAt == nil {
|
||||
t.Error("Expected ClosedAt to be set")
|
||||
}
|
||||
if tombstone.Title != "Original Parent" {
|
||||
t.Errorf("Expected title preserved, got %s", tombstone.Title)
|
||||
}
|
||||
if !contains(tombstone.Description, "[RESURRECTED]") {
|
||||
t.Error("Expected [RESURRECTED] marker in description")
|
||||
}
|
||||
if !contains(tombstone.Description, "Original description text") {
|
||||
t.Error("Expected original description appended to tombstone")
|
||||
}
|
||||
if tombstone.IssueType != types.TypeEpic {
|
||||
t.Errorf("Expected type=%s, got %s", types.TypeEpic, tombstone.IssueType)
|
||||
}
|
||||
if !tombstone.CreatedAt.Equal(parentIssue.CreatedAt) {
|
||||
t.Error("Expected CreatedAt to be preserved from original")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryResurrectParent_NotFoundInJSONL verifies proper handling when parent not in JSONL
|
||||
func TestTryResurrectParent_NotFoundInJSONL(t *testing.T) {
|
||||
s := newTestStore(t, "")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a JSONL file with different issue
|
||||
dbDir := filepath.Dir(s.dbPath)
|
||||
jsonlPath := filepath.Join(dbDir, "issues.jsonl")
|
||||
|
||||
otherIssue := types.Issue{
|
||||
ID: "test-other",
|
||||
Title: "Other Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := writeIssuesToJSONL(jsonlPath, []types.Issue{otherIssue}); err != nil {
|
||||
t.Fatalf("Failed to create JSONL: %v", err)
|
||||
}
|
||||
|
||||
// Try to resurrect non-existent parent
|
||||
resurrected, err := s.TryResurrectParent(ctx, "test-missing")
|
||||
if err != nil {
|
||||
t.Fatalf("TryResurrectParent should not error on missing parent: %v", err)
|
||||
}
|
||||
if resurrected {
|
||||
t.Error("Expected resurrected=false for missing parent")
|
||||
}
|
||||
|
||||
// Verify parent was not created
|
||||
issue, err := s.GetIssue(ctx, "test-missing")
|
||||
if err == nil && issue != nil {
|
||||
t.Error("Expected nil issue when retrieving non-existent parent")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryResurrectParent_NoJSONLFile verifies graceful handling when JSONL file missing
|
||||
func TestTryResurrectParent_NoJSONLFile(t *testing.T) {
|
||||
s := newTestStore(t, "")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Don't create JSONL file
|
||||
|
||||
// Try to resurrect - should return false (not found) without error
|
||||
resurrected, err := s.TryResurrectParent(ctx, "test-parent")
|
||||
if err != nil {
|
||||
t.Fatalf("TryResurrectParent should not error when JSONL missing: %v", err)
|
||||
}
|
||||
if resurrected {
|
||||
t.Error("Expected resurrected=false when JSONL missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryResurrectParent_MalformedJSONL verifies handling of malformed JSONL lines
|
||||
func TestTryResurrectParent_MalformedJSONL(t *testing.T) {
|
||||
s := newTestStore(t, "")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create JSONL file with malformed lines and one valid entry
|
||||
dbDir := filepath.Dir(s.dbPath)
|
||||
jsonlPath := filepath.Join(dbDir, "issues.jsonl")
|
||||
|
||||
validIssue := types.Issue{
|
||||
ID: "test-valid",
|
||||
Title: "Valid Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
validJSON, _ := json.Marshal(validIssue)
|
||||
|
||||
content := "this is not valid json\n" +
|
||||
"{\"id\": \"incomplete\"\n" +
|
||||
string(validJSON) + "\n" +
|
||||
"\n" // empty line
|
||||
|
||||
if err := os.WriteFile(jsonlPath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create JSONL: %v", err)
|
||||
}
|
||||
|
||||
// Try to resurrect valid issue - should succeed despite malformed lines
|
||||
resurrected, err := s.TryResurrectParent(ctx, "test-valid")
|
||||
if err != nil {
|
||||
t.Fatalf("TryResurrectParent failed: %v", err)
|
||||
}
|
||||
if !resurrected {
|
||||
t.Error("Expected successful resurrection of valid issue")
|
||||
}
|
||||
|
||||
// Try to resurrect from malformed line - should return false
|
||||
resurrected, err = s.TryResurrectParent(ctx, "incomplete")
|
||||
if err != nil {
|
||||
t.Fatalf("TryResurrectParent should not error on malformed JSON: %v", err)
|
||||
}
|
||||
if resurrected {
|
||||
t.Error("Expected resurrected=false for malformed JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryResurrectParent_WithDependencies verifies dependency resurrection
|
||||
func TestTryResurrectParent_WithDependencies(t *testing.T) {
|
||||
s := newTestStore(t, "")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create dependency target in database
|
||||
target := &types.Issue{
|
||||
ID: "bd-target",
|
||||
Title: "Target Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := s.CreateIssue(ctx, target, "test"); err != nil {
|
||||
t.Fatalf("Failed to create target: %v", err)
|
||||
}
|
||||
|
||||
// Create JSONL with parent that has dependencies
|
||||
dbDir := filepath.Dir(s.dbPath)
|
||||
jsonlPath := filepath.Join(dbDir, "issues.jsonl")
|
||||
|
||||
parentIssue := types.Issue{
|
||||
ID: "test-parent",
|
||||
Title: "Parent with Deps",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeEpic,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Dependencies: []*types.Dependency{
|
||||
{IssueID: "test-parent", DependsOnID: "bd-target", Type: types.DepBlocks},
|
||||
{IssueID: "test-parent", DependsOnID: "test-missing", Type: types.DepBlocks},
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeIssuesToJSONL(jsonlPath, []types.Issue{parentIssue}); err != nil {
|
||||
t.Fatalf("Failed to create JSONL: %v", err)
|
||||
}
|
||||
|
||||
// Resurrect parent
|
||||
resurrected, err := s.TryResurrectParent(ctx, "test-parent")
|
||||
if err != nil {
|
||||
t.Fatalf("TryResurrectParent failed: %v", err)
|
||||
}
|
||||
if !resurrected {
|
||||
t.Fatal("Expected successful resurrection")
|
||||
}
|
||||
|
||||
// Verify dependency to existing target was resurrected
|
||||
_, err = s.GetIssue(ctx, "test-parent")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve tombstone: %v", err)
|
||||
}
|
||||
|
||||
// Get dependencies separately (GetIssue doesn't load them)
|
||||
depIssues, err := s.GetDependencies(ctx, "test-parent")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get dependencies: %v", err)
|
||||
}
|
||||
if len(depIssues) != 1 {
|
||||
t.Fatalf("Expected 1 dependency (only the valid one), got %d", len(depIssues))
|
||||
}
|
||||
if depIssues[0].ID != "bd-target" {
|
||||
t.Errorf("Expected dependency to bd-target, got %s", depIssues[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryResurrectParentChain_MultiLevel verifies recursive chain resurrection
|
||||
func TestTryResurrectParentChain_MultiLevel(t *testing.T) {
|
||||
s := newTestStore(t, "")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create JSONL with multi-level hierarchy
|
||||
dbDir := filepath.Dir(s.dbPath)
|
||||
jsonlPath := filepath.Join(dbDir, "issues.jsonl")
|
||||
|
||||
root := types.Issue{
|
||||
ID: "test-root",
|
||||
Title: "Root Epic",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeEpic,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
level1 := types.Issue{
|
||||
ID: "test-root.1",
|
||||
Title: "Level 1 Task",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
level2 := types.Issue{
|
||||
ID: "test-root.1.1",
|
||||
Title: "Level 2 Subtask",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := writeIssuesToJSONL(jsonlPath, []types.Issue{root, level1, level2}); err != nil {
|
||||
t.Fatalf("Failed to create JSONL: %v", err)
|
||||
}
|
||||
|
||||
// Resurrect entire chain for deepest child
|
||||
resurrected, err := s.TryResurrectParentChain(ctx, "test-root.1.1")
|
||||
if err != nil {
|
||||
t.Fatalf("TryResurrectParentChain failed: %v", err)
|
||||
}
|
||||
if !resurrected {
|
||||
t.Fatal("Expected successful chain resurrection")
|
||||
}
|
||||
|
||||
// Verify all parents were created
|
||||
for _, id := range []string{"test-root", "test-root.1"} {
|
||||
issue, err := s.GetIssue(ctx, id)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to retrieve %s: %v", id, err)
|
||||
continue
|
||||
}
|
||||
if issue.Status != types.StatusClosed {
|
||||
t.Errorf("Expected %s to be closed tombstone, got %s", id, issue.Status)
|
||||
}
|
||||
if !contains(issue.Description, "[RESURRECTED]") {
|
||||
t.Errorf("Expected %s to have [RESURRECTED] marker", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryResurrectParentChain_PartialChainMissing verifies behavior when some parents missing
|
||||
func TestTryResurrectParentChain_PartialChainMissing(t *testing.T) {
|
||||
s := newTestStore(t, "")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create JSONL with only root, missing intermediate level
|
||||
dbDir := filepath.Dir(s.dbPath)
|
||||
jsonlPath := filepath.Join(dbDir, "issues.jsonl")
|
||||
|
||||
root := types.Issue{
|
||||
ID: "test-root",
|
||||
Title: "Root Epic",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeEpic,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Note: test-root.1 is NOT in JSONL
|
||||
|
||||
if err := writeIssuesToJSONL(jsonlPath, []types.Issue{root}); err != nil {
|
||||
t.Fatalf("Failed to create JSONL: %v", err)
|
||||
}
|
||||
|
||||
// Try to resurrect chain - should fail when intermediate parent not found
|
||||
resurrected, err := s.TryResurrectParentChain(ctx, "test-root.1.1")
|
||||
if err != nil {
|
||||
t.Fatalf("TryResurrectParentChain should not error: %v", err)
|
||||
}
|
||||
if resurrected {
|
||||
t.Error("Expected resurrected=false when intermediate parent missing")
|
||||
}
|
||||
|
||||
// Verify root was created (first in chain)
|
||||
rootIssue, err := s.GetIssue(ctx, "test-root")
|
||||
if err != nil {
|
||||
t.Error("Expected root to be resurrected before failure")
|
||||
} else if rootIssue.Status != types.StatusClosed {
|
||||
t.Error("Expected root to be tombstone")
|
||||
}
|
||||
|
||||
// Verify intermediate level was NOT created
|
||||
midIssue, err := s.GetIssue(ctx, "test-root.1")
|
||||
if err == nil && midIssue != nil {
|
||||
t.Error("Expected nil issue when retrieving missing intermediate parent")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractParentChain verifies parent chain extraction logic
|
||||
func TestExtractParentChain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "top-level ID",
|
||||
id: "test-abc",
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "one level deep",
|
||||
id: "test-abc.1",
|
||||
expected: []string{"test-abc"},
|
||||
},
|
||||
{
|
||||
name: "two levels deep",
|
||||
id: "test-abc.1.2",
|
||||
expected: []string{"test-abc", "test-abc.1"},
|
||||
},
|
||||
{
|
||||
name: "three levels deep",
|
||||
id: "test-abc.1.2.3",
|
||||
expected: []string{"test-abc", "test-abc.1", "test-abc.1.2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := extractParentChain(tt.id)
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Fatalf("Expected %d parents, got %d", len(tt.expected), len(result))
|
||||
}
|
||||
for i := range result {
|
||||
if result[i] != tt.expected[i] {
|
||||
t.Errorf("Parent[%d]: expected %s, got %s", i, tt.expected[i], result[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryResurrectParent_Idempotent verifies resurrection can be called multiple times safely
|
||||
func TestTryResurrectParent_Idempotent(t *testing.T) {
|
||||
s := newTestStore(t, "")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create JSONL with parent
|
||||
dbDir := filepath.Dir(s.dbPath)
|
||||
jsonlPath := filepath.Join(dbDir, "issues.jsonl")
|
||||
|
||||
parentIssue := types.Issue{
|
||||
ID: "test-parent",
|
||||
Title: "Parent Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeEpic,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := writeIssuesToJSONL(jsonlPath, []types.Issue{parentIssue}); err != nil {
|
||||
t.Fatalf("Failed to create JSONL: %v", err)
|
||||
}
|
||||
|
||||
// First resurrection
|
||||
resurrected, err := s.TryResurrectParent(ctx, "test-parent")
|
||||
if err != nil {
|
||||
t.Fatalf("First resurrection failed: %v", err)
|
||||
}
|
||||
if !resurrected {
|
||||
t.Fatal("Expected first resurrection to succeed")
|
||||
}
|
||||
|
||||
firstTombstone, err := s.GetIssue(ctx, "test-parent")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve first tombstone: %v", err)
|
||||
}
|
||||
|
||||
// Second resurrection (should be no-op)
|
||||
resurrected, err = s.TryResurrectParent(ctx, "test-parent")
|
||||
if err != nil {
|
||||
t.Fatalf("Second resurrection failed: %v", err)
|
||||
}
|
||||
if !resurrected {
|
||||
t.Fatal("Expected second resurrection to succeed (already exists)")
|
||||
}
|
||||
|
||||
// Verify tombstone unchanged
|
||||
secondTombstone, err := s.GetIssue(ctx, "test-parent")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve second tombstone: %v", err)
|
||||
}
|
||||
|
||||
if firstTombstone.UpdatedAt != secondTombstone.UpdatedAt {
|
||||
t.Error("Expected tombstone to be unchanged by second resurrection")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to write issues to JSONL file
|
||||
func writeIssuesToJSONL(path string, issues []types.Issue) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
for _, issue := range issues {
|
||||
if err := encoder.Encode(issue); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestTryResurrectParent_MultipleVersionsInJSONL verifies that the LAST occurrence is used
|
||||
func TestTryResurrectParent_MultipleVersionsInJSONL(t *testing.T) {
|
||||
s := newTestStore(t, "")
|
||||
ctx := context.Background()
|
||||
|
||||
// Create JSONL with multiple versions of the same issue (append-only semantics)
|
||||
dbDir := filepath.Dir(s.dbPath)
|
||||
jsonlPath := filepath.Join(dbDir, "issues.jsonl")
|
||||
|
||||
// First version: priority 3, title "Old Version"
|
||||
v1 := &types.Issue{
|
||||
ID: "bd-multi",
|
||||
Title: "Old Version",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 3,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
v1JSON, _ := json.Marshal(v1)
|
||||
|
||||
// Second version: priority 2, title "Updated Version"
|
||||
time.Sleep(10 * time.Millisecond) // Ensure different timestamp
|
||||
v2 := &types.Issue{
|
||||
ID: "bd-multi",
|
||||
Title: "Updated Version",
|
||||
Status: types.StatusInProgress,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: v1.CreatedAt, // Same creation time
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
v2JSON, _ := json.Marshal(v2)
|
||||
|
||||
// Third version: priority 1, title "Latest Version"
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
v3 := &types.Issue{
|
||||
ID: "bd-multi",
|
||||
Title: "Latest Version",
|
||||
Status: types.StatusClosed,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: v1.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
v3JSON, _ := json.Marshal(v3)
|
||||
|
||||
// Write all three versions (append-only)
|
||||
content := string(v1JSON) + "\n" + string(v2JSON) + "\n" + string(v3JSON) + "\n"
|
||||
if err := os.WriteFile(jsonlPath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create JSONL: %v", err)
|
||||
}
|
||||
|
||||
// Resurrect - should get the LAST version (v3)
|
||||
resurrected, err := s.TryResurrectParent(ctx, "bd-multi")
|
||||
if err != nil {
|
||||
t.Fatalf("TryResurrectParent failed: %v", err)
|
||||
}
|
||||
if !resurrected {
|
||||
t.Error("Expected successful resurrection")
|
||||
}
|
||||
|
||||
// Verify we got the latest version's data
|
||||
retrieved, err := s.GetIssue(ctx, "bd-multi")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve resurrected issue: %v", err)
|
||||
}
|
||||
|
||||
// Most important: title should be from LAST occurrence (v3)
|
||||
if retrieved.Title != "Latest Version" {
|
||||
t.Errorf("Expected title 'Latest Version', got '%s' (should use LAST occurrence in JSONL)", retrieved.Title)
|
||||
}
|
||||
|
||||
// CreatedAt should be preserved from original (all versions share this)
|
||||
if !retrieved.CreatedAt.Equal(v1.CreatedAt) {
|
||||
t.Errorf("Expected CreatedAt %v, got %v", v1.CreatedAt, retrieved.CreatedAt)
|
||||
}
|
||||
|
||||
// Note: Priority, Status, and Description are modified by tombstone logic
|
||||
// (Priority=4, Status=Closed, Description="[RESURRECTED]...")
|
||||
// This is expected behavior - the test verifies we read the LAST occurrence
|
||||
// before creating the tombstone.
|
||||
}
|
||||
|
||||
// Helper function to check if string contains substring
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) >= len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr)))
|
||||
}
|
||||
|
||||
func containsMiddle(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -179,21 +179,21 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
|
||||
return err
|
||||
}
|
||||
|
||||
// For hierarchical IDs (bd-a3f8e9.1), validate parent exists
|
||||
// For hierarchical IDs (bd-a3f8e9.1), ensure parent exists
|
||||
if strings.Contains(issue.ID, ".") {
|
||||
// Extract parent ID (everything before the last dot)
|
||||
lastDot := strings.LastIndex(issue.ID, ".")
|
||||
parentID := issue.ID[:lastDot]
|
||||
|
||||
var parentCount int
|
||||
err = conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, parentID).Scan(&parentCount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check parent existence: %w", err)
|
||||
}
|
||||
if parentCount == 0 {
|
||||
return fmt.Errorf("parent issue %s does not exist", parentID)
|
||||
}
|
||||
// Try to resurrect entire parent chain if any parents are missing
|
||||
// Use the conn-based version to participate in the same transaction
|
||||
resurrected, err := s.tryResurrectParentChainWithConn(ctx, conn, issue.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resurrect parent chain for %s: %w", issue.ID, err)
|
||||
}
|
||||
if !resurrected {
|
||||
// Parent(s) not found in JSONL history - cannot proceed
|
||||
lastDot := strings.LastIndex(issue.ID, ".")
|
||||
parentID := issue.ID[:lastDot]
|
||||
return fmt.Errorf("parent issue %s does not exist and could not be resurrected from JSONL history", parentID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert issue
|
||||
|
||||
Reference in New Issue
Block a user