Project

General

Profile

Patch #5110 » migrate_from_trac-3597.patch

Patch file against migrate_from_trac.rake on trunk r3597 - Bryce Nordgren, 2010-03-17 22:12

View differences:

lib/tasks/migrate_from_trac.rake (working copy)
19 19
require 'iconv'
20 20
require 'pp'
21 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

  
22 29
namespace :redmine do
23 30
  desc 'Trac migration script'
24 31
  task :migrate_from_trac => :environment do
......
192 199
        def time; Time.at(read_attribute(:time)) end
193 200
      end
194 201

  
195
      TRAC_WIKI_PAGES = %w(InterMapTxt InterTrac InterWiki RecentChanges SandBox TracAccessibility TracAdmin TracBackup TracBrowser TracCgi TracChangeset \
202
      TRAC_WIKI_PAGES = %w(InterMapTxt TracInstallPlatforms TracMultipleProjects InterTrac InterWiki RecentChanges SandBox \
203
               TracAccessibility TracAdmin TracBackup TracBrowser TracCgi TracChangeset \
196 204
                           TracEnvironment TracFastCgi TracGuide TracImport TracIni TracInstall TracInterfaceCustomization \
197 205
                           TracLinks TracLogging TracModPython TracNotification TracPermissions TracPlugins TracQuery \
198 206
                           TracReports TracRevisionLog TracRoadmap TracRss TracSearch TracStandalone TracSupport TracSyntaxColoring TracTickets \
......
271 279

  
272 280
      # Basic wiki syntax conversion
273 281
      def self.convert_wiki_text(text)
274
        # Titles
275
        text = text.gsub(/^(\=+)\s(.+)\s(\=+)/) {|s| "\nh#{$1.length}. #{$2}\n"}
276
        # External Links
277
        text = text.gsub(/\[(http[^\s]+)\s+([^\]]+)\]/) {|s| "\"#{$2}\":#{$1}"}
278
        # Ticket links:
279
        #      [ticket:234 Text],[ticket:234 This is a test]
280
        text = text.gsub(/\[ticket\:([^\ ]+)\ (.+?)\]/, '"\2":/issues/show/\1')
281
        #      ticket:1234
282
        #      #1 is working cause Redmine uses the same syntax.
283
        text = text.gsub(/ticket\:([^\ ]+)/, '#\1')
284
        # Milestone links:
285
        #      [milestone:"0.1.0 Mercury" Milestone 0.1.0 (Mercury)]
286
        #      The text "Milestone 0.1.0 (Mercury)" is not converted,
287
        #      cause Redmine's wiki does not support this.
288
        text = text.gsub(/\[milestone\:\"([^\"]+)\"\ (.+?)\]/, 'version:"\1"')
289
        #      [milestone:"0.1.0 Mercury"]
290
        text = text.gsub(/\[milestone\:\"([^\"]+)\"\]/, 'version:"\1"')
291
        text = text.gsub(/milestone\:\"([^\"]+)\"/, 'version:"\1"')
292
        #      milestone:0.1.0
293
        text = text.gsub(/\[milestone\:([^\ ]+)\]/, 'version:\1')
294
        text = text.gsub(/milestone\:([^\ ]+)/, 'version:\1')
295
        # Internal Links
296
        text = text.gsub(/\[\[BR\]\]/, "\n") # This has to go before the rules below
297
        text = text.gsub(/\[\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
298
        text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
299
        text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
300
        text = text.gsub(/\[wiki:([^\s\]]+)\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
301
        text = text.gsub(/\[wiki:([^\s\]]+)\s(.*)\]/) {|s| "[[#{$1.delete(',./?;|:')}|#{$2.delete(',./?;|:')}]]"}
302

  
303
  # Links to pages UsingJustWikiCaps
304
  text = text.gsub(/([^!]|^)(^| )([A-Z][a-z]+[A-Z][a-zA-Z]+)/, '\\1\\2[[\3]]')
305
  # Normalize things that were supposed to not be links
306
  # like !NotALink
307
  text = text.gsub(/(^| )!([A-Z][A-Za-z]+)/, '\1\2')
308
        # Revisions links
309
        text = text.gsub(/\[(\d+)\]/, 'r\1')
310
        # Ticket number re-writing
311
        text = text.gsub(/#(\d+)/) do |s|
312
          if $1.length < 10
313
#            TICKET_MAP[$1.to_i] ||= $1
314
            "\##{TICKET_MAP[$1.to_i] || $1}"
315
          else
316
            s
317
          end
318
        end
319
        # We would like to convert the Code highlighting too
320
        # This will go into the next line.
321
        shebang_line = false
322
        # Reguar expression for start of code
323
        pre_re = /\{\{\{/
324
        # Code hightlighing...
325
        shebang_re = /^\#\!([a-z]+)/
326
        # Regular expression for end of code
327
        pre_end_re = /\}\}\}/
328

  
329
        # Go through the whole text..extract it line by line
330
        text = text.gsub(/^(.*)$/) do |line|
331
          m_pre = pre_re.match(line)
332
          if m_pre
333
            line = '<pre>'
334
          else
335
            m_sl = shebang_re.match(line)
336
            if m_sl
337
              shebang_line = true
338
              line = '<code class="' + m_sl[1] + '">'
339
            end
340
            m_pre_end = pre_end_re.match(line)
341
            if m_pre_end
342
              line = '</pre>'
343
              if shebang_line
344
                line = '</code>' + line
345
              end
346
            end
347
          end
348
          line
349
        end
350

  
351
        # Highlighting
352
        text = text.gsub(/'''''([^\s])/, '_*\1')
353
        text = text.gsub(/([^\s])'''''/, '\1*_')
354
        text = text.gsub(/'''/, '*')
355
        text = text.gsub(/''/, '_')
356
        text = text.gsub(/__/, '+')
357
        text = text.gsub(/~~/, '-')
358
        text = text.gsub(/`/, '@')
359
        text = text.gsub(/,,/, '~')
360
        # Lists
361
        text = text.gsub(/^([ ]+)\* /) {|s| '*' * $1.length + " "}
362

  
363
        text
282
        convert_wiki_text_mapping(text, TICKET_MAP)
364 283
      end
365 284

  
366 285
      def self.migrate
......
400 319
        # Milestones
401 320
        print "Migrating milestones"
402 321
        version_map = {}
322
        milestone_wiki = Array.new
403 323
        TracMilestone.find(:all).each do |milestone|
404 324
          print '.'
405 325
          STDOUT.flush
......
419 339

  
420 340
          next unless v.save
421 341
          version_map[milestone.name] = v
342
          milestone_wiki.push(milestone.name);
422 343
          migrated_milestones += 1
423 344
        end
424 345
        puts
425

  
346
  
426 347
        # Custom fields
427 348
        # TODO: read trac.ini instead
428 349
        print "Migrating custom fields"
429 350
        custom_field_map = {}
351
        
430 352
        TracTicketCustom.find_by_sql("SELECT DISTINCT name FROM #{TracTicketCustom.table_name}").each do |field|
431 353
          print '.'
432 354
          STDOUT.flush
......
456 378
        r.save!
457 379
        custom_field_map['resolution'] = r
458 380

  
381
        # Trac ticket id as a Redmine custom field
382
        tid = IssueCustomField.find(:first, :conditions => { :name => "TracID" })
383
        tid = IssueCustomField.new(:name => 'TracID',
384
                                 :field_format => 'string',
385
                                 :is_filter => true) if tid.nil?
386
        tid.trackers = Tracker.find(:all)
387
        tid.projects << @target_project
388
        tid.save!
389
        custom_field_map['tracid'] = tid
390
  
459 391
        # Tickets
460 392
        print "Migrating tickets"
461 393
          TracTicket.find_each(:batch_size => 200) do |ticket|
......
463 395
          STDOUT.flush
464 396
          i = Issue.new :project => @target_project,
465 397
                          :subject => encode(ticket.summary[0, limit_for(Issue, 'subject')]),
466
                          :description => convert_wiki_text(encode(ticket.description)),
398
                          :description => encode(ticket.description),
467 399
                          :priority => PRIORITY_MAPPING[ticket.priority] || DEFAULT_PRIORITY,
468 400
                          :created_on => ticket.time
469 401
          i.author = find_or_create_user(ticket.reporter)
......
488 420
              resolution_change = changeset.select {|change| change.field == 'resolution'}.first
489 421
              comment_change = changeset.select {|change| change.field == 'comment'}.first
490 422

  
491
              n = Journal.new :notes => (comment_change ? convert_wiki_text(encode(comment_change.newvalue)) : ''),
423
              n = Journal.new :notes => (comment_change ? encode(comment_change.newvalue) : ''),
492 424
                              :created_on => time
493 425
              n.user = find_or_create_user(changeset.first.author)
494 426
              n.journalized = i
......
534 466
          if custom_field_map['resolution'] && !ticket.resolution.blank?
535 467
            custom_values[custom_field_map['resolution'].id] = ticket.resolution
536 468
          end
469
          if custom_field_map['tracid'] 
470
            custom_values[custom_field_map['tracid'].id] = ticket.id
471
          end
537 472
          i.custom_field_values = custom_values
538 473
          i.save_custom_field_values
539 474
        end
......
576 511
            end
577 512
          end
578 513

  
514
        end
515
        puts
516

  
517
    # Now load each wiki page and transform its content into textile format
518
    print "Fixing ticket identifiers"
519
    puts
520
    
521
    print "...in Wiki pages"
579 522
          wiki.reload
580 523
          wiki.pages.each do |page|
524
            print '.'
581 525
            page.content.text = convert_wiki_text(page.content.text)
582 526
            Time.fake(page.content.updated_on) { page.content.save }
583 527
          end
584
        end
585
        puts
528
    puts
586 529

  
530
    print "...in Issue descriptions"
531
          TICKET_MAP.each do |newId|
532

  
533
            next if newId.nil?
534
            
535
            print '.'
536
            issue = findIssue(newId)
537
            next if issue.nil?
538

  
539
            issue.description = convert_wiki_text(issue.description)
540
      issue.save            
541
          end
542
    puts
543

  
544
    print "...in Issue journal descriptions"
545
          TICKET_MAP.each do |newId|
546
            next if newId.nil?
547
            
548
            print '.'
549
            issue = findIssue(newId)
550
            next if issue.nil?
551
            
552
            issue.journals.find(:all).each do |journal|
553
              print '.'
554
              journal.notes = convert_wiki_text(journal.notes)
555
              journal.save
556
            end
557
  
558
          end
559
    puts
560
    
561
    print "...in Milestone descriptions"
562

  
563

  
564
    # Now load each page and transform its content into textile format
565
          milestone_wiki.each do |name|
566
            p = wiki.find_page(name)            
567
            next if p.nil?
568
                  
569
            print '.'            
570
            p.content.text = convert_wiki_text(p.content.text)
571
            p.content.save
572
    end
573
    puts
574

  
587 575
        puts
588 576
        puts "Components:      #{migrated_components}/#{TracComponent.count}"
589 577
        puts "Milestones:      #{migrated_milestones}/#{TracMilestone.count}"
......
593 581
        puts "Wiki edits:      #{migrated_wiki_edits}/#{wiki_edit_count}"
594 582
        puts "Wiki files:      #{migrated_wiki_attachments}/" + TracAttachment.count(:conditions => {:type => 'wiki'}).to_s
595 583
      end
584
      
585
      def self.findIssue(id)
586
        
587
        return Issue.find(id)
596 588

  
589
      rescue ActiveRecord::RecordNotFound
590
  puts
591
        print "[#{id}] not found"
592

  
593
        nil      
594
      end
595
      
597 596
      def self.limit_for(klass, attribute)
598 597
        klass.columns_hash[attribute.to_s].limit
599 598
      end
......
746 745
    DEFAULT_PORTS = {'mysql' => 3306, 'postgresql' => 5432}
747 746

  
748 747
    prompt('Trac directory') {|directory| TracMigrate.set_trac_directory directory.strip}
749
    prompt('Trac database adapter (sqlite, sqlite3, mysql, postgresql)', :default => 'sqlite') {|adapter| TracMigrate.set_trac_adapter adapter}
748
    prompt('Trac database adapter (sqlite, sqlite3, mysql, postgresql)', :default => 'sqlite3') {|adapter| TracMigrate.set_trac_adapter adapter}
750 749
    unless %w(sqlite sqlite3).include?(TracMigrate.trac_adapter)
751 750
      prompt('Trac database host', :default => 'localhost') {|host| TracMigrate.set_trac_db_host host}
752 751
      prompt('Trac database port', :default => DEFAULT_PORTS[TracMigrate.trac_adapter]) {|port| TracMigrate.set_trac_db_port port}
......
764 763
    
765 764
    TracMigrate.migrate
766 765
  end
766

  
767

  
768
  desc 'Subversion migration script'
769
  task :migrate_svn_commits => :environment do
770
  
771
    module SvnMigrate 
772
        TICKET_MAP = []
773

  
774
        class Commit
775
          attr_accessor :revision, :message
776
          
777
          def initialize(attributes={})
778
            self.message = attributes[:message] || ""
779
            self.revision = attributes[:revision]
780
          end
781
        end
782
        
783
        class SvnExtendedAdapter < Redmine::Scm::Adapters::SubversionAdapter
784
        
785

  
786

  
787
            def set_message(path=nil, revision=nil, msg=nil)
788
              path ||= ''
789

  
790
              Tempfile.open('msg') do |tempfile|
791

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

  
796
                filePath = tempfile.path.gsub(File::SEPARATOR, File::ALT_SEPARATOR || File::SEPARATOR)
797

  
798
                cmd = "#{SVN_BIN} propset svn:log --quiet --revprop -r #{revision}  -F \"#{filePath}\" "
799
                cmd << credentials_string
800
                cmd << ' ' + target(URI.escape(path))
801

  
802
                shellout(cmd) do |io|
803
                  begin
804
                    loop do 
805
                      line = io.readline
806
                      puts line
807
                    end
808
                  rescue EOFError
809
                  end  
810
                end
811

  
812
                raise if $? && $?.exitstatus != 0
813

  
814
              end
815
              
816
            end
817
        
818
            def messages(path=nil)
819
              path ||= ''
820

  
821
              commits = Array.new
822

  
823
              cmd = "#{SVN_BIN} log --xml -r 1:HEAD"
824
              cmd << credentials_string
825
              cmd << ' ' + target(URI.escape(path))
826
                            
827
              shellout(cmd) do |io|
828
                begin
829
                  doc = REXML::Document.new(io)
830
                  doc.elements.each("log/logentry") do |logentry|
831

  
832
                    commits << Commit.new(
833
                                                {
834
                                                  :revision => logentry.attributes['revision'].to_i,
835
                                                  :message => logentry.elements['msg'].text
836
                                                })
837
                  end
838
                rescue => e
839
                  puts"Error !!!"
840
                  puts e
841
                end
842
              end
843
              return nil if $? && $?.exitstatus != 0
844
              commits
845
            end
846
          
847
        end
848
        
849
        def self.migrate
850

  
851
          project = Project.find(@@redmine_project)
852
          if !project
853
            puts "Could not find project identifier '#{@@redmine_project}'"
854
            raise 
855
          end
856
                    
857
          tid = IssueCustomField.find(:first, :conditions => { :name => "TracID" })
858
          if !tid
859
            puts "Could not find issue custom field 'TracID'"
860
            raise 
861
          end
862
          
863
          Issue.find( :all, :conditions => { :project_id => project }).each do |issue|
864
            val = nil
865
            issue.custom_values.each do |value|
866
              if value.custom_field.id == tid.id
867
                val = value
868
                break
869
              end
870
            end
871
            
872
            TICKET_MAP[val.value.to_i] = issue.id if !val.nil?            
873
          end
874
          
875
          svn = self.scm          
876
          msgs = svn.messages(@svn_url)
877
          msgs.each do |commit| 
878
          
879
            newText = convert_wiki_text(commit.message)
880
            
881
            if newText != commit.message             
882
              puts "Updating message #{commit.revision}"
883
              scm.set_message(@svn_url, commit.revision, newText)
884
            end
885
          end
886
          
887
          
888
        end
889
        
890
        # Basic wiki syntax conversion
891
        def self.convert_wiki_text(text)
892
          convert_wiki_text_mapping(text, TICKET_MAP )
893
        end
894
        
895
        def self.set_svn_url(url)
896
          @@svn_url = url
897
        end
898

  
899
        def self.set_svn_username(username)
900
          @@svn_username = username
901
        end
902

  
903
        def self.set_svn_password(password)
904
          @@svn_password = password
905
        end
906

  
907
        def self.set_redmine_project_identifier(identifier)
908
          @@redmine_project = identifier
909
        end
910
      
911
        def self.scm
912
          @scm ||= SvnExtendedAdapter.new @@svn_url, @@svn_url, @@svn_username, @@svn_password, 0, "", nil
913
          @scm
914
        end
915
    end
916
    
917
    def prompt(text, options = {}, &block)
918
      default = options[:default] || ''
919
      while true
920
        print "#{text} [#{default}]: "
921
        value = STDIN.gets.chomp!
922
        value = default if value.blank?
923
        break if yield value
924
      end
925
    end
926

  
927
    puts
928
    if Redmine::DefaultData::Loader.no_data?
929
      puts "Redmine configuration need to be loaded before importing data."
930
      puts "Please, run this first:"
931
      puts
932
      puts "  rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
933
      exit
934
    end
935

  
936
    puts "WARNING: all commit messages with references to trac pages will be modified"
937
    print "Are you sure you want to continue ? [y/N] "
938
    break unless STDIN.gets.match(/^y$/i)
939
    puts
940

  
941
    prompt('Subversion repository url') {|repository| SvnMigrate.set_svn_url repository.strip}
942
    prompt('Subversion repository username') {|username| SvnMigrate.set_svn_username username}
943
    prompt('Subversion repository password') {|password| SvnMigrate.set_svn_password password}
944
    prompt('Redmine project identifier') {|identifier| SvnMigrate.set_redmine_project_identifier identifier}
945
    puts
946

  
947
    SvnMigrate.migrate
948
    
949
  end
950

  
951

  
952
  # Basic wiki syntax conversion
953
  def convert_wiki_text_mapping(text, ticket_map = [])
954
    # Images
955
    text = text.gsub(/\[\[Image\(([a-zA-Z]+\:(\d+\:)?)?([^\)^,]+)[^\)]*\)\]\]/, '!\3!') 
956
    # Titles
957
    text = text.gsub(/^(\=+)\s(.+)\s(\=+)/) {|s| "\nh#{$1.length}. #{$2}\n"}
958
    # External Links
959
    text = text.gsub(/\[(http[^\s]+)\s+([^\]]+)\]/) {|s| "\"#{$2}\":#{$1}"}
960
    # Ticket links:
961
    #      [ticket:234 Text],[ticket:234 This is a test]
962
    text = text.gsub(/\[ticket\:([^\ ]+)\ (.+?)\]/, '"\2":/issues/show/\1')
963
    #      ticket:1234
964
    #      #1 is working cause Redmine uses the same syntax.
965
    text = text.gsub(/ticket\:([^\ ]+)/, '#\1')
966

  
967
    # Source links:
968
    #      [source:/trunk/readme.txt Readme File]
969
    #      The text "Readme File" is not converted,
970
    #      cause Redmine's wiki does not support this.
971
    text = text.gsub(/\[source\:\"([^\"]+)\"\ (.+?)\]/, 'source:"\1"')
972
    #      [source:/trunk/readme.txt]
973
    text = text.gsub(/\[source\:\"([^\"]+)\"\]/, 'source:"\1"')
974
    text = text.gsub(/source\:\"([^\"]+)\"/, 'source:"\1"')
975

  
976
    # Milestone links:
977
    #      [milestone:"0.1.0 Mercury" Milestone 0.1.0 (Mercury)]
978
    #      The text "Milestone 0.1.0 (Mercury)" is not converted,
979
    #      cause Redmine's wiki does not support this.
980
    text = text.gsub(/\[milestone\:\"([^\"]+)\"\ (.+?)\]/, 'version:"\1"')
981
    #      [milestone:"0.1.0 Mercury"]
982
    text = text.gsub(/\[milestone\:\"([^\"]+)\"\]/, 'version:"\1"')
983
    text = text.gsub(/milestone\:\"([^\"]+)\"/, 'version:"\1"')
984
    #      milestone:0.1.0
985
    text = text.gsub(/\[milestone\:([^\ ]+)\]/, 'version:\1')
986
    text = text.gsub(/milestone\:([^\ ]+)/, 'version:\1')
987
    # Internal Links
988
    text = text.gsub(/\[\[BR\]\]/, "\n") # This has to go before the rules below
989
    text = text.gsub(/\[\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
990
    text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
991
    text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
992
    text = text.gsub(/\[wiki:([^\s\]]+)\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
993
    text = text.gsub(/\[wiki:([^\s\]]+)\s(.*)\]/) {|s| "[[#{$1.delete(',./?;|:')}|#{$2.delete(',./?;|:')}]]"}
994

  
995
    # Links to pages UsingJustWikiCaps
996
    text = text.gsub(/([^!]|^)(^| )([A-Z][a-z]+[A-Z][a-zA-Z]+)/, '\\1\\2[[\3]]')
997
    # Normalize things that were supposed to not be links
998
    # like !NotALink
999
    text = text.gsub(/(^| )!([A-Z][A-Za-z]+)/, '\1\2')
1000

  
1001
    # Revisions links
1002
    text = text.gsub(/\[(\d+)\]/, 'r\1')
1003
    # Ticket number re-writing
1004
    text = text.gsub(/#(\d+)/) do |s|
1005
      if $1.length < 10
1006
        ticket_map[$1.to_i] ||= $1
1007
        "\##{ticket_map[$1.to_i] || $1}"
1008
      else
1009
        s
1010
      end
1011
    end
1012
    # We would like to convert the Code highlighting too
1013
    # This will go into the next line.
1014
    shebang_line = false
1015
    # Reguar expression for start of code
1016
    pre_re = /\{\{\{/
1017
    # Code hightlighing...
1018
    shebang_re = /^\#\!([a-z]+)/
1019
    # Regular expression for end of code
1020
    pre_end_re = /\}\}\}/
1021

  
1022
    # Go through the whole text..extract it line by line
1023
    text = text.gsub(/^(.*)$/) do |line|
1024
      m_pre = pre_re.match(line)
1025
      if m_pre
1026
        line = '<pre>'
1027
      else
1028
        m_sl = shebang_re.match(line)
1029
        if m_sl
1030
          shebang_line = true
1031
          line = '<code class="' + m_sl[1] + '">'
1032
        end
1033
        m_pre_end = pre_end_re.match(line)
1034
        if m_pre_end
1035
          line = '</pre>'
1036
          if shebang_line
1037
            line = '</code>' + line
1038
          end
1039
        end
1040
      end
1041
      line
1042
    end
1043

  
1044
    # Highlighting
1045
    text = text.gsub(/'''''([^\s])/, '_*\1')
1046
    text = text.gsub(/([^\s])'''''/, '\1*_')
1047
    text = text.gsub(/'''/, '*')
1048
    text = text.gsub(/''/, '_')
1049
    text = text.gsub(/__/, '+')
1050
    text = text.gsub(/~~/, '-')
1051
    text = text.gsub(/`/, '@')
1052
    text = text.gsub(/,,/, '~')
1053
    # Lists
1054
    text = text.gsub(/^([ ]+)\* /) {|s| '*' * $1.length + " "}
1055
    text = text.gsub(/^([ ]+)[0-9]+\. /) {|s| '#' * $1.length + " "}
1056

  
1057
    text
1058
  end
767 1059
end
768 1060

  
(1-1/2)