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 } // Before resurrecting this parent, ensure its entire ancestor chain exists (bd-ar2.4) // This handles deeply nested cases where we're resurrecting bd-root.1.2 and bd-root.1 is also missing ancestors := extractParentChain(parentID) for _, ancestor := range ancestors { // Recursively resurrect each ancestor in the chain resurrected, err := s.tryResurrectParentWithConn(ctx, conn, ancestor) if err != nil { return false, fmt.Errorf("failed to resurrect ancestor %s: %w", ancestor, err) } if !resurrected { return false, nil // Ancestor not found in history, can't continue } } // 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 }