#!/usr/bin/env ruby # Trac2Unfuddle: The Unfuddle Trac import tool # Copyright © 2007-2008, Unfuddle LLC. All rights reserved. # Author: Joshua W. Frappier # Version: 0.7 # # No portion of this code may be duplicated or reproduced # without the express written consent of Unfuddle LLC. # For more information, contact support@Unfuddle.com. # # NOTE: For this script to work, the following gems must be installed: # sqlite3-ruby, xml-simple # # NOTE: You MUST change the values in the section below before # running the script. Once changed, you can simply run the script # without any additional parameters: # # ruby trac2unfuddle.rb ######################################################################## # CHANGE THE VALUES IN THIS SECTION ######################################################################## # change these to your own settings # be sure that the user is an account administrator UNFUDDLE_SETTINGS = { :subdomain => 'subdomain', :username => 'username', :password => 'password', :ssl => true } # values for the newly created Unfuddle project # NOTE: this project must not currently exist within the specified account PROJECT_TITLE = "Imported Trac Project" PROJECT_SHORT_NAME = "tracimport" # the location of the trac project to import TRAC_ROOT_PATH = "/path/to/trac/project/" # a map of usernames in trac and their unfuddle counterparts # if the username does not appear in the mapping, the username in the UNFUDDLE_SETTINGS will be used # NOTE: the unfuddle users must already exist for the mapping to take hold USERNAME_MAP = { :trac_user1 => :unf_user1, :trac_user2 => :unf_user2, :trac_user3 => :unf_user3 } ######################################################################## # NO NEED TO CHANGE BELOW THIS LINE ######################################################################## require 'rubygems' require 'date' require 'net/https' require 'sqlite3' require 'xmlsimple' # an adequate hash to xml conversion method class Hash def to_xml(options={}) xml = '' xml += "\n" unless options[:skip_instruct] xml += "<#{options[:root]}>" if options[:root] self.each { |key,val| key_formatted = key.to_s.gsub('_','-') case val when Date: val_formatted = val.strftime("%Y-%m-%d") when DateTime, Time: val_formatted = val.strftime("%Y-%m-%d %H:%M:%S") when Hash: val_formatted = val.to_xml(:skip_instruct => true) else val_formatted = val.to_s.gsub('&', '&').gsub('>', '>').gsub('<', '<') end xml += "<#{key_formatted}>" xml += val_formatted.to_s xml += "" } xml += "" if options[:root] xml end end # convenience method to get a date class Time def to_date ::Date.new(year, month, day) end end # get a resource # returns a hash of the resource def api_get_hash(short_url) request = Net::HTTP::Get.new("/api/v1/#{short_url}.xml", {'Accept' => 'application/xml'}) request.basic_auth UNFUDDLE_SETTINGS[:username], UNFUDDLE_SETTINGS[:password] response = @http.request(request) if response.code == "200" XmlSimple.xml_in(response.body, { 'ForceArray' => false }) else {} end end # create a resource via the unfuddle api and return the # new id of the resource on success def api_create(resource_type, short_url, attributes) request = Net::HTTP::Post.new("/api/v1/#{short_url}", {'Accept' => 'application/xml', 'Content-type' => 'application/xml'}) request.basic_auth UNFUDDLE_SETTINGS[:username], UNFUDDLE_SETTINGS[:password] request.body = attributes.to_xml(:root => resource_type) response = @http.request(request) if response.code == "201" response['Location'].split('/').last.to_i else puts "ERROR (#{response.code}):\n" + response.body 0 end end # update a resource via the unfuddle api def api_update(resource_type, short_url, attributes) request = Net::HTTP::Put.new("/api/v1/#{short_url}", {'Accept' => 'application/xml', 'Content-type' => 'application/xml'}) request.basic_auth UNFUDDLE_SETTINGS[:username], UNFUDDLE_SETTINGS[:password] request.body = attributes.to_xml(:root => resource_type) response = @http.request(request) if response.code == "200" true else puts "ERROR (#{response.code}):\n" + response.body false end end # upload a file to the unfuddle api # returns the upload key of the file def api_upload(short_url, path) request = Net::HTTP::Post.new("/api/v1/#{short_url}.xml", {'Accept' => 'application/xml', 'Content-type' => 'application/octet-stream'}) request.basic_auth UNFUDDLE_SETTINGS[:username], UNFUDDLE_SETTINGS[:password] path = path.gsub(' ', '%20') unless File.exists?(path) request.body = File.read(path) response = @http.request(request) if response.code == "200" XmlSimple.xml_in(response.body, { 'ForceArray' => false })['key'] else nil end end # return the content type for a filename (very limited) # needed so that images will show inline def get_content_type(filename) ext = filename.split('.').last content_type = 'application/octet-stream' content_type = 'image/jpeg' if ext == 'jpg' || ext == 'jpeg' content_type = 'image/gif' if ext == 'gif' content_type = 'image/png' if ext == 'png' content_type end # return the associated unfuddle username, or the default if no mapping exists def person_id(trac_username) @person_id_hash[(USERNAME_MAP[trac_username && !trac_username.empty? ? trac_username.to_sym : nil] || UNFUDDLE_SETTINGS[:username]).to_s] end # setup the http object for later use @http = Net::HTTP.new("#{UNFUDDLE_SETTINGS[:subdomain]}.unfuddle.com", UNFUDDLE_SETTINGS[:ssl] ? 443 : 80) @http.use_ssl = true if UNFUDDLE_SETTINGS[:ssl] @http.verify_mode = OpenSSL::SSL::VERIFY_NONE if UNFUDDLE_SETTINGS[:ssl] # retrieve ids for people in the unfuddle account print "Retrieving usernames..." @person_id_hash = {} people = api_get_hash('people')['person'] people = [people] unless people.is_a?(Array) people.each { |p| @person_id_hash[p['username']] = p['id']['content'].to_i } print "#{@person_id_hash.length} found...done\n" # a hash storing mapping old to new ids @id_map = { :component => Hash.new, :milestone => Hash.new, :severity => Hash.new, :version => Hash.new, :ticket => Hash.new } print "Opening Trac database..." trac_db = SQLite3::Database.new(File.join(TRAC_ROOT_PATH,'db','trac.db')) trac_db.results_as_hash = true print "done\n" # create the project print "Creating Project..." project_id = api_create(:project, 'projects', {:title => PROJECT_TITLE, :short_name => PROJECT_SHORT_NAME, :theme => 'blue'}) print "#{project_id}...done\n" # components trac_db.execute("select * from component") do |row| print "Creating Component #{row['name']}..." @id_map[:component][row['name']] = api_create(:component, "projects/#{project_id}/components", {:name => row['name']}) print "#{@id_map[:component][row['name']]}...done\n" end # milestones trac_db.execute("select * from milestone") do |row| print "Creating Milestone #{row['name']}..." @id_map[:milestone][row['name']] = api_create(:milestone, "projects/#{project_id}/milestones", {:title => row['name'], :due_on => (row['due'].to_i == 0 ? Time.now.to_date : Time.at(row['due'].to_i).to_date), :completed => (row['completed'] != '0')}) print "#{@id_map[:milestone][row['name']]}...done\n" end # severities sevs = Array.new trac_db.execute("select * from ticket") { |row| sevs << row['type'] } sevs.uniq.each { |s| print "Creating Severity #{s}..." @id_map[:severity][s] = api_create(:severity, "projects/#{project_id}/severities", {:name => s}) print "#{@id_map[:severity][s]}...done\n" } # versions trac_db.execute("select * from version") do |row| print "Creating Version #{row['name']}..." @id_map[:version][row['name']] = api_create(:version, "projects/#{project_id}/versions", {:name => row['name']}) print "#{@id_map[:version][row['name']]}...done\n" end # tickets PRIORITY_MAP = { :blocker => '5', :critical => '4', :major => '3', :minor => '2', :trivial => '1' } trac_db.execute("select * from ticket") do |row| print "Creating Ticket #{row['id']}..." ticket_attrs = {} ticket_attrs[:number] = row['id'] ticket_attrs[:created_at] = row['time'].to_i == 0 ? Time.now : Time.at(row['time'].to_i) ticket_attrs[:updated_at] = row['changetime'].to_i == 0 ? Time.now : Time.at(row['changetime'].to_i) ticket_attrs[:component_id] = @id_map[:component][row['component']] ticket_attrs[:severity_id] = @id_map[:severity][row['type']] ticket_attrs[:assignee_id] = person_id(row['owner']) ticket_attrs[:reporter_id] = person_id(row['reporter']) ticket_attrs[:version_id] = @id_map[:version][row['version']] ticket_attrs[:milestone_id] = @id_map[:milestone][row['milestone']] ticket_attrs[:status] = row['status'] ticket_attrs[:resolution] = row['resolution'] ticket_attrs[:summary] = row['summary'] ticket_attrs[:description] = row['description'] begin ticket_attrs[:priority] = PRIORITY_MAP[row['priority'].to_sym] ? PRIORITY_MAP[row['priority'].to_sym] : '3' rescue ticket_attrs[:priority] = '3' end ticket_id = api_create(:ticket, "projects/#{project_id}/tickets", ticket_attrs) @id_map[:ticket][row['id']] = ticket_id print "#{@id_map[:ticket][row['id']]}...done\n" # attachments trac_db.execute("select * from attachment where type = 'ticket' and id = #{ticket_attrs[:number]}") do |row2| print " Upload Attachment #{row2['filename']}..." upload_key = api_upload("projects/#{project_id}/tickets/#{ticket_id}/attachments/upload", File.join(TRAC_ROOT_PATH, 'attachments', 'ticket', row2['id'], row2['filename'])) attachment_id = api_create(:attachment, "projects/#{project_id}/tickets/#{ticket_id}/attachments", { :filename => row2['filename'], :content_type => get_content_type(row2['filename']), :upload => { :key => upload_key } }) print "#{attachment_id}...done\n" end # comments trac_db.execute("select * from ticket_change where field = 'comment' and ticket = #{ticket_attrs[:number]}") do |row2| if row2['newvalue'] && !row2['newvalue'].chomp.empty? print " Creating Comment by #{row2['author']}..." comment_id = api_create(:comment, "projects/#{project_id}/tickets/#{ticket_id}/comments", { :created_at => (row2['time'].to_i == 0 ? Time.now : Time.at(row2['time'].to_i)), :author_id => person_id(row2['author']), :body => row2['newvalue'] }) print "#{comment_id}...done\n" end end end # notebook and pages print "Creating Notebook..." notebook_id = api_create(:notebook, "projects/#{project_id}/notebooks", {:title => "Trac Wiki"}) print "#{notebook_id}...done\n" current_page = nil trac_db.execute("select * from wiki order by name,version") do |row| print " Creating Page #{row['name']} version #{row['version']}..." page_attrs = { :created_at => (row['time'].to_i == 0 ? Time.now : Time.at(row['time'].to_i)), :title => row['name'], :body => row['text'], :author_id => person_id(row['author']), :message => row['comment'] } if !current_page || row['name'] != current_page['title'] page_id = api_create(:page, "projects/#{project_id}/notebooks/#{notebook_id}/pages", page_attrs) current_page = api_get_hash("projects/#{project_id}/notebooks/#{notebook_id}/pages/#{page_id}") else page_id = current_page['id']['content'] api_update(:page, "projects/#{project_id}/notebooks/#{notebook_id}/pages/#{page_id}", page_attrs) end print "#{page_id}...done\n" # attachments if row['version'].to_i == 1 trac_db.execute("select * from attachment where type = 'wiki' and id = '#{row['name'].gsub("'", "''")}'") do |row2| print " Upload Attachment #{row2['filename']}..." upload_key = api_upload("projects/#{project_id}/notebooks/#{notebook_id}/attachments/upload", File.join(TRAC_ROOT_PATH, 'attachments', 'wiki', row2['id'], row2['filename'])) attachment_id = api_create(:attachment, "projects/#{project_id}/notebooks/#{notebook_id}/attachments", { :filename => row2['filename'], :content_type => get_content_type(row2['filename']), :upload => { :key => upload_key } }) print "#{attachment_id}...done\n" end end end