package migrations import ( "database/sql" "fmt" "log" ) // MigrateOrphanDetection detects orphaned child issues and logs them for user action // Orphaned children are issues with hierarchical IDs (e.g., "parent.1") where the // parent issue no longer exists in the database. // // Hierarchical IDs have the format {parentID}.{N} where N is a numeric child suffix. // This correctly handles prefixes that contain dots (e.g., "my.project-abc123" is NOT // hierarchical, but "my.project-abc123.1" IS hierarchical). See GH#508. // // This migration does NOT automatically delete or convert orphans - it only logs them // so the user can decide whether to: // - Delete the orphans if they're no longer needed // - Convert them to top-level issues by renaming them // - Restore the missing parent issues func MigrateOrphanDetection(db *sql.DB) error { // Query for orphaned children: // - Must end with .N where N is 1-4 digits (covers child numbers 0-9999) // - Parent (everything before the last .N) must not exist in issues table // - Uses GLOB patterns to ensure suffix is purely numeric // - rtrim removes trailing digits, then trailing dot, to get parent ID // // GH#508: The old query used instr() to find the first dot, which incorrectly // flagged IDs with dots in the prefix (e.g., "my.project-abc") as orphans. // The fix uses GLOB patterns to only match IDs ending with .{digits}. rows, err := db.Query(` SELECT id FROM issues WHERE -- Must end with .N where N is 1-4 digits (child number suffix) (id GLOB '*.[0-9]' OR id GLOB '*.[0-9][0-9]' OR id GLOB '*.[0-9][0-9][0-9]' OR id GLOB '*.[0-9][0-9][0-9][0-9]') -- Parent (remove trailing digits then dot) must not exist AND rtrim(rtrim(id, '0123456789'), '.') NOT IN (SELECT id FROM issues) -- Skip tombstones and closed issues - no point warning about dead orphans AND status NOT IN ('tombstone', 'closed') ORDER BY id `) if err != nil { return fmt.Errorf("failed to query for orphaned children: %w", err) } defer rows.Close() var orphans []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return fmt.Errorf("failed to scan orphan ID: %w", err) } orphans = append(orphans, id) } if err := rows.Err(); err != nil { return fmt.Errorf("error iterating orphan results: %w", err) } // Log results for user review if len(orphans) > 0 { log.Printf("⚠️ Orphan Detection: Found %d orphaned child issue(s):", len(orphans)) for _, id := range orphans { log.Printf(" - %s", id) } log.Println("\nThese issues have hierarchical IDs but their parent issues no longer exist.") log.Println("You can:") log.Println(" 1. Delete them if no longer needed: bd delete ") log.Println(" 2. Convert to top-level issues by exporting and reimporting with new IDs") log.Println(" 3. Restore the missing parent issues") } // Migration is idempotent - always succeeds since it's just detection/logging return nil }