migrate_from_trac.rake

Modified importer - Mathias Kühn, 2009-02-13 23:22

Download (38.5 KB)

 
1
# redMine - project management software
2
# Copyright (C) 2006-2007  Jean-Philippe Lang
3
#
4
# This program is free software; you can redistribute it and/or
5
# modify it under the terms of the GNU General Public License
6
# as published by the Free Software Foundation; either version 2
7
# of the License, or (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU General Public License for more details.
13
#
14
# You should have received a copy of the GNU General Public License
15
# along with this program; if not, write to the Free Software
16
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17

    
18
require 'active_record'
19
require 'iconv'
20
require 'pp'
21

    
22
require 'redmine/scm/adapters/abstract_adapter'
23
require 'redmine/scm/adapters/subversion_adapter'
24
require 'rexml/document'
25
require 'uri'
26

    
27
require 'tempfile'
28

    
29
namespace :redmine do
30
  desc 'Trac migration script'
31
  task :migrate_from_trac => :environment do
32

    
33
    module TracMigrate
34
        TICKET_MAP = []
35

    
36
        DEFAULT_STATUS = IssueStatus.default
37
        assigned_status = IssueStatus.find_by_position(2)
38
        resolved_status = IssueStatus.find_by_position(3)
39
        feedback_status = IssueStatus.find_by_position(4)
40
        closed_status = IssueStatus.find :first, :conditions => { :is_closed => true }
41
        STATUS_MAPPING = {'new' => DEFAULT_STATUS,
42
                          'reopened' => feedback_status,
43
                          'assigned' => assigned_status,
44
                          'closed' => closed_status
45
                          }
46

    
47
        priorities = Enumeration.get_values('IPRI')
48
        DEFAULT_PRIORITY = priorities[0]
49
        PRIORITY_MAPPING = {'lowest' => priorities[0],
50
                            'low' => priorities[0],
51
                            'normal' => priorities[1],
52
                            'high' => priorities[2],
53
                            'highest' => priorities[3],
54
                            # ---
55
                            'trivial' => priorities[0],
56
                            'minor' => priorities[1],
57
                            'major' => priorities[2],
58
                            'critical' => priorities[3],
59
                            'blocker' => priorities[4]
60
                            }
61

    
62
        TRACKER_BUG = Tracker.find_by_position(1)
63
        TRACKER_FEATURE = Tracker.find_by_position(2)
64
        DEFAULT_TRACKER = TRACKER_BUG
65
        TRACKER_MAPPING = {'defect' => TRACKER_BUG,
66
                           'enhancement' => TRACKER_FEATURE,
67
                           'task' => TRACKER_FEATURE,
68
                           'patch' =>TRACKER_FEATURE
69
                           }
70

    
71
        roles = Role.find(:all, :conditions => {:builtin => 0}, :order => 'position ASC')
72
        manager_role = roles[0]
73
        developer_role = roles[1]
74
        DEFAULT_ROLE = roles.last
75
        ROLE_MAPPING = {'admin' => manager_role,
76
                        'developer' => developer_role
77
                        }
78

    
79
      class ::Time
80
        class << self
81
          alias :real_now :now
82
          def now
83
            real_now - @fake_diff.to_i
84
          end
85
          def fake(time)
86
            @fake_diff = real_now - time
87
            res = yield
88
            @fake_diff = 0
89
           res
90
          end
91
        end
92
      end
93

    
94
      class TracComponent < ActiveRecord::Base
95
        set_table_name :component
96
      end
97

    
98
      class TracMilestone < ActiveRecord::Base
99
        set_table_name :milestone
100
        # If this attribute is set a milestone has a defined target timepoint
101
        def due
102
          if read_attribute(:due) && read_attribute(:due) > 0
103
            Time.at(read_attribute(:due)).to_date
104
          else
105
            nil
106
          end
107
        end
108
        # This is the real timepoint at which the milestone has finished.
109
        def completed
110
          if read_attribute(:completed) && read_attribute(:completed) > 0
111
            Time.at(read_attribute(:completed)).to_date
112
          else
113
            nil
114
          end
115
        end
116

    
117
        def description
118
          # Attribute is named descr in Trac v0.8.x
119
          has_attribute?(:descr) ? read_attribute(:descr) : read_attribute(:description)
120
        end
121
      end
122

    
123
      class TracTicketCustom < ActiveRecord::Base
124
        set_table_name :ticket_custom
125
      end
126

    
127
      class TracAttachment < ActiveRecord::Base
128
        set_table_name :attachment
129
        set_inheritance_column :none
130

    
131
        def time; Time.at(read_attribute(:time)) end
132

    
133
        def original_filename
134
          filename
135
        end
136

    
137
        def content_type
138
          Redmine::MimeType.of(filename) || ''
139
        end
140

    
141
        def exist?
142
          File.file? trac_fullpath
143
        end
144

    
145
        def read
146
          File.open("#{trac_fullpath}", 'rb').read
147
        end
148

    
149
        def description
150
          read_attribute(:description).to_s.slice(0,255)
151
        end
152

    
153
      private
154
        def trac_fullpath
155
          attachment_type = read_attribute(:type)
156
          trac_file = filename.gsub( /[^a-zA-Z0-9\-_\.!~*']/n ) {|x| sprintf('%%%02x', x[0]) }
157
          "#{TracMigrate.trac_attachments_directory}/#{attachment_type}/#{id}/#{trac_file}"
158
        end
159
      end
160

    
161
      class TracTicket < ActiveRecord::Base
162
        set_table_name :ticket
163
        set_inheritance_column :none
164

    
165
        # ticket changes: only migrate status changes and comments
166
        has_many :changes, :class_name => "TracTicketChange", :foreign_key => :ticket
167
        has_many :attachments, :class_name => "TracAttachment",
168
                               :finder_sql => "SELECT DISTINCT attachment.* FROM #{TracMigrate::TracAttachment.table_name}" +
169
                                              " WHERE #{TracMigrate::TracAttachment.table_name}.type = 'ticket'" +
170
                                              ' AND #{TracMigrate::TracAttachment.table_name}.id = \'#{id}\''
171
        has_many :customs, :class_name => "TracTicketCustom", :foreign_key => :ticket
172

    
173
        def ticket_type
174
          read_attribute(:type)
175
        end
176

    
177
        def summary
178
          read_attribute(:summary).blank? ? "(no subject)" : read_attribute(:summary)
179
        end
180

    
181
        def description
182
          read_attribute(:description).blank? ? summary : read_attribute(:description)
183
        end
184

    
185
        def time; Time.at(read_attribute(:time)) end
186
        def changetime; Time.at(read_attribute(:changetime)) end
187
      end
188

    
189
      class TracTicketChange < ActiveRecord::Base
190
        set_table_name :ticket_change
191

    
192
        def time; Time.at(read_attribute(:time)) end
193
      end
194

    
195
      TRAC_WIKI_PAGES = %w(InterMapTxt TracInstallPlatforms TracMultipleProjects InterTrac InterWiki RecentChanges SandBox \
196
               TracAccessibility TracAdmin TracBackup TracBrowser TracCgi TracChangeset \
197
                           TracEnvironment TracFastCgi TracGuide TracImport TracIni TracInstall TracInterfaceCustomization \
198
                           TracLinks TracLogging TracModPython TracNotification TracPermissions TracPlugins TracQuery \
199
                           TracReports TracRevisionLog TracRoadmap TracRss TracSearch TracStandalone TracSupport TracSyntaxColoring TracTickets \
200
                           TracTicketsCustomFields TracTimeline TracUnicode TracUpgrade TracWiki WikiDeletePage WikiFormatting \
201
                           WikiHtml WikiMacros WikiNewPage WikiPageNames WikiProcessors WikiRestructuredText WikiRestructuredTextLinks \
202
                           CamelCase TitleIndex)
203

    
204
      class TracWikiPage < ActiveRecord::Base
205
        set_table_name :wiki
206
        set_primary_key :name
207

    
208
        has_many :attachments, :class_name => "TracAttachment",
209
                               :finder_sql => "SELECT DISTINCT attachment.* FROM #{TracMigrate::TracAttachment.table_name}" +
210
                                      " WHERE #{TracMigrate::TracAttachment.table_name}.type = 'wiki'" +
211
                                      ' AND #{TracMigrate::TracAttachment.table_name}.id = \'#{id}\''
212

    
213
        def self.columns
214
          # Hides readonly Trac field to prevent clash with AR readonly? method (Rails 2.0)
215
          super.select {|column| column.name.to_s != 'readonly'}
216
        end
217

    
218
        def time; Time.at(read_attribute(:time)) end
219
      end
220

    
221
      class TracPermission < ActiveRecord::Base
222
        set_table_name :permission
223
      end
224

    
225
      class TracSessionAttribute < ActiveRecord::Base
226
        set_table_name :session_attribute
227
      end
228

    
229
      def self.find_or_create_user(username, project_member = false)
230
        return User.anonymous if username.blank?
231

    
232
        u = User.find_by_login(username)
233
        if !u
234
          # Create a new user if not found
235
          mail = username[0,limit_for(User, 'mail')]
236
          if mail_attr = TracSessionAttribute.find_by_sid_and_name(username, 'email')
237
            mail = mail_attr.value
238
          end
239
          mail = "#{mail}@foo.bar" unless mail.include?("@")
240

    
241
          name = username
242
          if name_attr = TracSessionAttribute.find_by_sid_and_name(username, 'name')
243
            name = name_attr.value
244
          end
245
          name =~ (/(.*)(\s+\w+)?/)
246
          fn = $1.strip
247
          ln = ($2 || '-').strip
248

    
249
          u = User.new :mail => mail.gsub(/[^-@a-z0-9\.]/i, '-'),
250
                       :firstname => fn[0, limit_for(User, 'firstname')].gsub(/[^\w\s\'\-]/i, '-'),
251
                       :lastname => ln[0, limit_for(User, 'lastname')].gsub(/[^\w\s\'\-]/i, '-')
252

    
253
          u.login = username[0,limit_for(User, 'login')].gsub(/[^a-z0-9_\-@\.]/i, '-')
254
          u.password = 'trac'
255
          u.admin = true if TracPermission.find_by_username_and_action(username, 'admin')
256
          # finally, a default user is used if the new user is not valid
257
          u = User.find(:first) unless u.save
258
        end
259
        # Make sure he is a member of the project
260
        if project_member && !u.member_of?(@target_project)
261
          role = DEFAULT_ROLE
262
          if u.admin
263
            role = ROLE_MAPPING['admin']
264
          elsif TracPermission.find_by_username_and_action(username, 'developer')
265
            role = ROLE_MAPPING['developer']
266
          end
267
          Member.create(:user => u, :project => @target_project, :role => role)
268
          u.reload
269
        end
270
        u
271
      end
272

    
273
      # Basic wiki syntax conversion
274
      def self.convert_wiki_text(text)
275
        convert_wiki_text_mapping(text, TICKET_MAP)
276
      end
277

    
278
      def self.migrate
279
        establish_connection
280

    
281
        # Quick database test
282
        TracComponent.count
283

    
284
        migrated_components = 0
285
        migrated_milestones = 0
286
        migrated_tickets = 0
287
        migrated_custom_values = 0
288
        migrated_ticket_attachments = 0
289
        migrated_wiki_edits = 0
290
        migrated_wiki_attachments = 0
291

    
292
        #Wiki system initializing...
293
        @target_project.wiki.destroy if @target_project.wiki
294
        @target_project.reload
295
        wiki = Wiki.new(:project => @target_project, :start_page => 'WikiStart')
296
        wiki_edit_count = 0
297

    
298
        # Components
299
        print "Migrating components"
300
        issues_category_map = {}
301
        TracComponent.find(:all).each do |component|
302
        print '.'
303
        STDOUT.flush
304
          c = IssueCategory.new :project => @target_project,
305
                                :name => encode(component.name[0, limit_for(IssueCategory, 'name')])
306
        next unless c.save
307
        issues_category_map[component.name] = c
308
        migrated_components += 1
309
        end
310
        puts
311

    
312
        # Milestones
313
        print "Migrating milestones"
314
        version_map = {}
315
        milestone_wiki = Array.new
316
        TracMilestone.find(:all).each do |milestone|
317
          print '.'
318
          STDOUT.flush
319
          # First we try to find the wiki page...
320
          p = wiki.find_or_new_page(milestone.name.to_s)
321
          p.content = WikiContent.new(:page => p) if p.new_record?
322
          p.content.text = milestone.description.to_s
323
          p.content.author = find_or_create_user('trac')
324
          p.content.comments = 'Milestone'
325
          p.save
326

    
327
          v = Version.new :project => @target_project,
328
                          :name => encode(milestone.name[0, limit_for(Version, 'name')]),
329
                          :description => nil,
330
                          :wiki_page_title => milestone.name.to_s,
331
                          :effective_date => milestone.completed
332

    
333
          next unless v.save
334
          version_map[milestone.name] = v
335
          milestone_wiki.push(milestone.name);
336
          migrated_milestones += 1
337
        end
338
        puts
339
  
340
        # Custom fields
341
        # TODO: read trac.ini instead
342
        print "Migrating custom fields"
343
        custom_field_map = {}
344
        
345
        TracTicketCustom.find_by_sql("SELECT DISTINCT name FROM #{TracTicketCustom.table_name}").each do |field|
346
          print '.'
347
          STDOUT.flush
348
          # Redmine custom field name
349
          field_name = encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize
350
          # Find if the custom already exists in Redmine
351
          f = IssueCustomField.find_by_name(field_name)
352
          # Or create a new one
353
          f ||= IssueCustomField.create(:name => encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize,
354
                                        :field_format => 'string')
355

    
356
          next if f.new_record?
357
          f.trackers = Tracker.find(:all)
358
          f.projects << @target_project
359
          custom_field_map[field.name] = f
360
        end
361
        puts
362

    
363
        # Trac 'resolution' field as a Redmine custom field
364
        r = IssueCustomField.find(:first, :conditions => { :name => "Resolution" })
365
        r = IssueCustomField.new(:name => 'Resolution',
366
                                 :field_format => 'list',
367
                                 :is_filter => true) if r.nil?
368
        r.trackers = Tracker.find(:all)
369
        r.projects << @target_project
370
        r.possible_values = (r.possible_values + %w(fixed invalid wontfix duplicate worksforme)).flatten.compact.uniq
371
        r.save!
372
        custom_field_map['resolution'] = r
373

    
374
        # Trac ticket id as a Redmine custom field
375
        tid = IssueCustomField.find(:first, :conditions => { :name => "TracID" })
376
        tid = IssueCustomField.new(:name => 'TracID',
377
                                 :field_format => 'string',
378
                                 :is_filter => true) if tid.nil?
379
        tid.trackers = Tracker.find(:all)
380
        tid.projects << @target_project
381
        tid.save!
382
        custom_field_map['tracid'] = tid
383
  
384
        # Tickets
385
        print "Migrating tickets"
386
          TracTicket.find(:all, :order => 'id ASC').each do |ticket|
387
          print '.'
388
          STDOUT.flush
389
          i = Issue.new :project => @target_project,
390
                          :subject => encode(ticket.summary[0, limit_for(Issue, 'subject')]),
391
                          :description => encode(ticket.description),
392
                          :priority => PRIORITY_MAPPING[ticket.priority] || DEFAULT_PRIORITY,
393
                          :created_on => ticket.time
394
          i.author = find_or_create_user(ticket.reporter)
395
          i.category = issues_category_map[ticket.component] unless ticket.component.blank?
396
          i.fixed_version = version_map[ticket.milestone] unless ticket.milestone.blank?
397
          i.status = STATUS_MAPPING[ticket.status] || DEFAULT_STATUS
398
          i.tracker = TRACKER_MAPPING[ticket.ticket_type] || DEFAULT_TRACKER
399
          i.id = ticket.id unless Issue.exists?(ticket.id)
400
          next unless Time.fake(ticket.changetime) { i.save }
401
          TICKET_MAP[ticket.id] = i.id
402
          migrated_tickets += 1
403

    
404
          # Owner
405
            unless ticket.owner.blank?
406
              i.assigned_to = find_or_create_user(ticket.owner, true)
407
              Time.fake(ticket.changetime) { i.save }
408
            end
409

    
410
          # Comments and status/resolution changes
411
          ticket.changes.group_by(&:time).each do |time, changeset|
412
              status_change = changeset.select {|change| change.field == 'status'}.first
413
              resolution_change = changeset.select {|change| change.field == 'resolution'}.first
414
              comment_change = changeset.select {|change| change.field == 'comment'}.first
415

    
416
              n = Journal.new :notes => (comment_change ? encode(comment_change.newvalue) : ''),
417
                              :created_on => time
418
              n.user = find_or_create_user(changeset.first.author)
419
              n.journalized = i
420
              if status_change &&
421
                   STATUS_MAPPING[status_change.oldvalue] &&
422
                   STATUS_MAPPING[status_change.newvalue] &&
423
                   (STATUS_MAPPING[status_change.oldvalue] != STATUS_MAPPING[status_change.newvalue])
424
                n.details << JournalDetail.new(:property => 'attr',
425
                                               :prop_key => 'status_id',
426
                                               :old_value => STATUS_MAPPING[status_change.oldvalue].id,
427
                                               :value => STATUS_MAPPING[status_change.newvalue].id)
428
              end
429
              if resolution_change
430
                n.details << JournalDetail.new(:property => 'cf',
431
                                               :prop_key => custom_field_map['resolution'].id,
432
                                               :old_value => resolution_change.oldvalue,
433
                                               :value => resolution_change.newvalue)
434
              end
435
              n.save unless n.details.empty? && n.notes.blank?
436
          end
437

    
438
          # Attachments
439
          ticket.attachments.each do |attachment|
440
            next unless attachment.exist?
441
              a = Attachment.new :created_on => attachment.time
442
              a.file = attachment
443
              a.author = find_or_create_user(attachment.author)
444
              a.container = i
445
              a.description = attachment.description
446
              migrated_ticket_attachments += 1 if a.save
447
          end
448

    
449
          # Custom fields
450
          custom_values = ticket.customs.inject({}) do |h, custom|
451
            if custom_field = custom_field_map[custom.name]
452
              h[custom_field.id] = custom.value
453
              migrated_custom_values += 1
454
            end
455
            h
456
          end
457
          if custom_field_map['resolution'] && !ticket.resolution.blank?
458
            custom_values[custom_field_map['resolution'].id] = ticket.resolution
459
          end
460
          if custom_field_map['tracid'] 
461
            custom_values[custom_field_map['tracid'].id] = ticket.id
462
          end
463
          i.custom_field_values = custom_values
464
          i.save_custom_field_values
465
        end
466

    
467
        # update issue id sequence if needed (postgresql)
468
        Issue.connection.reset_pk_sequence!(Issue.table_name) if Issue.connection.respond_to?('reset_pk_sequence!')
469
        puts
470

    
471
        # Wiki
472
        print "Migrating wiki"
473
        if wiki.save
474
          TracWikiPage.find(:all, :order => 'name, version').each do |page|
475
            # Do not migrate Trac manual wiki pages
476
            next if TRAC_WIKI_PAGES.include?(page.name)
477
            wiki_edit_count += 1
478
            print '.'
479
            STDOUT.flush
480
            p = wiki.find_or_new_page(page.name)
481
            p.content = WikiContent.new(:page => p) if p.new_record?
482
            p.content.text = page.text
483
            p.content.author = find_or_create_user(page.author) unless page.author.blank? || page.author == 'trac'
484
            p.content.comments = page.comment
485
            Time.fake(page.time) { p.new_record? ? p.save : p.content.save }
486

    
487
            next if p.content.new_record?
488
            migrated_wiki_edits += 1
489

    
490
            # Attachments
491
            page.attachments.each do |attachment|
492
              next unless attachment.exist?
493
              next if p.attachments.find_by_filename(attachment.filename.gsub(/^.*(\\|\/)/, '').gsub(/[^\w\.\-]/,'_')) #add only once per page
494
              a = Attachment.new :created_on => attachment.time
495
              a.file = attachment
496
              a.author = find_or_create_user(attachment.author)
497
              a.description = attachment.description
498
              a.container = p
499
              migrated_wiki_attachments += 1 if a.save
500
            end
501
          end
502

    
503
        end
504
        puts
505

    
506
    # Now load each wiki page and transform its content into textile format
507
    print "Fixing ticket identifiers"
508
    puts
509
    
510
    print "...in Wiki pages"
511
          wiki.reload
512
          wiki.pages.each do |page|
513
            print '.'
514
            page.content.text = convert_wiki_text(page.content.text)
515
            Time.fake(page.content.updated_on) { page.content.save }
516
          end
517
    puts
518

    
519
    print "...in Issue descriptions"
520
          TICKET_MAP.each do |newId|
521

    
522
            next if newId.nil?
523
            
524
            print '.'
525
            issue = findIssue(newId)
526
            next if issue.nil?
527

    
528
            issue.description = convert_wiki_text(issue.description)
529
      issue.save            
530
          end
531
    puts
532

    
533
    print "...in Issue journal descriptions"
534
          TICKET_MAP.each do |newId|
535
            next if newId.nil?
536
            
537
            print '.'
538
            issue = findIssue(newId)
539
            next if issue.nil?
540
            
541
            issue.journals.find(:all).each do |journal|
542
              print '.'
543
              journal.notes = convert_wiki_text(journal.notes)
544
              journal.save
545
            end
546
  
547
          end
548
    puts
549
    
550
    print "...in Milestone descriptions"
551

    
552

    
553
    # Now load each page and transform its content into textile format
554
          milestone_wiki.each do |name|
555
            p = wiki.find_page(name)            
556
            next if p.nil?
557
                  
558
            print '.'            
559
            p.content.text = convert_wiki_text(p.content.text)
560
            p.content.save
561
    end
562
    puts
563

    
564
        puts
565
        puts "Components:      #{migrated_components}/#{TracComponent.count}"
566
        puts "Milestones:      #{migrated_milestones}/#{TracMilestone.count}"
567
        puts "Tickets:         #{migrated_tickets}/#{TracTicket.count}"
568
        puts "Ticket files:    #{migrated_ticket_attachments}/" + TracAttachment.count(:conditions => {:type => 'ticket'}).to_s
569
        puts "Custom values:   #{migrated_custom_values}/#{TracTicketCustom.count}"
570
        puts "Wiki edits:      #{migrated_wiki_edits}/#{wiki_edit_count}"
571
        puts "Wiki files:      #{migrated_wiki_attachments}/" + TracAttachment.count(:conditions => {:type => 'wiki'}).to_s
572
      end
573
      
574
      def self.findIssue(id)
575
        
576
        return Issue.find(id)
577

    
578
      rescue ActiveRecord::RecordNotFound
579
  puts
580
        print "[#{id}] not found"
581

    
582
        nil      
583
      end
584
      
585
      def self.limit_for(klass, attribute)
586
        klass.columns_hash[attribute.to_s].limit
587
      end
588

    
589
      def self.encoding(charset)
590
        @ic = Iconv.new('UTF-8', charset)
591
      rescue Iconv::InvalidEncoding
592
        puts "Invalid encoding!"
593
        return false
594
      end
595

    
596
      def self.set_trac_directory(path)
597
        @@trac_directory = path
598
        raise "This directory doesn't exist!" unless File.directory?(path)
599
        raise "#{trac_attachments_directory} doesn't exist!" unless File.directory?(trac_attachments_directory)
600
        @@trac_directory
601
      rescue Exception => e
602
        puts e
603
        return false
604
      end
605

    
606
      def self.trac_directory
607
        @@trac_directory
608
      end
609

    
610
      def self.set_trac_adapter(adapter)
611
        return false if adapter.blank?
612
        raise "Unknown adapter: #{adapter}!" unless %w(sqlite sqlite3 mysql postgresql).include?(adapter)
613
        # If adapter is sqlite or sqlite3, make sure that trac.db exists
614
        raise "#{trac_db_path} doesn't exist!" if %w(sqlite sqlite3).include?(adapter) && !File.exist?(trac_db_path)
615
        @@trac_adapter = adapter
616
      rescue Exception => e
617
        puts e
618
        return false
619
      end
620

    
621
      def self.set_trac_db_host(host)
622
        return nil if host.blank?
623
        @@trac_db_host = host
624
      end
625

    
626
      def self.set_trac_db_port(port)
627
        return nil if port.to_i == 0
628
        @@trac_db_port = port.to_i
629
      end
630

    
631
      def self.set_trac_db_name(name)
632
        return nil if name.blank?
633
        @@trac_db_name = name
634
      end
635

    
636
      def self.set_trac_db_username(username)
637
        @@trac_db_username = username
638
      end
639

    
640
      def self.set_trac_db_password(password)
641
        @@trac_db_password = password
642
      end
643

    
644
      def self.set_trac_db_schema(schema)
645
        @@trac_db_schema = schema
646
      end
647

    
648
      mattr_reader :trac_directory, :trac_adapter, :trac_db_host, :trac_db_port, :trac_db_name, :trac_db_schema, :trac_db_username, :trac_db_password
649

    
650
      def self.trac_db_path; "#{trac_directory}/db/trac.db" end
651
      def self.trac_attachments_directory; "#{trac_directory}/attachments" end
652

    
653
      def self.target_project_identifier(identifier)
654
        project = Project.find_by_identifier(identifier)
655
        if !project
656
          # create the target project
657
          project = Project.new :name => identifier.humanize,
658
                                :description => ''
659
          project.identifier = identifier
660
          puts "Unable to create a project with identifier '#{identifier}'!" unless project.save
661
          # enable issues and wiki for the created project
662
          project.enabled_module_names = ['issue_tracking', 'wiki']
663
        else
664
          puts
665
          puts "This project already exists in your Redmine database."
666
          print "Are you sure you want to append data to this project ? [Y/n] "
667
          exit if STDIN.gets.match(/^n$/i)
668
        end
669
        project.trackers << TRACKER_BUG unless project.trackers.include?(TRACKER_BUG)
670
        project.trackers << TRACKER_FEATURE unless project.trackers.include?(TRACKER_FEATURE)
671
        @target_project = project.new_record? ? nil : project
672
      end
673

    
674
      def self.connection_params
675
        if %w(sqlite sqlite3).include?(trac_adapter)
676
          {:adapter => trac_adapter,
677
           :database => trac_db_path}
678
        else
679
          {:adapter => trac_adapter,
680
           :database => trac_db_name,
681
           :host => trac_db_host,
682
           :port => trac_db_port,
683
           :username => trac_db_username,
684
           :password => trac_db_password,
685
           :schema_search_path => trac_db_schema
686
          }
687
        end
688
      end
689

    
690
      def self.establish_connection
691
        constants.each do |const|
692
          klass = const_get(const)
693
          next unless klass.respond_to? 'establish_connection'
694
          klass.establish_connection connection_params
695
        end
696
      end
697

    
698
    private
699
      def self.encode(text)
700
        @ic.iconv text
701
      rescue
702
        text
703
      end
704
    end
705

    
706
    puts
707
    if Redmine::DefaultData::Loader.no_data?
708
      puts "Redmine configuration need to be loaded before importing data."
709
      puts "Please, run this first:"
710
      puts
711
      puts "  rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
712
      exit
713
    end
714

    
715
    puts "WARNING: a new project will be added to Redmine during this process."
716
    print "Are you sure you want to continue ? [y/N] "
717
    break unless STDIN.gets.match(/^y$/i)
718
    puts
719

    
720
    def prompt(text, options = {}, &block)
721
      default = options[:default] || ''
722
      while true
723
        print "#{text} [#{default}]: "
724
        value = STDIN.gets.chomp!
725
        value = default if value.blank?
726
        break if yield value
727
      end
728
    end
729

    
730
    DEFAULT_PORTS = {'mysql' => 3306, 'postgresql' => 5432}
731

    
732
    prompt('Trac directory') {|directory| TracMigrate.set_trac_directory directory.strip}
733
    prompt('Trac database adapter (sqlite, sqlite3, mysql, postgresql)', :default => 'sqlite3') {|adapter| TracMigrate.set_trac_adapter adapter}
734
    unless %w(sqlite sqlite3).include?(TracMigrate.trac_adapter)
735
      prompt('Trac database host', :default => 'localhost') {|host| TracMigrate.set_trac_db_host host}
736
      prompt('Trac database port', :default => DEFAULT_PORTS[TracMigrate.trac_adapter]) {|port| TracMigrate.set_trac_db_port port}
737
      prompt('Trac database name') {|name| TracMigrate.set_trac_db_name name}
738
      prompt('Trac database schema', :default => 'public') {|schema| TracMigrate.set_trac_db_schema schema}
739
      prompt('Trac database username') {|username| TracMigrate.set_trac_db_username username}
740
      prompt('Trac database password') {|password| TracMigrate.set_trac_db_password password}
741
    end
742
    prompt('Trac database encoding', :default => 'UTF-8') {|encoding| TracMigrate.encoding encoding}
743
    prompt('Target project identifier') {|identifier| TracMigrate.target_project_identifier identifier}
744
    puts
745

    
746
    TracMigrate.migrate
747
  end
748

    
749

    
750
  desc 'Subversion migration script'
751
  task :migrate_svn_commits => :environment do
752
  
753
    module SvnMigrate 
754
        TICKET_MAP = []
755

    
756
        class Commit
757
          attr_accessor :revision, :message
758
          
759
          def initialize(attributes={})
760
            self.message = attributes[:message] || ""
761
            self.revision = attributes[:revision]
762
          end
763
        end
764
        
765
        class SvnExtendedAdapter < Redmine::Scm::Adapters::SubversionAdapter
766
        
767

    
768

    
769
            def set_message(path=nil, revision=nil, msg=nil)
770
              path ||= ''
771

    
772
              Tempfile.open('msg') do |tempfile|
773

    
774
                # This is a weird thing. We need to cleanup cr/lf so we have uniform line separators              
775
                tempfile.print msg.gsub(/\r\n/,'\n')
776
                tempfile.flush
777

    
778
                filePath = tempfile.path.gsub(File::SEPARATOR, File::ALT_SEPARATOR || File::SEPARATOR)
779

    
780
                cmd = "#{SVN_BIN} propset svn:log --quiet --revprop -r #{revision}  -F \"#{filePath}\" "
781
                cmd << credentials_string
782
                cmd << ' ' + target(URI.escape(path))
783

    
784
                shellout(cmd) do |io|
785
                  begin
786
                    loop do 
787
                      line = io.readline
788
                      puts line
789
                    end
790
                  rescue EOFError
791
                  end  
792
                end
793

    
794
                raise if $? && $?.exitstatus != 0
795

    
796
              end
797
              
798
            end
799
        
800
            def messages(path=nil)
801
              path ||= ''
802

    
803
              commits = Array.new
804

    
805
              cmd = "#{SVN_BIN} log --xml -r 1:HEAD"
806
              cmd << credentials_string
807
              cmd << ' ' + target(URI.escape(path))
808
                            
809
              shellout(cmd) do |io|
810
                begin
811
                  doc = REXML::Document.new(io)
812
                  doc.elements.each("log/logentry") do |logentry|
813

    
814
                    commits << Commit.new(
815
                                                {
816
                                                  :revision => logentry.attributes['revision'].to_i,
817
                                                  :message => logentry.elements['msg'].text
818
                                                })
819
                  end
820
                rescue => e
821
                  puts"Error !!!"
822
                  puts e
823
                end
824
              end
825
              return nil if $? && $?.exitstatus != 0
826
              commits
827
            end
828
          
829
        end
830
        
831
        def self.migrate
832

    
833
          project = Project.find(@@redmine_project)
834
          if !project
835
            puts "Could not find project identifier '#{@@redmine_project}'"
836
            raise 
837
          end
838
                    
839
          tid = IssueCustomField.find(:first, :conditions => { :name => "TracID" })
840
          if !tid
841
            puts "Could not find issue custom field 'TracID'"
842
            raise 
843
          end
844
          
845
          Issue.find( :all, :conditions => { :project_id => project }).each do |issue|
846
            val = nil
847
            issue.custom_values.each do |value|
848
              if value.custom_field.id == tid.id
849
                val = value
850
                break
851
              end
852
            end
853
            
854
            TICKET_MAP[val.value.to_i] = issue.id if !val.nil?            
855
          end
856
          
857
          svn = self.scm          
858
          msgs = svn.messages(@svn_url)
859
          msgs.each do |commit| 
860
          
861
            newText = convert_wiki_text(commit.message)
862
            
863
            if newText != commit.message             
864
              puts "Updating message #{commit.revision}"
865
              scm.set_message(@svn_url, commit.revision, newText)
866
            end
867
          end
868
          
869
          
870
        end
871
        
872
        # Basic wiki syntax conversion
873
        def self.convert_wiki_text(text)
874
          convert_wiki_text_mapping(text, TICKET_MAP )
875
        end
876
        
877
        def self.set_svn_url(url)
878
          @@svn_url = url
879
        end
880

    
881
        def self.set_svn_username(username)
882
          @@svn_username = username
883
        end
884

    
885
        def self.set_svn_password(password)
886
          @@svn_password = password
887
        end
888

    
889
        def self.set_redmine_project_identifier(identifier)
890
          @@redmine_project = identifier
891
        end
892
      
893
        def self.scm
894
          @scm ||= SvnExtendedAdapter.new @@svn_url, @@svn_url, @@svn_username, @@svn_password, 0, "", nil
895
          @scm
896
        end
897
    end
898
    
899
    def prompt(text, options = {}, &block)
900
      default = options[:default] || ''
901
      while true
902
        print "#{text} [#{default}]: "
903
        value = STDIN.gets.chomp!
904
        value = default if value.blank?
905
        break if yield value
906
      end
907
    end
908

    
909
    puts
910
    if Redmine::DefaultData::Loader.no_data?
911
      puts "Redmine configuration need to be loaded before importing data."
912
      puts "Please, run this first:"
913
      puts
914
      puts "  rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
915
      exit
916
    end
917

    
918
    puts "WARNING: all commit messages with references to trac pages will be modified"
919
    print "Are you sure you want to continue ? [y/N] "
920
    break unless STDIN.gets.match(/^y$/i)
921
    puts
922

    
923
    prompt('Subversion repository url') {|repository| SvnMigrate.set_svn_url repository.strip}
924
    prompt('Subversion repository username') {|username| SvnMigrate.set_svn_username username}
925
    prompt('Subversion repository password') {|password| SvnMigrate.set_svn_password password}
926
    prompt('Redmine project identifier') {|identifier| SvnMigrate.set_redmine_project_identifier identifier}
927
    puts
928

    
929
    SvnMigrate.migrate
930
    
931
  end
932

    
933

    
934
  # Basic wiki syntax conversion
935
  def convert_wiki_text_mapping(text, ticket_map = [])
936
    # Titles
937
    text = text.gsub(/^(\=+)\s(.+)\s(\=+)/) {|s| "\nh#{$1.length}. #{$2}\n"}
938
    # External Links
939
    text = text.gsub(/\[(http[^\s]+)\s+([^\]]+)\]/) {|s| "\"#{$2}\":#{$1}"}
940
    # Ticket links:
941
    #      [ticket:234 Text],[ticket:234 This is a test]
942
    text = text.gsub(/\[ticket\:([^\ ]+)\ (.+?)\]/, '"\2":/issues/show/\1')
943
    #      ticket:1234
944
    #      #1 is working cause Redmine uses the same syntax.
945
    text = text.gsub(/ticket\:([^\ ]+)/, '#\1')
946

    
947
    # Source links:
948
    #      [source:/trunk/readme.txt Readme File]
949
    #      The text "Readme File" is not converted,
950
    #      cause Redmine's wiki does not support this.
951
    text = text.gsub(/\[source\:\"([^\"]+)\"\ (.+?)\]/, 'source:"\1"')
952
    #      [source:/trunk/readme.txt]
953
    text = text.gsub(/\[source\:\"([^\"]+)\"\]/, 'source:"\1"')
954
    text = text.gsub(/source\:\"([^\"]+)\"/, 'source:"\1"')
955

    
956
    # Milestone links:
957
    #      [milestone:"0.1.0 Mercury" Milestone 0.1.0 (Mercury)]
958
    #      The text "Milestone 0.1.0 (Mercury)" is not converted,
959
    #      cause Redmine's wiki does not support this.
960
    text = text.gsub(/\[milestone\:\"([^\"]+)\"\ (.+?)\]/, 'version:"\1"')
961
    #      [milestone:"0.1.0 Mercury"]
962
    text = text.gsub(/\[milestone\:\"([^\"]+)\"\]/, 'version:"\1"')
963
    text = text.gsub(/milestone\:\"([^\"]+)\"/, 'version:"\1"')
964
    #      milestone:0.1.0
965
    text = text.gsub(/\[milestone\:([^\ ]+)\]/, 'version:\1')
966
    text = text.gsub(/milestone\:([^\ ]+)/, 'version:\1')
967
    # Internal Links
968
    text = text.gsub(/\[\[BR\]\]/, "\n") # This has to go before the rules below
969
    text = text.gsub(/\[\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
970
    text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
971
    text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
972
    text = text.gsub(/\[wiki:([^\s\]]+)\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
973
    text = text.gsub(/\[wiki:([^\s\]]+)\s(.*)\]/) {|s| "[[#{$1.delete(',./?;|:')}|#{$2.delete(',./?;|:')}]]"}
974

    
975
    # Links to pages UsingJustWikiCaps
976
    text = text.gsub(/([^!]|^)(^| )([A-Z][a-z]+[A-Z][a-zA-Z]+)/, '\\1\\2[[\3]]')
977
    # Normalize things that were supposed to not be links
978
    # like !NotALink
979
    text = text.gsub(/(^| )!([A-Z][A-Za-z]+)/, '\1\2')
980

    
981
    # Revisions links
982
    text = text.gsub(/\[(\d+)\]/, 'r\1')
983
    # Ticket number re-writing
984
    text = text.gsub(/#(\d+)/) do |s|
985
      if $1.length < 10
986
        ticket_map[$1.to_i] ||= $1
987
        "\##{ticket_map[$1.to_i] || $1}"
988
      else
989
        s
990
      end
991
    end
992
    # We would like to convert the Code highlighting too
993
    # This will go into the next line.
994
    shebang_line = false
995
    # Reguar expression for start of code
996
    pre_re = /\{\{\{/
997
    # Code hightlighing...
998
    shebang_re = /^\#\!([a-z]+)/
999
    # Regular expression for end of code
1000
    pre_end_re = /\}\}\}/
1001

    
1002
    # Go through the whole text..extract it line by line
1003
    text = text.gsub(/^(.*)$/) do |line|
1004
      m_pre = pre_re.match(line)
1005
      if m_pre
1006
        line = '<pre>'
1007
      else
1008
        m_sl = shebang_re.match(line)
1009
        if m_sl
1010
          shebang_line = true
1011
          line = '<code class="' + m_sl[1] + '">'
1012
        end
1013
        m_pre_end = pre_end_re.match(line)
1014
        if m_pre_end
1015
          line = '</pre>'
1016
          if shebang_line
1017
            line = '</code>' + line
1018
          end
1019
        end
1020
      end
1021
      line
1022
    end
1023

    
1024
    # Highlighting
1025
    text = text.gsub(/'''''([^\s])/, '_*\1')
1026
    text = text.gsub(/([^\s])'''''/, '\1*_')
1027
    text = text.gsub(/'''/, '*')
1028
    text = text.gsub(/''/, '_')
1029
    text = text.gsub(/__/, '+')
1030
    text = text.gsub(/~~/, '-')
1031
    text = text.gsub(/`/, '@')
1032
    text = text.gsub(/,,/, '~')
1033
    # Lists
1034
    text = text.gsub(/^([ ]+)\* /) {|s| '*' * $1.length + " "}
1035
    text = text.gsub(/^([ ]+)[0-9]+\. /) {|s| '#' * $1.length + " "}
1036

    
1037
    text
1038
  end
1039
end
1040