Fix P0 bug: JSONL lookup now returns LAST match (bd-58c0)

Addresses code review feedback:

 P0 (Must Fix):
- Fix JSONL lookup to return LAST match, not FIRST (resurrection.go:160-162)
  - Changed from early return to scan all matches and keep last
  - Respects JSONL append-only semantics

 P1 (Should Fix):
- Add test for multiple JSONL versions
  - TestTryResurrectParent_MultipleVersionsInJSONL verifies correct behavior
- Document error message change in CHANGELOG.md
  - Old: "parent issue X does not exist"
  - New: "parent issue X does not exist and could not be resurrected from JSONL history"
  - Marked as breaking change for script parsers

 P2/P3 (Nice to Have):
- Add documentation to AGENTS.md explaining auto-resurrection behavior
- Document best-effort dependency resurrection

⏸️ Deferred (P1 - Optimize batch resurrection):
- Caching optimization deferred (no batch use cases currently)

All tests pass:
- Unit tests: internal/storage/sqlite/
- Integration test: TestImportWithDeletedParent
This commit is contained in:
Steve Yegge
2025-11-04 22:07:36 -08:00
parent 4c77631e48
commit 3af353ade3
6 changed files with 121 additions and 390 deletions

View File

@@ -133,6 +133,7 @@ func (s *SQLiteStorage) findIssueInJSONL(issueID string) (*types.Issue, error) {
scanner.Buffer(buf, maxCapacity)
lineNum := 0
var lastMatch *types.Issue
for scanner.Scan() {
lineNum++
line := scanner.Text()
@@ -156,9 +157,10 @@ func (s *SQLiteStorage) findIssueInJSONL(issueID string) (*types.Issue, error) {
continue
}
// Check if this is the issue we're looking for
// Keep the last occurrence (JSONL append-only semantics)
if issue.ID == issueID {
return &issue, nil
issueCopy := issue
lastMatch = &issueCopy
}
}
@@ -166,7 +168,7 @@ func (s *SQLiteStorage) findIssueInJSONL(issueID string) (*types.Issue, error) {
return nil, fmt.Errorf("error reading JSONL file: %w", err)
}
return nil, nil // Not found
return lastMatch, nil // Returns last match or nil if not found
}
// TryResurrectParentChain recursively resurrects all missing parents in a hierarchical ID chain.

View File

@@ -539,6 +539,90 @@ func writeIssuesToJSONL(path string, issues []types.Issue) error {
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)))