Initial version.\nSupports Trac -> Ditz syncing
2 files changed, 266 insertions(+), 0 deletions(-)

A => .hgignore
A => trac-sync.rb
A => .hgignore +4 -0
@@ 0,0 1,4 @@ 
+syntax: glob
+
+.*.sw?
+*~

          
A => trac-sync.rb +262 -0
@@ 0,0 1,262 @@ 
+# Trac syncing ditz plugin
+#
+# Provides ditz <-> trac synchronization
+#
+# Command added:
+#   ditz sync: synchronize issues with Trac
+#
+# Usage:
+#   1. add a line "- trac-sync" to the .ditz-plugins file in the project root.
+
+require 'rubygems'
+require 'trac4r'
+require 'ditz'
+
+module Ditz
+  class Issue
+    def log_at time, what, who, comment
+      add_log_event([time, who, what, comment || ""])
+      self
+    end
+  end
+
+  class TracUtil
+    # If new types or dispositions are added to either ditz or trac, they'll need
+    # to be mapped here
+    #
+    # trac_type -> ditz_type
+    TTYPE_DTYPE = { "defect" => :bugfix, 
+      "enhancement" => :feature, 
+      "task" => :task }
+    DTYPE_TTYPE = TTYPE_DTYPE.invert
+    # trac_resolution -> ditz_disposition
+    RES_DISPO = { "fixed" => :fixed,
+      "wontfix" => :wontfix,
+      "worksforme" => :wontfix,
+      "invalid" => :wontfix,
+      "duplicate" => :duplicate,
+      }
+    DISPO_RES = RES_DISPO.invert
+    # But, because it isn't 1-1, fix the mapping
+    DISPO_RES[ :wontfix ] = "wontfix"
+    DISPO_RES[ :reorg ]   = "wontfix"
+    TSTATUS_DSTATUS = { "accepted" => :in_progress,
+      "repoened" => :in_progress,
+      "assigned" => :unstarted,
+      "new" => :unstarted,
+      "closed" => :closed,
+      }
+    DSTATUS_TSTATUS = TSTATUS_DSTATUS.invert
+    # But, because it isn't 1-1, fix the mapping
+    DSTATUS_TSTATUS[ :in_progress ] = "accepted" 
+    DSTATUS_TSTATUS[ :unstarted ] = "assigned" 
+    DSTATUS_TSTATUS[ :paused ] = "assigned" 
+
+    def initialize( project, config, trac )
+      @project, @config, @trac = project, config, trac
+    end
+
+    def equal?( ticket, issue )
+      ticket.summary == issue.title
+    end
+
+    def pair( tickets )
+      rv = []
+      issues = @project.issues.clone
+      for ticket in tickets 
+        issue = issues.find { |i| equal?( ticket, i ) }
+        issues.delete(issue) if issue
+        rv << [ticket,issue]
+      end
+      for issue in issues
+        rv << [nil,issue]
+      end
+      rv
+    end
+
+    def create_tickets( issues )
+    end
+
+    def create_issues( tickets )
+      tickets.each do |t,i|   # i will always be nil
+        # trac4r doesn't yet support resolution
+        resolution = t.status == "closed" ? :fixed : nil
+        release = @project.releases.find { |r| r.name == t.milestone }
+        unless release 
+          puts "Creating release #{t.milestone}"
+          release = Ditz::Release.create({:name=>t.milestone}, [@config, @project])
+          @project.add_release(release)
+        end
+
+        if release.status == :released
+          puts "Orphaned ticket ##{t.id}: milestone #{t.milestone} already released!"
+          puts "\t#{t.summary}"
+        else
+          issue = Ditz::Issue.create({:reporter => t.reporter,
+                                     :creation_time => t.created_at.to_time,
+                                     :title => t.summary,
+                                     :type  => TTYPE_DTYPE[ t.type ],
+                                     :desc  => t.description,
+                                     :release => t.milestone,
+                                     :component => t.component,
+                                     :status => TSTATUS_DSTATUS[ t.status ],
+                                     :disposition => resolution,
+                                     :references => []
+          }, [@config, @project])
+
+          @project.add_issue( issue )
+          puts "Created issue #{issue.id[0,4]}: #{issue.title}"
+        end
+      end
+    end
+
+    def change_status status, issue
+      if issue.status != status
+        old_status = issue.status
+        issue.status = status
+        return "changed status from #{old_status} to #{status}"
+      end
+      nil
+    end
+
+    def group_by_time(changelog)
+      rv = {}
+      changelog.each do |log|
+        t = log[0].to_time
+        rv[t] = [] unless rv[t]
+        rv[t] << log
+      end
+      rv.sort
+    end
+
+    def update_issues( pairs, trac )
+      pairs.each do |ticket, issue|
+        puts "Working on #{ticket.id}/#{issue.id[0,4]}"
+        issue_last_updated = if issue.log_events.size == 0
+                               issue.creation_time
+                             else
+                               issue.log_events[-1][0]
+                             end
+        cl = trac.tickets.changelog( ticket.id )
+        puts "Got #{cl.length} changes from Trac"
+        puts "Ditz issue last changed #{issue_last_updated}"
+        group_by_time( cl ).each do |time, array_of_changes|
+          puts "Trac changegroup @ #{time}"
+          if time > issue_last_updated
+            whats = []
+            comment = nil
+            array_of_changes.each do |new_change|
+              case new_change[2]
+              when "comment"
+                comment = new_change[2]
+              when "description"
+                if new_change[3] != issue.description
+                  whats << "edited description"
+                  issue.desc = new_change[3]
+                end
+              when "milestone"
+                if new_change[3] != issue.release
+                  whats << "assigned to release #{new_change[3]} from #{issue.release || 'unassigned'}"
+                  issue.release = new_change[3]
+                end
+              when "owner"
+                whats << change_status( :in_progress, issue )
+              when "resolution"
+                RES_DISPO
+                issue.disposition = RES_DISPO[ new_change[3] ]
+              when "status"
+                whats << change_status( TSTATUS_DSTATUS[ new_change[3] ], issue )
+              when "type"
+                new_t = TTYPE_DTYPE[ new_change[3] ]
+                whats << "type changed to #{new_t} from #{issue.type}"
+                issue.type = new_t
+
+              when "attachment", "cc", "component", "os", "priority", 
+                "severity", "version"
+                # NOOP
+              else
+                # NOOP
+              end
+            end
+            # All changes were at the same time, and therefore by the same person
+            issue.log_at( time, whats.join(", "), array_of_changes[0][1], comment )
+          end
+        end
+      end
+    end
+  end
+
+  class Config
+    field :trac_sync_url, :prompt => "URL of Trac project (without the login/xmlrpc)"
+    field :trac_sync_user, :prompt => "The Trac user ID that has XMLRPC permissions"
+    field :trac_sync_pass, :prompt => "The Trac password for the account"
+  end
+
+  class Operator
+    operation :sync, "Sync with a Trac repository"
+    def sync( project, config )
+      unless config.trac_sync_url
+        STDERR.puts( "Please run 'ditz reconfigure' and set the Trac URL" )
+        return
+      end
+      unless config.trac_sync_user
+        STDERR.puts( "Please run 'ditz reconfigure' and set the Trac XMLRPC user name" )
+        return
+      end
+      unless config.trac_sync_pass
+        STDERR.puts( "Please run 'ditz reconfigure' and set the Trac XMLRPC password" )
+        return
+      end
+      trac = Trac.new(config.trac_sync_url, config.trac_sync_user, config.trac_sync_pass)
+      util = Ditz::TracUtil.new( project, config, trac )
+
+      tickets = trac.tickets.get_all.values
+      changelogs = []
+
+      pairs = util.pair(tickets)
+      util.create_issues( pairs.find_all {|m| m[1] == nil} )
+      pairs = util.pair(tickets)
+      util.update_issues( pairs.find_all {|m| m[0] != nil && m[1] != nil}, trac )
+      #util.create_tickets( pairs.find_all {|m| m[0] == nil} )
+      #issue.log "commented", config.user, comment
+    end
+  end
+end
+
+
+    
+
+
+
+# ======================================  ======================================
+# Ditz                                    Trac
+# ======================================  ======================================
+# Tickets
+# id                                      id
+# reporter                                reporter               
+# creation_time                           time                   
+# title                                   summary                
+# type                                    type                   
+# desc                                    description            
+# release                                 milestone              
+# component                               component              
+# status                                  status                 
+# ???                                     comments               
+# disposition                             resolution
+# references                              ???                    
+# N/A                                     keywords               
+# N/A                                     operating system       
+# N/A                                     priority               
+# N/A                                     owner                  
+# N/A                                     cc                     
+# N/A                                     attachments
+# N/A                                     version
+# N/A                                     severity
+# N/A                                     changetime
+#
+# Releases
+# release                                 name
+#                                         due_date
+# status                                  completed
+# release_time                            completion date
+#                                         description