# redMine - project management software # Copyright (C) 2006-2007 Jean-Philippe Lang # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # Rake task to migrate from Eventum to Redmine. # Based on migrate_from_trac.rake. # # Instructions: # 1. Edit the constants in the beginning of the script. # 2. Search for example.com and enter some sane values for each hit. # 3. Place file in lib/tasks. # 4. Run with rake redmine:migrate_from_eventum RAILS_ENV="production" # # Apologies for the lack of polish. Once the script worked for me I had # to move on. I hope it can serve as a starting point for your own # migration. # # Alexander Ljungberg, 2009 # require 'active_record' require 'iconv' require 'pp' namespace :redmine do desc 'Eventum migration script' task :migrate_from_eventum => :environment do module EventumMigrate TICKET_MAP = [] DEFAULT_STATUS = IssueStatus.default assigned_status = IssueStatus.find_by_position(2) resolved_status = IssueStatus.find_by_position(3) feedback_status = IssueStatus.find_by_position(4) # Edit until it makes sense for you. closed_status = IssueStatus.find_by_name("Closed") rejected_status = IssueStatus.find_by_name("Rejected") STATUS_MAPPING = {'discovery' => DEFAULT_STATUS, 'stalled' => feedback_status, 'deathrow' => feedback_status, 'assigned' => assigned_status, 'implementation' => assigned_status, 'evaluation and testing' => resolved_status, 'verified' => resolved_status, 'released' => resolved_status, 'killed' => closed_status, 'closed' => closed_status } priorities = Enumeration.get_values('IPRI') DEFAULT_PRIORITY = priorities[0] PRIORITY_MAPPING = {'not prioritized' => priorities[0], 'minor' => priorities[1], 'major' => priorities[2], 'critical' => priorities[2], 'blocker' => priorities[4] } TRACKER_BUG = Tracker.find_by_name("Bug") TRACKER_FEATURE = Tracker.find_by_name("Feature") TRACKER_SUPPORT = Tracker.find_by_name("Support") DEFAULT_TRACKER = TRACKER_BUG TRACKER_MAPPING = {'bug' => TRACKER_BUG, 'feature request' => TRACKER_FEATURE, 'technical support' => TRACKER_SUPPORT } roles = Role.find(:all, :conditions => {:builtin => 0}, :order => 'position ASC') manager_role = roles[0] developer_role = roles[1] DEFAULT_ROLE = roles.last ROLE_MAPPING = {'admin' => manager_role, 'developer' => developer_role } DEFAULT_USER = "admin@example.com" class ::Time class << self alias :real_now :now def now real_now - @fake_diff.to_i end def fake(time) @fake_diff = real_now - time res = yield @fake_diff = 0 res end end end class EventumProject < ActiveRecord::Base set_table_name :eventum_project has_many :issues, :class_name => "EventumIssue", :foreign_key => :iss_prj_id end class EventumStatus < ActiveRecord::Base set_table_name :eventum_status end class EventumProjectPriority < ActiveRecord::Base set_table_name :eventum_project_priority end class EventumProjectCategory < ActiveRecord::Base set_table_name :eventum_project_category end class EventumIssueUser < ActiveRecord::Base set_table_name :eventum_issue_user end class EventumUser < ActiveRecord::Base set_table_name :eventum_user end class EventumRelease < ActiveRecord::Base set_table_name :eventum_project_release # If this attribute is set a milestone has a defined target timepoint def due if read_attribute(:pre_scheduled_date) && read_attribute(:pre_scheduled_date) > 0 Time.at(read_attribute(:pre_scheduled_date)).to_date else nil end end # This is the real timepoint at which the milestone has finished. def completed due end def description read_attribute(:pre_title) end end class EventumAttachment < ActiveRecord::Base set_table_name :eventum_issue_attachment set_inheritance_column :none set_primary_key :iat_id has_many :files, :class_name => "EventumAttachmentFile", :foreign_key => :iaf_iat_id def time; Time.at(read_attribute(:iat_created_date)) end def description read_attribute(:iat_description).to_s.slice(0,255) end end class EventumAttachmentFile < ActiveRecord::Base set_table_name :eventum_issue_attachment_file def original_filename iaf_filename end def content_type iaf_filetype end def open @file = StringIO.new(iaf_file) yield self end def read(*args) @file.read(*args) end def size() @file.size end def close(*args) @file.close(*args) @file = nil end end class EventumIssueHistory < ActiveRecord::Base set_table_name :eventum_issue_history def time; Time.at(read_attribute(:his_created_date)) end end class EventumNote < ActiveRecord::Base set_table_name :eventum_note end class EventumIssue < ActiveRecord::Base set_table_name :eventum_issue set_inheritance_column :none set_primary_key :iss_id # ticket changes: only migrate status changes and comments has_many :changes, :class_name => "EventumIssueHistory", :foreign_key => :his_iss_id has_many :notes, :class_name => "EventumNote", :foreign_key => :not_iss_id has_many :attachments, :class_name => "EventumAttachment", :foreign_key => :iat_iss_id has_many :assignees, :class_name => "EventumIssueUser", :foreign_key => :isu_iss_id def iss_sta_id iss_sta_id_before_type_cast end def ticket_type read_attribute(:type) end def time; Time.at(read_attribute(:iss_created_date)) end def changetime; Time.at(read_attribute(:iss_updated_date)) end end def self.find_or_create_user(email, project_member = false) u = User.find_by_mail(email) if !u # Create a new user if not found mail = email[0,limit_for(User, 'mail')] mail = "#{mail}@example.com" unless mail.include?("@") name = email[0,email.index("@")]; u = User.new :firstname => name[0,limit_for(User, 'firstname')].gsub(/[^\w\s\'\-]/i, '-'), :lastname => '-', :mail => mail.gsub(/[^-@a-z0-9\.]/i, '-') u.login = email[0,limit_for(User, 'login')].gsub(/[^a-z0-9_\-@\.]/i, '-') u.password = '273278' u.admin = false # finally, a default user is used if the new user is not valid # puts "Created User: "+ u.to_yaml u = User.find(:first) unless u.save end # Make sure he is a member of the project if project_member && !u.member_of?(@target_project) role = ROLE_MAPPING['developer'] Member.create(:user => u, :project => @target_project, :role => role) u.reload end u end def self.migrate establish_connection wiki = @target_project.wiki || Wiki.new(:project => @target_project, :start_page => @target_project.name) wiki_edit_count = 0 # Quick database test EventumProject.count # Find the source project. source_project = EventumProject.find_by_prj_title(@source_project_identifier) if !source_project puts "Couldn't find source project #{@source_project_identifier}" exit end puts "Found source project: id=#{source_project.prj_id}" migrated_milestones = 0 migrated_tickets = 0 migrated_ticket_attachments = 0 print "Mapping statuses" status_map = {} EventumStatus.find(:all).each do |status| print '.' STDOUT.flush status_map[status.sta_id] = STATUS_MAPPING[status.sta_title] if !status_map[status.sta_id] puts puts "No mapping for Eventum status #{status.sta_title}. Edit the source code STATUS_MAPPING." exit end end puts puts "Status map: #{status_map.inspect}" print "Mapping priorities" priority_map = {} EventumProjectPriority.find_all_by_pri_prj_id(source_project.prj_id).each do |priority| print '.' STDOUT.flush if !PRIORITY_MAPPING[priority.pri_title.downcase] priority_map[priority.pri_id] = DEFAULT_PRIORITY else priority_map[priority.pri_id] = PRIORITY_MAPPING[priority.pri_title.downcase] end end puts #puts "Priority map: #{priority_map.inspect}" print "Mapping trackers" tracker_map = {} EventumProjectCategory.find(:all).each do |category| print '.' STDOUT.flush if !TRACKER_MAPPING[category.prc_title.downcase] tracker_map[category.prc_id] = DEFAULT_TRACKER else tracker_map[category.prc_id] = TRACKER_MAPPING[category.prc_title.downcase] end end puts #puts "Tracker map: #{tracker_map.inspect}" print "Mapping versions" version_map = {} EventumRelease.find_all_by_pre_prj_id(source_project.prj_id).each do |release| print '.' STDOUT.flush # First we try to find the wiki page... p = wiki.find_or_new_page(release.pre_title.to_s) p.content = WikiContent.new(:page => p) if p.new_record? p.content.text = "h1. Release #{release.pre_title}\n\nImported from Eventum." p.content.author = find_or_create_user('admin@example.com') p.content.comments = 'Release page imported from Eventum.' p.save v = Version.new :project => @target_project, :name => encode(release.pre_title[0, limit_for(Version, 'name')]), :description => nil, :wiki_page_title => release.pre_title.to_s, :effective_date => release.pre_scheduled_date next unless v.save version_map[release.pre_id] = v migrated_milestones += 1 # puts "Created Version #{v} for Release #{release.pre_title}" end puts # Tickets print "Migrating tickets" EventumIssue.find(:all, :conditions => { :iss_prj_id => source_project.prj_id }, :order => 'iss_id ASC').each do |ticket| print '.' STDOUT.flush i = Issue.new :project => @target_project, :subject => encode(ticket.iss_summary[0, limit_for(Issue, 'subject')]), :description => encode(ticket.iss_description), :priority => priority_map[ticket.iss_pri_id] || DEFAULT_PRIORITY, :created_on => ticket.time i.author = find_or_create_user(EventumUser.find_by_usr_id(ticket.iss_usr_id).usr_email) i.due_date = ticket.iss_expected_resolution_date i.estimated_hours = ticket.iss_developer_est_time i.done_ratio = ticket.iss_percent_complete #i.category = issues_category_map[ticket.component] unless ticket.component.blank? i.fixed_version = version_map[ticket.iss_pre_id] unless ticket.iss_pre_id.blank? i.status = status_map[ticket.iss_sta_id.to_i] || DEFAULT_STATUS i.tracker = tracker_map[ticket.iss_prc_id.to_i] || DEFAULT_TRACKER i.id = ticket.iss_id unless Issue.exists?(ticket.iss_id) next unless Time.fake(ticket.changetime) { i.save } TICKET_MAP[ticket.iss_id] = i.id migrated_tickets += 1 # Owner if ticket.assignees.length > 0 assigned = ticket.assignees.sort {|x,y| x.isu_assigned_date <=> y.isu_assigned_date}.last eu = EventumUser.find_by_usr_id(assigned.isu_usr_id) i.assigned_to = find_or_create_user(eu.usr_email, true) Time.fake(assigned.isu_assigned_date) { i.save } end ticket.changes.each do |history| # These messages are just spam because we get 'associated changesets' instead in Redmine. next if history.his_summary.match(/^SCM Checkins associated by/) eu = EventumUser.find_by_usr_id(history.his_usr_id) n = Journal.new :notes => history.his_summary, :created_on => history.his_created_date n.user = find_or_create_user(eu.usr_email) n.journalized = i n.save unless n.details.empty? && n.notes.blank? end ticket.notes.each do |note| n = Journal.new :notes => note.not_note, :created_on => note.not_created_date eu = EventumUser.find_by_usr_id(note.not_usr_id) n.user = find_or_create_user(eu.usr_email) n.journalized = i n.save unless n.details.empty? && n.notes.blank? end # Attachments ticket.attachments.each do |attachment| attachment.files.each do |file| file.open { a = Attachment.new :created_on => attachment.iat_created_date a.file = file eu = EventumUser.find_by_usr_id(attachment.iat_usr_id) a.author = find_or_create_user(eu.usr_email) a.container = i a.description = attachment.description migrated_ticket_attachments += 1 if a.save } end end end # update issue id sequence if needed (postgresql) Issue.connection.reset_pk_sequence!(Issue.table_name) if Issue.connection.respond_to?('reset_pk_sequence!') puts puts puts "Milestones: #{migrated_milestones}/#{EventumRelease.count}" puts "Tickets: #{migrated_tickets}/#{EventumIssue.count}" puts "Ticket files: #{migrated_ticket_attachments}/#{EventumAttachmentFile.count}" end def self.limit_for(klass, attribute) klass.columns_hash[attribute.to_s].limit end def self.encoding(charset) @ic = Iconv.new('UTF-8', charset) rescue Iconv::InvalidEncoding puts "Invalid encoding!" return false end def self.set_eventum_adapter(adapter) return false if adapter.blank? raise "Unknown adapter: #{adapter}!" unless %w(mysql).include?(adapter) @@eventum_adapter = adapter rescue Exception => e puts e return false end def self.set_eventum_db_host(host) return nil if host.blank? @@eventum_db_host = host end def self.set_eventum_db_port(port) return nil if port.to_i == 0 @@eventum_db_port = port.to_i end def self.set_eventum_db_name(name) return nil if name.blank? @@eventum_db_name = name end def self.set_eventum_db_username(username) @@eventum_db_username = username end def self.set_eventum_db_password(password) @@eventum_db_password = password end def self.set_eventum_db_schema(schema) @@eventum_db_schema = schema end mattr_reader :trac_directory, :eventum_adapter, :eventum_db_host, :eventum_db_port, :eventum_db_name, :eventum_db_schema, :eventum_db_username, :eventum_db_password def self.source_project_identifier(identifier) @source_project_identifier = identifier end def self.target_project_identifier(identifier) project = Project.find_by_identifier(identifier) if !project # create the target project project = Project.new :name => identifier.humanize, :description => '' project.identifier = identifier puts "Unable to create a project with identifier '#{identifier}'!" unless project.save # enable issues and wiki for the created project project.enabled_module_names = ['issue_tracking', 'wiki'] else puts puts "This project already exists in your Redmine database." print "Are you sure you want to append data to this project ? [Y/n] " exit if STDIN.gets.match(/^n$/i) end project.trackers << TRACKER_BUG unless project.trackers.include?(TRACKER_BUG) project.trackers << TRACKER_FEATURE unless project.trackers.include?(TRACKER_FEATURE) @target_project = project.new_record? ? nil : project end def self.connection_params {:adapter => eventum_adapter, :database => eventum_db_name, :host => eventum_db_host, :port => eventum_db_port, :username => eventum_db_username, :password => eventum_db_password, :schema_search_path => eventum_db_schema } end def self.establish_connection constants.each do |const| klass = const_get(const) next unless klass.respond_to? 'establish_connection' klass.establish_connection connection_params end end private def self.encode(text) @ic.iconv text rescue text end end puts if Redmine::DefaultData::Loader.no_data? puts "Redmine configuration needs to be loaded before importing data." puts "Please, run this first:" puts puts " rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\"" exit end puts "WARNING: a new project will be added to Redmine during this process." print "Are you sure you want to continue ? [y/N] " break unless STDIN.gets.match(/^y$/i) puts def prompt(text, options = {}, &block) default = options[:default] || '' while true print "#{text} [#{default}]: " value = STDIN.gets.chomp! value = default if value.blank? break if yield value end end DEFAULT_PORTS = {'mysql' => 3306, 'postgresql' => 5432} #prompt('Eventum database adapter (mysql)', :default => 'mysql') {|adapter| EventumMigrate.set_eventum_adapter adapter} EventumMigrate.set_eventum_adapter 'mysql' unless %w(sqlite sqlite3).include?(EventumMigrate.eventum_adapter) prompt('Eventum database host', :default => 'localhost') {|host| EventumMigrate.set_eventum_db_host host} prompt('Eventum database port', :default => DEFAULT_PORTS[EventumMigrate.eventum_adapter]) {|port| EventumMigrate.set_eventum_db_port port} prompt('Eventum database name') {|name| EventumMigrate.set_eventum_db_name name} prompt('Eventum database schema', :default => 'public') {|schema| EventumMigrate.set_eventum_db_schema schema} prompt('Eventum database username') {|username| EventumMigrate.set_eventum_db_username username} prompt('Eventum database password') {|password| EventumMigrate.set_eventum_db_password password} end prompt('Eventum database encoding', :default => 'UTF-8') {|encoding| EventumMigrate.encoding encoding} prompt('Source project identifier') {|identifier| EventumMigrate.source_project_identifier identifier} prompt('Target project identifier') {|identifier| EventumMigrate.target_project_identifier identifier} puts EventumMigrate.migrate end end