annotate .svn/pristine/ab/ab057f25c21b7df0687551bcc5efdab05be328b5.svn-base @ 1519:afce8026aaeb redmine-2.4-integration

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