# # Trac to Lighthouse ticket importer # # Original Author: Shay Arnett # # Contributions by : # Maxim Chernyak # João Abecasis # Gaspard Bucher # # # NOTES # ----- # # You'll need to get lighthouse.rb from # http://ar-code.svn.engineyard.com/lighthouse-api/lib # # Enter Lighthouse and Trac configuration data in the ###marked### sections. # # Usage: # # require 'trachouse' # # t = Ticket.new # # # grabs all tickets from trac # tickets = t.populate_tickets # # import tickets to lighthouse # t.import_tickets(tickets) # # profit # # You may want to inspect tickets and import a subset for testing, before # bulk processing all tickets. # Copyright (c) 2008 Shay Arnett # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation # files (the "Software"), to deal in the Software without # restriction, including without limitation the rights to use, # copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the # Software is furnished to do so, subject to the following # conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR # OTHER DEALINGS IN THE SOFTWARE. require 'hpricot' require 'net/http' require 'activeresource' require 'lighthouse' class Ticket < ActiveResource::Base include Lighthouse ############ ### Lighthouse configuration ### # Lighthouse Account Name -- NOT your username! Lighthouse.account = 'foo_bar' # Lighthouse API token Lighthouse.token = 'xxxxxxxxx' Config = {} #setup project_ids and associated trac components # :project_id should be the lighthouse id of the project # you want to import the tickets to # # :components should be an array of the trac components you wish # to import into this project. Use a component list like [""] if your # trac installation does not use components. # # project_1 = { :project_id => 1234, # :components => ['Core','Module 1', 'etc']} # project_2 = { etc } # # Config[:projects] = [project_1, project_2] Config[:projects] = [] # setup milestones (milestone_id from lighthouse) # # Config[:milestones] = {'name_of_trac_milestone' => milestone_id, etc } Config[:milestones] = {} ### ### END of Lighthouse configuration ############ def initialize ############ ### Trac configuration ### # URL pointing to root of Trac installation. Don't include '/wiki/WikiStart' # and such. Don't include trailing slash... @trac_url = 'http://trac.example.com/myproject/trac' # Credentials are required to access user data. # Does Trac use basic http authentication? @trac_basic_auth = true @trac_username = 'tracuser@email.com' @trac_password = 'password' ### ### END of Trac configuration ############ raise Exception.new("Projects not defined, set this first") if Config[:projects] == [] raise Exception.new("Lighthouse account not set, set this first") if Lighthouse.account == 'foo_bar' raise Exception.new("Trac account not set, set this first") if @trac_url == 'http://trac.example.com/myproject/trac' @tickets = [] @ticket = {} @ticket_list = [] @useragent = 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X; en-us) AppleWebKit/523.10.6 (KHTML, like Gecko) Version/3.0.4 Safari/523.10.6' trac_uri = URI.parse(@trac_url) @trac_host = trac_uri.host @trac_port = trac_uri.port @trac_path = trac_uri.path @trac_address = "http://#{@trac_host}:#{@trac_port}/" # setup headers for grabbing cookie and tiket info @headers = { 'Referer' => @trac_url, 'User-Agent' => @useragent } # setup connection @http = Net::HTTP.new(@trac_host, @trac_port) end def tag_prep(tags) returning tags do |tag| tag.collect! do |t| unless tag.blank? t.downcase! t.gsub! /(^')|('$)/, '' t.gsub! ' ','_' t.gsub! /[^a-z0-9 \-_@\!']/, '' t.strip! t end end tag.compact! tag.uniq! end end def get_state_and_tags(status) # new, open, resolved, hold, invalid trans = {'assigned' => 'open', 'closed' => 'resolved'} if status =~ /\A\((\w+)\s+(.*)\)\Z/ state = trans[$1] ? trans[$1] : $1 tags = $2.split.map { |t| t[-1..-1] == ':' ? t[0..-2] : t }.reject {|t| t== 'fixed' || t=='defect'} end return [state, tags] end def get_project(ticket) project_id = nil Config[:projects].each do |project| project_id = project[:project_id] if project[:components].include? ticket[:component] break unless project_id.nil? end return project_id end def build_ticket(doc, ticket_num) # this is all based on a pretty standard trac template # if you have done any customizing you will need to check your html # and change the necessary Hpricot searches to pull the correct data # build the base ticket ticket = { :title => (doc/"h2.summary").inner_html, :trac_url => '"Original Trac Ticket":' + @trac_url + '/ticket/' + ticket_num, :reporter => (doc/"//td[@headers='h_reporter']").inner_html, :priority => (doc/"//td[@headers='h_priority']").inner_html, :component => (doc/"//td[@headers='h_component']").inner_html, :status => (doc/"span.status").first.inner_html, :milestone => (doc/"//td[@headers='h_milestone']").inner_html, :description => (doc/"div.description").inner_html, :comments => [], :attachments => [] } # clean up the description Hpricot(ticket[:description]).search("h3").remove ticket[:description].gsub!(/<\/?pre( class=\"wiki\")?>/,"@@@\n") ticket[:description].gsub!(/<\/?[^>]*>/, "") ticket[:description] = unescapeHTML(ticket[:description].gsub!(/\n\s*\n\s*\n/,"\n\n")) # gather and clean up the ticket changes changes = [] (doc/"div.change").each do |c| changes << { :name => (c/"h3").inner_html, :comment => (c/"[.comment]|[.changes]").inner_html } end changes.each do |change| change[:name].gsub!(/<\/?[^>]*>/, "") change[:name].strip! change[:comment].gsub!(change[:name],"") change[:comment].gsub!(/<\/?[^>]*>/, "") change[:comment].gsub!(/\n\s*\n\s*\n/,"\n\n") ticket[:comments] << change[:name] + "\n@@@\n" + change[:comment] + "\n@@@\n" end ticket[:comments] = unescapeHTML(ticket[:comments].join("\n")) ticket[:comments].gsub!(/\((follow|in)[^\)]*\)/,'') # gather and cleanup the attachments (doc/"dl.attachments/dt/a").each do |a| ticket[:attachments] << "#{@trac_address}#{a.attributes['href']}" end ticket[:attachments] = unescapeHTML(ticket[:attachments].join("\n")) # put together the final body ticket[:body] = [ "Originally posted on Trac by #{ticket[:reporter]}", ticket[:trac_url], ticket[:description], "h3. Trac Attachments", ticket[:attachments], "h3. Trac Comments", ticket[:comments]].join("\n") ticket[:tags] = [ticket[:priority],ticket[:component]] ticket[:tags] << "patch" if ticket[:title].match /patch/i ticket[:project_id] = get_project(ticket) return ticket end def unescapeHTML(string) # from CGI.rb - don't need the slow just the unescape if string == nil return '' end string.gsub(/&(.*?);/n) do match = $1.dup case match when /\Aamp\z/ni then '&' when /\Aquot\z/ni then '"' when /\Agt\z/ni then '>' when /\Alt\z/ni then '<' when /\A#0*(\d+)\z/n then if Integer($1) < 256 Integer($1).chr else if Integer($1) < 65536 and ($KCODE[0] == ?u or $KCODE[0] == ?U) [Integer($1)].pack("U") else "&##{$1};" end end when /\A#x([0-9a-f]+)\z/ni then if $1.hex < 256 $1.hex.chr else if $1.hex < 65536 and ($KCODE[0] == ?u or $KCODE[0] == ?U) [$1.hex].pack("U") else "&#x#{$1};" end end else "&#{match};" end end end def steal_cookie # get request to gather tokens needed to hijack cookie resp = @http.request_get(@trac_path + '/login', {'User-Agent' => @useragent}) cookie = resp.response['Set-Cookie'] resp.body.match(/TOKEN\" value\=\"(\w+)\"/) url_params = "user=#{@trac_username}&password=#{@trac_password}&__FORM_TOKEN=#{$1}" @headers = { 'Cookie' => cookie, 'Referer' => @trac_url + '/login', 'Content-Type' => 'application/x-www-form-urlencoded' } # post to login and grab cookie for later resp = @http.request_post(@trac_path + '/login', url_params, @headers) cookie = resp.response['Set-Cookie'] @headers = {} @headers['Cookie'] = cookie if cookie end def get_html_for_ticket(ticket) #change url if you go somewhere other than /ticket/1 to pull up ticket #1 ticket_url = @trac_path + "/ticket/#{ticket}" if @trac_basic_auth resp = Net::HTTP.start(@trac_host, @trac_port) do |http| req = Net::HTTP::Get.new(ticket_url) req.basic_auth @trac_username, @trac_password resp = http.request(req) end else # change url in get2() if you go somewhere other than /ticket/1 to pull up ticket #1 resp = @http.request_get(ticket_url, @headers) end Hpricot(unescapeHTML(resp.body)) if resp.code == '200' end def create_ticket(trac_ticket) ticket = Lighthouse::Ticket.new(:project_id => trac_ticket[:project_id]) ticket.title = trac_ticket[:title].to_s state, status_tags = get_state_and_tags(trac_ticket[:status]) ticket.tags = tag_prep(trac_ticket[:tags] + status_tags) ticket.body = trac_ticket[:body].to_s ticket.state = state ticket.milestone_id = Config[:milestones][trac_ticket[:milestone]] ticket.save end def import_tickets(tickets) if not @trac_basic_auth steal_cookie end new_tickets = [] tickets.each do |ticket| # grab the page for this ticket puts "Getting data for ticket #{@trac_path + "/ticket/#{ticket}"}" ticket_html = get_html_for_ticket(ticket) # pull data for ticket new_ticket = build_ticket(ticket_html,ticket) # push into Lighthouse puts "Sending data to Lighthouse (#{new_ticket[:trac_id]} #{new_ticket[:title]})" create_ticket(new_ticket) end end def populate_tickets # url should be the path to a trac report that shows you all tickets from # all components url = @trac_path + '/query?order=id' ticket_list = [] if @trac_basic_auth resp = Net::HTTP.start(@trac_host, @trac_port) do |http| req = Net::HTTP::Get.new(url) req.basic_auth @trac_username, @trac_password resp = http.request(req) end else resp = @http.request_get(url, {'User-Agent' => @useragent}) end html = Hpricot(resp.body) (html/'.id/a').each do |a| a.inner_html =~ /^(\d{1,3})$/ # For some reason, the XPath expression also matches the table header, with # class="id asc". We work around that. if $1; ticket_list << $1; end end ticket_list end end