annotate lib/tasks/migrate_from_trac.rake @ 8:0c83d98252d9 yuya

* Add custom repo prefix and proper auth realm, remove auth cache (seems like an unwise feature), pass DB handle around, various other bits of tidying
author Chris Cannam
date Thu, 12 Aug 2010 15:31:37 +0100
parents 513646585e45
children af80e5618e9b 8661b858af72
rev   line source
Chris@0 1 # redMine - project management software
Chris@0 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
Chris@0 3 #
Chris@0 4 # This program is free software; you can redistribute it and/or
Chris@0 5 # modify it under the terms of the GNU General Public License
Chris@0 6 # as published by the Free Software Foundation; either version 2
Chris@0 7 # of the License, or (at your option) any later version.
Chris@0 8 #
Chris@0 9 # This program is distributed in the hope that it will be useful,
Chris@0 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
Chris@0 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Chris@0 12 # GNU General Public License for more details.
Chris@0 13 #
Chris@0 14 # You should have received a copy of the GNU General Public License
Chris@0 15 # along with this program; if not, write to the Free Software
Chris@0 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
Chris@0 17
Chris@0 18 require 'active_record'
Chris@0 19 require 'iconv'
Chris@0 20 require 'pp'
Chris@0 21
Chris@0 22 namespace :redmine do
Chris@0 23 desc 'Trac migration script'
Chris@0 24 task :migrate_from_trac => :environment do
Chris@0 25
Chris@0 26 module TracMigrate
Chris@0 27 TICKET_MAP = []
Chris@0 28
Chris@0 29 DEFAULT_STATUS = IssueStatus.default
Chris@0 30 assigned_status = IssueStatus.find_by_position(2)
Chris@0 31 resolved_status = IssueStatus.find_by_position(3)
Chris@0 32 feedback_status = IssueStatus.find_by_position(4)
Chris@0 33 closed_status = IssueStatus.find :first, :conditions => { :is_closed => true }
Chris@0 34 STATUS_MAPPING = {'new' => DEFAULT_STATUS,
Chris@0 35 'reopened' => feedback_status,
Chris@0 36 'assigned' => assigned_status,
Chris@0 37 'closed' => closed_status
Chris@0 38 }
Chris@0 39
Chris@0 40 priorities = IssuePriority.all
Chris@0 41 DEFAULT_PRIORITY = priorities[0]
Chris@0 42 PRIORITY_MAPPING = {'lowest' => priorities[0],
Chris@0 43 'low' => priorities[0],
Chris@0 44 'normal' => priorities[1],
Chris@0 45 'high' => priorities[2],
Chris@0 46 'highest' => priorities[3],
Chris@0 47 # ---
Chris@0 48 'trivial' => priorities[0],
Chris@0 49 'minor' => priorities[1],
Chris@0 50 'major' => priorities[2],
Chris@0 51 'critical' => priorities[3],
Chris@0 52 'blocker' => priorities[4]
Chris@0 53 }
Chris@0 54
Chris@0 55 TRACKER_BUG = Tracker.find_by_position(1)
Chris@0 56 TRACKER_FEATURE = Tracker.find_by_position(2)
Chris@0 57 DEFAULT_TRACKER = TRACKER_BUG
Chris@0 58 TRACKER_MAPPING = {'defect' => TRACKER_BUG,
Chris@0 59 'enhancement' => TRACKER_FEATURE,
Chris@0 60 'task' => TRACKER_FEATURE,
Chris@0 61 'patch' =>TRACKER_FEATURE
Chris@0 62 }
Chris@0 63
Chris@0 64 roles = Role.find(:all, :conditions => {:builtin => 0}, :order => 'position ASC')
Chris@0 65 manager_role = roles[0]
Chris@0 66 developer_role = roles[1]
Chris@0 67 DEFAULT_ROLE = roles.last
Chris@0 68 ROLE_MAPPING = {'admin' => manager_role,
Chris@0 69 'developer' => developer_role
Chris@0 70 }
Chris@0 71
Chris@0 72 class ::Time
Chris@0 73 class << self
Chris@0 74 alias :real_now :now
Chris@0 75 def now
Chris@0 76 real_now - @fake_diff.to_i
Chris@0 77 end
Chris@0 78 def fake(time)
Chris@0 79 @fake_diff = real_now - time
Chris@0 80 res = yield
Chris@0 81 @fake_diff = 0
Chris@0 82 res
Chris@0 83 end
Chris@0 84 end
Chris@0 85 end
Chris@0 86
Chris@0 87 class TracComponent < ActiveRecord::Base
Chris@0 88 set_table_name :component
Chris@0 89 end
Chris@0 90
Chris@0 91 class TracMilestone < ActiveRecord::Base
Chris@0 92 set_table_name :milestone
Chris@0 93 # If this attribute is set a milestone has a defined target timepoint
Chris@0 94 def due
Chris@0 95 if read_attribute(:due) && read_attribute(:due) > 0
Chris@0 96 Time.at(read_attribute(:due)).to_date
Chris@0 97 else
Chris@0 98 nil
Chris@0 99 end
Chris@0 100 end
Chris@0 101 # This is the real timepoint at which the milestone has finished.
Chris@0 102 def completed
Chris@0 103 if read_attribute(:completed) && read_attribute(:completed) > 0
Chris@0 104 Time.at(read_attribute(:completed)).to_date
Chris@0 105 else
Chris@0 106 nil
Chris@0 107 end
Chris@0 108 end
Chris@0 109
Chris@0 110 def description
Chris@0 111 # Attribute is named descr in Trac v0.8.x
Chris@0 112 has_attribute?(:descr) ? read_attribute(:descr) : read_attribute(:description)
Chris@0 113 end
Chris@0 114 end
Chris@0 115
Chris@0 116 class TracTicketCustom < ActiveRecord::Base
Chris@0 117 set_table_name :ticket_custom
Chris@0 118 end
Chris@0 119
Chris@0 120 class TracAttachment < ActiveRecord::Base
Chris@0 121 set_table_name :attachment
Chris@0 122 set_inheritance_column :none
Chris@0 123
Chris@0 124 def time; Time.at(read_attribute(:time)) end
Chris@0 125
Chris@0 126 def original_filename
Chris@0 127 filename
Chris@0 128 end
Chris@0 129
Chris@0 130 def content_type
Chris@0 131 ''
Chris@0 132 end
Chris@0 133
Chris@0 134 def exist?
Chris@0 135 File.file? trac_fullpath
Chris@0 136 end
Chris@0 137
Chris@0 138 def open
Chris@0 139 File.open("#{trac_fullpath}", 'rb') {|f|
Chris@0 140 @file = f
Chris@0 141 yield self
Chris@0 142 }
Chris@0 143 end
Chris@0 144
Chris@0 145 def read(*args)
Chris@0 146 @file.read(*args)
Chris@0 147 end
Chris@0 148
Chris@0 149 def description
Chris@0 150 read_attribute(:description).to_s.slice(0,255)
Chris@0 151 end
Chris@0 152
Chris@0 153 private
Chris@0 154 def trac_fullpath
Chris@0 155 attachment_type = read_attribute(:type)
Chris@0 156 trac_file = filename.gsub( /[^a-zA-Z0-9\-_\.!~*']/n ) {|x| sprintf('%%%02x', x[0]) }
Chris@0 157 "#{TracMigrate.trac_attachments_directory}/#{attachment_type}/#{id}/#{trac_file}"
Chris@0 158 end
Chris@0 159 end
Chris@0 160
Chris@0 161 class TracTicket < ActiveRecord::Base
Chris@0 162 set_table_name :ticket
Chris@0 163 set_inheritance_column :none
Chris@0 164
Chris@0 165 # ticket changes: only migrate status changes and comments
Chris@0 166 has_many :changes, :class_name => "TracTicketChange", :foreign_key => :ticket
Chris@0 167 has_many :attachments, :class_name => "TracAttachment",
Chris@0 168 :finder_sql => "SELECT DISTINCT attachment.* FROM #{TracMigrate::TracAttachment.table_name}" +
Chris@0 169 " WHERE #{TracMigrate::TracAttachment.table_name}.type = 'ticket'" +
Chris@0 170 ' AND #{TracMigrate::TracAttachment.table_name}.id = \'#{id}\''
Chris@0 171 has_many :customs, :class_name => "TracTicketCustom", :foreign_key => :ticket
Chris@0 172
Chris@0 173 def ticket_type
Chris@0 174 read_attribute(:type)
Chris@0 175 end
Chris@0 176
Chris@0 177 def summary
Chris@0 178 read_attribute(:summary).blank? ? "(no subject)" : read_attribute(:summary)
Chris@0 179 end
Chris@0 180
Chris@0 181 def description
Chris@0 182 read_attribute(:description).blank? ? summary : read_attribute(:description)
Chris@0 183 end
Chris@0 184
Chris@0 185 def time; Time.at(read_attribute(:time)) end
Chris@0 186 def changetime; Time.at(read_attribute(:changetime)) end
Chris@0 187 end
Chris@0 188
Chris@0 189 class TracTicketChange < ActiveRecord::Base
Chris@0 190 set_table_name :ticket_change
Chris@0 191
Chris@0 192 def time; Time.at(read_attribute(:time)) end
Chris@0 193 end
Chris@0 194
Chris@0 195 TRAC_WIKI_PAGES = %w(InterMapTxt InterTrac InterWiki RecentChanges SandBox TracAccessibility TracAdmin TracBackup TracBrowser TracCgi TracChangeset \
Chris@0 196 TracEnvironment TracFastCgi TracGuide TracImport TracIni TracInstall TracInterfaceCustomization \
Chris@0 197 TracLinks TracLogging TracModPython TracNotification TracPermissions TracPlugins TracQuery \
Chris@0 198 TracReports TracRevisionLog TracRoadmap TracRss TracSearch TracStandalone TracSupport TracSyntaxColoring TracTickets \
Chris@0 199 TracTicketsCustomFields TracTimeline TracUnicode TracUpgrade TracWiki WikiDeletePage WikiFormatting \
Chris@0 200 WikiHtml WikiMacros WikiNewPage WikiPageNames WikiProcessors WikiRestructuredText WikiRestructuredTextLinks \
Chris@0 201 CamelCase TitleIndex)
Chris@0 202
Chris@0 203 class TracWikiPage < ActiveRecord::Base
Chris@0 204 set_table_name :wiki
Chris@0 205 set_primary_key :name
Chris@0 206
Chris@0 207 has_many :attachments, :class_name => "TracAttachment",
Chris@0 208 :finder_sql => "SELECT DISTINCT attachment.* FROM #{TracMigrate::TracAttachment.table_name}" +
Chris@0 209 " WHERE #{TracMigrate::TracAttachment.table_name}.type = 'wiki'" +
Chris@0 210 ' AND #{TracMigrate::TracAttachment.table_name}.id = \'#{id}\''
Chris@0 211
Chris@0 212 def self.columns
Chris@0 213 # Hides readonly Trac field to prevent clash with AR readonly? method (Rails 2.0)
Chris@0 214 super.select {|column| column.name.to_s != 'readonly'}
Chris@0 215 end
Chris@0 216
Chris@0 217 def time; Time.at(read_attribute(:time)) end
Chris@0 218 end
Chris@0 219
Chris@0 220 class TracPermission < ActiveRecord::Base
Chris@0 221 set_table_name :permission
Chris@0 222 end
Chris@0 223
Chris@0 224 class TracSessionAttribute < ActiveRecord::Base
Chris@0 225 set_table_name :session_attribute
Chris@0 226 end
Chris@0 227
Chris@0 228 def self.find_or_create_user(username, project_member = false)
Chris@0 229 return User.anonymous if username.blank?
Chris@0 230
Chris@0 231 u = User.find_by_login(username)
Chris@0 232 if !u
Chris@0 233 # Create a new user if not found
Chris@0 234 mail = username[0,limit_for(User, 'mail')]
Chris@0 235 if mail_attr = TracSessionAttribute.find_by_sid_and_name(username, 'email')
Chris@0 236 mail = mail_attr.value
Chris@0 237 end
Chris@0 238 mail = "#{mail}@foo.bar" unless mail.include?("@")
Chris@0 239
Chris@0 240 name = username
Chris@0 241 if name_attr = TracSessionAttribute.find_by_sid_and_name(username, 'name')
Chris@0 242 name = name_attr.value
Chris@0 243 end
Chris@0 244 name =~ (/(.*)(\s+\w+)?/)
Chris@0 245 fn = $1.strip
Chris@0 246 ln = ($2 || '-').strip
Chris@0 247
Chris@0 248 u = User.new :mail => mail.gsub(/[^-@a-z0-9\.]/i, '-'),
Chris@0 249 :firstname => fn[0, limit_for(User, 'firstname')].gsub(/[^\w\s\'\-]/i, '-'),
Chris@0 250 :lastname => ln[0, limit_for(User, 'lastname')].gsub(/[^\w\s\'\-]/i, '-')
Chris@0 251
Chris@0 252 u.login = username[0,limit_for(User, 'login')].gsub(/[^a-z0-9_\-@\.]/i, '-')
Chris@0 253 u.password = 'trac'
Chris@0 254 u.admin = true if TracPermission.find_by_username_and_action(username, 'admin')
Chris@0 255 # finally, a default user is used if the new user is not valid
Chris@0 256 u = User.find(:first) unless u.save
Chris@0 257 end
Chris@0 258 # Make sure he is a member of the project
Chris@0 259 if project_member && !u.member_of?(@target_project)
Chris@0 260 role = DEFAULT_ROLE
Chris@0 261 if u.admin
Chris@0 262 role = ROLE_MAPPING['admin']
Chris@0 263 elsif TracPermission.find_by_username_and_action(username, 'developer')
Chris@0 264 role = ROLE_MAPPING['developer']
Chris@0 265 end
Chris@0 266 Member.create(:user => u, :project => @target_project, :roles => [role])
Chris@0 267 u.reload
Chris@0 268 end
Chris@0 269 u
Chris@0 270 end
Chris@0 271
Chris@0 272 # Basic wiki syntax conversion
Chris@0 273 def self.convert_wiki_text(text)
Chris@0 274 # Titles
Chris@0 275 text = text.gsub(/^(\=+)\s(.+)\s(\=+)/) {|s| "\nh#{$1.length}. #{$2}\n"}
Chris@0 276 # External Links
Chris@0 277 text = text.gsub(/\[(http[^\s]+)\s+([^\]]+)\]/) {|s| "\"#{$2}\":#{$1}"}
Chris@0 278 # Ticket links:
Chris@0 279 # [ticket:234 Text],[ticket:234 This is a test]
Chris@0 280 text = text.gsub(/\[ticket\:([^\ ]+)\ (.+?)\]/, '"\2":/issues/show/\1')
Chris@0 281 # ticket:1234
Chris@0 282 # #1 is working cause Redmine uses the same syntax.
Chris@0 283 text = text.gsub(/ticket\:([^\ ]+)/, '#\1')
Chris@0 284 # Milestone links:
Chris@0 285 # [milestone:"0.1.0 Mercury" Milestone 0.1.0 (Mercury)]
Chris@0 286 # The text "Milestone 0.1.0 (Mercury)" is not converted,
Chris@0 287 # cause Redmine's wiki does not support this.
Chris@0 288 text = text.gsub(/\[milestone\:\"([^\"]+)\"\ (.+?)\]/, 'version:"\1"')
Chris@0 289 # [milestone:"0.1.0 Mercury"]
Chris@0 290 text = text.gsub(/\[milestone\:\"([^\"]+)\"\]/, 'version:"\1"')
Chris@0 291 text = text.gsub(/milestone\:\"([^\"]+)\"/, 'version:"\1"')
Chris@0 292 # milestone:0.1.0
Chris@0 293 text = text.gsub(/\[milestone\:([^\ ]+)\]/, 'version:\1')
Chris@0 294 text = text.gsub(/milestone\:([^\ ]+)/, 'version:\1')
Chris@0 295 # Internal Links
Chris@0 296 text = text.gsub(/\[\[BR\]\]/, "\n") # This has to go before the rules below
Chris@0 297 text = text.gsub(/\[\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
Chris@0 298 text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
Chris@0 299 text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
Chris@0 300 text = text.gsub(/\[wiki:([^\s\]]+)\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
Chris@0 301 text = text.gsub(/\[wiki:([^\s\]]+)\s(.*)\]/) {|s| "[[#{$1.delete(',./?;|:')}|#{$2.delete(',./?;|:')}]]"}
Chris@0 302
Chris@0 303 # Links to pages UsingJustWikiCaps
Chris@0 304 text = text.gsub(/([^!]|^)(^| )([A-Z][a-z]+[A-Z][a-zA-Z]+)/, '\\1\\2[[\3]]')
Chris@0 305 # Normalize things that were supposed to not be links
Chris@0 306 # like !NotALink
Chris@0 307 text = text.gsub(/(^| )!([A-Z][A-Za-z]+)/, '\1\2')
Chris@0 308 # Revisions links
Chris@0 309 text = text.gsub(/\[(\d+)\]/, 'r\1')
Chris@0 310 # Ticket number re-writing
Chris@0 311 text = text.gsub(/#(\d+)/) do |s|
Chris@0 312 if $1.length < 10
Chris@0 313 # TICKET_MAP[$1.to_i] ||= $1
Chris@0 314 "\##{TICKET_MAP[$1.to_i] || $1}"
Chris@0 315 else
Chris@0 316 s
Chris@0 317 end
Chris@0 318 end
Chris@0 319 # We would like to convert the Code highlighting too
Chris@0 320 # This will go into the next line.
Chris@0 321 shebang_line = false
Chris@0 322 # Reguar expression for start of code
Chris@0 323 pre_re = /\{\{\{/
Chris@0 324 # Code hightlighing...
Chris@0 325 shebang_re = /^\#\!([a-z]+)/
Chris@0 326 # Regular expression for end of code
Chris@0 327 pre_end_re = /\}\}\}/
Chris@0 328
Chris@0 329 # Go through the whole text..extract it line by line
Chris@0 330 text = text.gsub(/^(.*)$/) do |line|
Chris@0 331 m_pre = pre_re.match(line)
Chris@0 332 if m_pre
Chris@0 333 line = '<pre>'
Chris@0 334 else
Chris@0 335 m_sl = shebang_re.match(line)
Chris@0 336 if m_sl
Chris@0 337 shebang_line = true
Chris@0 338 line = '<code class="' + m_sl[1] + '">'
Chris@0 339 end
Chris@0 340 m_pre_end = pre_end_re.match(line)
Chris@0 341 if m_pre_end
Chris@0 342 line = '</pre>'
Chris@0 343 if shebang_line
Chris@0 344 line = '</code>' + line
Chris@0 345 end
Chris@0 346 end
Chris@0 347 end
Chris@0 348 line
Chris@0 349 end
Chris@0 350
Chris@0 351 # Highlighting
Chris@0 352 text = text.gsub(/'''''([^\s])/, '_*\1')
Chris@0 353 text = text.gsub(/([^\s])'''''/, '\1*_')
Chris@0 354 text = text.gsub(/'''/, '*')
Chris@0 355 text = text.gsub(/''/, '_')
Chris@0 356 text = text.gsub(/__/, '+')
Chris@0 357 text = text.gsub(/~~/, '-')
Chris@0 358 text = text.gsub(/`/, '@')
Chris@0 359 text = text.gsub(/,,/, '~')
Chris@0 360 # Lists
Chris@0 361 text = text.gsub(/^([ ]+)\* /) {|s| '*' * $1.length + " "}
Chris@0 362
Chris@0 363 text
Chris@0 364 end
Chris@0 365
Chris@0 366 def self.migrate
Chris@0 367 establish_connection
Chris@0 368
Chris@0 369 # Quick database test
Chris@0 370 TracComponent.count
Chris@0 371
Chris@0 372 migrated_components = 0
Chris@0 373 migrated_milestones = 0
Chris@0 374 migrated_tickets = 0
Chris@0 375 migrated_custom_values = 0
Chris@0 376 migrated_ticket_attachments = 0
Chris@0 377 migrated_wiki_edits = 0
Chris@0 378 migrated_wiki_attachments = 0
Chris@0 379
Chris@0 380 #Wiki system initializing...
Chris@0 381 @target_project.wiki.destroy if @target_project.wiki
Chris@0 382 @target_project.reload
Chris@0 383 wiki = Wiki.new(:project => @target_project, :start_page => 'WikiStart')
Chris@0 384 wiki_edit_count = 0
Chris@0 385
Chris@0 386 # Components
Chris@0 387 print "Migrating components"
Chris@0 388 issues_category_map = {}
Chris@0 389 TracComponent.find(:all).each do |component|
Chris@0 390 print '.'
Chris@0 391 STDOUT.flush
Chris@0 392 c = IssueCategory.new :project => @target_project,
Chris@0 393 :name => encode(component.name[0, limit_for(IssueCategory, 'name')])
Chris@0 394 next unless c.save
Chris@0 395 issues_category_map[component.name] = c
Chris@0 396 migrated_components += 1
Chris@0 397 end
Chris@0 398 puts
Chris@0 399
Chris@0 400 # Milestones
Chris@0 401 print "Migrating milestones"
Chris@0 402 version_map = {}
Chris@0 403 TracMilestone.find(:all).each do |milestone|
Chris@0 404 print '.'
Chris@0 405 STDOUT.flush
Chris@0 406 # First we try to find the wiki page...
Chris@0 407 p = wiki.find_or_new_page(milestone.name.to_s)
Chris@0 408 p.content = WikiContent.new(:page => p) if p.new_record?
Chris@0 409 p.content.text = milestone.description.to_s
Chris@0 410 p.content.author = find_or_create_user('trac')
Chris@0 411 p.content.comments = 'Milestone'
Chris@0 412 p.save
Chris@0 413
Chris@0 414 v = Version.new :project => @target_project,
Chris@0 415 :name => encode(milestone.name[0, limit_for(Version, 'name')]),
Chris@0 416 :description => nil,
Chris@0 417 :wiki_page_title => milestone.name.to_s,
Chris@0 418 :effective_date => milestone.completed
Chris@0 419
Chris@0 420 next unless v.save
Chris@0 421 version_map[milestone.name] = v
Chris@0 422 migrated_milestones += 1
Chris@0 423 end
Chris@0 424 puts
Chris@0 425
Chris@0 426 # Custom fields
Chris@0 427 # TODO: read trac.ini instead
Chris@0 428 print "Migrating custom fields"
Chris@0 429 custom_field_map = {}
Chris@0 430 TracTicketCustom.find_by_sql("SELECT DISTINCT name FROM #{TracTicketCustom.table_name}").each do |field|
Chris@0 431 print '.'
Chris@0 432 STDOUT.flush
Chris@0 433 # Redmine custom field name
Chris@0 434 field_name = encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize
Chris@0 435 # Find if the custom already exists in Redmine
Chris@0 436 f = IssueCustomField.find_by_name(field_name)
Chris@0 437 # Or create a new one
Chris@0 438 f ||= IssueCustomField.create(:name => encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize,
Chris@0 439 :field_format => 'string')
Chris@0 440
Chris@0 441 next if f.new_record?
Chris@0 442 f.trackers = Tracker.find(:all)
Chris@0 443 f.projects << @target_project
Chris@0 444 custom_field_map[field.name] = f
Chris@0 445 end
Chris@0 446 puts
Chris@0 447
Chris@0 448 # Trac 'resolution' field as a Redmine custom field
Chris@0 449 r = IssueCustomField.find(:first, :conditions => { :name => "Resolution" })
Chris@0 450 r = IssueCustomField.new(:name => 'Resolution',
Chris@0 451 :field_format => 'list',
Chris@0 452 :is_filter => true) if r.nil?
Chris@0 453 r.trackers = Tracker.find(:all)
Chris@0 454 r.projects << @target_project
Chris@0 455 r.possible_values = (r.possible_values + %w(fixed invalid wontfix duplicate worksforme)).flatten.compact.uniq
Chris@0 456 r.save!
Chris@0 457 custom_field_map['resolution'] = r
Chris@0 458
Chris@0 459 # Tickets
Chris@0 460 print "Migrating tickets"
Chris@0 461 TracTicket.find_each(:batch_size => 200) do |ticket|
Chris@0 462 print '.'
Chris@0 463 STDOUT.flush
Chris@0 464 i = Issue.new :project => @target_project,
Chris@0 465 :subject => encode(ticket.summary[0, limit_for(Issue, 'subject')]),
Chris@0 466 :description => convert_wiki_text(encode(ticket.description)),
Chris@0 467 :priority => PRIORITY_MAPPING[ticket.priority] || DEFAULT_PRIORITY,
Chris@0 468 :created_on => ticket.time
Chris@0 469 i.author = find_or_create_user(ticket.reporter)
Chris@0 470 i.category = issues_category_map[ticket.component] unless ticket.component.blank?
Chris@0 471 i.fixed_version = version_map[ticket.milestone] unless ticket.milestone.blank?
Chris@0 472 i.status = STATUS_MAPPING[ticket.status] || DEFAULT_STATUS
Chris@0 473 i.tracker = TRACKER_MAPPING[ticket.ticket_type] || DEFAULT_TRACKER
Chris@0 474 i.id = ticket.id unless Issue.exists?(ticket.id)
Chris@0 475 next unless Time.fake(ticket.changetime) { i.save }
Chris@0 476 TICKET_MAP[ticket.id] = i.id
Chris@0 477 migrated_tickets += 1
Chris@0 478
Chris@0 479 # Owner
Chris@0 480 unless ticket.owner.blank?
Chris@0 481 i.assigned_to = find_or_create_user(ticket.owner, true)
Chris@0 482 Time.fake(ticket.changetime) { i.save }
Chris@0 483 end
Chris@0 484
Chris@0 485 # Comments and status/resolution changes
Chris@0 486 ticket.changes.group_by(&:time).each do |time, changeset|
Chris@0 487 status_change = changeset.select {|change| change.field == 'status'}.first
Chris@0 488 resolution_change = changeset.select {|change| change.field == 'resolution'}.first
Chris@0 489 comment_change = changeset.select {|change| change.field == 'comment'}.first
Chris@0 490
Chris@0 491 n = Journal.new :notes => (comment_change ? convert_wiki_text(encode(comment_change.newvalue)) : ''),
Chris@0 492 :created_on => time
Chris@0 493 n.user = find_or_create_user(changeset.first.author)
Chris@0 494 n.journalized = i
Chris@0 495 if status_change &&
Chris@0 496 STATUS_MAPPING[status_change.oldvalue] &&
Chris@0 497 STATUS_MAPPING[status_change.newvalue] &&
Chris@0 498 (STATUS_MAPPING[status_change.oldvalue] != STATUS_MAPPING[status_change.newvalue])
Chris@0 499 n.details << JournalDetail.new(:property => 'attr',
Chris@0 500 :prop_key => 'status_id',
Chris@0 501 :old_value => STATUS_MAPPING[status_change.oldvalue].id,
Chris@0 502 :value => STATUS_MAPPING[status_change.newvalue].id)
Chris@0 503 end
Chris@0 504 if resolution_change
Chris@0 505 n.details << JournalDetail.new(:property => 'cf',
Chris@0 506 :prop_key => custom_field_map['resolution'].id,
Chris@0 507 :old_value => resolution_change.oldvalue,
Chris@0 508 :value => resolution_change.newvalue)
Chris@0 509 end
Chris@0 510 n.save unless n.details.empty? && n.notes.blank?
Chris@0 511 end
Chris@0 512
Chris@0 513 # Attachments
Chris@0 514 ticket.attachments.each do |attachment|
Chris@0 515 next unless attachment.exist?
Chris@0 516 attachment.open {
Chris@0 517 a = Attachment.new :created_on => attachment.time
Chris@0 518 a.file = attachment
Chris@0 519 a.author = find_or_create_user(attachment.author)
Chris@0 520 a.container = i
Chris@0 521 a.description = attachment.description
Chris@0 522 migrated_ticket_attachments += 1 if a.save
Chris@0 523 }
Chris@0 524 end
Chris@0 525
Chris@0 526 # Custom fields
Chris@0 527 custom_values = ticket.customs.inject({}) do |h, custom|
Chris@0 528 if custom_field = custom_field_map[custom.name]
Chris@0 529 h[custom_field.id] = custom.value
Chris@0 530 migrated_custom_values += 1
Chris@0 531 end
Chris@0 532 h
Chris@0 533 end
Chris@0 534 if custom_field_map['resolution'] && !ticket.resolution.blank?
Chris@0 535 custom_values[custom_field_map['resolution'].id] = ticket.resolution
Chris@0 536 end
Chris@0 537 i.custom_field_values = custom_values
Chris@0 538 i.save_custom_field_values
Chris@0 539 end
Chris@0 540
Chris@0 541 # update issue id sequence if needed (postgresql)
Chris@0 542 Issue.connection.reset_pk_sequence!(Issue.table_name) if Issue.connection.respond_to?('reset_pk_sequence!')
Chris@0 543 puts
Chris@0 544
Chris@0 545 # Wiki
Chris@0 546 print "Migrating wiki"
Chris@0 547 if wiki.save
Chris@0 548 TracWikiPage.find(:all, :order => 'name, version').each do |page|
Chris@0 549 # Do not migrate Trac manual wiki pages
Chris@0 550 next if TRAC_WIKI_PAGES.include?(page.name)
Chris@0 551 wiki_edit_count += 1
Chris@0 552 print '.'
Chris@0 553 STDOUT.flush
Chris@0 554 p = wiki.find_or_new_page(page.name)
Chris@0 555 p.content = WikiContent.new(:page => p) if p.new_record?
Chris@0 556 p.content.text = page.text
Chris@0 557 p.content.author = find_or_create_user(page.author) unless page.author.blank? || page.author == 'trac'
Chris@0 558 p.content.comments = page.comment
Chris@0 559 Time.fake(page.time) { p.new_record? ? p.save : p.content.save }
Chris@0 560
Chris@0 561 next if p.content.new_record?
Chris@0 562 migrated_wiki_edits += 1
Chris@0 563
Chris@0 564 # Attachments
Chris@0 565 page.attachments.each do |attachment|
Chris@0 566 next unless attachment.exist?
Chris@0 567 next if p.attachments.find_by_filename(attachment.filename.gsub(/^.*(\\|\/)/, '').gsub(/[^\w\.\-]/,'_')) #add only once per page
Chris@0 568 attachment.open {
Chris@0 569 a = Attachment.new :created_on => attachment.time
Chris@0 570 a.file = attachment
Chris@0 571 a.author = find_or_create_user(attachment.author)
Chris@0 572 a.description = attachment.description
Chris@0 573 a.container = p
Chris@0 574 migrated_wiki_attachments += 1 if a.save
Chris@0 575 }
Chris@0 576 end
Chris@0 577 end
Chris@0 578
Chris@0 579 wiki.reload
Chris@0 580 wiki.pages.each do |page|
Chris@0 581 page.content.text = convert_wiki_text(page.content.text)
Chris@0 582 Time.fake(page.content.updated_on) { page.content.save }
Chris@0 583 end
Chris@0 584 end
Chris@0 585 puts
Chris@0 586
Chris@0 587 puts
Chris@0 588 puts "Components: #{migrated_components}/#{TracComponent.count}"
Chris@0 589 puts "Milestones: #{migrated_milestones}/#{TracMilestone.count}"
Chris@0 590 puts "Tickets: #{migrated_tickets}/#{TracTicket.count}"
Chris@0 591 puts "Ticket files: #{migrated_ticket_attachments}/" + TracAttachment.count(:conditions => {:type => 'ticket'}).to_s
Chris@0 592 puts "Custom values: #{migrated_custom_values}/#{TracTicketCustom.count}"
Chris@0 593 puts "Wiki edits: #{migrated_wiki_edits}/#{wiki_edit_count}"
Chris@0 594 puts "Wiki files: #{migrated_wiki_attachments}/" + TracAttachment.count(:conditions => {:type => 'wiki'}).to_s
Chris@0 595 end
Chris@0 596
Chris@0 597 def self.limit_for(klass, attribute)
Chris@0 598 klass.columns_hash[attribute.to_s].limit
Chris@0 599 end
Chris@0 600
Chris@0 601 def self.encoding(charset)
Chris@0 602 @ic = Iconv.new('UTF-8', charset)
Chris@0 603 rescue Iconv::InvalidEncoding
Chris@0 604 puts "Invalid encoding!"
Chris@0 605 return false
Chris@0 606 end
Chris@0 607
Chris@0 608 def self.set_trac_directory(path)
Chris@0 609 @@trac_directory = path
Chris@0 610 raise "This directory doesn't exist!" unless File.directory?(path)
Chris@0 611 raise "#{trac_attachments_directory} doesn't exist!" unless File.directory?(trac_attachments_directory)
Chris@0 612 @@trac_directory
Chris@0 613 rescue Exception => e
Chris@0 614 puts e
Chris@0 615 return false
Chris@0 616 end
Chris@0 617
Chris@0 618 def self.trac_directory
Chris@0 619 @@trac_directory
Chris@0 620 end
Chris@0 621
Chris@0 622 def self.set_trac_adapter(adapter)
Chris@0 623 return false if adapter.blank?
Chris@0 624 raise "Unknown adapter: #{adapter}!" unless %w(sqlite sqlite3 mysql postgresql).include?(adapter)
Chris@0 625 # If adapter is sqlite or sqlite3, make sure that trac.db exists
Chris@0 626 raise "#{trac_db_path} doesn't exist!" if %w(sqlite sqlite3).include?(adapter) && !File.exist?(trac_db_path)
Chris@0 627 @@trac_adapter = adapter
Chris@0 628 rescue Exception => e
Chris@0 629 puts e
Chris@0 630 return false
Chris@0 631 end
Chris@0 632
Chris@0 633 def self.set_trac_db_host(host)
Chris@0 634 return nil if host.blank?
Chris@0 635 @@trac_db_host = host
Chris@0 636 end
Chris@0 637
Chris@0 638 def self.set_trac_db_port(port)
Chris@0 639 return nil if port.to_i == 0
Chris@0 640 @@trac_db_port = port.to_i
Chris@0 641 end
Chris@0 642
Chris@0 643 def self.set_trac_db_name(name)
Chris@0 644 return nil if name.blank?
Chris@0 645 @@trac_db_name = name
Chris@0 646 end
Chris@0 647
Chris@0 648 def self.set_trac_db_username(username)
Chris@0 649 @@trac_db_username = username
Chris@0 650 end
Chris@0 651
Chris@0 652 def self.set_trac_db_password(password)
Chris@0 653 @@trac_db_password = password
Chris@0 654 end
Chris@0 655
Chris@0 656 def self.set_trac_db_schema(schema)
Chris@0 657 @@trac_db_schema = schema
Chris@0 658 end
Chris@0 659
Chris@0 660 mattr_reader :trac_directory, :trac_adapter, :trac_db_host, :trac_db_port, :trac_db_name, :trac_db_schema, :trac_db_username, :trac_db_password
Chris@0 661
Chris@0 662 def self.trac_db_path; "#{trac_directory}/db/trac.db" end
Chris@0 663 def self.trac_attachments_directory; "#{trac_directory}/attachments" end
Chris@0 664
Chris@0 665 def self.target_project_identifier(identifier)
Chris@0 666 project = Project.find_by_identifier(identifier)
Chris@0 667 if !project
Chris@0 668 # create the target project
Chris@0 669 project = Project.new :name => identifier.humanize,
Chris@0 670 :description => ''
Chris@0 671 project.identifier = identifier
Chris@0 672 puts "Unable to create a project with identifier '#{identifier}'!" unless project.save
Chris@0 673 # enable issues and wiki for the created project
Chris@0 674 project.enabled_module_names = ['issue_tracking', 'wiki']
Chris@0 675 else
Chris@0 676 puts
Chris@0 677 puts "This project already exists in your Redmine database."
Chris@0 678 print "Are you sure you want to append data to this project ? [Y/n] "
Chris@0 679 STDOUT.flush
Chris@0 680 exit if STDIN.gets.match(/^n$/i)
Chris@0 681 end
Chris@0 682 project.trackers << TRACKER_BUG unless project.trackers.include?(TRACKER_BUG)
Chris@0 683 project.trackers << TRACKER_FEATURE unless project.trackers.include?(TRACKER_FEATURE)
Chris@0 684 @target_project = project.new_record? ? nil : project
Chris@0 685 @target_project.reload
Chris@0 686 end
Chris@0 687
Chris@0 688 def self.connection_params
Chris@0 689 if %w(sqlite sqlite3).include?(trac_adapter)
Chris@0 690 {:adapter => trac_adapter,
Chris@0 691 :database => trac_db_path}
Chris@0 692 else
Chris@0 693 {:adapter => trac_adapter,
Chris@0 694 :database => trac_db_name,
Chris@0 695 :host => trac_db_host,
Chris@0 696 :port => trac_db_port,
Chris@0 697 :username => trac_db_username,
Chris@0 698 :password => trac_db_password,
Chris@0 699 :schema_search_path => trac_db_schema
Chris@0 700 }
Chris@0 701 end
Chris@0 702 end
Chris@0 703
Chris@0 704 def self.establish_connection
Chris@0 705 constants.each do |const|
Chris@0 706 klass = const_get(const)
Chris@0 707 next unless klass.respond_to? 'establish_connection'
Chris@0 708 klass.establish_connection connection_params
Chris@0 709 end
Chris@0 710 end
Chris@0 711
Chris@0 712 private
Chris@0 713 def self.encode(text)
Chris@0 714 @ic.iconv text
Chris@0 715 rescue
Chris@0 716 text
Chris@0 717 end
Chris@0 718 end
Chris@0 719
Chris@0 720 puts
Chris@0 721 if Redmine::DefaultData::Loader.no_data?
Chris@0 722 puts "Redmine configuration need to be loaded before importing data."
Chris@0 723 puts "Please, run this first:"
Chris@0 724 puts
Chris@0 725 puts " rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
Chris@0 726 exit
Chris@0 727 end
Chris@0 728
Chris@0 729 puts "WARNING: a new project will be added to Redmine during this process."
Chris@0 730 print "Are you sure you want to continue ? [y/N] "
Chris@0 731 STDOUT.flush
Chris@0 732 break unless STDIN.gets.match(/^y$/i)
Chris@0 733 puts
Chris@0 734
Chris@0 735 def prompt(text, options = {}, &block)
Chris@0 736 default = options[:default] || ''
Chris@0 737 while true
Chris@0 738 print "#{text} [#{default}]: "
Chris@0 739 STDOUT.flush
Chris@0 740 value = STDIN.gets.chomp!
Chris@0 741 value = default if value.blank?
Chris@0 742 break if yield value
Chris@0 743 end
Chris@0 744 end
Chris@0 745
Chris@0 746 DEFAULT_PORTS = {'mysql' => 3306, 'postgresql' => 5432}
Chris@0 747
Chris@0 748 prompt('Trac directory') {|directory| TracMigrate.set_trac_directory directory.strip}
Chris@0 749 prompt('Trac database adapter (sqlite, sqlite3, mysql, postgresql)', :default => 'sqlite') {|adapter| TracMigrate.set_trac_adapter adapter}
Chris@0 750 unless %w(sqlite sqlite3).include?(TracMigrate.trac_adapter)
Chris@0 751 prompt('Trac database host', :default => 'localhost') {|host| TracMigrate.set_trac_db_host host}
Chris@0 752 prompt('Trac database port', :default => DEFAULT_PORTS[TracMigrate.trac_adapter]) {|port| TracMigrate.set_trac_db_port port}
Chris@0 753 prompt('Trac database name') {|name| TracMigrate.set_trac_db_name name}
Chris@0 754 prompt('Trac database schema', :default => 'public') {|schema| TracMigrate.set_trac_db_schema schema}
Chris@0 755 prompt('Trac database username') {|username| TracMigrate.set_trac_db_username username}
Chris@0 756 prompt('Trac database password') {|password| TracMigrate.set_trac_db_password password}
Chris@0 757 end
Chris@0 758 prompt('Trac database encoding', :default => 'UTF-8') {|encoding| TracMigrate.encoding encoding}
Chris@0 759 prompt('Target project identifier') {|identifier| TracMigrate.target_project_identifier identifier}
Chris@0 760 puts
Chris@0 761
Chris@0 762 # Turn off email notifications
Chris@0 763 Setting.notified_events = []
Chris@0 764
Chris@0 765 TracMigrate.migrate
Chris@0 766 end
Chris@0 767 end
Chris@0 768