extractParentChain was using strings.Split(id, ".") which incorrectly parsed prefixes containing dots (like "alicealexandra.com"). This caused --parent to fail with "parent does not exist" even when the parent was present in the database. The fix uses IsHierarchicalID to walk up the hierarchy correctly, only splitting on dots followed by numeric suffixes (the actual hierarchy delimiter). Example: - "test.example-abc.1" now correctly returns ["test.example-abc"] - Previously it incorrectly returned ["test", "test.example-abc"] 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
248 lines
8.4 KiB
Go
248 lines
8.4 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
|
|
}
|
|
|
|
// 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"]
|
|
// Example: "test.example-abc.1" → ["test.example-abc"] (prefix with dot is preserved)
|
|
//
|
|
// This function uses IsHierarchicalID to correctly handle prefixes containing dots (GH#664).
|
|
// It only splits on dots followed by numeric suffixes (the hierarchy delimiter).
|
|
func extractParentChain(id string) []string {
|
|
var parents []string
|
|
current := id
|
|
|
|
// Walk up the hierarchy by repeatedly finding the parent
|
|
for {
|
|
isHierarchical, parentID := IsHierarchicalID(current)
|
|
if !isHierarchical {
|
|
break // No more parents
|
|
}
|
|
// Prepend to build root-to-leaf order
|
|
parents = append([]string{parentID}, parents...)
|
|
current = parentID
|
|
}
|
|
|
|
return parents
|
|
}
|