Files
beads/internal/storage/sqlite/resurrection.go

227 lines
7.5 KiB
Go

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) // #nosec G304 -- jsonlPath is from trusted beads directory
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
}