Files
beads/internal/storage/sqlite/resurrection.go
Steve Yegge 93195e336b feat(import): implement parent resurrection (bd-cc4f, bd-d76d, bd-02a4)
Phase 2 of fixing import failure on missing parent issues (bd-d19a).

Implemented:
- TryResurrectParent: searches JSONL history for deleted parents
- TryResurrectParentChain: recursively resurrects entire parent chains
- Creates tombstones (status=closed) to preserve hierarchical structure
- Modified EnsureIDs and CreateIssue to call resurrection before validation

When importing a child issue with missing parent:
1. Searches .beads/issues.jsonl for parent in git history
2. If found, creates tombstone with status=closed
3. Preserves original title and metadata
4. Appends original description to tombstone
5. Copies dependencies if targets exist

This allows imports to proceed even when parents were deleted,
enabling multi-repo workflows and normal database hygiene operations.

Part of bd-d19a (fix import failure on missing parents).

Amp-Thread-ID: https://ampcode.com/threads/T-a1c9e824-885e-40ce-a179-148cf39c7e64
Co-authored-by: Amp <amp@ampcode.com>
2025-11-04 22:25:33 -08:00

206 lines
6.4 KiB
Go

package sqlite
import (
"bufio"
"context"
"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) {
// First check if parent already exists in database
var count int
err := s.db.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)
}
// Get a connection for the transaction
conn, err := s.db.Conn(ctx)
if err != nil {
return false, fmt.Errorf("failed to get database connection: %w", err)
}
defer conn.Close()
// Insert tombstone into database
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 := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, dep.DependsOnID).Scan(&targetCount)
if err == nil && targetCount > 0 {
_, err := s.db.ExecContext(ctx, `
INSERT OR IGNORE INTO dependencies (issue_id, depends_on_id, dep_type)
VALUES (?, ?, ?)
`, parentID, dep.DependsOnID, dep.Type)
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
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
}
// Check if this is the issue we're looking for
if issue.ID == issueID {
return &issue, nil
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading JSONL file: %w", err)
}
return nil, nil // 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) {
// 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.TryResurrectParent(ctx, 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
}