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>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -33,7 +33,7 @@ func validateBatchIssues(issues []*types.Issue) error {
|
||||
}
|
||||
|
||||
// generateBatchIDs generates IDs for all issues that need them atomically
|
||||
func generateBatchIDs(ctx context.Context, conn *sql.Conn, issues []*types.Issue, actor string) error {
|
||||
func (s *SQLiteStorage) generateBatchIDs(ctx context.Context, conn *sql.Conn, issues []*types.Issue, actor string) error {
|
||||
// Get prefix from config (needed for both generation and validation)
|
||||
var prefix string
|
||||
err := conn.QueryRowContext(ctx, `SELECT value FROM config WHERE key = ?`, "issue_prefix").Scan(&prefix)
|
||||
@@ -45,7 +45,7 @@ func generateBatchIDs(ctx context.Context, conn *sql.Conn, issues []*types.Issue
|
||||
}
|
||||
|
||||
// Generate or validate IDs for all issues
|
||||
if err := EnsureIDs(ctx, conn, prefix, issues, actor); err != nil {
|
||||
if err := s.EnsureIDs(ctx, conn, prefix, issues, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ func (s *SQLiteStorage) CreateIssues(ctx context.Context, issues []*types.Issue,
|
||||
}()
|
||||
|
||||
// Phase 3: Generate IDs for issues that need them
|
||||
if err := generateBatchIDs(ctx, conn, issues, actor); err != nil {
|
||||
if err := s.generateBatchIDs(ctx, conn, issues, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ func TestGenerateBatchIDs(t *testing.T) {
|
||||
{Title: "Issue 3", Description: "Third", CreatedAt: time.Now()},
|
||||
}
|
||||
|
||||
err = generateBatchIDs(ctx, conn, issues, "test-actor")
|
||||
err = s.generateBatchIDs(ctx, conn, issues, "test-actor")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate IDs: %v", err)
|
||||
}
|
||||
@@ -299,7 +299,7 @@ func TestGenerateBatchIDs(t *testing.T) {
|
||||
{ID: "wrong-prefix-123", Title: "Wrong", CreatedAt: time.Now()},
|
||||
}
|
||||
|
||||
err = generateBatchIDs(ctx, conn, issues, "test-actor")
|
||||
err = s.generateBatchIDs(ctx, conn, issues, "test-actor")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong prefix")
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ func GenerateBatchIssueIDs(ctx context.Context, conn *sql.Conn, prefix string, i
|
||||
// EnsureIDs generates or validates IDs for issues
|
||||
// For issues with empty IDs, generates unique hash-based IDs
|
||||
// For issues with existing IDs, validates they match the prefix and parent exists (if hierarchical)
|
||||
func EnsureIDs(ctx context.Context, conn *sql.Conn, prefix string, issues []*types.Issue, actor string) error {
|
||||
func (s *SQLiteStorage) EnsureIDs(ctx context.Context, conn *sql.Conn, prefix string, issues []*types.Issue, actor string) error {
|
||||
usedIDs := make(map[string]bool)
|
||||
|
||||
// First pass: record explicitly provided IDs
|
||||
@@ -186,20 +186,19 @@ func EnsureIDs(ctx context.Context, conn *sql.Conn, prefix string, issues []*typ
|
||||
return err
|
||||
}
|
||||
|
||||
// For hierarchical IDs (bd-a3f8e9.1), validate parent exists
|
||||
// For hierarchical IDs (bd-a3f8e9.1), ensure parent exists
|
||||
if strings.Contains(issues[i].ID, ".") {
|
||||
// Extract parent ID (everything before the last dot)
|
||||
lastDot := strings.LastIndex(issues[i].ID, ".")
|
||||
parentID := issues[i].ID[:lastDot]
|
||||
|
||||
var parentCount int
|
||||
err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, parentID).Scan(&parentCount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check parent existence: %w", err)
|
||||
}
|
||||
if parentCount == 0 {
|
||||
return fmt.Errorf("parent issue %s does not exist", parentID)
|
||||
}
|
||||
// Try to resurrect entire parent chain if any parents are missing
|
||||
resurrected, err := s.TryResurrectParentChain(ctx, issues[i].ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resurrect parent chain for %s: %w", issues[i].ID, err)
|
||||
}
|
||||
if !resurrected {
|
||||
// Parent(s) not found in JSONL history - cannot proceed
|
||||
lastDot := strings.LastIndex(issues[i].ID, ".")
|
||||
parentID := issues[i].ID[:lastDot]
|
||||
return fmt.Errorf("parent issue %s does not exist and could not be resurrected from JSONL history", parentID)
|
||||
}
|
||||
}
|
||||
|
||||
usedIDs[issues[i].ID] = true
|
||||
|
||||
205
internal/storage/sqlite/resurrection.go
Normal file
205
internal/storage/sqlite/resurrection.go
Normal file
@@ -0,0 +1,205 @@
|
||||
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
|
||||
}
|
||||
@@ -179,20 +179,19 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
|
||||
return err
|
||||
}
|
||||
|
||||
// For hierarchical IDs (bd-a3f8e9.1), validate parent exists
|
||||
// For hierarchical IDs (bd-a3f8e9.1), ensure parent exists
|
||||
if strings.Contains(issue.ID, ".") {
|
||||
// Extract parent ID (everything before the last dot)
|
||||
lastDot := strings.LastIndex(issue.ID, ".")
|
||||
parentID := issue.ID[:lastDot]
|
||||
|
||||
var parentCount int
|
||||
err = conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, parentID).Scan(&parentCount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check parent existence: %w", err)
|
||||
}
|
||||
if parentCount == 0 {
|
||||
return fmt.Errorf("parent issue %s does not exist", parentID)
|
||||
}
|
||||
// Try to resurrect entire parent chain if any parents are missing
|
||||
resurrected, err := s.TryResurrectParentChain(ctx, issue.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resurrect parent chain for %s: %w", issue.ID, err)
|
||||
}
|
||||
if !resurrected {
|
||||
// Parent(s) not found in JSONL history - cannot proceed
|
||||
lastDot := strings.LastIndex(issue.ID, ".")
|
||||
parentID := issue.ID[:lastDot]
|
||||
return fmt.Errorf("parent issue %s does not exist and could not be resurrected from JSONL history", parentID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user