package fix import ( "bufio" "encoding/json" "fmt" "os" "path/filepath" "time" "github.com/steveyegge/beads/internal/types" ) // legacyDeletionRecord represents a single deletion entry from the legacy deletions.jsonl manifest. // This is inlined here for migration purposes only - new code uses inline tombstones. type legacyDeletionRecord struct { ID string `json:"id"` // Issue ID that was deleted Timestamp time.Time `json:"ts"` // When the deletion occurred Actor string `json:"by"` // Who performed the deletion Reason string `json:"reason,omitempty"` // Optional reason for deletion } // loadLegacyDeletions reads the legacy deletions.jsonl manifest. // Returns a map of deletion records keyed by issue ID. // This is inlined here for migration purposes only. func loadLegacyDeletions(path string) (map[string]legacyDeletionRecord, error) { records := make(map[string]legacyDeletionRecord) f, err := os.Open(path) // #nosec G304 - controlled path from caller if err != nil { if os.IsNotExist(err) { return records, nil } return nil, fmt.Errorf("failed to open deletions file: %w", err) } defer f.Close() scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 1024), 1024*1024) for scanner.Scan() { line := scanner.Text() if line == "" { continue } var record legacyDeletionRecord if err := json.Unmarshal([]byte(line), &record); err != nil { continue // Skip corrupt lines } if record.ID == "" { continue // Skip records without ID } records[record.ID] = record } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("error reading deletions file: %w", err) } return records, nil } // MigrateTombstones converts legacy deletions.jsonl entries to inline tombstones. // This is called by bd doctor --fix when legacy deletions are detected. func MigrateTombstones(path string) error { if err := validateBeadsWorkspace(path); err != nil { return err } beadsDir := filepath.Join(path, ".beads") deletionsPath := filepath.Join(beadsDir, "deletions.jsonl") jsonlPath := filepath.Join(beadsDir, "issues.jsonl") // Check if deletions.jsonl exists if _, err := os.Stat(deletionsPath); os.IsNotExist(err) { fmt.Println(" No deletions.jsonl found - already using tombstones") return nil } // Load deletions records, err := loadLegacyDeletions(deletionsPath) if err != nil { return fmt.Errorf("failed to load deletions: %w", err) } if len(records) == 0 { fmt.Println(" deletions.jsonl is empty - nothing to migrate") return nil } // Load existing JSONL to check for already-existing tombstones existingTombstones := make(map[string]bool) if file, err := os.Open(filepath.Clean(jsonlPath)); err == nil { scanner := bufio.NewScanner(file) scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) for scanner.Scan() { var issue struct { ID string `json:"id"` Status string `json:"status"` } if err := json.Unmarshal(scanner.Bytes(), &issue); err == nil { if issue.Status == string(types.StatusTombstone) { existingTombstones[issue.ID] = true } } } _ = file.Close() } // Convert deletions to tombstones var toMigrate []legacyDeletionRecord var skipped int for _, record := range records { if existingTombstones[record.ID] { skipped++ continue } toMigrate = append(toMigrate, record) } if len(toMigrate) == 0 { fmt.Printf(" All %d deletion(s) already have tombstones - archiving deletions.jsonl\n", skipped) } else { // Append tombstones to issues.jsonl file, err := os.OpenFile(filepath.Clean(jsonlPath), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) if err != nil { return fmt.Errorf("failed to open issues.jsonl: %w", err) } defer file.Close() for _, record := range toMigrate { tombstone := convertLegacyDeletionToTombstone(record) data, err := json.Marshal(tombstone) if err != nil { return fmt.Errorf("failed to marshal tombstone for %s: %w", record.ID, err) } if _, err := file.Write(append(data, '\n')); err != nil { return fmt.Errorf("failed to write tombstone for %s: %w", record.ID, err) } } fmt.Printf(" Migrated %d deletion(s) to tombstones\n", len(toMigrate)) if skipped > 0 { fmt.Printf(" Skipped %d (already had tombstones)\n", skipped) } } // Archive deletions.jsonl migratedPath := deletionsPath + ".migrated" if err := os.Rename(deletionsPath, migratedPath); err != nil { return fmt.Errorf("failed to archive deletions.jsonl: %w", err) } fmt.Printf(" Archived deletions.jsonl → deletions.jsonl.migrated\n") return nil } // convertLegacyDeletionToTombstone converts a legacy DeletionRecord to a tombstone Issue. func convertLegacyDeletionToTombstone(record legacyDeletionRecord) *types.Issue { now := time.Now() deletedAt := record.Timestamp if deletedAt.IsZero() { deletedAt = now } return &types.Issue{ ID: record.ID, Title: "[Deleted]", Status: types.StatusTombstone, IssueType: types.TypeTask, // Default type for validation Priority: 0, // Unknown priority CreatedAt: deletedAt, UpdatedAt: now, DeletedAt: &deletedAt, DeletedBy: record.Actor, DeleteReason: record.Reason, OriginalType: string(types.TypeTask), } }